## 2026-05-11 — 세션 윈도우 하드닝: 크로스-프로젝트 도크 트레이 + 워크스페이스 전환 복구 + 중복 이미지 페이스트 + 제스처 어보트

### 배경

세션 윈도우의 워크스페이스 전환 / 페이스트 / 드래그 흐름 전반에 걸친 결함들을 한 번에 정리했다.

(1) 프로젝트 A에서 세션을 최소화하고 B로 이동하면 최소화 칩이 사라져 다시 펼칠 길이 없었다. (2) 프로젝트 A → B → A 왕복 시 windows가 복원되지 않는 일이 있었다 — 마운트 후 hydrate 효과보다 persist 효과의 첫 commit이 먼저 빈 `[]`를 localStorage에 써버리는 경합이었다. (3) 복원된 running 세션은 portal/container가 settle 되기 전 transient 0×0 사이즈로 `session:resize` SIGWINCH를 PTY에 쏴 화면 버퍼가 망가졌고, 사용자가 윈도우를 리사이즈하는 순간 깨진 상태가 드러났다. (4) 한 번의 Ctrl/Cmd+V로 이미지가 `[Image #1]` + `[Image #2]`로 두 번 들어가거나 동시 페이스트 시 두 번째 이미지의 비트맵이 첫 번째의 자리에 새는 레이스가 있었다 — 브라우저가 xterm keydown 핸들러와 컨테이너 `paste` ClipboardEvent를 같은 제스처에 대해 둘 다 발화했고, OS 클립보드 쓰기와 CLI의 Alt+V 읽기가 atomically 일어나지 않았기 때문. (5) 드래그/리사이즈/스플리터/탭 드래그 제스처가 alt-tab / 탭 hide / Escape로 중단되면 글로벌 listener와 `pointer-events: none`가 wrapper에 남아 페이지가 클릭을 잃었다. (6) 숨겨진 탭(`display:none`)의 SessionTerminal이 마운트 시 `term.focus()`를 호출해 form input에서 타이핑 중인 사용자의 포커스를 빼앗는 일이 있었다.

### 주요 변경

#### 1. 앱 레벨 크로스-프로젝트 도크 트레이 (`ca064d7`)

- **`src/client/src/components/GlobalSessionDockTray.tsx`** (신규, 225줄): App.tsx에 마운트되는 portal-based 트레이. localStorage의 `sessionGroups:<projectId>` 접두사 키를 전부 스캔해 `minimized === true`인 그룹의 칩을 집계한다. `storage` 이벤트(다른 탭에서의 쓰기) + 커스텀 `session-windows:changed` 이벤트(같은 탭 호스트의 쓰기)로 양쪽 채널 갱신. 칩 클릭 시:
  - 같은 프로젝트: `session-windows:restore` / `session-windows:close` 커스텀 이벤트 발화 (호스트가 `confirmRunningStop` + z-bump을 캐노니컬 경로로 처리)
  - 다른 프로젝트: `sessionStorage.pendingSessionRestore`에 intent stash 후 `useNavigate`로 이동. 도착 프로젝트의 host가 마운트 시점에 intent를 픽업해 그룹을 un-minimize. 닫기는 그 프로젝트의 localStorage entry를 직접 수정 (PTY는 stop 하지 않음 — "윈도우 닫기"이지 "세션 중지"가 아님)
- **`src/client/src/App.tsx`**: `<GlobalSessionDockTray />`를 BrowserRouter 안쪽, Layout 바깥에 마운트 (useNavigate/useLocation 접근용)
- **`src/client/src/components/SessionWindowsHost.tsx`**: 호스트가 더 이상 자체 도크 트레이를 렌더링하지 않음 (기존 per-host 트레이 + drag-to-reorder 제거 — 정렬은 트레이가 project/group id로 글로벌 정렬). persist payload에 `titles` 스냅샷 추가 — 트레이는 다른 프로젝트의 sessions 목록을 로드하지 않으므로 칩 라벨용 제목이 별도로 필요. 매 persist마다 `session-windows:changed` 이벤트 dispatch

#### 2. 워크스페이스 전환 hydration 레이스 수정 (`ca064d7`)

- **`src/client/src/components/SessionWindowsHost.tsx`**:
  - hydrate를 **`useState` initializer**로 옮겨 첫 render에서 동기적으로 localStorage를 읽도록 변경. 이전에는 `sessions.length > 0`을 기다리는 post-mount effect였는데, 같은 첫 commit에서 persist effect가 먼저 빈 `[]`를 localStorage에 써 복원할 state를 스스로 지워버리는 경합이었다
  - 복원된 탭은 `intent='open'`, `nonce=0`으로 demote (`replay-only` — 자기가 spawn 한 적 없는 PTY를 재spawn 하지 않음)
  - `sanitizeGeom`을 initializer에서도 적용해 중단된 drag/resize로 오프스크린에 굳은 상태도 안전하게 복구
- **`src/client/src/components/ProjectDetail.tsx`**: `<SessionWindowsHost key={id!} ...>`로 프로젝트별 remount 강제. `key` 없이는 A→B→A 시 동일 host 인스턴스가 한 프로젝트의 groups를 다른 프로젝트의 localStorage 키에 쓰는 cross-bleed 발생

#### 3. PTY 사이즈 sync 안정화 (`ca064d7`)

- **`src/client/src/components/SessionTerminal.tsx`**: 마운트 직후 `onFitted` 호출 제거. portal/container가 settle 되기 전 transient 0×0 또는 sub-cell-grid 측정으로 PTY를 SIGWINCH 하던 경로를 차단. ResizeObserver는 `term.cols >= 20 && term.rows >= 5`로 안정 측정을 본 한 번에만 `onFitted`를 emit (`firstFitNotified` 플래그). 마운트 시 `fitAddon.fit()`은 best-effort로 유지 (xterm 내부 cols/rows 초기화용, `onFitted`는 미발화)
- **`src/client/src/components/group/SessionPane.tsx`**:
  - `initialPhase`: running 세션도 `'subscribed'`가 아닌 `'pendingFit'`으로 시작. 즉시 `'subscribed'`로 들어가면 SessionTerminal의 `subscribed` 효과가 `session:subscribe` + `session:resize`를 portal settle 전 발화하는 race가 재현됨
  - `handleFitted`: 복원된 running 세션은 `POST /start` 중복 spawn을 피하고 곧장 `'subscribed'`로 promote (실제 dims를 손에 쥔 상태에서). 새 세션은 기존대로 `tryStart()`

#### 4. 동시 / 중복 이미지 페이스트 레이스 수정 (`64f95bf`)

- **`src/server/routes/sessions.ts`**: `POST /api/sessions/:id/paste-image`가 이제 클립보드 쓰기와 같은 트랜잭션 안에서 `claudeManager.writeStdinRaw(pid, '\x1bv')`까지 처리. 두 페이스트가 동시에 도착했을 때 두 번째 클립보드 쓰기가 첫 번째의 CLI 읽기 전에 덮어쓰는 케이스를 차단 (clipboard-writer의 promise-chain mutex와 결합). `hasPendingPrompt`로 게이트해 Send/Skip 사전 배너가 떠 있는 PTY에는 ESC+v를 흘리지 않음
- **`src/client/src/components/SessionTerminal.tsx`**:
  - `pasteFromClipboard` 진입 시 `pasteHandledAt = Date.now()`로 제스처를 클레임. 같은 Ctrl/Cmd+V에 대해 브라우저가 이어서 발화하는 `paste` ClipboardEvent는 `isPasteAlreadyHandled()`(300ms 윈도우)로 bail
  - 클레임은 `window.isSecureContext`일 때만 — HTTP/LAN-IP 출처에서는 `clipboard.read()`가 reject되므로 컨테이너 `paste` 이벤트의 `clipboardData` 폴백이 실제 핸들러가 되어야 함
  - 두 페이스트 경로 모두에서 클라이언트의 `\x1bv` 전송 제거 (서버가 클립보드 쓰기와 함께 처리)
  - `InputSetupArgs`에 `isPasteAlreadyHandled` 추가, mobile/desktop 셋업 양쪽에 전달

#### 5. 드래그/리사이즈/스플리터/탭 드래그 어보트 패스 (`a7649bd`)

블러/Escape/visibilitychange로 제스처가 중단됐을 때 글로벌 listener leak + wrapper `pointer-events: none` leak을 막는 어보트 핸들러를 모든 제스처에 추가. 한 번만 실행되는 `detachListeners` 가드를 두어 mouseup과 abort가 중복 클린업되지 않게 함.

- **`src/client/src/components/SessionWindow.tsx`** — chrome drag, resize (8방향): blur / Escape / visibilitychange에서 현재 geometry를 commit하고 listener 분리
- **`src/client/src/components/SessionWindowsHost.tsx`** — tab drag: 어보트 시 `setDragState(null)`로 깔끔히 종료, 탭이 이미 떨어져 나갔으면 그 위치 유지
- **`src/client/src/components/group/Splitter.tsx`** — split bar drag: 어보트 시 `onDragEnd()` 호출 + listener 분리

#### 6. 영속화 geometry sanitization (`a7649bd`)

- **`src/client/src/components/SessionWindowsHost.tsx`**: `sanitizeGeom()` 헬퍼 신설. NaN / `< MIN_W` / `< MIN_H` / 오프스크린 항목을 클램프 또는 디폴트로 복구. 중단된 제스처로 0×0이 된 wrapper가 invisible-but-clickable 형태로 페이지 밑 클릭을 빨아들이는 사고를 차단. hydrate 경로 양쪽(`useState` initializer + 레거시 post-mount path)에서 동일하게 적용

#### 7. 숨겨진 pane의 mount-time autofocus 게이트 (`a7649bd`)

- **`src/client/src/components/SessionTerminal.tsx`**: `autoFocusOnMount` prop 신설. body-only 가드를 input / textarea / select / contenteditable 어떤 것이든 포커스돼 있으면 가로채지 않도록 강화
- **`src/client/src/components/group/SessionPane.tsx`**: `autoFocusOnMount={visible}`만 전달 — StackView가 `display`로 토글하는 hidden 탭은 mount 시점에도 포커스를 빼앗지 않음

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/client/src/components/GlobalSessionDockTray.tsx` | 신규 — 앱 레벨 크로스-프로젝트 최소화 칩 트레이 (localStorage 스캔 + storage/session-windows:changed 이벤트 + 네비게이션 핸드오프) |
| `src/client/src/App.tsx` | GlobalSessionDockTray를 BrowserRouter 안에 마운트 |
| `src/client/src/components/SessionWindowsHost.tsx` | hydrate를 useState initializer로 이동 (persist 레이스 수정) + per-host 트레이 제거 + persist에 titles 스냅샷 + 어보트 패스 + sanitizeGeom + restore/close 이벤트 수신 + 크로스-프로젝트 intent 픽업 |
| `src/client/src/components/SessionTerminal.tsx` | 마운트 즉시 onFitted 제거 + 첫 안정 측정 시 한 번만 발화 + paste 클레임 + autoFocusOnMount 게이트 + form-element-aware focus 가드 |
| `src/client/src/components/SessionWindow.tsx` | chrome drag / 8방향 resize에 blur / Escape / visibilitychange 어보트 패스 |
| `src/client/src/components/group/SessionPane.tsx` | 복원된 running 세션도 pendingFit으로 시작 + handleFitted에서 status 분기 (POST /start 중복 spawn 회피) + autoFocusOnMount=visible 전달 |
| `src/client/src/components/group/Splitter.tsx` | 어보트 패스 |
| `src/client/src/components/ProjectDetail.tsx` | SessionWindowsHost를 `key={id!}`로 프로젝트별 remount |
| `src/server/routes/sessions.ts` | paste-image가 클립보드 쓰기 + ESC+v PTY 주입을 같은 트랜잭션으로 처리 (hasPendingPrompt 게이트) |

### 아키텍처 결정

1. **도크 트레이는 앱 레벨**: 최소화 칩이 워크스페이스 전환에도 살아남아야 한다는 요구는 ProjectDetail의 생명 주기와 충돌한다. App.tsx에 띄우고 localStorage를 truth source로 삼아 모든 프로젝트의 상태를 동시에 본다. 다른 프로젝트 호스트가 마운트돼 있지 않아도 트레이는 그 프로젝트의 칩을 그릴 수 있어야 하므로 persist payload에 `titles`를 함께 스냅샷.
2. **hydrate는 첫 render initializer에서**: post-mount effect로 hydrate 하면 같은 first commit에서 발화하는 persist effect와 경합한다. `useState(() => readPersisted(...))` 형태로 동기 hydrate 하면 persist는 항상 복원된 state를 보고 쓴다. 부수 효과: `sessions.length > 0`을 기다릴 수 없으므로 일부 stale id가 트리에 잠깐 남지만, `pruneInvalid`가 sessions 로드 후 정리하고 StackView는 그 사이 missing session을 `null`로 안전 렌더.
3. **PTY 사이즈는 안정 측정 후 한 번만 통보**: portal 마운트 직후의 cols/rows는 종종 0이거나 sub-cell-grid다. 그 값으로 `session:resize`를 보내면 PTY가 그 사이즈에 맞춰 화면을 redraw 했다가 직후 진짜 사이즈로 또 SIGWINCH 받아 버퍼가 망가진다. `firstFitNotified` 가드로 RO가 본 첫 ≥20×5 측정에만 통보. `SessionPane`도 running 세션을 곧장 `'subscribed'`로 보내지 않고 `'pendingFit'` → `handleFitted`에서 promote.
4. **이미지 페이스트의 클립보드+ESC+v는 서버에서 atomic**: 두 페이스트가 동시에 들어왔을 때 클라이언트가 ESC+v를 따로 보내면 OS 클립보드는 단일 슬롯이라 두 번째 paste의 비트맵이 첫 번째 CLI 읽기를 덮어쓸 수 있다. `clipboard-writer`의 process-wide promise-chain mutex 안쪽에서 `writeStdinRaw`까지 마치는 것으로 원자성 보장.
5. **모든 글로벌 제스처에 abort path를 의무화**: drag/resize/splitter/tab-drag 모두 같은 패턴 — `cleaned` 가드 + `blur` / `keydown` (Escape) / `visibilitychange` 리스너 + 현재 상태를 가능한 한 commit. 한 곳이라도 누락되면 wrapper의 `pointer-events: none`이 잔존하거나 글로벌 mousemove listener가 leak 한다.
