## 2026-05-28 — 세션 윈도우 UX 다듬기 (IME 합성 오버레이 + 프로젝트 탭 재정렬 + 워크스페이스 전환 시 자동 최소화) + 팝아웃 재할당 레이스 fix

### 배경

세션 floating window 서브시스템에 일상 마찰이 누적돼 있었다:

1. **xterm.js는 IME 합성(in-flight composition) 텍스트를 자체 그리드에 그리지 않는다**. 한글을 치는 동안 OS IME candidate panel에 의존해야 하는데, TUI가 redraw할 때마다 패널이 점프하거나 가려져 "지금 뭐 쳤지"가 안 보였다. 데스크톱 사용자에게 가장 자주 불평이 나오던 부분.
2. **프로젝트 상세 페이지 탭 순서**가 Files 같은 작업 영역과 plugin 탭이 섞여 있어 매일 쓰는 탭(Vault / Planner / Sessions)이 항상 같은 위치에 있지 않았다.
3. **워크스페이스(프로젝트)를 전환하면 main이 소유한 세션 floating window들이 사라졌다**. 서버의 PTY는 살아 있지만 클라이언트 UI 핸들이 없어졌고, 다시 그 프로젝트로 돌아와도 자동 복구되지 않아 매번 minimize → 다른 프로젝트 → 돌아오기 같은 우회가 필요했다.
4. **세션 팝아웃 OS 윈도우가 main에 의해 재할당되면 두 윈도우가 같은 세션의 binary stream을 동시 구독**해서 모든 `term.write()`가 중복으로 호출됐다. `useWebSocket`의 per-sessionId fan-out Set이 main + popout 양쪽 콜백을 모두 들고 있어서 발생한 레이스. 결과적으로 출력이 두 배로 찍히고 커서가 깨졌다.

### 주요 변경

#### 1. xterm.js IME 합성 오버레이 (`SessionTerminal`)

- **`src/client/src/components/SessionTerminal.tsx`**: 터미널 host element에 `compositionstart` / `compositionupdate` / `compositionend` 리스너를 부착. 합성 중 텍스트를 React state(`composing`)로 미러링하고, 터미널 좌하단에 `position: fixed`로 떠 있는 작은 알약 모양 오버레이를 렌더. `pointer-events: none`이라 TUI 입력을 가리지 않으며, TUI가 redraw하더라도 React 컴포넌트라 사라지지 않음.
- 데스크톱에만 적용 — 모바일은 기존 `HangulComposer` 오버레이 경로 유지 (iOS Safari 18 한글 splice 버그가 별도 인프라를 요구하기 때문).

#### 2. 프로젝트 탭 재정렬 (`ProjectDetail`)

- **`src/client/src/components/ProjectDetail.tsx`**: 탭 빌더에서 Vault → Planner → Sessions를 항상 앞 세 자리에 강제. 나머지 plugin 탭(jira / notion / github 등)과 기본 탭들은 기존 상대 순서를 유지하며 그 뒤로 밀림. 매일 만지는 작업 영역이 앞에 모이도록 정돈.

#### 3. 워크스페이스 전환 시 자동 최소화 (`SessionWindowsHost`)

- **`src/client/src/components/SessionWindowsHost.tsx`**: 호스트가 unmount(= 다른 프로젝트로 이동)되기 직전, main-owned + visible 상태인 모든 그룹을 persisted snapshot에서 `minimized = true`로 뒤집은 뒤 tear-down. `GlobalSessionDockTray`는 이 상태 변경을 감지해 좌하단에 chip으로 칩셋을 렌더 → 사용자는 다른 프로젝트에 있는 동안에도 그 세션들의 핸들을 유지하고 클릭으로 다시 띄울 수 있음.
- **팝아웃 소유 그룹은 의도적으로 skip**: 별도 OS 윈도우가 여전히 살아 있으므로 칩으로 또 띄우면 중복.

#### 4. 팝아웃 재할당 레이스 fix (`SessionWindowsHost` + `popoutBus` + `PopoutPage`)

- **`src/client/src/components/SessionWindowsHost.tsx`**: main이 heartbeat sweep으로 silent popout의 ownership을 회수할 때, 회수 직전에 `group-reclaimed` BroadcastChannel 메시지를 post한 뒤 ownership을 main으로 플립.
- **`src/client/src/components/popout/popoutBus.ts`**: `group-reclaimed` 메시지 타입 추가, sender / listener 양쪽 export.
- **`src/client/src/components/popout/PopoutPage.tsx`**: `group-reclaimed`를 받으면 캐시된 그룹을 드롭(SessionTerminal unmount → WS 구독 콜백 해제), 짧은 안내 노티스를 표시한 후 1.5초 뒤 OS 윈도우 자동 종료. 이로써 두 윈도우가 같은 sessionId fan-out Set에 동시에 들어가는 시점이 사라져 `term.write()` 중복이 차단됨.

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/client/src/components/SessionTerminal.tsx` | composition 이벤트 → React state → 좌하단 알약 오버레이 (데스크톱 전용) |
| `src/client/src/components/ProjectDetail.tsx` | 탭 순서 Vault → Planner → Sessions 고정 |
| `src/client/src/components/SessionWindowsHost.tsx` | unmount 직전 main-owned visible → minimized 플립 + `group-reclaimed` 브로드캐스트 |
| `src/client/src/components/popout/popoutBus.ts` | `group-reclaimed` 메시지 타입 추가 |
| `src/client/src/components/popout/PopoutPage.tsx` | `group-reclaimed` 수신 시 그룹 드롭 + 안내 + 1.5s 후 자동 종료 |

### 아키텍처 결정

1. **IME 합성은 React 오버레이로 분리, xterm.js에 패치 안 함**: xterm.js 내부 렌더 파이프라인에 합성 텍스트를 끼워 넣는 건 fork 부담이 큼. React DOM 오버레이는 TUI redraw와 무관하게 살아 있고, `pointer-events: none`이면 입력 가로채기도 없어 부작용 없음.
2. **워크스페이스 전환 = "destroy"가 아니라 "minimize"**: 서버 PTY를 죽이는 게 아니라 클라이언트 핸들만 칩으로 압축한다는 시맨틱이 사용자 멘탈 모델과 더 가까움. 다른 프로젝트로 갔다가 돌아왔을 때 "잃어버린 것이 없다"는 보장이 도크 트레이로 표현됨. 팝아웃 소유 그룹은 이미 별도 OS 윈도우가 핸들 역할을 하므로 칩 자동 생성에서 제외.
3. **팝아웃 재할당은 양방향 합의로 처리**: ownership flip을 main이 silently 결정하면 popout은 자기가 reclaim 당했는지 모름. `group-reclaimed` 메시지를 main이 *먼저 post하고 나서* flip → popout이 자기 view를 정리하고 자살. WS 구독 해제가 React 컴포넌트 unmount에 묶여 있으므로 이 순서만 지켜지면 fan-out Set에서 자동으로 빠지고 중복 write가 사라짐.
4. **팝아웃 자살은 즉시가 아니라 1.5초 딜레이**: 사용자가 "이 창 왜 갑자기 닫혔지" 하지 않도록 짧은 안내 노티스를 보여줄 시간 확보. UX 메시지 손실 방지가 race fix의 부산물.
