Multi-adapter mobile UI for AI coding assistants. Supports Claude Code, Codex CLI, and Gemini CLI through one interface. Features: - Real-time bidirectional sync via tmux + WebSocket - Cross-AI review (send one AI's output to another for review) - Multi-review tabs with minimize/expand - Push notifications (PWA) with smart session-aware filtering - Three-channel event system (hooks, file watcher, pane monitor) - Voice input, image paste, draft persistence - Terminal-native design (JetBrains Mono, dark theme, pixel art claw) - CLI with --adapter flag on every command - Zero-overhead fire-and-forget hooks
26 KiB
Cross-AI Review Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Enable sending messages between CLI sessions (e.g., Claude to Codex) for cross-AI review, with a floating panel UI inside ChatView.
Architecture: Three phases: (1) backend infrastructure (DB, tmux, WS events, message IDs), (2) review session lifecycle (create, send-back, end, reconnect), (3) frontend UI (floating panel, action buttons, history view, remove old components).
Tech Stack: TypeScript, SQLite (better-sqlite3), tmux, React, WebSocket
Spec: docs/superpowers/specs/2026-03-23-cross-ai-review-design.md
Phase 1: Backend Infrastructure
Task 1: Add session_reviews DB table
Files:
-
Modify:
server/db.ts -
Step 1: Add CREATE TABLE in initDB()
In server/db.ts, inside initDB() after the existing CREATE TABLE statements (~line 58), add:
CREATE TABLE IF NOT EXISTS session_reviews (
id TEXT PRIMARY KEY,
parent_cli_session_id TEXT NOT NULL,
child_cli_session_id TEXT NOT NULL,
child_adapter TEXT NOT NULL,
anchor_message_id TEXT,
review_prompt TEXT,
review_title TEXT,
message_count INTEGER DEFAULT 0,
started_at TEXT DEFAULT (datetime('now')),
ended_at TEXT DEFAULT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id);
- Step 2: Add SessionReviewRow interface
After the SessionRow interface (~line 249):
export interface SessionReviewRow {
id: string;
parent_cli_session_id: string;
child_cli_session_id: string;
child_adapter: string;
anchor_message_id: string | null;
review_prompt: string | null;
review_title: string | null;
message_count: number;
started_at: string;
ended_at: string | null;
}
- Step 3: Add prepared statements to PreparedStatements interface and stmts()
Add five statements: reviewCreate, reviewGetById, reviewGetActiveForParent, reviewGetAllForParent, reviewGetAllChildIds, reviewEnd.
- Step 4: Add sessionReviews operations export
export const sessionReviews = {
create(id, parentCliId, childCliId, childAdapter, anchorMsgId?, prompt?, title?): void,
getById(reviewId): SessionReviewRow | undefined,
getActiveForParent(parentCliSessionId): SessionReviewRow[],
getAllForParent(parentCliSessionId): SessionReviewRow[],
getAllChildIds(): Set<string>,
endReview(reviewId, messageCount?): void,
updateChildCliId(internalId, cliId): void,
};
- Step 5: Verify DB loads without errors
Run: CLAUDE_UI_PASSWORD=test npx tsx server/index.ts
Expected: [db] SQLite database initialized with no errors.
- Step 6: Commit
git add server/db.ts
git commit -m "feat: add session_reviews DB table for cross-AI review tracking"
Task 2: Add pasteBuffer() to TmuxManager + pasteToSession() to IAdapter
Files:
-
Modify:
server/adapters/claude/tmux-manager.ts -
Modify:
server/adapters/interface.ts -
Modify:
server/adapters/claude/index.ts -
Modify:
server/adapters/claude/tmux-adapter.ts -
Modify:
server/adapters/codex/index.ts -
Modify:
server/adapters/codex/codex-tmux-adapter.ts -
Step 1: Add pasteBuffer method to TmuxManager
Add imports for writeFileSync, unlinkSync from fs and randomUUID from crypto. Then add after sendControl() (~line 48):
async pasteBuffer(windowId: string, content: string): Promise<void> {
const tmpFile = `/tmp/codetap-buf-${randomUUID()}.txt`;
writeFileSync(tmpFile, content);
const target = `${SESSION_NAME}:${windowId}`;
try {
await exec(TMUX, ['load-buffer', tmpFile]);
await exec(TMUX, ['paste-buffer', '-t', target]);
await exec(TMUX, ['send-keys', '-t', target, 'Enter']);
} finally {
try { unlinkSync(tmpFile); } catch {}
}
}
Note: exec here is the existing promisify(execFile) wrapper already in the file. Not child_process.exec.
- Step 2: Add
pasteToSession()to IAdapter interface
In server/adapters/interface.ts, add to the IAdapter class:
async pasteToSession(sessionId: string, content: string): Promise<void> {
throw new Error('Not implemented');
}
- Step 3: Implement in both adapters
In Claude's tmux-adapter.ts:
async pasteToSession(sessionId: string, content: string): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
await tmuxManager.pasteBuffer(session.windowId, content);
}
In Claude's index.ts, delegate: async pasteToSession(sid: string, content: string) { return this._tmux.pasteToSession(sid, content); }
In Codex's codex-tmux-adapter.ts, same pattern. In Codex's index.ts, delegate.
This keeps tmuxManager as an internal detail. server/index.ts only calls adapter.pasteToSession().
- Step 4: Commit
git add server/adapters/claude/tmux-manager.ts server/adapters/interface.ts server/adapters/claude/index.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/index.ts server/adapters/codex/codex-tmux-adapter.ts
git commit -m "feat: add pasteBuffer to TmuxManager and pasteToSession to IAdapter"
Task 3: Add WS event types
Files:
-
Modify:
server/ws-types.ts -
Modify:
src/lib/ws-types.ts -
Modify:
server/types/messages.ts -
Step 1: Add REVIEW_STARTED and REVIEW_ENDED to both ws-types files
In both server/ws-types.ts and src/lib/ws-types.ts, add to the WS object:
REVIEW_STARTED: 'review-started',
REVIEW_ENDED: 'review-ended',
- Step 2: Update ServerMessageType
In server/types/messages.ts, add | 'review-started' | 'review-ended' to ServerMessageType.
- Step 3: Commit
git add server/ws-types.ts src/lib/ws-types.ts server/types/messages.ts
git commit -m "feat: add REVIEW_STARTED and REVIEW_ENDED WS event types"
Task 4: Add deterministic message IDs to parsers
Files:
- Modify:
server/adapters/claude/transcript-parser.ts - Modify:
server/adapters/codex/transcript-parser.ts - Modify:
src/hooks/useChat.ts
Critical design note: IDs must be deterministic — the same JSONL entry must produce the same ID every time it's parsed (across reconnects, server restarts, page refreshes). Using randomUUID() would break anchor_message_id lookups in history.
Approach: Use a monotonic counter per parser instance. Each parser tracks an _entryIndex that increments for every message. The ID is msg-{entryIndex} (e.g., msg-0, msg-1, msg-5). Since JSONL is append-only, the same entries always produce the same indices.
- Step 1: Add
idfield to Claude's ParsedMessage interface
In server/adapters/claude/transcript-parser.ts (~line 15), add id: string to ParsedMessage.
- Step 2: Generate deterministic IDs in Claude parser
Add a counter private _msgIndex = 0 to the TranscriptParser class. In _parseUserEntry() and _parseAssistantEntry(), set id: \msg-${this._msgIndex++}`on each returned message. Reset counter in constructor or whenparse()` is called fresh for history.
- Step 3: Generate deterministic IDs in Codex parser
Same pattern in server/adapters/codex/transcript-parser.ts. Add counter, generate msg-{index} IDs. The ChatMessage type already has id?: string.
- Step 4: Thread IDs through useChat
In src/hooks/useChat.ts:
-
Add
id?: stringto the localChatMessagetype (~line 14) -
In
convertMessages()(~line 68), preserveidfrom incoming messages:{ id: msg.id, role: ..., content: ... } -
In the
MESSAGE_COMPLETEhandler, preserveidwhen converting messages -
In the
HISTORY_LOADhandler, preserveidwhen converting messages -
Step 5: Commit
git add server/adapters/claude/transcript-parser.ts server/adapters/codex/transcript-parser.ts src/hooks/useChat.ts
git commit -m "feat: add deterministic message IDs to parsers for stable anchor references"
Task 5: Filter child sessions from API endpoints
Files:
-
Modify:
server/index.ts -
Step 1: Import sessionReviews
Add import { sessionReviews } from './db.js'; at top.
- Step 2: Filter /api/sessions
After aggregating sessions from all adapters, filter out children:
const childIds = sessionReviews.getAllChildIds();
const filtered = allSessions.filter((s: any) => !childIds.has(s.sessionId));
res.json(filtered);
- Step 3: Filter /api/active-sessions
After building allActiveSessions, filter:
const childIds = sessionReviews.getAllChildIds();
const filtered = allActiveSessions.filter((s: any) => !childIds.has(s.cliSessionId));
res.json(filtered);
- Step 4: Commit
git add server/index.ts
git commit -m "feat: filter child review sessions from session list and active sessions"
Phase 2: Review Session Lifecycle
Task 6: Add review API endpoints
Files:
- Modify:
server/index.ts
All endpoints use authMiddleware, following the existing pattern.
- Step 1: Add POST /api/reviews
Creates a child CLI session, saves review to DB, pastes context, returns review metadata.
Key logic:
-
Check for existing active review (
sessionReviews.getActiveForParent) — return 409 if active -
Look up parent's
cwdfrom DB (dbSessions.findByCliSession) -
Call
adapter.startSession(cwd, { permissionMode: 'bypassPermissions' }) -
Codex UUID timing issue: For Claude,
cliSessionIdis available immediately afterstartSession(). For Codex, it's empty untilSessionStarthook fires. Workaround: Create thesession_reviewsrow with the internal session ID as a temporarychild_cli_session_id. Add a step in Codex'shandleSessionStarthook to update the review row once the real UUID is known. AddsessionReviews.updateChildCliId(reviewId, newCliId)method. -
Paste context via
adapter.pasteToSession(childSessionId, context)(NOT tmuxManager directly) -
Context truncation: cap at last 50 messages or 30KB, whichever is smaller
-
Return
{ reviewId, childSessionId, childCliSessionId, childAdapter } -
Step 2: Add DELETE /api/reviews/:id
Ends review: sets ended_at, destroys child tmux window, broadcasts REVIEW_ENDED.
Key logic:
-
sessionReviews.getById(reviewId)to find the review -
sessionReviews.endReview(reviewId) -
Find child adapter via
getAdapter(review.child_adapter) -
Resolve child CLI UUID to internal ID, call
adapter.destroySession(childSessionId) -
Broadcast
REVIEW_ENDEDto parent session clients -
Step 3: Add POST /api/reviews/:id/send-back
Sends a child message back to parent.
Key logic:
-
Look up review, find parent session via
dbSessions.findByCliSession(review.parent_cli_session_id) -
Resolve parent internal ID from DB row
-
Guard: check
adapter.isProcessing(parentInternalId)— return 409 if busy with toast message -
Format message:
[Review feedback from {childAdapter}]:\n{message} -
Call
parentAdapter.pasteToSession(parentInternalId, formatted)(NOT tmuxManager directly) -
Step 4: Add GET /api/reviews
Returns reviews for a parent session (for history rendering):
GET /api/reviews?parentCliSessionId=xxx
Returns SessionReviewRow[] from sessionReviews.getAllForParent(parentCliSessionId).
- Step 5: Add sessionReviews.updateChildCliId() to db.ts
For the Codex UUID timing issue:
updateChildCliId(internalId: string, cliId: string): void {
stmts().reviewUpdateChildCliId.run(cliId, internalId);
}
With prepared statement: UPDATE session_reviews SET child_cli_session_id = ? WHERE child_cli_session_id = ?
- Step 6: Commit
git add server/index.ts server/db.ts
git commit -m "feat: add review API endpoints (create, end, send-back, list)"
Task 7: Broadcast review events, reconnect, cascade cleanup, push suppression
Files:
-
Modify:
server/session-manager.ts -
Step 1: Import sessionReviews and add broadcast helpers
import { sessionReviews } from './db.js';
Add broadcastReviewStarted() and broadcastReviewEnded() helper functions that call the existing broadcast() function with WS.REVIEW_STARTED / WS.REVIEW_ENDED payloads.
Export them so server/index.ts can call them from review API endpoints.
- Step 2: Add child session restore to handleReconnect
After existing reconnect logic, query sessionReviews.getActiveForParent(cliSessionId). For each active child:
-
Resolve child CLI UUID to internal ID
-
If session not managed: call
resumeSessionto recreate tmux window -
Send
REVIEW_STARTEDevent to the reconnecting client -
Step 3: Add cascade cleanup on parent session destruction
In setupSessionManager(), inside the session-ended event handler for each adapter, add:
adapter.on('session-ended', (sessionId: string) => {
// existing: broadcast SESSION_ENDED, clean up maps
// NEW: cascade-end any active child reviews
const session = adapter.getSession(sessionId) as { cliSessionId?: string } | null;
const parentCliId = session?.cliSessionId;
if (parentCliId) {
const activeChildren = sessionReviews.getActiveForParent(parentCliId);
for (const child of activeChildren) {
sessionReviews.endReview(child.id);
const childAdapter = getAdapter(child.child_adapter);
if (childAdapter) {
const childInternalId = /* resolve from child_cli_session_id */;
childAdapter.destroySession(childInternalId).catch(() => {});
}
broadcast(sessionId, { type: WS.REVIEW_ENDED, reviewId: child.id });
}
}
});
- Step 4: Suppress push notifications for child sessions
In the triggerPush() function at the top of session-manager.ts, add a guard:
function triggerPush(adapter: IAdapter, sessionId: string, opts: PushOptions): void {
// existing: skip if clients connected
// NEW: skip push for child review sessions
const session = adapter.getSession(sessionId) as { cliSessionId?: string } | null;
if (session?.cliSessionId && sessionReviews.getAllChildIds().has(session.cliSessionId)) return;
// existing push logic...
}
- Step 5: Commit
git add server/session-manager.ts
git commit -m "feat: review events, reconnect restore, cascade cleanup, push suppression"
Phase 3: Frontend UI
Task 8: Add review API methods and state to frontend
Files:
-
Modify:
src/lib/api.ts -
Modify:
src/hooks/useChat.ts -
Step 1: Add review API methods to api.ts
Add createReview, endReview, sendBackToParent, getReviews methods.
- Step 2: Add review state to useChat
Add activeReview state (object with reviewId, childSessionId, childAdapter, etc.) and reviewPanelState ('expanded'|'minimized'|'hidden').
- Step 3: Handle REVIEW_STARTED and REVIEW_ENDED in WS handler
In the handleWsMessage switch, add cases for WS.REVIEW_STARTED (set activeReview + expand panel) and WS.REVIEW_ENDED (clear activeReview + hide panel).
- Step 4: Remove old crossAdapterFlow state, methods, and imports
Remove from useChat.ts:
import { getQuickCommand } from '../lib/quick-commands'(line 5) — this file will be deleted in Task 13CrossAdapterFlowStatetype and exportcrossAdapterFlowstate andcrossAdapterFlowRefstartCrossAdapterFlowandcompleteCrossAdapterFlowcallbacks- The
crossAdapterFlowcheck inTURN_COMPLETEhandler - All related entries in the return statement
Note: Task 13 Step 6 deletes quick-commands.ts. If this import isn't removed first, the build breaks.
-
Step 5: Export new review state and actions
-
Step 6: Commit
git add src/lib/api.ts src/hooks/useChat.ts
git commit -m "feat: add review API methods and state to useChat, remove crossAdapterFlow"
Task 9: Add action buttons to MessageBubble
Files:
-
Modify:
src/components/MessageBubble.tsx -
Step 1: Add props for messageId, showActions, otherAdapterName, onSendTo
-
Step 2: Render Copy and "Send to [Adapter]" buttons after assistant message content
Only show when showActions && !isStreaming && otherAdapterName is truthy.
- Step 3: Commit
git add src/components/MessageBubble.tsx
git commit -m "feat: add Copy and Send-to action buttons to MessageBubble"
Task 10: Create ReviewActionMenu component
Files:
-
Create:
src/components/ReviewActionMenu.tsx -
Step 1: Create component
Modal overlay with 4 options: Direct send, Code Review, Suggest alternatives, Custom instruction.
Custom shows an inline text input.
Props: visible, adapterName, onSelect(templateId, customPrompt?), onClose.
- Step 2: Commit
git add src/components/ReviewActionMenu.tsx
git commit -m "feat: create ReviewActionMenu for prompt template selection"
Task 11: Create FloatingReviewPanel component
Files:
-
Create:
src/components/FloatingReviewPanel.tsx -
Step 1: Create component
Key details:
-
Uses its own
useChat(childSessionId, undefined, childAdapter)hook instance -
Three states: expanded (55% height), minimized (pill button), hidden
-
Shows child session messages with MessageBubble (including "Send to [Parent]" buttons)
-
Has ShimmerInput for user input to child session
-
Header shows adapter brand color, review title, End button
-
End button calls
onEndcallback -
Step 2: Commit
git add src/components/FloatingReviewPanel.tsx
git commit -m "feat: create FloatingReviewPanel with independent useChat"
Task 12: Create CollapsedReviewCard and BlockMarker
Files:
-
Create:
src/components/CollapsedReviewCard.tsx -
Create:
src/components/BlockMarker.tsx -
Step 1: Create BlockMarker
Simple divider line with a centered label pill. Props: label, color.
- Step 2: Create CollapsedReviewCard
Card showing adapter name, title, message count, summary. Props: adapter, title, messageCount, summary, onClick. Uses adapter brand colors.
- Step 3: Commit
git add src/components/CollapsedReviewCard.tsx src/components/BlockMarker.tsx
git commit -m "feat: create CollapsedReviewCard and BlockMarker components"
Task 13: Integrate into ChatView + remove old components
Files:
-
Modify:
src/components/ChatView.tsx -
Delete:
src/components/QuickActionCards.tsx -
Delete:
src/components/CrossAdapterFlow.tsx -
Delete:
src/lib/quick-commands.ts -
Step 1: Remove old imports and JSX
Remove QuickActionCards, CrossAdapterFlow imports and their JSX. Remove crossAdapterFlow related destructuring from useChat.
- Step 2: Add new imports
Import FloatingReviewPanel, ReviewActionMenu, CollapsedReviewCard, BlockMarker.
- Step 3: Fetch review history on mount
On mount (and on cliSessionId change), fetch reviews for this session:
const [reviews, setReviews] = useState<SessionReviewRow[]>([]);
useEffect(() => {
if (!cliSessionId) return;
api.getReviews(cliSessionId).then(setReviews).catch(() => {});
}, [cliSessionId]);
Build a lookup map for rendering:
const reviewsByAnchor = useMemo(() => {
const map = new Map<string, SessionReviewRow>();
for (const r of reviews) {
if (r.anchor_message_id) map.set(r.anchor_message_id, r);
}
return map;
}, [reviews]);
- Step 4: Add review trigger logic
Add reviewMenuMessageId state, handleSendTo callback (opens menu for a message), handleReviewSelect callback (calls api.createReview with context built from messages).
Context building: slice messages up to anchor, format as text with 50-message / 30KB cap. Append highlighted message and prompt template.
- Step 5: Render messages with block markers and review cards
In the messages.map() loop, after rendering each message, check if it's an anchor:
{messages.map((msg, i) => (
<React.Fragment key={msg.id || i}>
<MessageBubble ... />
{msg.id && reviewsByAnchor.has(msg.id) && (() => {
const review = reviewsByAnchor.get(msg.id)!;
return (
<>
<BlockMarker label={`${getBrand(review.child_adapter).displayName} ${review.review_title || 'Review'} started`} color={getBrand(review.child_adapter).color} />
<CollapsedReviewCard
adapter={review.child_adapter}
title={review.review_title}
messageCount={0} // TODO: fetch from child JSONL
summary="Tap to view review conversation"
onClick={() => { /* open read-only panel */ }}
/>
{review.ended_at && (
<BlockMarker label="Review ended" color={getBrand(review.child_adapter).color} />
)}
</>
);
})()}
</React.Fragment>
))}
- Step 6: Add new components to JSX (FloatingReviewPanel + ReviewActionMenu)
Replace old QuickActionCards / CrossAdapterFlow with FloatingReviewPanel (conditional on activeReview) and ReviewActionMenu (conditional on reviewMenuMessageId).
- Step 7: Pass action props to MessageBubble
Add messageId, showActions, otherAdapterName, onSendTo props. Only show actions when availableAdapters.length > 1.
- Step 8: Delete old files
rm src/components/QuickActionCards.tsx src/components/CrossAdapterFlow.tsx src/lib/quick-commands.ts
- Step 9: Commit
git add -A
git commit -m "feat: integrate Cross-AI Review into ChatView, remove old QuickActionCards"
Edge Cases Handled in Plan
The following edge cases were identified and addressed across the tasks above:
Session State Edge Cases
- Active review should NOT show CollapsedReviewCard (Task 13 Step 5): When rendering, check
review.ended_at— only show collapsed card for ended reviews. Active reviews are shown via the floating panel, not inline. - Multiple reviews on same anchor message (Task 13 Step 3): Use
Map<string, SessionReviewRow[]>(array), notMap<string, SessionReviewRow>, to support multiple reviews anchored to the same message. - CollapsedReviewCard message count (Task 1): Add
message_count INTEGER DEFAULT 0column tosession_reviews. Set it when the review ends (endReviewmethod also stores the count). Avoids needing to read child JSONL at render time.
User Action Edge Cases
- 409 when review already active (Task 13 Step 4):
handleReviewSelectshould catch 409 fromapi.createReview(), show a confirmation dialog "End current review and start new one?", and if confirmed, callendReviewthen retry. - "Send to Parent" while parent is busy (Task 11): Pass
parentStreamingstate as a prop toFloatingReviewPanel. Disable the "Send to Parent" button whenparentStreamingis true. The parent'suseChatstreamingstate is available in ChatView and can be passed down. - Input focus confusion (Task 11): Use distinct placeholder text ("Message Claude..." vs "Message Codex reviewer...") and border colors on the two input fields to prevent accidentally typing in the wrong one.
Connection/Lifecycle Edge Cases
- Stale review (server restarted, review never ended) (Task 7 Step 2): During reconnect, if the child CLI UUID cannot be resolved AND no tmux window exists, mark the review as ended (
sessionReviews.endReview(review.id)) instead of trying to resume. Show it as a collapsed card. - Parent tmux crashes → cascade cleanup timing (Task 7 Step 3): The
session-endedevent fires AFTERsessions.delete(), soadapter.getSession()returns null. SavecliSessionIdBEFORE the session is deleted by looking it up in DB (dbSessions.findByCliSession) as a fallback. - Codex UUID window (Task 6 Step 5): Between
startSession()andhandleSessionStarthook, the child CLI UUID is unknown. During this brief window (~1-3 seconds), the child session may appear in session list. Accept this as a known limitation for v1 — the cleanup interval will correct it. - Child tmux crashes during review (Task 7 Step 3): Add the same
session-endedcascade handler for child sessions — when a child session ends unexpectedly, mark the review as ended and broadcastREVIEW_ENDED.
History Edge Cases
- Keep reviews state in sync via WS (Task 8 Step 3): On
REVIEW_STARTED, append to localreviewsstate array. OnREVIEW_ENDED, update the matching review'sended_at. Avoid re-fetching from API on every event. - Anchor message compacted away (Task 13 Step 5): If the anchor message ID is not found in the rendered messages, render the review card at the END of the message list as a fallback (with a note "Original message no longer available").
Multi-Client Edge Cases
- Two tabs open (Task 7 Step 1):
broadcastReviewStartedandbroadcastReviewEndedare broadcast to ALL clients on the parent session. Both tabs receive the events and update independently. Tab A gets the API response directly; Tab B gets the WS event. Both converge.
Verification
After all tasks:
CLAUDE_UI_PASSWORD=test npm run dev- Open
http://localhost:5173 - Open a Claude session -- assistant messages show "Copy" and "Send to Codex" buttons
- Tap "Send to Codex" -- ReviewActionMenu appears with template options
- Select "Code Review" -- floating panel opens, Codex session starts, context pasted
- Chat with Codex in floating panel -- messages appear with streaming
- Tap "Send to Claude" on a Codex response -- content injected into Claude's tmux
- Minimize panel -- pill button appears, tap to re-expand
- End review -- panel disappears, tmux window killed
- Session list -- child session NOT visible in project sessions or active sessions
- Reconnect -- refresh page, active review panel restores
- Scroll history -- block markers and collapsed review card visible at anchor position