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 v2 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: Fix review-ended marker position, support multi-review with tabbed panel UI, and improve send-to UX when active reviews exist.
Architecture: Convert activeReview (single object) to activeReviews (array) throughout useChat and ChatView. Split review markers into start-anchor and end-anchor maps. Add "send to existing review" path in the send-to flow. Refactor FloatingReviewPanel to render tabs for multiple reviews with independent useChat hooks per tab.
Tech Stack: React, TypeScript, SQLite (better-sqlite3), WebSocket, Tailwind CSS
Spec: docs/superpowers/specs/2026-03-26-cross-ai-review-v2-design.md
File Map
| File | Action | Responsibility |
|---|---|---|
server/db.ts |
Modify | Add end_anchor_message_id column, update endReview() signature |
server/index.ts |
Modify | Pass endAnchorMessageId to endReview() from DELETE handler |
src/hooks/useChat.ts |
Modify | activeReview → activeReviews (array), update WS handlers |
src/components/ChatView.tsx |
Modify | Split marker maps, new send-to-existing flow, multi-review state wiring |
src/components/FloatingReviewPanel.tsx |
Modify → Rename to ReviewPanelManager.tsx |
Manage array of child chats, render tabs, minimize/expand |
src/components/ReviewActionMenu.tsx |
Modify | Add "send to existing review" options when active reviews exist |
src/components/SendToExistingSheet.tsx |
Create | Simple bottom sheet for "send to active review" quick action |
src/index.css |
Modify | Add review panel textarea font-size override |
src/lib/api.ts |
Modify | Update endReview() to accept endAnchorMessageId param |
Task 1: DB Schema — Add end_anchor_message_id Column
Files:
-
Modify:
server/db.ts:48-60(CREATE TABLE),server/db.ts:206-218(SessionReviewRow type),server/db.ts:325-328(endReview method) -
Step 1: Add column to CREATE TABLE
In server/db.ts, add end_anchor_message_id TEXT DEFAULT NULL after the ended_at line in the CREATE TABLE statement (around line 59):
ended_at TEXT DEFAULT NULL,
end_anchor_message_id TEXT DEFAULT NULL
- Step 2: Update SessionReviewRow type
In the SessionReviewRow interface (around line 206), add:
end_anchor_message_id: string | null;
- Step 3: Update endReview() to accept endAnchorMessageId
Replace the endReview method (lines 325-328) with:
endReview(id: string, messageCount = 0, endAnchorMessageId?: string): void {
this.db.prepare(
`UPDATE session_reviews SET ended_at = datetime('now'), message_count = ?, end_anchor_message_id = ? WHERE id = ?`
).run(messageCount, endAnchorMessageId || null, id);
}
- Step 4: Run TypeScript check
Run: npx tsc --noEmit 2>&1 | grep db.ts
Expected: No errors in db.ts
- Step 5: Commit
git add server/db.ts
git commit -m "feat(db): add end_anchor_message_id to session_reviews"
Task 2: Server API — Pass endAnchorMessageId on Review End
Files:
-
Modify:
server/index.ts:284-308(DELETE /api/reviews/:id) -
Step 1: Update DELETE handler to accept endAnchorMessageId from request body
In server/index.ts, update the DELETE endpoint (around line 284). Express DELETE can have a body. Read endAnchorMessageId from req.body:
app.delete('/api/reviews/:id', authMiddleware, async (req: Request, res: Response) => {
try {
const review = sessionReviews.getById(req.params.id);
if (!review) return res.status(404).json({ error: 'Review not found' });
const { endAnchorMessageId } = req.body || {};
sessionReviews.endReview(review.id, 0, endAnchorMessageId);
broadcastReviewEnded(review.parent_cli_session_id, review.id);
const childAdapter = getAdapter(review.child_adapter);
if (childAdapter) {
try {
await childAdapter.destroySession(review.child_cli_session_id);
} catch (err) {
console.error('[review] Failed to destroy child session:', (err as Error).message);
}
}
res.json({ ok: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
- Step 2: Update frontend api.ts endReview() to send endAnchorMessageId
In src/lib/api.ts, find the endReview function and update it to accept and send endAnchorMessageId:
endReview: (reviewId: string, endAnchorMessageId?: string) =>
request(`/api/reviews/${reviewId}`, {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endAnchorMessageId }),
}),
- Step 3: TypeScript check
Run: npx tsc --noEmit 2>&1 | grep -E "index.ts|api.ts" | head -5
Expected: No new errors
- Step 4: Commit
git add server/index.ts src/lib/api.ts
git commit -m "feat(api): pass endAnchorMessageId when ending review"
Task 3: useChat — Convert activeReview to activeReviews Array
Files:
-
Modify:
src/hooks/useChat.ts:129-136(state),src/hooks/useChat.ts:293-307(WS handlers), return object -
Step 1: Define the ReviewInfo type and change state from single to array
Replace the activeReview state (lines 129-136) with:
export interface ReviewInfo {
reviewId: string;
childSessionId: string;
childCliSessionId: string;
childAdapter: string;
anchorMessageId?: string;
reviewTitle?: string;
}
const [activeReviews, setActiveReviews] = useState<ReviewInfo[]>([]);
- Step 2: Update REVIEW_STARTED handler to push to array
Replace the WS.REVIEW_STARTED case (lines 293-303):
case WS.REVIEW_STARTED:
setActiveReviews(prev => {
if (prev.some(r => r.reviewId === msg.reviewId)) return prev;
return [...prev, {
reviewId: msg.reviewId,
childSessionId: msg.childSessionId,
childCliSessionId: msg.childCliSessionId,
childAdapter: msg.childAdapter,
anchorMessageId: msg.anchorMessageId,
reviewTitle: msg.reviewTitle,
}];
});
setActiveReviewPanel('expanded');
break;
- Step 3: Update REVIEW_ENDED handler to remove from array
Replace the WS.REVIEW_ENDED case (lines 305-307):
case WS.REVIEW_ENDED:
setActiveReviews(prev => prev.filter(r => r.reviewId !== msg.reviewId));
break;
- Step 4: Update the return object
In the return statement, replace activeReview, setActiveReview with activeReviews, setActiveReviews. Keep activeReviewPanel, setActiveReviewPanel unchanged.
- Step 5: TypeScript check — expect errors in ChatView (will fix in Task 4)
Run: npx tsc --noEmit 2>&1 | grep -c "error"
Expected: Errors in ChatView.tsx and FloatingReviewPanel.tsx (they still reference activeReview)
- Step 6: Commit
git add src/hooks/useChat.ts
git commit -m "refactor: activeReview → activeReviews array in useChat"
Task 4: ChatView — Wire Up Multi-Review State + Fix Marker Position
Files:
-
Modify:
src/components/ChatView.tsx(multiple sections) -
Step 1: Update destructuring from useChat
Replace activeReview, setActiveReview with activeReviews, setActiveReviews in the useChat destructuring (around line 141).
- Step 2: Replace the reviews sync useEffect
Replace the prevActiveReviewRef / useEffect([activeReview]) block (lines 202-222) with a multi-review version:
const prevActiveReviewsRef = useRef(activeReviews);
useEffect(() => {
const prevIds = new Set(prevActiveReviewsRef.current.map(r => r.reviewId));
const currIds = new Set(activeReviews.map(r => r.reviewId));
// New reviews added — merge into reviews state
for (const review of activeReviews) {
if (!review.reviewId) continue; // skip placeholders
if (!prevIds.has(review.reviewId)) {
setReviews(prev => {
if (prev.some(r => r.id === review.reviewId)) return prev;
const cleaned = prev.filter(r => r.id); // remove placeholders
return [...cleaned, {
id: review.reviewId,
child_adapter: review.childAdapter,
anchor_message_id: review.anchorMessageId,
review_title: review.reviewTitle,
ended_at: null,
end_anchor_message_id: null,
}];
});
}
}
// Reviews removed — re-fetch from server to get ended_at + end_anchor_message_id
for (const prevId of prevIds) {
if (!currIds.has(prevId)) {
if (sessionId) {
api.getReviews(sessionId).then(setReviews).catch(() => {});
}
break; // one fetch is enough
}
}
prevActiveReviewsRef.current = activeReviews;
}, [activeReviews, sessionId]);
- Step 3: Split reviewsByAnchor into start and end maps
Replace the reviewsByAnchor useMemo (lines 229-239):
const { startMarkersByAnchor, endMarkersByAnchor } = useMemo(() => {
const startMap = new Map<string, any[]>();
const endMap = new Map<string, any[]>();
for (const r of reviews) {
if (r.anchor_message_id) {
const existing = startMap.get(r.anchor_message_id) || [];
existing.push(r);
startMap.set(r.anchor_message_id, existing);
}
if (r.ended_at) {
// Use end_anchor_message_id if available, fall back to anchor_message_id
// (for reviews ended before this feature was added)
const endKey = r.end_anchor_message_id || r.anchor_message_id;
if (endKey) {
const existing = endMap.get(endKey) || [];
existing.push(r);
endMap.set(endKey, existing);
}
}
}
return { startMarkersByAnchor: startMap, endMarkersByAnchor: endMap };
}, [reviews]);
- Step 4: Update renderReviewMarkers to use split maps
Replace the renderReviewMarkers callback (lines 283-312):
const renderReviewMarkers = useCallback((messageId: string, _index: number): React.ReactNode => {
const startReviews = startMarkersByAnchor.get(messageId);
const endReviews = endMarkersByAnchor.get(messageId);
if (!startReviews && !endReviews) return null;
return (
<>
{startReviews?.map((review: any) => (
<Fragment key={`start-${review.id}`}>
<BlockMarker
label={`${getBrand(review.child_adapter).displayName} ${review.review_title || 'Review'} started`}
color={getBrand(review.child_adapter).color}
/>
{review.ended_at ? (
<CollapsedReviewCard
adapter={review.child_adapter}
title={review.review_title}
summary="Tap to view review conversation"
onClick={() => handleOpenReadOnlyReview(review)}
/>
) : (
<BlockMarker
label={`${getBrand(review.child_adapter).displayName} Review in progress...`}
color={getBrand(review.child_adapter).color}
/>
)}
</Fragment>
))}
{endReviews?.map((review: any) => (
<BlockMarker
key={`end-${review.id}`}
label="Review ended"
color={getBrand(review.child_adapter).color}
/>
))}
</>
);
}, [startMarkersByAnchor, endMarkersByAnchor, handleOpenReadOnlyReview]);
- Step 5: Update closeReview to pass endAnchorMessageId
Replace the closeReview callback (lines 180-188):
const closeReview = useCallback(async (reviewId?: string) => {
const targetId = reviewId || activeReviews[0]?.reviewId;
if (!targetId) return;
// Find last message ID in parent chat for end marker positioning
const lastMsg = messages[messages.length - 1];
const endAnchorMessageId = lastMsg?.id || undefined;
try { await api.endReview(targetId, endAnchorMessageId); } catch {}
setActiveReviews(prev => prev.filter(r => r.reviewId !== targetId));
setHistoryReview(null);
setReviewInitialPrompt(null);
setReviewCwd(null);
}, [activeReviews, messages]);
- Step 6: Update openReview to push placeholder to array
Replace the openReview callback (around lines 247-260). Instead of setActiveReview({...}), push to the array:
const openReview = useCallback((adapter: string, model: string, prompt: string, title: string) => {
const anchorId = reviewMenuMessageId;
setReviewMenuMessageId(null);
if (!anchorId) return;
patchAdapterPrefs(adapter, { model });
setHistoryReview(null);
setActiveReviews(prev => [...prev, {
reviewId: '', childSessionId: '', childCliSessionId: '',
childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: title,
}]);
setReviewInitialPrompt(prompt);
setReviewCwd(cwd || null);
setActiveReviewPanel('expanded');
}, [reviewMenuMessageId, cwd]);
- Step 7: TypeScript check
Run: npx tsc --noEmit 2>&1 | grep ChatView
Expected: May have errors related to FloatingReviewPanel props (fixed in Task 5)
- Step 8: Commit
git add src/components/ChatView.tsx
git commit -m "feat: multi-review state, split start/end markers in ChatView"
Task 5: ReviewPanelManager — Tabbed Multi-Review Panel
Files:
-
Modify:
src/components/FloatingReviewPanel.tsx→ heavy refactor (rename conceptually to ReviewPanelManager) -
Modify:
src/components/ChatView.tsx(update the FloatingReviewPanel usage) -
Step 1: Refactor FloatingReviewPanel to accept an array of reviews
Update the props interface in FloatingReviewPanel.tsx:
interface ReviewPanelProps {
reviews: {
reviewId: string;
childSessionId: string;
childAdapter: string;
reviewTitle?: string;
}[];
onEnd: (reviewId: string) => void;
onMinimize: () => void;
initialPrompt?: string; // only for the latest (newly created) review
cwd?: string;
onSessionCreated?: (childSessionId: string) => void;
onSendToReview?: (reviewId: string, text: string) => void;
}
- Step 2: Implement tabbed panel with per-review useChat
The component needs one useChat hook per review. Since React hooks can't be called conditionally, use a child component pattern — create a ReviewTab component that each renders its own useChat:
function ReviewTab({ review, cwd, initialPrompt, onSessionCreated, isActive, onSendBack }: {
review: ReviewPanelProps['reviews'][0];
cwd?: string;
initialPrompt?: string;
onSessionCreated?: (sid: string) => void;
isActive: boolean;
onSendBack?: (text: string) => void;
}) {
const {
messages, streaming, liveStatus, toolStatuses,
sendMessage, abort, sessionId: chatSessionId,
} = useChat(
review.childSessionId || undefined,
cwd,
review.childAdapter,
initialPrompt,
);
// Notify parent when child session is created
useEffect(() => {
if (chatSessionId && !review.childSessionId && onSessionCreated) {
onSessionCreated(chatSessionId);
}
}, [chatSessionId, review.childSessionId, onSessionCreated]);
// Expose sendMessage to parent for "send to existing review"
const sendRef = useRef(sendMessage);
sendRef.current = sendMessage;
// IMPORTANT: Do NOT return null — hooks must stay mounted.
// Hide inactive tabs with CSS instead of unmounting.
// The outer div controls visibility.
const brand = getBrand(review.childAdapter);
return (
<ChatBody
messages={messages}
streaming={streaming}
liveStatus={liveStatus}
toolStatuses={toolStatuses || new Map()}
onSend={sendMessage}
onStop={abort}
disabled={false}
interrupted={false}
onSendBack={onSendBack ? (msgId: string) => {
const msg = messages.find(m => m.id === msgId);
if (msg) onSendBack(extractTextFromBlocks(msg.content));
} : undefined}
inputPlaceholder={`Reply to ${brand.displayName} review...`}
className="flex-1"
/>
);
}
Important: Each ReviewTab must always render (to keep hooks alive). Wrap each in a div with style={{ display: isActive ? 'flex' : 'none' }} so inactive tabs are hidden but hooks stay mounted. Do NOT conditionally return null — that unmounts the hook and loses the child session's WS connection.
- Step 3: Implement the outer panel with tab bar and minimize
The outer FloatingReviewPanel component renders:
- Handle bar (click to minimize)
- Tab bar (if multiple reviews) with ▼ minimize button, or single-review header
- Active tab's
ReviewTabcomponent - Hidden inactive tabs (hooks stay alive)
Key structure:
export function FloatingReviewPanel({ reviews, onEnd, onMinimize, initialPrompt, cwd, onSessionCreated }: ReviewPanelProps) {
const [activeTabIndex, setActiveTabIndex] = useState(reviews.length - 1);
// ... tab bar rendering + ReviewTab for each review
}
- Step 4: Update ChatView to pass reviews array to FloatingReviewPanel
In ChatView, replace the single FloatingReviewPanel render with the new array-based version. Filter out placeholder reviews (reviewId === ''):
{activeReviewPanel === 'expanded' && activeReviews.length > 0 && (
<FloatingReviewPanel
reviews={activeReviews.filter(r => r.reviewId || r === activeReviews[activeReviews.length - 1])}
onEnd={(reviewId) => closeReview(reviewId)}
onMinimize={() => setActiveReviewPanel('minimized')}
initialPrompt={reviewInitialPrompt || undefined}
cwd={reviewCwd || undefined}
onSessionCreated={onSessionCreatedCallback}
/>
)}
- Step 5: Implement minimized bar for multi-review
When activeReviewPanel === 'minimized', render the combined minimized bar:
{activeReviewPanel === 'minimized' && activeReviews.filter(r => r.reviewId).length > 0 && (
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border cursor-pointer hover:bg-white/5"
onClick={() => setActiveReviewPanel('expanded')}>
{activeReviews.filter(r => r.reviewId).map(r => (
<span key={r.reviewId} className="w-1.5 h-1.5 rounded-full" style={{ background: getBrand(r.childAdapter).color }} />
))}
<span className="text-xs text-text-dim flex-1 ml-1">
{activeReviews.filter(r => r.reviewId).length} review{activeReviews.filter(r => r.reviewId).length > 1 ? 's' : ''}: {activeReviews.filter(r => r.reviewId).map(r => getBrand(r.childAdapter).displayName).join(' · ')}
</span>
<span className="text-xs text-text-dim/50">▲ Expand</span>
</div>
)}
- Step 6: TypeScript check
Run: npx tsc --noEmit 2>&1 | head -10
Expected: Clean or minor issues only
- Step 7: Commit
git add src/components/FloatingReviewPanel.tsx src/components/ChatView.tsx
git commit -m "feat: tabbed multi-review panel with minimize/expand"
Task 6: Send-To Existing Review Bottom Sheet
Files:
-
Create:
src/components/SendToExistingSheet.tsx -
Modify:
src/components/ChatView.tsx(handleSendTo logic) -
Step 1: Create SendToExistingSheet component
Create src/components/SendToExistingSheet.tsx:
import { getBrand } from '../lib/adapters';
import type { ReviewInfo } from '../hooks/useChat';
interface SendToExistingSheetProps {
visible: boolean;
activeReviews: ReviewInfo[];
onSendToExisting: (reviewId: string) => void;
onStartNew: () => void;
onClose: () => void;
}
export function SendToExistingSheet({ visible, activeReviews, onSendToExisting, onStartNew, onClose }: SendToExistingSheetProps) {
if (!visible) return null;
return (
<div className="fixed inset-0 z-50 flex items-end justify-center" onClick={onClose}>
<div className="absolute inset-0 bg-black/50" />
<div
className="relative w-full max-w-lg bg-surface border-t border-border rounded-t-xl p-4 space-y-2 animate-slide-up"
onClick={e => e.stopPropagation()}
>
<div className="w-8 h-0.5 rounded-sm bg-border mx-auto mb-3" />
<p className="text-xs text-text-dim font-mono mb-2">Send to active review</p>
{activeReviews.map(r => {
const brand = getBrand(r.childAdapter);
return (
<button
key={r.reviewId}
onClick={() => onSendToExisting(r.reviewId)}
className="w-full flex items-center gap-2 px-3 py-2.5 rounded-lg border border-border hover:bg-white/5 transition-colors text-left"
>
<span
className="text-xs font-semibold px-2 py-0.5 rounded"
style={{ backgroundColor: `${brand.color}20`, color: brand.color }}
>
{brand.displayName}
</span>
<span className="text-sm text-text font-mono flex-1 truncate">
{r.reviewTitle || 'Review'}
</span>
<span className="text-xs text-text-dim">→</span>
</button>
);
})}
<div className="border-t border-border pt-2 mt-2">
<button
onClick={onStartNew}
className="w-full text-left px-3 py-2 text-xs text-text-dim hover:text-text hover:bg-white/5 rounded-lg transition-colors font-mono"
>
Start new review...
</button>
</div>
</div>
</div>
);
}
- Step 2: Update handleSendTo in ChatView
Replace the handleSendTo callback to check for active reviews:
const handleSendTo = useCallback((messageId: string, _adapter?: string) => {
const validReviews = activeReviews.filter(r => r.reviewId);
if (validReviews.length > 0) {
// Show the "send to existing" sheet
setSendToMessageId(messageId);
} else {
// No active reviews — go straight to ReviewActionMenu
setReviewMenuMessageId(messageId);
}
}, [activeReviews]);
Add new state:
const [sendToMessageId, setSendToMessageId] = useState<string | null>(null);
- Step 3: Add handlers for send-to-existing and start-new
const handleSendToExisting = useCallback((reviewId: string) => {
if (!sendToMessageId) return;
const msg = messages.find(m => m.id === sendToMessageId);
if (!msg) return;
const text = extractTextFromBlocks(msg.content);
// TODO: send text to the review's child session
// This requires accessing the ReviewTab's sendMessage — use a ref map
// exposed by FloatingReviewPanel (see Task 5 onSendToReview prop)
reviewPanelRef.current?.sendToReview(reviewId, text);
setSendToMessageId(null);
setActiveReviewPanel('expanded');
}, [sendToMessageId, messages]);
const handleStartNewFromSheet = useCallback(() => {
if (sendToMessageId) {
setReviewMenuMessageId(sendToMessageId);
setSendToMessageId(null);
}
}, [sendToMessageId]);
- Step 4: Render SendToExistingSheet in ChatView
Add the sheet render near the ReviewActionMenu render:
<SendToExistingSheet
visible={!!sendToMessageId}
activeReviews={activeReviews.filter(r => r.reviewId)}
onSendToExisting={handleSendToExisting}
onStartNew={handleStartNewFromSheet}
onClose={() => setSendToMessageId(null)}
/>
- Step 5: Expose sendToReview from FloatingReviewPanel via ref
In FloatingReviewPanel.tsx, use useImperativeHandle to expose a sendToReview(reviewId, text) method. Each ReviewTab registers its sendMessage in a ref map. The parent component looks up the right tab and calls sendMessage(text).
- Step 6: TypeScript check
Run: npx tsc --noEmit 2>&1 | head -10
Expected: Clean
- Step 7: Commit
git add src/components/SendToExistingSheet.tsx src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx
git commit -m "feat: send-to-existing-review bottom sheet + direct message routing"
Task 7: Placeholder Font Size Fix
Files:
-
Modify:
src/index.css:83-85 -
Modify:
src/components/FloatingReviewPanel.tsx(textarea class) -
Step 1: Add review panel textarea override in CSS
In src/index.css, after the existing input, textarea, select { font-size: 16px; } rule (line 85), add:
/* Review panel uses smaller text to fit the compact layout.
16px stays on main input to prevent iOS Safari auto-zoom. */
.review-panel-compact textarea {
font-size: 14px;
}
- Step 2: Add the class to FloatingReviewPanel wrapper
In FloatingReviewPanel.tsx, add review-panel-compact class to the panel's outer div:
<div className="review-panel-compact absolute bottom-0 left-0 right-0 z-10 flex flex-col rounded-t-xl" ...>
- Step 3: Verify visually
Build and check that the review panel placeholder is now 14px while the main chat input remains 16px.
- Step 4: Commit
git add src/index.css src/components/FloatingReviewPanel.tsx
git commit -m "fix: review panel textarea uses 14px to fit compact layout"
Task 8: Integration Test + Cleanup
Files:
-
Modify:
src/components/ChatView.tsx(remove any dead code from old single-review pattern) -
Modify:
src/hooks/useChat.ts(clean up old exports) -
Step 1: Remove old single-review exports from useChat
Ensure activeReview (singular) and setActiveReview (singular) are completely removed from the return object. Only activeReviews and setActiveReviews should be exported.
- Step 2: Search for any remaining references to old single-review pattern
Run: grep -rn "activeReview[^s]" src/ --include="*.tsx" --include="*.ts" | grep -v node_modules | grep -v ".d.ts"
Fix any remaining references.
- Step 3: Build and verify
Run: npm run build 2>&1 | tail -5
Expected: Clean build
- Step 4: Manual E2E verification checklist
- Start a Gemini session from UI → send "Hi" → get response
- Click "↗ Send to" on the response → should show ReviewActionMenu (no active reviews)
- Select Codex → Direct Send → child session starts → panel shows with single-review header
- Click "↗ Send to" on another message → should show SendToExistingSheet with "Send to Codex review" option
- Click "Start new review..." → ReviewActionMenu opens → select Claude → second tab appears in panel
- Switch between Codex and Claude tabs
- Click ▼ to minimize → combined bar shows "2 reviews: Codex · Claude"
- Click bar to expand → tabs restored
- Click ✕ on Codex tab → Codex review ends, Claude tab remains
- Click End on Claude → panel disappears
- Verify "Review ended" markers appear at the correct positions (not at anchor)
- Verify CollapsedReviewCards appear at the start anchor positions
- Step 5: Final commit
git add -A
git commit -m "refactor: clean up old single-review references, verify multi-review integration"