## 2026-06-10 — 세션 윈도우 도킹 확장: 팝아웃 내부 스플릿 + 크로스 프로젝트 re-dock + OS 창 간 탭 드래그

### 배경

팝아웃(별도 OS 창)으로 빼낸 터미널은 도킹이 전혀 안 됐다 — 창 안에서 탭을 끌어 스플릿을 만들 수도 없고, 두 팝아웃을 합칠 수도 없었다. 또 팝아웃 통신 채널이 프로젝트별이라 다른 프로젝트를 보는 중에 Re-dock하면 그룹이 받을 사람 없이 사라졌고, 서로 다른 프로젝트의 터미널을 한 그룹으로 묶는 것도 불가능했다.

### 주요 변경

#### 1. 팝아웃 내부 탭 드래그 도킹 (`da75c8b`)

- **클라이언트**: 팝아웃 안에서 탭을 드래그하면 메인 창과 동일한 독 오버레이(다이아몬드 5존)가 뜨고, 좌/우/상/하 드롭 시 스플릿, 가운데 드롭 시 탭 합치기.
- 메인과 달리 **자기 스택의 가장자리에 드롭해 스플릿 생성을 허용** (탭 2개 이상일 때) — 팝아웃엔 플로팅 창 중간 단계가 없어 이것이 첫 스플릿을 만드는 유일한 제스처.
- `groupTree.dockTab()` 신설: 앵커 기반 경로 재계산(탭 제거로 사이드 스플릿이 접혀도 안전). 유닛 테스트 7개 추가.

#### 2. 크로스 프로젝트 re-dock (`da75c8b`)

- `popoutBus` 채널을 프로젝트별 → **앱 전역**으로 변경. 다른 프로젝트를 보는 중에 팝아웃을 Re-dock하면 현재 보고 있는 워크스페이스가 그룹을 **입양**하고, 원 프로젝트의 persisted 항목을 제거해 중복 부활을 방지한다. 이것이 서로 다른 프로젝트의 터미널을 한 그룹으로 도킹하는 경로가 된다.
- **호스트의 foreign session 처리**: 자기 프로젝트 목록에 없는 그룹 멤버를 `GET /api/sessions/:id` 단건 조회로 해석 — 존재하면 `foreignSessions`로 렌더/유지, 404일 때만 prune. 목록 부재만으로는 더 이상 prune하지 않아 네트워크 블립이 그룹을 날리지 못한다. 타 프로젝트 세션의 상태 변화는 전역 WS 이벤트로 반영.

#### 3. OS 창 간 탭 드래그 도킹 (`3cf5ef3`)

- DOM 마우스 이벤트는 OS 창을 넘지 못하지만, 버튼을 누른 채면 소스 창이 창 밖에서도 screen 좌표로 mousemove를 계속 받는 점(메인 tear-out이 의존하는 캡처 동작)을 이용해 **버스 중계 프로토콜**을 추가:
  - `dock-probe`: 드래그 커서의 screen 좌표 브로드캐스트 (~30/s)
  - `dock-probe-result`: 수신 창이 자기 스택 명중 여부 + 최근 포커스 시각 회신 — 겹친 창은 가장 최근 포커스된 창 우선
  - `dock-commit` → `dock-commit-ack`: **수락 ack를 받은 경우에만** 소스가 자기 탭을 제거 — 메시지 유실/수신자 사망으로 세션이 증발하지 않음
- 수신자는 팝아웃과 메인 양쪽: `screenToClient` 근사(outer/inner 크기 델타)로 좌표 변환 후 공용 `hitTestStackAt`으로 존 판정·오버레이 표시. 이미 같은 세션을 가진 창은 거절해 동일 PTY 이중 구독을 차단. 마지막 탭을 보내면 소스 팝아웃은 `group-close` 후 자기 창을 닫는다.
- 한계: 메인 창 탭 → 팝아웃 방향은 미지원(기존 tear-out이 먼저 발동), 모니터별 DPI가 다른 환경에선 존 판정이 어긋날 수 있음.

### 수정된 주요 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/client/src/components/group/groupTree.ts` | `dockTab()` — 같은 트리 내 앵커 기반 도킹 |
| `src/client/src/components/popout/popoutBus.ts` | 채널 전역화, `group-return`에 projectId, `dock-*` 메시지 5종, `screenToClient` 헬퍼 |
| `src/client/src/components/popout/PopoutPage.tsx` | 탭 드래그 도킹(송신) + dock 수신 + foreign session fetch |
| `src/client/src/components/SessionWindowsHost.tsx` | 크로스 프로젝트 입양, foreign session 조회/prune 재설계, dock 수신 |
| `src/client/src/components/group/DockOverlay.tsx` | 공용 `hitTestStackAt` |
| `src/client/src/api/sessions.ts` | `getSession(id)` 단건 조회 |

### 아키텍처 결정

1. **전역 버스 + groupId 필터링**: 메인은 한 번에 하나의 호스트만 마운트되므로 채널을 전역화해도 메시지 충돌이 없고, 호스트가 자기 그룹 id로 필터링하면 충분하다. 이로써 "현재 보는 창이 그룹을 받는다"는 크로스 프로젝트 시맨틱이 자연히 성립한다.
2. **ack 기반 탭 이관**: 창 간 이동은 두 프로세스의 상태 변경이므로, 수신 확인 전에 소스가 탭을 지우면 유실 위험이 있다. commit→ack 2단계로 "최악의 경우에도 탭이 원래 자리에 남는" 쪽으로 실패하게 했다.
3. **404만 prune**: 세션이 자기 프로젝트 목록에 없다는 것만으로는 삭제 근거가 못 된다(크로스 프로젝트 멤버일 수 있음). 단건 조회 404를 유일한 삭제 신호로 삼아 일시적 네트워크 오류에 견디게 했다.
