## 2026-05-02 — Sessions: VS Code 스타일 윈도우 그룹화 + Aero 도킹 + 탭 분리/이동 + 폰트 사이즈 + 위키 주입 + iOS Hangul IME

### 배경

이전까지 세션 floating window는 자유 배치만 가능해 멀티 세션 운용이 불편했음. Aero 스타일 엣지 스냅 + 도크 트레이 → VS Code 스타일 윈도우 그룹화/탭 도킹 → 단일-스택 chrome 드래그 도킹까지 단계적으로 쌓아 올려 한 화면에서 여러 세션을 분할/탭으로 정리할 수 있게 됨. 세션 자체에도 인라인 편집 버튼, per-session 폰트 크기, wiki 주입 (Send/Skip pre-flight), iOS Safari 모바일에서 Hangul 분리되던 IME 버그 해결 등을 함께 처리. 세션 구독 시 in-memory ring tail과 DB 청크가 중복으로 재생되어 환영 배너가 두 번 보이던 버그도 수정.

### 주요 변경

#### 1. Aero 스타일 엣지 스냅 + 도크 트레이 (`bcb1186`)

- **엣지 스냅 존**: 뷰포트 가장자리 8px 내로 드래그 시 좌/우 절반 또는 4 코너 quarter로 스냅. 드래그 중 portal로 반투명 accent + dashed border preview, mouseup에 geometry commit
- **윈도우 간 sticky 스냅**: 10px threshold — 드래그 중 인접 윈도우 엣지(left↔right, top↔bottom, same-side alignment)에 자석처럼 붙음
- **Minimize + Dock Tray**: 타이틀바에 lucide `Minus` 버튼. minimized 윈도우는 unmount하되 서버 PTY는 살아있고 restore 시 replay-only path로 catch-up. 좌하단 portal dock tray에 `>_` + title + close chip
- **타이틀바 글리프 변경**: macOS traffic-light 도트 → 색상 적용된 `>_` glyph (CMD_FONT). 작은 타이틀바에서도 가독성 향상
- 컨텍스트에 `OpenWindow.minimized` 추가, neighbors geom prop 전달

#### 2. VS Code 스타일 윈도우 그룹화 + Layout Tree (`1508fdd`)

- **Layout Tree 모델**: 윈도우 = 그룹의 root LayoutNode (`stack` | `split`). 탭을 다른 윈도우로 드래그하면 5-zone 다이아몬드 indicator (top/bottom/left/right/center) 표시 → 사이드 드롭 시 분할 pane, center 드롭 시 같은 stack에 탭으로 합침
- **새 컴포넌트 (`src/client/src/components/group/`)**:
  - `groupTree.ts` — 순수 트리 op (findStack, removeTab, insertAtSide, insertIntoStack, simplify, reorderTab, setSplitSizes, pruneInvalid). 1-child split auto-collapse + 동일 orientation 중첩 split flatten
  - `colors.ts` — 8색 팔레트 + 그룹 내 중복 회피 assignColor
  - `SessionPane.tsx` — PTY viewport + phase machine을 별도 추출 (탭이 hide 상태여도 mount 유지 → 라이브 PTY output 끊김 없음)
  - `StackView.tsx` — 탭바 + pane 영역. 모든 탭을 mount하고 `display`로 토글, 비활성 탭의 xterm.js state 보존. `data-group-id`/`data-stack-path`로 hit-test 태깅
  - `Splitter.tsx` — 4px draggable bar. 드래그 중 flex-basis 직접 mutate, mouseup에 size commit
  - `LayoutNodeView.tsx` — stack/split dispatch 재귀 렌더러
  - `DockOverlay.tsx` — portal 기반 5-zone diamond + drop preview rect + `detectDockZone` hit-test
- **그룹 상태 모델 재설계 (`SessionWindowsHost`)**: `OpenWindow` → `OpenGroup` (id, geom, z, minimized, root LayoutNode, per-session colors, intent + nonce). `closeGroup`, `minimizeGroup`, `restoreGroup`, `setSplitSizes`, `setActiveTab`, `reorderTab`, `beginTabDrag` API 신규. 외부 API (sessionId-키 `openOrFocus`, `close`, `focus`, `minimize`, `restore`, `isOpen`)는 그대로 유지 (SessionList 등 caller 무변경)
- **그룹 chrome (`SessionWindow.tsx`)**: 22px compact chrome + 4px color band — 단일 탭이면 단일 accent, 멀티 stack이면 multi-color ribbon. chrome이 그룹 드래그 핸들. 기존 Aero edge snap, neighbor sticky, viewport clamp, corner resize 그대로 작동
- **localStorage 키 변경**: `sessionGroups:{projectId}` (full group tree + zCounter). hydration은 `sessions` 로드 후 게이트해서 빈 sessions로 트리 복원이 nuke되지 않도록
- **모바일 (<768px)**: docking widgets 없이 active tab만 fullscreen (이전 single-window 모바일 UX 유지)

#### 3. 단일-스택 그룹 chrome 숨김 + 도크 다이아몬드 확장 (`2ad04a6`)

- **`group.root.kind === 'split'` 일 때만 unified chrome 렌더**. 단일-stack 그룹은 StackView 탭바가 그룹의 minimize/close 버튼을 호스팅 (새 optional `groupActions` prop), spacer가 wrapper로 mousedown 버블링해서 그룹 드래그 그대로 작동
- **단일-stack 모드**: wrapper 자체가 chrome 드래그 핸들
- **DockOverlay 다이아몬드 확장**: ARM_OFFSET 36→68, ARM_SIZE 30→28 — 각 zone icon 56×56, sibling 간 12px gap. wrapper 크기는 `ARM_OFFSET + ARM_SIZE` 동적 계산. `detectDockZone`도 같은 상수 공유

#### 4. 단일-스택 윈도우 chrome 드래그로 도킹 + 터미널 overflow containment (`ae945ac`)

- **단일-stack chrome 드래그 + 도크 감지**: 분할된 그룹은 chrome drag = move-only, 단일-stack 그룹은 wrapper drag + dock detection 동시. 드래그 중 `document.elementFromPoint`로 다른 그룹의 stack hit-test → 5-zone DockOverlay 표시 → mouseup 시 host-level `dockGroup(srcId, dstId, dstPath, side)` 커밋. center → 대상 stack에 탭 흡수, side → 새 split sibling으로 wrap. source 그룹 dissolve
- **mouseup 우선순위**: dock > edge snap > free move. 도크 타깃 활성화 시 edge snap preview suppress
- **StackView tab `onMouseDown` 분기**: 단일-stack 모드는 event bubble (wrapper drag + dock 감지), split 모드는 기존 `beginTabDrag` per-tab flow
- **터미널 overflow / wheel containment (`SessionTerminal.tsx`)**: wrapper에 `box-sizing: border-box` + `overflow: hidden` (8px padding이 부모 height을 초과하던 문제로 wheel 이벤트가 외부 chrome으로 새던 버그 fix). inner xterm container에서 `wheel` propagation 차단 — xterm이 자체 viewport scroll 처리하므로 외부 페이지 스크롤되지 않도록

#### 5. chrome-drag dock가 실제로 다른 그룹을 찾도록 fix (`07a45de`)

- 이전 랜딩에서 chrome-drag dock 감지가 실제로는 no-op였음: 드래그된 윈도우가 cursor를 따라 DOM mutation으로 움직이므로 `document.elementFromPoint`가 항상 자기 자신을 hit-test → same-group guard 매 프레임 발동 → 도크 오버레이 표시 안 됨
- **fix**: 드래그 중 wrapper의 `pointer-events: none` 설정 (mouseup에 복원). mousemove/mouseup은 `window`에 살아 있으므로 제스처 자체에는 영향 없음. tab-level drag는 source 윈도우를 움직이지 않으므로 영향 없음

#### 6. 세션 chrome / 탭 추가 개선 (`77c425e`, `c4ceb71`, `a2ff7c0`, `1934b5b`)

- **Eager tab tearing (`77c425e`)**: 도킹된 그룹에서 탭 드래그 시 mouseup까지 기다리지 않고 cursor가 source 그룹 rect를 벗어나는 순간 즉시 floating 윈도우로 detach. Chrome/VS Code와 동일한 모델. 같은 stack → same-group post-detach self-skip로 전환
- **탭 인디케이터 변경**: 8×8 컬러 도트 → `>_` glyph (per-session 색상 유지). A-/A+ 텍스트 → lucide ZoomOut/ZoomIn 아이콘
- **12px 거리 threshold (`c4ceb71`)**: 넓은 사이드-바이-사이드 도크에서 source rect 안에 탭이 머물러 tear-off가 발동하지 않던 문제. `Math.hypot(dx, dy) >= 12px`로 의도 인식. 단순 클릭(움직임 X)은 active tab 스왑만
- **same-group dock 시 탭 사라짐 fix (`a2ff7c0`)**: applyDock의 if/else if 분기로 src와 dst가 같은 그룹일 때 dst 트리가 버려지던 버그. same-group dock을 사전 감지해 단일 트리로 op. 형제 탭 id에 anchor + `findStackContaining`로 path re-resolve (sibling stack collapse 후 dstPath 무효화 방지)
- **Per-session 폰트 크기 (`1934b5b`)**: hardcoded `fontSize=13`이 high-DPI에서 안 읽힘. 신규 `useSessionFontSize` hook (module-level cache + per-sessionId listener set, localStorage `sessionFontSize:{sessionId}`, 8-28px clamp, default 13). xterm.js options.fontSize 갱신 + FitAddon 재피팅 → 새 cols/rows를 기존 resize path로 PTY에 전파 (`stty size` 즉시 반영). StackView 탭바에 A−/A+ 버튼 + Ctrl/Cmd `=`/`+`/`-` 단축키 (xterm `attachCustomKeyEventHandler`로 가로채서 브라우저 zoom 차단)

#### 7. 세션 인라인 편집 버튼 + 위키 주입 Send/Skip pre-flight (`7c92964`, `885989b`)

- **인라인 편집 (`7c92964`)**: 세션 row에 Edit2 버튼 (running 시 disabled + 툴팁). PUT /api/sessions/:id 서버 path는 이미 모든 필드 (title, description, cli_tool/model, use_worktree, memory inject mode + node ids) 지원하던 상태였으나 클라이언트 호출 path가 없었음. SessionForm에 optional `initial: SessionFormInitial` prop으로 prefill, submit 라벨이 "Create"/"Save" 토글. SessionList가 `editingId` 추적, parseMemoryNodeIds로 JSON 파싱, create+edit 폼은 mutually exclusive (한쪽 열면 다른 쪽 닫힘). 서버 가드 (`Cannot edit a running session`)는 안전망 유지
- **세션 위키 주입 + Send/Skip pre-flight (`885989b`)**:
  - **DB**: sessions 테이블에 `memory_inject_mode` (default 'none') + `memory_node_ids` ALTER TABLE 마이그레이션. todos/discussions와 동일 정규화 (`all`|`selected`|`auto` or `none`). createSession/updateSession + 라우트 validator에 두 필드 통과
  - **session-manager.ts**: 오케스트레이터와 동일한 `applyMemoryInjection()` 호출. auto 모드 retrieval query는 `${title}\n${description}`, session_logs callback으로 가시성 확보
  - **Pre-flight 패널**: claude-manager가 ready 감지 시 자동으로 prompt를 PTY에 쓰는 대신 sessionManager가 in-memory `pendingInitialPrompts` Map에 stash, PTY에는 빈 stdinPrompt 전달
  - 신규 엔드포인트 3개: `GET /sessions/:id/pending-prompt` (preview), `POST /sessions/:id/submit-initial` (PTY write), `POST /sessions/:id/skip-initial` (discard). `/start`는 `pendingInitialPrompt + length`를 응답에 포함해 클라이언트가 round-trip 없이 패널 렌더 가능
  - **SessionPane 배너**: 터미널 viewport 위에 "Initial prompt ready · N chars" + Preview/Send/Skip 버튼. Preview는 첫 expand 시 lazy-load, 에러는 인라인. stop/exit/error 시 pending entry도 같이 정리 (stuck panel 방지)
  - **무변경 동작**: description 없고 위키 주입 없는 세션은 prompt empty → 패널 안 뜨고 PTY가 그냥 빈 input으로 열림

#### 8. xterm 재생 중복 fix (`e67606b`)

- 클라이언트가 raw byte 스트림 구독 시 DB 청크 + claudeManager의 in-memory ring tail을 모두 재생해 환영 배너 + 프롬프트가 두 번 그려지던 버그 (cursor가 첫 렌더에서 화면 중간에 위치, 스크롤 업 시 "Claude Code" 블록 중복)
- ring은 DB의 strict subset (둘 다 같은 PTY 스트림에서 채움)이므로 **DB가 single source of truth**로 단일화
- `SessionManager.flushPendingRaw(sessionId)` (public): subscription 제거 없이 per-session pending buffer를 `session_raw_chunks`로 sync drain
- `websocket/index.ts session:subscribe` handler: subscribe → flushPendingRaw → `getSessionRawChunks`만 send. `claudeManager.getRawHistory` ring-tail send 제거. Node single-thread 보장으로 synchronous handler 내에서 PTY data event interleave 없음 → drain 후 도착 청크는 freshly subscribed 클라이언트로 라이브 전달

#### 9. iOS Safari 모바일 Hangul IME 안정화 (`98de04d`, 시도 commits revert)

- **이력**: `438061e`(Hangul fallback font) + `482d46a`(jamo 게이팅 + NFC normalize + 50ms guard) → 둘 다 분리 렌더링 미해결 + 데스크탑 회귀까지 발생해 `748e646`로 롤백 → `98de04d`에서 클라이언트-사이드 dubeolsik composer로 재구성
- **iOS Safari 18 동작 분석**: 모바일 오버레이 textarea에서 compositionstart/end가 fire되지 않고 per-jamo input(insertText) 이벤트 + deleteContentBackward + insertText(precomposed syllable) 페어로 syllable splice. 이전 코드는 raw jamo를 final input으로 PTY에 forward → "잘지내?"가 "ㅈㅏㄹㅈㅣㄴㅐ?"로 echo
- **Dubeolsik composer**: 호환자모 입력이 cho/jung/jong 슬롯 채우고, 슬롯 transition 시 새 syllable 시작으로 인식해 직전 syllable commit. iOS가 이미 splice한 precomposed syllable은 다시 jamo로 decompose해서 다음 jamo로 확장 가능 (ㅈ → "자" → ㄹ → "잘"). backspace는 composer 전체 clear, non-Hangul/Enter/special key/600ms idle timeout은 composer flush
- **textarea 속성 강화**: autocorrect/autocapitalize/spellcheck 제거 (iOS에서 IME 비활성화 가능), `lang="ko"` + `inputmode="text"` 추가, caretColor를 `transparent` → 거의 투명 alpha (iOS가 caret 위치로 composition 가능 여부 판단)
- **수용 가능한 한계**: 이중 모음 (ㅘ/ㅝ 등) + 복합 종성 (ㄺ/ㄿ 등)은 분리 syllable commit. 채팅/CLI 입력의 일반 케이스는 커버

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/client/src/components/SessionWindow.tsx` | Aero 스냅, group chrome, 단일-stack chrome drag (pointer-events: none + dock detection) |
| `src/client/src/components/SessionWindowsHost.tsx` | OpenWindow → OpenGroup 모델, group/dock API, eager tab tearing, same-group dock fix |
| `src/client/src/components/group/groupTree.ts` | NEW — pure 트리 op |
| `src/client/src/components/group/StackView.tsx` | NEW — 탭바 + pane 영역, groupActions, A-/A+ 폰트 버튼 |
| `src/client/src/components/group/Splitter.tsx` | NEW — 4px draggable splitter |
| `src/client/src/components/group/LayoutNodeView.tsx` | NEW — stack/split 재귀 렌더러 |
| `src/client/src/components/group/DockOverlay.tsx` | NEW — 5-zone 다이아몬드 indicator + detectDockZone |
| `src/client/src/components/group/SessionPane.tsx` | NEW — PTY viewport 추출 + phase machine |
| `src/client/src/components/group/colors.ts` | NEW — 8색 팔레트 |
| `src/client/src/components/SessionTerminal.tsx` | per-session 폰트 크기, wheel containment, iOS dubeolsik composer |
| `src/client/src/hooks/useSessionFontSize.ts` | NEW — module-level cache + per-sessionId listener |
| `src/client/src/components/SessionList.tsx` | 인라인 편집 (editingId), parseMemoryNodeIds |
| `src/client/src/components/SessionForm.tsx` | optional `initial` prop, MemoryInjectControl 노출, mode 토글 |
| `src/server/services/session-manager.ts` | applyMemoryInjection, pendingInitialPrompts, flushPendingRaw public |
| `src/server/routes/sessions.ts` | memory_inject_mode/node_ids 필드, /pending-prompt, /submit-initial, /skip-initial |
| `src/server/db/schema.ts` | sessions ALTER (memory_inject_mode, memory_node_ids) |
| `src/server/websocket/index.ts` | session:subscribe → flushPendingRaw → DB chunk only |
| `src/client/src/i18n.tsx` | session.fontDecrease/fontIncrease, session.edit/save, group.closeTab |

### 아키텍처 결정

1. **Layout tree로 그룹화 일반화**: stack-only 모델로는 split을 표현 못 하고, ad-hoc split 모델은 트리 정합성이 깨지기 쉬움. 순수 트리 + simplify/prune 보강으로 어떤 dock 시퀀스도 canonical 형태로 수렴
2. **모든 탭 mount 유지 (display 토글)**: 탭 스위치 = unmount는 xterm.js state 손실 + PTY output 누락. display:none + 비활성 탭 keep-mounted로 라이브 스트림 무결성 확보 (메모리 비용은 PTY ring buffer가 capping)
3. **drag 동안 wrapper pointer-events: none**: chrome-drag dock detection의 self-hit-test 문제 해결을 위한 단순 트릭. mousemove/mouseup이 window에 살아 있어 제스처는 그대로
4. **Raw byte single source of truth = DB**: ring + DB 둘 다 재생하면 본질적으로 redundant. flush + DB-only로 dedup, single-thread 보장으로 race 없음
5. **iOS Hangul: composer를 클라이언트에서 재구현**: iOS Safari가 compositionevent를 안 쏠 때 OS가 splice한 결과를 그대로 forward하지 말고 직접 dubeolsik 조립. 모바일/데스크탑 모두 같은 path로 가지 않고 모바일 overlay textarea에서만 분기

---
