feat: ClawTap v0.1.0 — initial release

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
This commit is contained in:
kuannnn
2026-03-18 10:24:45 +08:00
commit 42861ea7fa
151 changed files with 33897 additions and 0 deletions
@@ -0,0 +1,690 @@
# 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:
```sql
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):
```typescript
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**
```typescript
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**
```bash
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):
```typescript
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:
```typescript
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`:
```typescript
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**
```bash
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:
```typescript
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**
```bash
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 `id` field 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 when `parse()` 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?: string` to the local `ChatMessage` type (~line 14)
- In `convertMessages()` (~line 68), preserve `id` from incoming messages: `{ id: msg.id, role: ..., content: ... }`
- In the `MESSAGE_COMPLETE` handler, preserve `id` when converting messages
- In the `HISTORY_LOAD` handler, preserve `id` when converting messages
- [ ] **Step 5: Commit**
```bash
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:
```typescript
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:
```typescript
const childIds = sessionReviews.getAllChildIds();
const filtered = allActiveSessions.filter((s: any) => !childIds.has(s.cliSessionId));
res.json(filtered);
```
- [ ] **Step 4: Commit**
```bash
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 `cwd` from DB (`dbSessions.findByCliSession`)
- Call `adapter.startSession(cwd, { permissionMode: 'bypassPermissions' })`
- **Codex UUID timing issue:** For Claude, `cliSessionId` is available immediately after `startSession()`. For Codex, it's empty until `SessionStart` hook fires. **Workaround:** Create the `session_reviews` row with the internal session ID as a temporary `child_cli_session_id`. Add a step in Codex's `handleSessionStart` hook to update the review row once the real UUID is known. Add `sessionReviews.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_ENDED` to 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:
```typescript
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**
```bash
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**
```typescript
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 `resumeSession` to recreate tmux window
- Send `REVIEW_STARTED` event 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:
```typescript
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:
```typescript
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**
```bash
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 13
- `CrossAdapterFlowState` type and export
- `crossAdapterFlow` state and `crossAdapterFlowRef`
- `startCrossAdapterFlow` and `completeCrossAdapterFlow` callbacks
- The `crossAdapterFlow` check in `TURN_COMPLETE` handler
- 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**
```bash
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**
```bash
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**
```bash
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 `onEnd` callback
- [ ] **Step 2: Commit**
```bash
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**
```bash
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:
```typescript
const [reviews, setReviews] = useState<SessionReviewRow[]>([]);
useEffect(() => {
if (!cliSessionId) return;
api.getReviews(cliSessionId).then(setReviews).catch(() => {});
}, [cliSessionId]);
```
Build a lookup map for rendering:
```typescript
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:
```tsx
{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**
```bash
rm src/components/QuickActionCards.tsx src/components/CrossAdapterFlow.tsx src/lib/quick-commands.ts
```
- [ ] **Step 9: Commit**
```bash
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), not `Map<string, SessionReviewRow>`, to support multiple reviews anchored to the same message.
- **CollapsedReviewCard message count** (Task 1): Add `message_count INTEGER DEFAULT 0` column to `session_reviews`. Set it when the review ends (`endReview` method 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): `handleReviewSelect` should catch 409 from `api.createReview()`, show a confirmation dialog "End current review and start new one?", and if confirmed, call `endReview` then retry.
- **"Send to Parent" while parent is busy** (Task 11): Pass `parentStreaming` state as a prop to `FloatingReviewPanel`. Disable the "Send to Parent" button when `parentStreaming` is true. The parent's `useChat` `streaming` state 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-ended` event fires AFTER `sessions.delete()`, so `adapter.getSession()` returns null. Save `cliSessionId` BEFORE the session is deleted by looking it up in DB (`dbSessions.findByCliSession`) as a fallback.
- **Codex UUID window** (Task 6 Step 5): Between `startSession()` and `handleSessionStart` hook, 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-ended` cascade handler for child sessions — when a child session ends unexpectedly, mark the review as ended and broadcast `REVIEW_ENDED`.
### History Edge Cases
- **Keep reviews state in sync via WS** (Task 8 Step 3): On `REVIEW_STARTED`, append to local `reviews` state array. On `REVIEW_ENDED`, update the matching review's `ended_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): `broadcastReviewStarted` and `broadcastReviewEnded` are 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:
1. `CLAUDE_UI_PASSWORD=test npm run dev`
2. Open `http://localhost:5173`
3. **Open a Claude session** -- assistant messages show "Copy" and "Send to Codex" buttons
4. **Tap "Send to Codex"** -- ReviewActionMenu appears with template options
5. **Select "Code Review"** -- floating panel opens, Codex session starts, context pasted
6. **Chat with Codex** in floating panel -- messages appear with streaming
7. **Tap "Send to Claude"** on a Codex response -- content injected into Claude's tmux
8. **Minimize panel** -- pill button appears, tap to re-expand
9. **End review** -- panel disappears, tmux window killed
10. **Session list** -- child session NOT visible in project sessions or active sessions
11. **Reconnect** -- refresh page, active review panel restores
12. **Scroll history** -- block markers and collapsed review card visible at anchor position
@@ -0,0 +1,294 @@
# InsightBlock 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:** Render Claude Code's Insight blocks as collapsible cards instead of ugly inline code elements.
**Architecture:** Frontend-only text transform. A generic segment splitter in `src/lib/` accepts adapter-scoped regex patterns. Claude-specific patterns and UI live in `src/components/adapters/claude/`. MessageBubble splits text into segments and renders InsightBlocks for matched segments. No server changes.
**Tech Stack:** React, ReactMarkdown, TypeScript, Tailwind CSS, lucide-react icons
---
### Task 1: Generic Text Segment Splitter
**Files:**
- Create: `src/lib/text-transforms.ts`
- [ ] **Step 1: Create the text-transforms module**
```typescript
// src/lib/text-transforms.ts
export interface TextPattern {
type: string;
regex: RegExp;
}
export interface TextSegment {
type: string;
text: string;
}
/**
* Split text into typed segments based on regex patterns.
* Unmatched regions become { type: 'markdown' } segments.
* Fast path: returns single markdown segment when no patterns match.
*/
export function splitTextSegments(text: string, patterns: TextPattern[]): TextSegment[] {
if (!text || patterns.length === 0) return [{ type: 'markdown', text }];
// Collect all matches from all patterns with their positions
const matches: { type: string; start: number; end: number; captured: string }[] = [];
for (const pattern of patterns) {
const re = new RegExp(pattern.regex.source, pattern.regex.flags);
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
matches.push({
type: pattern.type,
start: m.index,
end: m.index + m[0].length,
captured: m[1] ?? m[0],
});
}
}
if (matches.length === 0) return [{ type: 'markdown', text }];
matches.sort((a, b) => a.start - b.start);
const segments: TextSegment[] = [];
let cursor = 0;
for (const match of matches) {
if (match.start < cursor) continue;
if (match.start > cursor) {
const before = text.slice(cursor, match.start).trim();
if (before) segments.push({ type: 'markdown', text: before });
}
segments.push({ type: match.type, text: match.captured.trim() });
cursor = match.end;
}
if (cursor < text.length) {
const after = text.slice(cursor).trim();
if (after) segments.push({ type: 'markdown', text: after });
}
return segments;
}
```
- [ ] **Step 2: Verify build passes**
- [ ] **Step 3: Commit**
---
### Task 2: Claude Adapter Patterns
**Files:**
- Create: `src/components/adapters/claude/patterns.ts`
- [ ] **Step 1: Create Claude patterns module**
```typescript
// src/components/adapters/claude/patterns.ts
import type { TextPattern } from '@/lib/text-transforms';
/**
* Claude Code text patterns for special content rendering.
*
* Insight format:
* `★ Insight ─────────────────────────────────────`
* [content lines]
* `─────────────────────────────────────────────────`
*/
export const CLAUDE_PATTERNS: TextPattern[] = [
{
type: 'insight',
regex: /`[★✦]?\s*Insight\s*[─\-]+`\n([\s\S]*?)\n`[─\-]+[.。]?`/g,
},
];
```
- [ ] **Step 2: Verify build passes**
- [ ] **Step 3: Commit**
---
### Task 3: InsightBlock Collapsible Component
**Files:**
- Create: `src/components/adapters/claude/InsightBlock.tsx`
**Reference:** Follow `src/components/ToolCallCard.tsx` expand/collapse pattern (useState, ChevronDown/Up icons).
- [ ] **Step 1: Create InsightBlock component**
```tsx
// src/components/adapters/claude/InsightBlock.tsx
import { useState } from 'react';
import { ChevronDown, ChevronUp } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { cn } from '@/lib/utils';
export function InsightBlock({ text }: { text: string }) {
const [expanded, setExpanded] = useState(false);
const summary = text.split('\n').find(l => l.trim())?.trim() || 'Insight';
const truncated = summary.length > 80 ? summary.slice(0, 80) + '...' : summary;
return (
<div className="my-2">
<button
onClick={() => setExpanded(!expanded)}
className={cn(
'w-full text-left px-3 py-2 transition-colors',
'bg-surface/30 border border-border/50 hover:bg-surface/60',
expanded ? 'rounded-t-lg' : 'rounded-lg',
)}
>
<div className="flex items-center gap-2">
<span className="text-accent-light text-sm shrink-0"></span>
<span className="text-xs text-accent-light font-medium shrink-0">Insight</span>
{!expanded && (
<span className="text-xs text-text-dim truncate flex-1">{truncated}</span>
)}
{expanded
? <ChevronUp className="size-3.5 text-text-dim shrink-0 ml-auto" />
: <ChevronDown className="size-3.5 text-text-dim shrink-0 ml-auto" />
}
</div>
</button>
{expanded && (
<div className={cn(
'bg-surface/20 border border-t-0 border-border/50 rounded-b-lg px-3 py-2',
'prose prose-invert prose-sm max-w-none',
'[&_p]:my-1 [&_ul]:my-1 [&_ol]:my-1 [&_li]:my-0.5',
'[&_code]:text-accent-light [&_code]:text-xs',
)}>
<ReactMarkdown>{text}</ReactMarkdown>
</div>
)}
</div>
);
}
```
- [ ] **Step 2: Verify build passes**
- [ ] **Step 3: Commit**
---
### Task 4: Integrate into MessageBubble
**Files:**
- Modify: `src/components/MessageBubble.tsx`
- [ ] **Step 1: Add imports and segment splitting**
Add imports at top of file:
```typescript
import { splitTextSegments } from '@/lib/text-transforms';
import { CLAUDE_PATTERNS } from './adapters/claude/patterns';
import { InsightBlock } from './adapters/claude/InsightBlock';
```
In the assistant message render block, replace lines 64-66:
Before:
```tsx
<ReactMarkdown components={markdownComponents}>
{textContent}
</ReactMarkdown>
```
After:
```tsx
{(() => {
const segments = splitTextSegments(textContent, CLAUDE_PATTERNS);
return segments.map((seg, i) =>
seg.type === 'insight'
? <InsightBlock key={i} text={seg.text} />
: <ReactMarkdown key={i} components={markdownComponents}>{seg.text}</ReactMarkdown>
);
})()}
```
- [ ] **Step 2: Verify build passes**
- [ ] **Step 3: Manual verification**
1. Start server: `CLAUDE_UI_PASSWORD=test npx tsx server/index.ts`
2. Open app, find or create a session with an Insight block
3. Verify: collapsed card with ★ label, expand/collapse works, surrounding markdown intact, messages without insights unaffected
- [ ] **Step 4: Commit**
---
### Task 5: E2E Test Specs
**Files:**
- Modify: `tests/e2e-spec.feature`
- Modify: `tests/e2e-progress.md`
- [ ] **Step 1: Add E2E scenarios to e2e-spec.feature**
Append to the end of the file:
```gherkin
# =============================================================================
# Feature: Insight Block Rendering
# =============================================================================
Feature: Insight Block Display
Scenario: Insight block renders as collapsible card
Given I have an active chat session with an Insight block in the response
Then the Insight block shows as a collapsed card
And the card shows " Insight" label with a summary
And a chevron icon is visible
Scenario: Insight block expands on tap
Given I see a collapsed Insight card
When I tap the Insight card
Then the card expands to show full markdown content
And the chevron changes to up arrow
Scenario: Insight block collapses on second tap
Given I see an expanded Insight card
When I tap the Insight card again
Then the card collapses back to summary view
Scenario: Multiple Insight blocks in one message
Given I have a response with two Insight blocks separated by text
Then both render as separate collapsible cards
And the text between them renders as normal markdown
Scenario: Message without Insight blocks renders normally
Given I have a response with no Insight delimiters
Then the message renders as plain markdown
Scenario: Insight block in reconnected session history
Given I reconnect to a session that had Insight blocks
Then the Insight blocks render correctly as collapsible cards
```
- [ ] **Step 2: Add progress entries to e2e-progress.md**
Add at end of Progress section:
```markdown
### Feature 54: Insight Block Display — NOT STARTED (0/6)
Scenarios:
- [ ] Insight block renders as collapsible card
- [ ] Insight block expands on tap
- [ ] Insight block collapses on second tap
- [ ] Multiple Insight blocks in one message
- [ ] Message without Insight blocks renders normally
- [ ] Insight block in reconnected session history
```
- [ ] **Step 3: Commit**
@@ -0,0 +1,563 @@
# Session ID Unification — 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:** Unify session ID management across all adapters — single storage (SQLite), adapter-prefixed internal IDs, CLI UUID in chat header, and real-time session discovery via API-based SessionStart hook.
**Architecture:** Bottom-up: DB schema migration → server adapter changes (Claude, Codex) → session-manager protocol update → client UI → CLI script → E2E spec updates. Each task produces a committable, non-breaking state.
**Tech Stack:** TypeScript, SQLite (better-sqlite3), React, Bash (CLI), Gherkin (E2E specs)
**Spec:** `docs/superpowers/specs/2026-03-23-session-id-unification-design.md`
---
## File Structure
| File | Action | Responsibility |
|------|--------|----------------|
| `server/db.ts` | Modify | Schema migration, rename columns, add `clearAll()`, remove session-map migration |
| `server/config.ts` | Modify | Remove `sessionMap` path |
| `server/index.ts` | Modify | Call `clearAll()` on shutdown |
| `server/adapters/interface.ts` | Modify | Add `adapter`, rename `claudeSessionId``cliSessionId` in `ActiveSessionInfo` |
| `server/adapters/claude/hook-config.ts` | Modify | SessionStart → `fireAndForget` |
| `server/adapters/claude/index.ts` | Modify | Add `session-start` hook route |
| `server/adapters/claude/tmux-adapter.ts` | Modify | `claude-` prefix, remove `desktop-`, add `handleSessionStart`, update `resolveSessionId` recovery |
| `server/adapters/codex/codex-tmux-adapter.ts` | Modify | `codex-` prefix, remove `desktop-`, align with Claude pattern |
| `server/adapters/codex/index.ts` | Verify | Ensure `session-start` hook route exists |
| `server/session-manager.ts` | Modify | `SESSION_CREATED` includes `cliSessionId` |
| `src/hooks/useChat.ts` | Modify | Store `cliSessionId` from `SESSION_CREATED` |
| `src/components/ChatView.tsx` | Modify | Header shows CLI UUID (primary) + internal ID (secondary) |
| `bin/codetap` | Modify | `--adapter` flag, window naming, resume/continue logic, `-a`/`-A` display |
| `bin/codetap-hook` | Delete | Replaced by API POST |
| `tests/e2e-spec.feature` | Modify | 9 scenario updates for new session ID architecture |
---
### Task 1: DB Schema Migration
**Files:**
- Modify: `server/db.ts:19-29` (CREATE TABLE), `server/db.ts:105-130` (prepared statements), `server/db.ts:252-287` (operations)
- [ ] **Step 1: Update CREATE TABLE for fresh installs (line 19-29)**
Change `claude_session``cli_session`, add `adapter`, remove `is_active`:
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
cli_session TEXT NOT NULL,
adapter TEXT DEFAULT 'claude',
cwd TEXT NOT NULL,
window_id TEXT,
permission_mode TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now')),
last_activity TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessions_cli ON sessions(cli_session);
CREATE INDEX IF NOT EXISTS idx_sessions_adapter ON sessions(adapter);
```
- [ ] **Step 2: Add migration logic after CREATE TABLE block**
After line 59, add migration for existing databases:
```typescript
try {
const tableInfo = d.prepare("PRAGMA table_info('sessions')").all() as { name: string }[];
const hasOldColumn = tableInfo.some(c => c.name === 'claude_session');
const hasNewColumn = tableInfo.some(c => c.name === 'cli_session');
const hasAdapter = tableInfo.some(c => c.name === 'adapter');
if (hasOldColumn && !hasNewColumn) {
d.exec(`ALTER TABLE sessions RENAME COLUMN claude_session TO cli_session`);
console.log('[db] Migrated: claude_session → cli_session');
}
if (!hasAdapter) {
d.exec(`ALTER TABLE sessions ADD COLUMN adapter TEXT DEFAULT 'claude'`);
d.exec(`CREATE INDEX IF NOT EXISTS idx_sessions_adapter ON sessions(adapter)`);
console.log('[db] Migrated: added adapter column');
}
} catch (e) {
console.warn('[db] Migration check:', (e as Error).message);
}
```
- [ ] **Step 3: Update `SessionRow` interface (line 252-261)**
```typescript
export interface SessionRow {
id: string;
cli_session: string;
adapter: string;
cwd: string;
window_id: string | null;
permission_mode: string;
created_at: string;
last_activity: string;
}
```
- [ ] **Step 4: Update prepared statements (lines 105-130)**
All SQL: `claude_session``cli_session`, remove `is_active` references. Add `adapter` to upsert. Rename `sessionsFindByClaudeSession``sessionsFindByCliSession`. Change `sessionsRemove` from `UPDATE SET is_active=0` to `DELETE`. Remove `sessionsCleanupStale`.
- [ ] **Step 5: Update `sessions` operations object (line 263-287)**
```typescript
export const sessions = {
upsert(id: string, cliSession: string, cwd: string, windowId?: string, adapter?: string): void {
stmts().sessionsUpsert.run(id, cliSession, cwd, windowId ?? null, adapter ?? 'claude');
},
findByCliSession(cliSession: string): SessionRow | undefined {
return stmts().sessionsFindByCliSession.get(cliSession) as SessionRow | undefined;
},
findByWindowId(windowId: string): SessionRow | undefined {
return stmts().sessionsFindByWindowId.get(windowId) as SessionRow | undefined;
},
remove(id: string): void { stmts().sessionsRemove.run(id); },
getAll(): SessionRow[] { return stmts().sessionsGetAll.all() as SessionRow[]; },
clearAll(): void { getDB().exec('DELETE FROM sessions'); },
};
```
- [ ] **Step 6: Remove session-map.json migration (lines 196-219)**
Delete the session-map section of `migrateJsonToSqlite`. Keep the push-subscriptions migration. Remove `SessionMapJsonEntry` interface.
- [ ] **Step 7: Commit**
```bash
git add server/db.ts
git commit -m "refactor: migrate session DB schema — cli_session, adapter column, remove is_active"
```
---
### Task 2: Remove `sessionMap` from config + add `clearAll()` to shutdown
**Files:**
- Modify: `server/config.ts:18,58`
- Modify: `server/index.ts:245-253`
- [ ] **Step 1: Remove `sessionMap` from config**
In `AppConfig.paths` (line 18), remove `sessionMap: string;`.
In `loadConfig()` (line 58), remove `sessionMap: path.join(CODETAP_DIR, 'session-map.json'),`.
- [ ] **Step 2: Add `sessions.clearAll()` to shutdown**
In `shutdown()` (line 245-253), before `closeDB()`:
```typescript
import { sessions as dbSessions } from './db.js';
// ...
dbSessions.clearAll();
closeDB();
```
- [ ] **Step 3: Commit**
```bash
git add server/config.ts server/index.ts
git commit -m "refactor: remove sessionMap config, clear sessions on shutdown"
```
---
### Task 3: Update `ActiveSessionInfo` — rename `claudeSessionId` → `cliSessionId`
**Files:**
- Modify: `server/adapters/interface.ts:18-28`
- Modify: all files referencing `claudeSessionId`
- [ ] **Step 1: Update interface**
In `ActiveSessionInfo` (line 18-28), rename `claudeSessionId``cliSessionId`, add `adapter`:
```typescript
export interface ActiveSessionInfo {
sessionId: string;
cwd: string;
cliSessionId: string;
adapter: string;
permissionMode: string;
lastActivity: number | null;
hasClients: boolean;
hasDesktop: boolean;
isNonInteractive: boolean;
firstPrompt: string | null;
}
```
- [ ] **Step 2: Find and fix all `claudeSessionId` references**
```bash
grep -rn 'claudeSessionId' server/ src/ --include='*.ts' --include='*.tsx'
```
Replace `claudeSessionId``cliSessionId` in ALL files:
- `server/index.ts` (active-sessions endpoint)
- `server/session-manager.ts` (push notifications, pending sessions)
- `server/adapters/claude/tmux-adapter.ts` (`SessionState` interface field, `getActiveSessions`, `_createSession`, all usages)
- `server/adapters/codex/codex-tmux-adapter.ts` (same: `SessionState` field → rename to `cliSessionId`)
- `src/hooks/useSessions.ts` (activeSessionIds set)
- `src/components/SessionsView.tsx` (pending badge)
Note: The `SessionState` interfaces in both adapter files have a `claudeSessionId` / `codexSessionId` field that stores the CLI UUID. Rename both to `cliSessionId` for consistency across adapters.
- [ ] **Step 3: Commit**
```bash
git add -A
git commit -m "refactor: rename claudeSessionId → cliSessionId across codebase"
```
---
### Task 4: Claude adapter — SessionStart hook + internal ID format
**Files:**
- Modify: `server/adapters/claude/hook-config.ts:174,197`
- Modify: `server/adapters/claude/index.ts:113-147`
- Modify: `server/adapters/claude/tmux-adapter.ts:130,858`
- [ ] **Step 1: SessionStart hook → `fireAndForget` (hook-config.ts)**
Line 197: change `hookPath` to `fireAndForget('session-start')`.
Remove `hookPath` from `_hookIdentifiers()` (line 174). Update `_isOurHookEntry` to only check `portTag`.
- [ ] **Step 2: Add `session-start` route (index.ts)**
After line 147, add:
```typescript
hookRoute(`${prefix}/session-start`, (body) => {
this._tmux.handleSessionStart(body);
});
```
- [ ] **Step 3: Add `handleSessionStart` method (tmux-adapter.ts)**
New method. Algorithm:
```typescript
async handleSessionStart(body: HookBody): Promise<void> {
const cliUuid = body.session_id;
if (!cliUuid) return;
// 1. Already known? (idempotent — safe if hook fires twice)
const cached = this.claudeToSessionId.get(cliUuid);
if (cached && this.sessions.has(cached)) {
this.sessions.get(cached)!.lastActivity = Date.now();
return;
}
const windows = await tmuxManager.listWindows();
const cwd = body.cwd || process.cwd();
// 2. Recovery: check DB for original internal ID (non-graceful restart)
const dbRow = dbSessions.findByCliSession(cliUuid);
if (dbRow?.window_id && windows.some(w => w.id === dbRow.window_id)) {
const sessionId = dbRow.id; // Restore ORIGINAL internal ID
if (!this.sessions.has(sessionId)) {
this.sessions.set(sessionId, this._createSession(dbRow.window_id, cwd, cliUuid, dbRow.permission_mode || 'default'));
this._startMonitor(sessionId, dbRow.window_id);
this._ensureWatcher(sessionId);
}
this.claudeToSessionId.set(cliUuid, sessionId);
return;
}
// 3. New session: find unmanaged tmux window with claude- prefix
// The hook body doesn't contain the window name, but the tmux window
// was created by bin/codetap with name "claude-{timestamp}".
// We find the first claude-* window that isn't already managed.
for (const w of windows) {
if (w.name.startsWith('claude-') && !this.sessions.has(w.name)) {
const alreadyManaged = [...this.sessions.values()].some(s => s.windowId === w.id);
if (!alreadyManaged) {
const sessionId = w.name;
this.sessions.set(sessionId, this._createSession(w.id, cwd, cliUuid, 'default'));
this.claudeToSessionId.set(cliUuid, sessionId);
dbSessions.upsert(sessionId, cliUuid, cwd, w.id, 'claude');
this._startMonitor(sessionId, w.id);
this._ensureWatcher(sessionId);
this.emit('session-discovered', sessionId);
return;
}
}
}
}
```
- [ ] **Step 4: Change `startSession` ID format (tmux-adapter.ts:130)**
```typescript
const windowName = `claude-${Date.now()}`;
```
Update `dbSessions.upsert` to pass `'claude'` as adapter.
- [ ] **Step 5: Update `resolveSessionId` — remove `desktop-` prefix entirely (tmux-adapter.ts:858)**
The `desktop-` prefix logic is no longer needed. Change line 858 from:
```typescript
const sessionId = `desktop-${claudeSessionId.slice(0, 8)}`;
```
to:
```typescript
const sessionId = dbRow.id; // Restore original internal ID from DB (e.g., claude-1774210269126)
```
The DB row's `id` field will now always be in `{adapter}-{timestamp}` format. No new ID is generated — we reuse what was stored.
- [ ] **Step 6: Rename `findByClaudeSession` → `findByCliSession` in all calls**
- [ ] **Step 7: Update `getActiveSessions` — add `adapter: 'claude'`, rename field**
- [ ] **Step 8: Commit**
```bash
git add server/adapters/claude/
git commit -m "feat: Claude adapter — session-start API hook, claude- prefix, remove desktop-"
```
---
### Task 5: Codex adapter — align with unified schema
**Files:**
- Modify: `server/adapters/codex/codex-tmux-adapter.ts:121,242`
- Modify: `server/adapters/codex/index.ts`
- [ ] **Step 1: Change `startSession` ID to `codex-` prefix (line 121)**
- [ ] **Step 2: Remove `desktop-` in `handleSessionStart` (line 242) — use DB original ID**
- [ ] **Step 3: Update `getActiveSessions` — add `adapter: 'codex'`, rename field**
- [ ] **Step 4: Rename `findByClaudeSession` → `findByCliSession` in all calls**
- [ ] **Step 5: Commit**
```bash
git add server/adapters/codex/
git commit -m "feat: Codex adapter — codex- prefix, remove desktop-, align with unified schema"
```
---
### Task 6: `SESSION_CREATED` includes `cliSessionId`
**Files:**
- Modify: `server/session-manager.ts:198,266`
- [ ] **Step 1: Update `handleQuery` SESSION_CREATED (line 198)**
```typescript
// After Task 3 rename, SessionState.claudeSessionId → cliSessionId
const sessionObj = adapter.getSession(handle.sessionId) as { cliSessionId?: string } | null;
send(conn, {
type: WS.SESSION_CREATED,
sessionId: handle.sessionId,
cliSessionId: sessionObj?.cliSessionId || handle.sessionId,
});
```
- [ ] **Step 2: Update `handleReconnect` SESSION_CREATED (line 266)**
Same pattern — cast `getSession()` result and read `cliSessionId`.
- [ ] **Step 3: Commit**
```bash
git add server/session-manager.ts
git commit -m "feat: SESSION_CREATED includes cliSessionId for chat header"
```
---
### Task 7: Client — store `cliSessionId` + update chat header
**Files:**
- Modify: `src/hooks/useChat.ts:95,143`
- Modify: `src/components/ChatView.tsx:54-88,230`
- [ ] **Step 1: Add `cliSessionId` state in useChat (line 95)**
```typescript
const [cliSessionId, setCliSessionId] = useState<string | null>(null);
```
Update SESSION_CREATED handler (line 143):
```typescript
case WS.SESSION_CREATED:
setSessionId(msg.sessionId);
if (msg.cliSessionId) setCliSessionId(msg.cliSessionId);
break;
```
Add `cliSessionId` to the returned object.
- [ ] **Step 2: Update ChatHeader component (ChatView.tsx:54-88)**
Accept `cliSessionId` prop. Display CLI UUID as primary (truncated, with copy), internal ID as secondary line below.
- [ ] **Step 3: Update ChatHeader usage (ChatView.tsx:230)**
```tsx
<ChatHeader sessionId={sessionId || initialSessionId} cliSessionId={cliSessionId} cwd={cwd} />
```
- [ ] **Step 4: Commit**
```bash
git add src/hooks/useChat.ts src/components/ChatView.tsx
git commit -m "feat: chat header shows CLI UUID (primary) + internal ID (secondary)"
```
---
### Task 8: CLI — `--adapter` flag + window naming + enhanced display
**Files:**
- Modify: `bin/codetap`
- Delete: `bin/codetap-hook`
- [ ] **Step 1: Add `--adapter` flag parsing**
Insert before the resume mode section (around line 304). This parses `--adapter` from anywhere in the args:
```bash
# --- Parse --adapter flag ---
ADAPTER="claude"
ADAPTER_CMD="claude"
prev_arg=""
for arg in "$@"; do
if [ "$prev_arg" = "--adapter" ]; then
case "$arg" in
claude) ADAPTER="claude"; ADAPTER_CMD="claude" ;;
codex) ADAPTER="codex"; ADAPTER_CMD="codex" ;;
*) echo "Unknown adapter: $arg"; exit 1 ;;
esac
fi
prev_arg="$arg"
done
# Strip --adapter and its value from positional args
CLEANED_ARGS=()
skip_next=false
for arg in "$@"; do
if $skip_next; then skip_next=false; continue; fi
if [ "$arg" = "--adapter" ]; then skip_next=true; continue; fi
CLEANED_ARGS+=("$arg")
done
set -- "${CLEANED_ARGS[@]}"
```
- [ ] **Step 2: Update window naming and commands**
Replace the resume/continue/new block (lines 305-316):
```bash
if [ "$1" = "--resume" ] && [ -n "$2" ]; then
WINDOW_NAME="$2"
COMMAND="$ADAPTER_CMD $YOLO --resume $2"
shift 2
elif [ "$1" = "--continue" ]; then
WINDOW_NAME="${ADAPTER}-$(date +%s)"
case "$ADAPTER" in
claude) COMMAND="$ADAPTER_CMD $YOLO --continue" ;;
codex) COMMAND="$ADAPTER_CMD resume --last" ;;
*) COMMAND="$ADAPTER_CMD --continue" ;;
esac
shift
else
WINDOW_NAME="${ADAPTER}-$(date +%s)"
COMMAND="$ADAPTER_CMD $YOLO $*"
fi
```
- [ ] **Step 3: Enhance `-a`/`-A` display**
Update the session listing loop to query the server API for UUID:
```bash
# Fetch session details from running server
SESSION_DATA=$(curl -sf $CURL_OPTS \
"$PROTOCOL://127.0.0.1:$PORT/api/active-sessions" 2>/dev/null)
# In the listing loop, extract UUID per window name:
UUID=$(echo "$SESSION_DATA" | python3 -c "
import json, sys
try:
for s in json.load(sys.stdin):
if s.get('sessionId') == '$NAME':
print(s.get('cliSessionId', '')); break
except: pass
" 2>/dev/null)
echo " $i) $NAME"
[ -n "$UUID" ] && echo " UUID: $UUID"
```
- [ ] **Step 4: Delete `bin/codetap-hook`**
- [ ] **Step 5: Commit**
```bash
git add bin/codetap
git rm bin/codetap-hook
git commit -m "feat: CLI — --adapter flag, adapter-prefixed windows, enhanced display"
```
---
### Task 9: Update E2E Specs
**Files:**
- Modify: `tests/e2e-spec.feature`
- [ ] **Step 1: Chat header display (L247)** — CLI UUID primary + internal ID secondary
- [ ] **Step 2: CLI `--adapter` scenarios (after L1238)**`codetap new --adapter codex`
- [ ] **Step 3: `-a`/`-A` display format (L1212)** — UUID + internal ID
- [ ] **Step 4: Remove session-map.json refs (L1308)** — DB-based recovery
- [ ] **Step 5: Session Dedup regression (L1829)** — updated root cause
- [ ] **Step 6: SessionStart hook scenario** — API POST flow
- [ ] **Step 7: tmux window naming (L1176)**`{adapter}-{timestamp}` format
- [ ] **Step 8: Non-graceful restart recovery (after L1325)** — restore from DB
- [ ] **Step 9: Active session card UUID (L1548)** — clarify display locations
- [ ] **Step 10: Commit**
```bash
git add tests/e2e-spec.feature
git commit -m "test: update E2E specs for session ID unification"
```
---
### Task 10: End-to-End Verification
- [ ] **Step 1: Build and start server**
```bash
npm run build && CLAUDE_UI_PASSWORD=test npx tsx server/index.ts
```
Verify: No migration errors in console.
- [ ] **Step 2: Web UI — new session**
Open CodeTap → New → send message.
Verify: Header shows CLI UUID (primary) + `claude-{timestamp}` (secondary).
- [ ] **Step 3: Active tab — no duplicates**
Verify: Only 1 session, no duplicates. Connect button works.
- [ ] **Step 4: CLI — codetap new**
Verify: tmux window named `claude-{timestamp}`, session appears immediately in Active tab.
- [ ] **Step 5: Server shutdown**
Verify: `codetap stop` clears sessions table and kills tmux windows.
@@ -0,0 +1,477 @@
# Codex UUID Discovery Fix + Session Architecture Cleanup — 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 Codex startSession deadlock, replace guess-based matching with JSONL marker matching, kill tmux windows on shutdown, remove DB sessions table.
**Architecture:** 6 tasks: (1) remove _waitForCliUUID + add marker matching in Codex adapter, (2) inject marker in session-manager + index.ts, (3) filter marker in frontend, (4) shutdown kills tmux + remove _findAndAttachWindow, (5) remove DB sessions table, (6) simplify handleReconnect + review endpoints.
**Spec:** `docs/superpowers/specs/2026-03-24-codex-uuid-discovery-fix.md`
---
### Task 1: Codex adapter — remove deadlock, add marker matching
**Files:**
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- [ ] **Step 1: Delete `_waitForCliUUID` method**
Remove the entire method. Also remove the call to it in `startSession()` and the `renameWindow` call after it. `startSession` now returns temp key immediately:
```typescript
return { sessionId: tempName };
```
- [ ] **Step 2: Delete `_rekeySession` method (if still exists from earlier)**
This was added in a previous fix. Will be replaced by `_rekeyAndRename`.
- [ ] **Step 3: Add `_rekeyAndRename` method**
```typescript
private async _rekeyAndRename(tempKey: string, cliUuid: string): Promise<void> {
const session = this.sessions.get(tempKey);
if (!session) return;
session.cliSessionId = cliUuid;
session._watcherPending = false;
this.sessions.delete(tempKey);
this.sessions.set(cliUuid, session);
await tmuxManager.renameWindow(session.windowId, cliUuid);
if (session.monitor) {
(session.monitor as any).sessionId = cliUuid;
}
}
```
Note: NO `dbSessions` calls here — DB sessions table will be removed in Task 5.
- [ ] **Step 4: Add `_matchByTranscriptMarker` method**
Reads JSONL at given path, finds `[CODETAP_REF:xxx]` in first user message, returns `xxx` if it's a key in `this.sessions`:
```typescript
private _matchByTranscriptMarker(transcriptPath: string): string | null {
try {
const content = readFileSync(transcriptPath, 'utf8');
const lines = content.split('\n').filter(Boolean);
for (const line of lines) {
try {
const entry = JSON.parse(line);
// Check for user message content containing marker
// NOTE: Read codex transcript-parser.ts to get correct field names
const text = this._extractTextFromEntry(entry);
if (text) {
const match = text.match(/\[CODETAP_REF:([^\]]+)\]/);
if (match && this.sessions.has(match[1])) return match[1];
}
} catch {}
}
} catch {}
return null;
}
```
Add a helper `_extractTextFromEntry` that handles the Codex JSONL format (check `transcript-parser.ts` for the actual field structure).
- [ ] **Step 5: Rewrite `handleSessionStart` matching**
Replace `pendingSessions.length === 1` logic:
```
1. Direct lookup: this.sessions.has(codexUuid) → already managed → update state, return
2. Marker matching: _matchByTranscriptMarker(body.transcript_path) → found tempKey → _rekeyAndRename(tempKey, codexUuid), start watcher, return
3. Fallback pending scan: pendingSessions.length === 1 → legacy (kept for sessions without marker)
4. No match: create session entry for this UUID (desktop/unknown origin)
```
In step 4, do NOT call `_findAndAttachWindow` (will be deleted in Task 4). Instead, try matching by tmux window name:
```typescript
const windows = await tmuxManager.listWindows();
const match = windows.find(w => w.name === codexUuid);
if (match) {
session.windowId = match.id;
this._startMonitor(codexUuid, match.id);
}
```
- [ ] **Step 6: Update `_watchForTranscript` with marker verification**
In `scanOnce`, after finding a JSONL file candidate, verify it belongs to this session:
```typescript
const firstChunk = readFileSync(fullPath, 'utf8').slice(0, 2000);
if (!firstChunk.includes(`CODETAP_REF:${sessionId}`)) continue;
```
When match confirmed, call `_rekeyAndRename(sessionId, uuid)`.
- [ ] **Step 7: Commit**
```bash
git add server/adapters/codex/codex-tmux-adapter.ts
git commit -m "fix: remove _waitForCliUUID deadlock, add marker-based matching"
```
---
### Task 2: Inject CODETAP_REF marker in session-manager + index.ts
**Files:**
- Modify: `server/session-manager.ts`
- Modify: `server/index.ts`
- [ ] **Step 1: Inject marker in handleQuery for new sessions**
In `handleQuery()`, after `startSession` returns and before `sendMessage`:
```typescript
let messageText = prompt;
if (!sessionId) {
// New session — prepend marker for Codex UUID matching
messageText = `[CODETAP_REF:${handle.sessionId}]\n${prompt}`;
}
await adapter.sendMessage(handle.sessionId, messageText, { clientId: conn.clientId });
```
For Claude, `handle.sessionId` is already a UUID — marker is harmless, just filtered out in ChatView.
- [ ] **Step 2: Inject marker in POST /api/reviews context**
```typescript
if (context) {
const markerContext = `[CODETAP_REF:${handle.sessionId}]\n${context}`;
await adapter.pasteToSession(handle.sessionId, markerContext);
}
```
- [ ] **Step 3: Commit**
```bash
git add server/session-manager.ts server/index.ts
git commit -m "feat: inject CODETAP_REF marker in first message for UUID matching"
```
---
### Task 3: Filter marker in frontend
**Files:**
- Modify: `src/lib/content-utils.ts`
- Modify: `src/hooks/useChat.ts`
- [ ] **Step 1: Add stripMarker to content-utils.ts**
```typescript
const CODETAP_REF_REGEX = /^\[CODETAP_REF:[^\]]+\]\n?/;
export function stripMarker(text: string): string {
return text.replace(CODETAP_REF_REGEX, '');
}
```
- [ ] **Step 2: Strip marker in convertMessages (useChat.ts)**
Import `stripMarker` from `@/lib/content-utils`. In `convertMessages()`, when processing user messages, strip marker from text content blocks:
```typescript
if (msg.role === 'user') {
const content = typeof msg.content === 'string'
? [{ type: 'text', text: stripMarker(msg.content) }]
: (msg.content || []).map((b: any) =>
b.type === 'text' ? { ...b, text: stripMarker(b.text || '') } : b
);
// ... rest of processing
}
```
- [ ] **Step 3: Commit**
```bash
git add src/lib/content-utils.ts src/hooks/useChat.ts
git commit -m "feat: strip CODETAP_REF marker from user messages in ChatView"
```
---
### Task 4: Shutdown kills tmux + remove _findAndAttachWindow
**Files:**
- Modify: `server/adapters/claude/tmux-adapter.ts`
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- [ ] **Step 1: Codex destroy() kills tmux session**
Claude's `destroy()` already has `tmuxManager.killSession()` (added in earlier task). Add the same to Codex's `destroy()`:
```typescript
await tmuxManager.killSession();
```
Note: Both adapters share the same tmuxManager. Calling `killSession()` twice is harmless (catch block swallows error).
- [ ] **Step 3: Delete _findAndAttachWindow from Codex adapter**
Remove the entire `_findAndAttachWindow` method. Remove all call sites (in `handleSessionStart` fallback path).
- [ ] **Step 4: Commit**
```bash
git add server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts
git commit -m "feat: shutdown kills tmux windows, remove _findAndAttachWindow"
```
---
### Task 5: Remove DB sessions table
**Files:**
- Modify: `server/db.ts`
- Modify: `server/adapters/claude/tmux-adapter.ts`
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- Modify: `server/session-manager.ts`
- Modify: `server/index.ts`
- Modify: `bin/codetap`
- [ ] **Step 1: Add parent_adapter to session_reviews table**
In `server/db.ts`, add `parent_adapter TEXT NOT NULL DEFAULT 'claude'` to the `session_reviews` CREATE TABLE.
Add migration: if `parent_adapter` column doesn't exist, add it:
```sql
ALTER TABLE session_reviews ADD COLUMN parent_adapter TEXT NOT NULL DEFAULT 'claude';
```
Update `SessionReviewRow` interface to include `parent_adapter: string`.
Update `sessionReviews.create()` to accept and store `parentAdapter`.
Update `POST /api/reviews` in `server/index.ts` to pass the parent's adapter name when creating a review.
- [ ] **Step 2: Remove sessions table from db.ts**
Delete:
- `CREATE TABLE IF NOT EXISTS sessions` from initDB
- Old schema detection/drop logic (`PRAGMA table_info`, `hasOldColumns`)
- `sessionsUpsert`, `sessionsGet`, `sessionsFindByWindowId`, `sessionsRemove`, `sessionsGetAll` prepared statements from `PreparedStatements` interface and `stmts()` function
- `SessionRow` interface
- `sessions` export object
- `CREATE INDEX idx_sessions_window`
Keep: everything related to `session_reviews`.
- [ ] **Step 2: Remove all dbSessions calls from Claude adapter**
Grep for `dbSessions` in `tmux-adapter.ts`. Remove every call (9 sites):
- `dbSessions.upsert(...)` in startSession (line 131), attachSession (line 181), resumeSession (line 224), handleSessionStart (line 547)
- `dbSessions.remove(...)` in handleSessionEnd (line 500), cleanup loop (line 707)
- `dbSessions.get(...)` in handleSessionStart (line 532), _findWindowForSession (line 875)
Also remove the `import { sessions as dbSessions } from '../../db.js'` line.
NOTE: `_findWindowForSession` currently does `dbSessions.get(sessionId)` first, then falls back to `windows.find(w => w.name === sessionId)`. After removing DB, keep ONLY the window name matching fallback.
- [ ] **Step 3: Remove all dbSessions calls from Codex adapter**
Same pattern. Grep and remove all `dbSessions.*` calls. Remove import.
- [ ] **Step 4: Remove dbSessions from session-manager.ts**
Remove:
- `import { sessions as dbSessions } from './db.js'` (keep `sessionReviews` import)
- `dbSessions.get(sessionId)` in handleReconnect (cwd lookup — no longer needed)
- `dbSessions.get(review.parent_cli_session_id)` in review restoration (Task 6 changes this)
- [ ] **Step 5: Remove dbSessions from index.ts**
Remove:
- `dbSessions.clearAll()` from shutdown handler
- `dbSessions.get(parentCliSessionId)` from review endpoints (Task 6 changes these)
- Import of `sessions as dbSessions`
- [ ] **Step 6: Update bin/codetap**
Remove all SQL queries that reference the `sessions` table:
- `get_project_sessions()` function — queries sessions by cwd
- `-a` listing block — queries sessions by window name
- `--resume` block — queries sessions by id
These CLI features will stop working without the DB. Options:
a) Remove these features from bin/codetap (they depend on DB)
b) Use tmux list-windows directly instead of DB queries
Recommended: option (b) — replace DB queries with tmux-based queries:
`get_project_sessions()`:
```bash
tmux list-windows -t codetap -F '#{window_name}\t#{pane_current_path}' 2>/dev/null | \
awk -F'\t' -v cwd="$CWD" '$2 == cwd { print $1 }'
```
`-a` listing:
```bash
tmux list-windows -t codetap -F '#{window_name}\t#{pane_current_command}\t#{pane_current_path}' 2>/dev/null
# window_name = UUID, pane_current_command = "claude" or "codex" (adapter detection)
```
`--resume`:
```bash
# Check if UUID exists as a tmux window name
tmux list-windows -t codetap -F '#{window_name}' 2>/dev/null | grep -q "^${RESUME_ID}$"
# Detect adapter from pane command
ADAPTER=$(tmux display -t "codetap:${RESUME_ID}" -p '#{pane_current_command}' 2>/dev/null)
```
- [ ] **Step 7: TypeScript compilation check**
`npx tsc --noEmit` — zero errors.
- [ ] **Step 8: Commit**
```bash
git add server/db.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts server/session-manager.ts server/index.ts bin/codetap
git commit -m "refactor: remove DB sessions table — in-memory Map is sole source of truth"
```
---
### Task 6: Simplify handleReconnect + review endpoints use Map for cwd
**Files:**
- Modify: `server/session-manager.ts`
- Modify: `server/index.ts`
- [ ] **Step 1: Simplify handleReconnect**
Remove the entire `hasActiveWindow` + `resumeSession` block:
```typescript
// BEFORE:
if (!adapter.getSession(sessionId)) {
const hasWindow = await adapter.hasActiveWindow(sessionId);
if (hasWindow) {
const dbRow = dbSessions.get(sessionId);
try { await adapter.resumeSession(sessionId, dbRow?.cwd || ''); } catch {}
}
}
// AFTER:
// (deleted — handleReconnect only loads history, handleQuery builds windows)
```
- [ ] **Step 2: Review endpoints use adapter.getSession for cwd**
In POST /api/reviews:
```typescript
// BEFORE:
const parentRow = dbSessions.get(parentCliSessionId);
const cwd = parentRow?.cwd || process.cwd();
// AFTER:
const parentSession = adapter.getSession(parentCliSessionId) as { cwd?: string } | null;
const cwd = parentSession?.cwd || process.cwd();
```
In POST /api/reviews/:id/send-back and DELETE /api/reviews/:id:
```typescript
// BEFORE:
const parentRow = dbSessions.get(review.parent_cli_session_id);
const parentAdapter = getAdapter(parentRow?.adapter || DEFAULT_ADAPTER);
// AFTER: parent_adapter is now stored in session_reviews
const parentAdapter = getAdapter(review.parent_adapter);
```
No need to iterate adapters — `parent_adapter` is directly in the review row.
In handleReconnect review restoration:
```typescript
// BEFORE:
const parentRow = dbSessions.get(review.parent_cli_session_id);
const cwd = parentRow?.cwd || '';
await childAdapterObj.resumeSession(review.child_cli_session_id, cwd);
// AFTER: don't call resumeSession — if server didn't restart, child is still in Map.
// If server restarted, windows are dead, review should be marked ended.
if (!childAdapterObj.getSession(review.child_cli_session_id)) {
// Child session gone (server restarted + windows killed) → mark review ended
sessionReviews.endReview(review.id);
continue;
}
// Child still alive → just send REVIEW_STARTED event, child's useChat reconnects itself
```
- [ ] **Step 3: Commit**
```bash
git add server/session-manager.ts server/index.ts
git commit -m "refactor: handleReconnect simplified, review endpoints use in-memory Map"
```
---
## Self-Review Checklist
### Compilation Safety
- Task 1 removes `_waitForCliUUID` calls → `startSession` returns temp key → compiles ✅
- Task 5 removes dbSessions → all callers must be updated in same task → grep to verify zero remaining references ✅
- Task 6 depends on Task 5 (dbSessions already removed) → correct ordering ✅
### Codex UUID Discovery Flow (after fix)
```
1. startSession → return tempKey immediately
2. handleQuery/reviews → paste [CODETAP_REF:tempKey] + prompt
3. Codex processes → SessionStart hook fires (or JSONL appears)
4. handleSessionStart → read JSONL → find CODETAP_REF:tempKey → match
5. _rekeyAndRename(tempKey, uuid) → Map re-key + tmux rename
6. From now on: session ID = UUID = tmux window name
```
### handleReconnect Flow (after fix)
```
User clicks session → RECONNECT
1. registerClient(conn, sessionId)
2. load JSONL history → HISTORY_LOAD
3. replay pending state
4. NO resumeSession, NO cwd lookup, NO DB query
5. If user sends message → handleQuery → resumeSession → builds tmux window
```
### Review Endpoints (after fix)
- POST /api/reviews: `cwd` from `adapter.getSession(parentId).cwd` (parent is active, must be in Map)
- send-back: find parent adapter by iterating `getAllAdapters()`, check `adapter.getSession(reviewParentId)`
- delete: same pattern
### bin/codetap (after fix)
- `-a` listing: `tmux list-windows` directly instead of DB query
- `--resume`: `tmux list-windows` to find window by name (= UUID)
- `get_project_sessions`: `tmux list-windows` + filter by `pane_current_path`
### Things NOT changed
- `session_reviews` DB table — stays (Cross-AI Review needs it)
- In-memory `sessions` Map — stays (runtime state store)
- JSONL files — untouched (historical record)
- Push notifications — untouched
- Permission manager — untouched
### Issues Found in Self-Review (all addressed in plan)
- Claude's `destroy()` already has `killSession()` — only Codex needs it added (Task 4 updated)
- Claude's `_findWindowForSession` has DB-first check — keep only name-matching fallback (Task 5 Step 2 noted)
- Review restoration in handleReconnect: don't call `resumeSession`, just check if child exists or mark ended (Task 6 Step 2 updated)
- `send-back`/`delete` review endpoints: added `parent_adapter` column to `session_reviews` — direct lookup, no iteration (Task 5 Step 1)
- `bin/codetap --resume` detects adapter from `pane_current_command` (Task 5 Step 6 updated)
- dbSessions has 26 call sites across 4 files — all enumerated in Task 5
## Verification
1. Server starts without sessions table in DB
2. New Claude session from Web UI → works (marker injected, UUID known immediately)
3. New Codex session from Web UI → works (marker injected, UUID discovered via hook, tmux renamed)
4. Cross-AI Review → works (marker in context, child session matched, floating panel opens)
5. Server shutdown → all tmux windows killed
6. Server restart → clean state, no stale windows
7. `bin/codetap -a` → lists sessions from tmux directly
8. `bin/codetap --resume <UUID>` → works
9. Historical session click → loads history (no 30s wait, no resumeSession)
10. ChatView shows no `[CODETAP_REF:...]` markers
@@ -0,0 +1,351 @@
# Remaining Session Fixes — 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.
**Goal:** Complete session architecture cleanup: remove pending guessing, remove desktop-discovery, add session API endpoints, update bin/codetap.
**Spec:** `docs/superpowers/specs/2026-03-24-remaining-session-fixes.md`
---
### Task 1: Codex handleSessionStart — remove pending matching, add _pendingHookBodies
**Files:** `server/adapters/codex/codex-tmux-adapter.ts`
- [ ] **Step 1: Add _pendingHookBodies field**
```typescript
private _pendingHookBodies: Map<string, CodexHookBody> = new Map();
```
- [ ] **Step 2: Rewrite handleSessionStart (line 275)**
Replace the entire method body:
```typescript
handleSessionStart(body: CodexHookBody): void {
const codexUuid = body.session_id;
if (!codexUuid) return;
// 1. Already managed
if (this.sessions.has(codexUuid)) {
this._applySessionStartBody(codexUuid, body);
return;
}
// 2. Has pending sessions → store hook body, let _watchForTranscript match later
const hasPending = [...this.sessions.values()].some(s => s._watcherPending);
if (hasPending) {
this._pendingHookBodies.set(codexUuid, body);
return;
}
// 3. Not our session → ignore
}
```
Remove the old `pendingSessions.length === 1` block (lines 297-308).
Remove the "desktop/unknown origin" block (lines 309-330).
- [ ] **Step 3: Update _watchForTranscript to read _pendingHookBodies after rekey**
In the `scanOnce` function, after `_rekeyAndRename(sessionId, uuid)` succeeds, check for stored hook body:
```typescript
const hookBody = this._pendingHookBodies.get(uuid);
if (hookBody) {
this._applySessionStartBody(uuid, hookBody);
this._pendingHookBodies.delete(uuid);
}
```
- [ ] **Step 4: Add cleanup for _pendingHookBodies**
In `_startSessionCleanup` interval, add a sweep:
```typescript
// Clean up stale pending hook bodies (older than 60s)
const now = Date.now();
for (const [uuid, body] of this._pendingHookBodies) {
// Use a timestamp field or just clean up all entries periodically
this._pendingHookBodies.delete(uuid);
}
```
Actually simpler: clean up in _pendingHookBodies when adding — if size > 10, delete oldest. Or just clear all entries older than 60s by storing a timestamp alongside.
- [ ] **Step 5: Commit**
```bash
git commit -m "refactor: Codex handleSessionStart uses _pendingHookBodies, no pending guessing"
```
---
### Task 2: Remove desktop-discovery from both adapters
**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts`
- [ ] **Step 1: Claude — simplify handleSessionStart (line 512)**
Current code (lines 512-539) does:
1. `sessions.has(cliUuid)` → update lastActivity → return
2. List tmux windows → search for `w.command.includes('claude') && !sessions.has(w.name)` → create session
Remove step 2 entirely. The method becomes:
```typescript
async handleSessionStart(body: HookBody): Promise<void> {
const cliUuid = body.session_id;
if (!cliUuid) return;
if (this.sessions.has(cliUuid)) {
this.sessions.get(cliUuid)!.lastActivity = Date.now();
return;
}
// Unknown UUID — not our session, ignore
}
```
Also remove the `await tmuxManager.listWindows()` call (no longer needed).
- [ ] **Step 2: Codex — verify desktop-discovery already removed in Task 1**
Check that Task 1's rewrite of `handleSessionStart` has no desktop-discovery path.
- [ ] **Step 3: Commit**
```bash
git commit -m "refactor: remove desktop-discovery from both adapters"
```
---
### Task 3: Add POST /api/sessions/start and /resume endpoints
**Files:** `server/index.ts`
- [ ] **Step 1: Add POST /api/sessions/start**
Place after the existing session endpoints (after DELETE /api/active-sessions/:id):
```typescript
app.post('/api/sessions/start', authMiddleware, async (req: Request, res: Response) => {
try {
const { adapter: adapterName, cwd, model, permissionMode } = req.body;
if (!cwd) return res.status(400).json({ error: 'cwd required' });
const adapter = getAdapter(adapterName || DEFAULT_ADAPTER);
if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${adapterName}` });
const handle = await adapter.startSession(cwd, { model, permissionMode });
// Register in sessionAdapterMap so events route correctly
sessionAdapterMap.set(handle.sessionId, adapterName || DEFAULT_ADAPTER);
res.json({ sessionId: handle.sessionId });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
```
Note: import `sessionAdapterMap` — check if it's already accessible. It's a module-level variable in `session-manager.ts`. May need to export a helper function `registerSessionAdapter(sessionId, adapterName)` from session-manager.
Actually, looking at the code: `sessionAdapterMap` is defined in `session-manager.ts` as a module-level const. It's NOT exported. The `handleQuery` function accesses it directly because it's in the same file.
For `server/index.ts` to set it, we need either:
a) Export `sessionAdapterMap` from session-manager.ts
b) Add a `registerSessionAdapter(id, name)` helper exported from session-manager.ts
c) Have `startSession` trigger an event that session-manager listens to
Option (b) is cleanest.
- [ ] **Step 2: Add POST /api/sessions/resume**
```typescript
app.post('/api/sessions/resume', authMiddleware, async (req: Request, res: Response) => {
try {
const { sessionId, adapter: adapterName, cwd } = req.body;
if (!sessionId) return res.status(400).json({ error: 'sessionId required' });
// Determine adapter if not provided
let resolvedAdapterName = adapterName;
if (!resolvedAdapterName) {
// Try to detect from JSONL file location
// ... (use existing findSessionFile logic from each adapter's jsonl-store)
resolvedAdapterName = DEFAULT_ADAPTER;
}
const adapter = getAdapter(resolvedAdapterName);
if (!adapter) return res.status(400).json({ error: `Unknown adapter: ${resolvedAdapterName}` });
const handle = await adapter.resumeSession(sessionId, cwd || process.cwd());
registerSessionAdapter(handle.sessionId, resolvedAdapterName);
res.json({ sessionId: handle.sessionId });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
```
- [ ] **Step 3: Export registerSessionAdapter from session-manager.ts**
```typescript
export function registerSessionAdapter(sessionId: string, adapterName: string): void {
sessionAdapterMap.set(sessionId, adapterName);
}
```
- [ ] **Step 4: Commit**
```bash
git commit -m "feat: add POST /api/sessions/start and /resume endpoints"
```
---
### Task 4: Update bin/codetap to use API endpoints
**Files:** `bin/codetap`
NOTE: `sqlite3` references were already removed in Fix 5. This task replaces direct `tmux new-window` calls with API calls.
- [ ] **Step 1: Add authentication function**
Near the top of the script (after the server-running check):
```bash
get_auth_token() {
curl -sk -X POST "https://localhost:$PORT/api/auth/login" \
-H "Content-Type: application/json" \
-d "{\"password\":\"$CLAUDE_UI_PASSWORD\"}" 2>/dev/null | \
python3 -c 'import sys,json; print(json.load(sys.stdin).get("token",""))' 2>/dev/null
}
```
- [ ] **Step 2: Replace `new` session creation**
Find the block that does `tmux new-window ... "$COMMAND"`. Replace with:
```bash
AUTH_TOKEN=$(get_auth_token)
if [ -z "$AUTH_TOKEN" ]; then
echo "Error: Failed to authenticate with CodeTap server"
exit 1
fi
RESULT=$(curl -sk -X POST "https://localhost:$PORT/api/sessions/start" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"adapter\":\"$ADAPTER\",\"cwd\":\"$(pwd)\"}")
SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null)
if [ -z "$SESSION_ID" ] || [ "$SESSION_ID" = "null" ]; then
echo "Error: Failed to create session"
echo "$RESULT"
exit 1
fi
tmux select-window -t "$TMUX_SESSION:$SESSION_ID"
```
- [ ] **Step 3: Replace `--resume` with API call**
```bash
AUTH_TOKEN=$(get_auth_token)
RESULT=$(curl -sk -X POST "https://localhost:$PORT/api/sessions/resume" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"sessionId\":\"$RESUME_ID\",\"adapter\":\"$ADAPTER\",\"cwd\":\"$(pwd)\"}")
SESSION_ID=$(echo "$RESULT" | python3 -c 'import sys,json; print(json.load(sys.stdin).get("sessionId",""))' 2>/dev/null)
tmux select-window -t "$TMUX_SESSION:$SESSION_ID"
```
- [ ] **Step 4: Replace `--continue`**
Find most recent tmux window, resume it:
```bash
LATEST=$(tmux list-windows -t "$TMUX_SESSION" -F '#{window_activity} #{window_name}' 2>/dev/null | sort -rn | head -1 | awk '{print $2}')
if [ -n "$LATEST" ] && [ "$LATEST" != "main" ]; then
tmux select-window -t "$TMUX_SESSION:$LATEST"
else
echo "No active sessions to continue"
exit 1
fi
```
- [ ] **Step 5: Verify -a listing uses tmux directly**
Should already be tmux-based (from Fix 5). Verify no remaining sqlite3 references.
- [ ] **Step 6: Commit**
```bash
git commit -m "refactor: bin/codetap uses API endpoints for session creation"
```
---
## Self-Review Checklist
### Compilation safety
- Task 1 changes only Codex adapter internals → compiles independently ✅
- Task 2 simplifies Claude handleSessionStart → compiles independently ✅
- Task 3 adds new endpoints, needs `registerSessionAdapter` export → export first, then add endpoints ✅
- Task 4 is shell script only → no compilation ✅
### Codex _watchForTranscript flow after Task 1
```
1. startSession → temp key in Map, _watcherPending = true
2. pasteToSession → marker + prompt pasted
3. SessionStart hook fires → has pending → stored in _pendingHookBodies
4. _watchForTranscript detects JSONL → reads marker → matches temp key
5. _rekeyAndRename(tempKey, uuid) → rekey + rename
6. Read _pendingHookBodies(uuid) → apply transcript_path, cwd
7. Start JSONL watcher
```
All steps covered ✅
### Claude handleSessionStart after Task 2
```
handleSessionStart(body):
sessions.has(uuid) → true → update → return
→ false → ignore
```
Two lines. Very simple. No matching, no discovery. ✅
### bin/codetap after Task 4
- `new`: API call → tmux select-window ✅
- `--resume`: API call → tmux select-window ✅
- `--continue`: tmux list-windows → select most recent ✅
- `-a`: tmux list-windows directly ✅
- No sqlite3 references ✅
- Requires server running + password (already a requirement) ✅
### Edge cases
- **bin/codetap when server is down:** API calls fail → script shows error → user knows server needs to be running. This is acceptable since CodeTap server is required for all functionality.
- **Multiple pending sessions with same UUID in _pendingHookBodies:** Won't happen — UUIDs are unique per CLI session.
- **_pendingHookBodies grows unbounded:** Mitigated by cleanup in _startSessionCleanup (60s sweep).
- **bin/codetap Codex new session — temp key returned:** Script does `tmux select-window -t codetap:codex-{timestamp}`. After rekey, window renamed to UUID. User is already inside — unaffected.
### No changes needed
- `server/session-manager.ts` — only needs `registerSessionAdapter` export (Task 3)
- `server/db.ts` — no changes
- Frontend — no changes
- `server/adapters/claude/tmux-manager.ts` — no changes
## Verification
1. Server starts cleanly
2. New Codex session from Web UI → hook stored in _pendingHookBodies → _watchForTranscript matches → rekey
3. New Claude session from Web UI → works (no matching needed)
4. `bin/codetap new --adapter claude` → API call → session created → window selected
5. `bin/codetap new --adapter codex` → API call → session created → window selected
6. `bin/codetap --resume UUID` → API call → session resumed
7. `bin/codetap -a` → lists sessions from tmux
8. Desktop-started sessions (user runs `claude`/`codex` directly) → hooks ignored by CodeTap (expected)
@@ -0,0 +1,468 @@
# Session ID Unification 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:** Eliminate the dual session ID system (internal ID + CLI UUID) and unify on CLI UUID as the single source of truth across the entire codebase.
**Architecture:** 5 phases -- (1) DB schema migration, (2) adapter internals (both Claude + Codex), (3) session manager + permissions + push, (4) server endpoints + frontend, (5) CLI script + cleanup. Each phase builds on the previous.
**Tech Stack:** TypeScript, SQLite (better-sqlite3), tmux, React, WebSocket, Shell (bin/codetap)
**Spec:** `docs/superpowers/specs/2026-03-24-session-id-unification-design.md`
---
## Phase 1: DB Schema
### Task 1: Migrate sessions table -- CLI UUID as primary key
**Files:**
- Modify: `server/db.ts`
- [ ] **Step 1: Update SessionRow interface (line 284)**
Remove `cli_session` field, add `window_name`:
```typescript
export interface SessionRow {
id: string; // CLI UUID (was internal ID)
cwd: string;
window_id: string | null;
window_name: string | null; // tmux window name for debug
adapter: string;
permission_mode: string;
created_at: string;
last_activity: string;
}
```
- [ ] **Step 2: Add schema migration in initDB() (after line 85)**
Detect old `cli_session` column and rebuild table:
```typescript
const hasCliSession = tableInfo.some((c: any) => c.name === 'cli_session');
const hasWindowName = tableInfo.some((c: any) => c.name === 'window_name');
if (hasCliSession && !hasWindowName) {
d.exec(`
CREATE TABLE IF NOT EXISTS sessions_new (
id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
window_id TEXT,
window_name TEXT,
adapter TEXT DEFAULT 'claude',
permission_mode TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now')),
last_activity TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO sessions_new (id, cwd, window_id, window_name, adapter, permission_mode, created_at, last_activity)
SELECT
CASE WHEN cli_session IS NOT NULL AND cli_session != '' AND cli_session != id THEN cli_session ELSE id END,
cwd, window_id, id, adapter, permission_mode, created_at, last_activity
FROM sessions;
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;
CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id);
`);
console.log('[db] Migrated sessions table: CLI UUID as primary key');
}
```
- [ ] **Step 3: Update CREATE TABLE for fresh installs (line 20)**
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
window_id TEXT,
window_name TEXT,
adapter TEXT DEFAULT 'claude',
permission_mode TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now')),
last_activity TEXT DEFAULT (datetime('now'))
);
```
- [ ] **Step 4: Update prepared statements**
Replace `sessionsUpsert` SQL:
```sql
INSERT INTO sessions (id, cwd, window_id, window_name, adapter)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
window_id = excluded.window_id,
window_name = excluded.window_name,
last_activity = datetime('now')
```
Replace `sessionsFindByCliSession` with `sessionsGet`:
```typescript
sessionsGet: d.prepare('SELECT * FROM sessions WHERE id = ?'),
```
- [ ] **Step 5: Update sessions operations export (line 308)**
```typescript
export const sessions = {
upsert(id: string, cwd: string, windowId?: string, windowName?: string, adapter?: string): void {
stmts().sessionsUpsert.run(id, cwd, windowId || null, windowName || null, adapter || 'claude');
},
get(id: string): SessionRow | undefined {
return stmts().sessionsGet.get(id) as SessionRow | undefined;
},
findByWindowId(windowId: string): SessionRow | undefined {
return stmts().sessionsFindByWindowId.get(windowId) as SessionRow | undefined;
},
remove(id: string): void { stmts().sessionsRemove.run(id); },
getAll(): SessionRow[] { return stmts().sessionsGetAll.all() as SessionRow[]; },
clearAll(): void { getDB().exec('DELETE FROM sessions'); },
};
```
Key: `upsert` signature changes from `(id, cliSession, cwd, windowId, adapter)` to `(id, cwd, windowId, windowName, adapter)`. `findByCliSession` replaced by `get` (PK lookup).
**IMPORTANT — Backward compatibility:** To allow each task to compile independently, KEEP the old methods as deprecated aliases alongside the new ones:
```typescript
/** @deprecated Use get() instead */
findByCliSession(cliSession: string): SessionRow | undefined {
return this.get(cliSession); // After migration, cli_session IS the id
},
/** @deprecated Use upsert(id, cwd, windowId, windowName, adapter) instead */
upsertLegacy(id: string, cliSession: string, cwd: string, windowId?: string, adapter?: string): void {
// During transition: use cliSession as new id if it looks like a UUID, else use id
const effectiveId = (cliSession && cliSession !== id && cliSession.includes('-')) ? cliSession : id;
this.upsert(effectiveId, cwd, windowId, id, adapter);
},
```
These aliases are removed in Task 8 (cleanup). This allows Tasks 2-5 to compile at each intermediate step.
- [ ] **Step 6: Wrap migration in transaction**
Ensure the migration SQL in Step 2 is wrapped in a transaction:
```typescript
d.transaction(() => {
d.exec(` ... migration SQL ... `);
})();
```
- [ ] **Step 7: Remove old index creation**
At lines 94-98 of db.ts, the `CREATE INDEX idx_sessions_cli ON sessions(cli_session)` must be removed or guarded (column no longer exists after migration).
- [ ] **Step 8: Commit**
```bash
git add server/db.ts
git commit -m "refactor: migrate sessions table -- CLI UUID as primary key"
```
---
## Phase 2: Adapter Internals
### Task 2: Unify Claude adapter to CLI UUID
**Files:**
- Modify: `server/adapters/claude/tmux-adapter.ts`
- Modify: `server/adapters/claude/index.ts`
- Modify: `server/adapters/interface.ts`
- [ ] **Step 1: Remove translation infrastructure**
In `tmux-adapter.ts`:
- Remove `cliToSessionId: Map<string, string>` field (line 88)
- Remove ALL `this.cliToSessionId.set(...)` calls
- Remove `resolveSessionId()` method (lines 912-970)
- Remove `_registerCliUUID()` (lines 797-805) -- also fixes `claude_session` bug
- Remove `_remapCliSession()` (lines 779-785)
- Remove `_removeCliMapping()` (lines 792-793)
In `interface.ts`: remove `resolveSessionId` method from IAdapter base class.
In `claude/index.ts`: remove `resolveSessionId` delegation (line 219).
- [ ] **Step 2: Change sessions Map key to CLI UUID**
In `startSession()`:
- `const sessionId = cliSessionId` (was `windowName`)
- `this.sessions.set(sessionId, ...)` keyed by CLI UUID
- `dbSessions.upsert(sessionId, cwd, windowId, windowName, 'claude')` -- new signature
- Return `{ sessionId }` -- now CLI UUID
In `resumeSession()`:
- `const newSessionId = cliUuid` (was `'claude-${Date.now()}'`)
- Keep `const windowName = 'claude-${Date.now()}'` for tmux display
- `this.sessions.set(newSessionId, ...)` keyed by CLI UUID
- `dbSessions.upsert(newSessionId, cwd, windowId, windowName, 'claude')`
- Return `{ sessionId: newSessionId }`
In `attachSession()`: same pattern -- use CLI UUID as Map key.
In `handleSessionStart()`: use CLI UUID from hook body directly as Map key.
- [ ] **Step 3: Update _findWindowForSession (line 986)**
Replace window-name matching AND the `findByCliSession` fallback (line 994) with DB PK lookup:
```typescript
const dbRow = dbSessions.get(sessionId);
if (dbRow?.window_id) return dbRow.window_id;
```
Remove `windows.find(w => w.name === sessionId)` -- no longer matching by window name.
- [ ] **Step 4: Update all dbSessions.upsert calls to new signature**
Enumerate ALL call sites in this file and update each from `(id, cliSession, cwd, windowId, adapter)` to `(id, cwd, windowId, windowName, adapter)`:
- Line 136 (startSession)
- Line 192 (attachSession)
- Line 239 (resumeSession)
- Line 572 (handleSessionStart)
- Line 946 (inside resolveSessionId -- goes away when method is deleted)
- [ ] **Step 5: Commit**
```bash
git add server/adapters/claude/tmux-adapter.ts server/adapters/claude/index.ts server/adapters/interface.ts
git commit -m "refactor: Claude adapter uses CLI UUID as session key"
```
---
### Task 3: Unify Codex adapter to CLI UUID + _waitForCliUUID
**Files:**
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- Modify: `server/adapters/codex/index.ts`
- Modify: `server/adapters/codex/pane-monitor.ts`
- [ ] **Step 1: Remove translation infrastructure**
Same as Claude: remove `cliToSessionId` Map, `resolveSessionId()`, `_removeCliMapping()`.
In `codex/index.ts`: remove `resolveSessionId` delegation.
- [ ] **Step 2: Add _waitForCliUUID method**
New method that polls `session.cliSessionId` every 500ms (max 15s). When UUID discovered: re-key session in Map, upsert DB, remove temp key. On timeout: kill tmux window, remove temp session, throw error.
- [ ] **Step 3: Update startSession**
Store session under temp `windowName` key initially. After `_waitForReady`, call `await this._waitForCliUUID(windowName)` which returns CLI UUID. Return `{ sessionId: cliUUID }`.
- [ ] **Step 4: Update resumeSession + handleSessionStart + _watchForTranscript**
All use CLI UUID as Map key directly. `handleSessionStart` and `_watchForTranscript` set `session.cliSessionId` (which `_waitForCliUUID` polls for).
- [ ] **Step 5: Update all dbSessions.upsert calls to new signature**
Enumerate ALL call sites in this file:
- Line 135 (startSession)
- Line 189 (resumeSession)
- Line 337 (handleSessionStart)
- Line 753 (_watchForTranscript scanOnce lambda)
- Line 861 (_findAndAttachWindow)
- [ ] **Step 6: Commit**
```bash
git add server/adapters/codex/codex-tmux-adapter.ts server/adapters/codex/index.ts server/adapters/codex/pane-monitor.ts
git commit -m "refactor: Codex adapter uses CLI UUID, add _waitForCliUUID"
```
---
## Phase 3: Session Manager
### Task 4: Unify session-manager to CLI UUID
**Files:**
- Modify: `server/session-manager.ts`
- [ ] **Step 1: Remove all resolveSessionId calls**
In `handleQuery`: remove resolution block. `sessionId` from client is CLI UUID.
In `handleReconnect`: remove resolution block. Use `sessionId` directly as `effectiveId`.
Remove all `(adapter as ...).resolveSessionId?.(...)` casts.
- [ ] **Step 2: Simplify sendSessionCreated**
Send single `sessionId` (CLI UUID). Remove `cliSessionId` field.
- [ ] **Step 3: Simplify handleReconnect**
Preserve all 11 steps. Key changes:
- Step 6: add `hasActiveWindow` guard (prevent creating unwanted tmux windows)
- Step 8: use `sessionId` directly for `getMessages()` (CLI UUID = JSONL key)
- Step 11: use `sessionId` directly for `getActiveForParent()`
- Replace `dbSessions.findByCliSession` with `dbSessions.get`
- Remove dynamic `import('./db.js')` -- use static import
- [ ] **Step 4: Simplify triggerPush**
Single `getSession()` call. Use `sessionId` directly for child review check.
- [ ] **Step 5: Simplify session-ended handler**
`sessionId` IS CLI UUID. Remove convoluted DB lookup for `endedCliId`. Use directly for review cascade.
- [ ] **Step 6: Update dbSessions calls**
Replace all `dbSessions.findByCliSession(...)` with `dbSessions.get(...)`.
- [ ] **Step 7: Commit**
```bash
git add server/session-manager.ts
git commit -m "refactor: session-manager uses CLI UUID for broadcast and registration"
```
---
## Phase 4: Server Endpoints + Frontend
### Task 5: Update server/index.ts
**Files:**
- Modify: `server/index.ts`
- [ ] **Step 1: Replace dbSessions.findByCliSession with dbSessions.get**
All `dbSessions.findByCliSession(...)` calls become `dbSessions.get(...)`.
- [ ] **Step 2: Simplify active-sessions client count**
Replace `getClientCount(s.sessionId) || getClientCount(s.cliSessionId)` with `getClientCount(s.sessionId)`.
- [ ] **Step 3: Update review endpoints to use dbSessions.get**
- [ ] **Step 4: Commit**
```bash
git add server/index.ts
git commit -m "refactor: server endpoints use CLI UUID, remove dual-ID lookups"
```
---
### Task 6: Unify frontend
**Files:**
- Modify: `src/hooks/useChat.ts`
- Modify: `src/components/ChatView.tsx`
- Modify: `src/hooks/useSessions.ts`
- Modify: `src/components/SessionsView.tsx`
- [ ] **Step 1: Merge sessionId + cliSessionId in useChat**
Remove `cliSessionId` state. Keep only `sessionId` (CLI UUID). Remove `setCliSessionId(msg.cliSessionId)` from SESSION_CREATED handler. Remove `cliSessionId` from return.
- [ ] **Step 2: Update ChatView**
Remove `cliSessionId` from useChat destructuring. Use `sessionId` for ChatHeader display and review API calls.
- [ ] **Step 3: Update useSessions**
Change `s.cliSessionId` to `s.sessionId` in `activeSessionIds` builder.
- [ ] **Step 4: Update SessionsView**
Change `pending[session.cliSessionId]` to `pending[session.sessionId]` for notification badges.
- [ ] **Step 5: Commit**
```bash
git add src/hooks/useChat.ts src/components/ChatView.tsx src/hooks/useSessions.ts src/components/SessionsView.tsx
git commit -m "refactor: frontend uses single sessionId (CLI UUID)"
```
---
## Phase 5: CLI + Cleanup
### Task 7: Update bin/codetap
**Files:**
- Modify: `bin/codetap`
- [ ] **Step 1: Update get_project_sessions() SQL (line 239)**
Change `SELECT id FROM sessions` to `SELECT window_name FROM sessions` -- returns tmux window names for matching.
- [ ] **Step 2: Update -a listing SQL (line 290)**
Change to `SELECT id, adapter, window_name, cwd FROM sessions WHERE window_name IN (...)`.
- [ ] **Step 3: Update --resume SQL (line 382)**
Change to `WHERE id='${SAFE_ID}' OR window_name='${SAFE_ID}'` -- accepts both CLI UUID and window name (backwards compatible for users who may pass old-style IDs).
- [ ] **Step 4: Commit**
```bash
git add bin/codetap
git commit -m "refactor: bin/codetap uses new DB schema"
```
---
### Task 8: Final cleanup -- remove deprecated aliases, verify all files
**Files:**
- Modify: `server/db.ts`
- Modify: `server/adapters/interface.ts`
- Modify: `tests/e2e-spec.feature`
- [ ] **Step 1: Remove deprecated DB method aliases**
In `server/db.ts`, remove `findByCliSession` and `upsertLegacy` deprecated aliases added in Task 1. Grep to verify zero remaining callers.
- [ ] **Step 2: Deprecate ActiveSessionInfo.cliSessionId**
In `server/adapters/interface.ts`, add `/** @deprecated Use sessionId instead */` comment.
- [ ] **Step 3: Verify no-op files from spec**
These files are listed in the spec as MODIFY but require no code changes (already use generic string params). Verify each with grep:
- `server/push.ts` -- callers now pass CLI UUID. NO CHANGE needed.
- `server/permission-manager.ts` -- callers now pass CLI UUID. NO CHANGE needed.
- `server/types/messages.ts` -- `QueryOptions.sessionId` is generic. NO CHANGE.
- `server/types/adapter.ts` -- `SessionInfo.sessionId` already CLI UUID. NO CHANGE.
- `src/lib/ws.ts`, `src/lib/api.ts`, `src/sw.ts`, `src/App.tsx`, `src/components/FloatingReviewPanel.tsx` -- NO CHANGE.
- [ ] **Step 4: Verify session_stats table**
Confirm `session_stats` table is never written to (no INSERT statements). No migration needed.
- [ ] **Step 5: Update e2e specs**
Remove references to dual-ID system, `resolveSessionId`, `cliSessionId` as separate concept.
- [ ] **Step 6: TypeScript compilation check**
`npx tsc --noEmit` -- zero errors.
- [ ] **Step 7: Commit**
```bash
git add -A
git commit -m "refactor: cleanup -- remove deprecated aliases, verify all files, update e2e specs"
```
---
## Verification
After all tasks:
1. Server starts, migration runs without errors
2. Open historical session from project list -- history loads immediately (no 30s wait)
3. Open session from active list -- connects correctly
4. Send message -- goes to correct tmux window
5. Desktop opens same session -- mobile receives streaming events in real-time
6. Push notification click -- navigates to correct session
7. Cross-AI Review -- create, chat, send-back, end -- all work
8. `bin/codetap -a` -- lists sessions correctly
9. `bin/codetap --resume <UUID>` -- resumes correctly
10. Server restart -- sessions re-discovered, reviews survive
@@ -0,0 +1,409 @@
# Window Name to CLI UUID + Backward Compat Cleanup 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:** Two things: (1) Use CLI UUID as tmux window name, eliminating `window_name` column and all name-mapping. (2) Delete all backward-compat code (old migrations, deprecated aliases) since the app is pre-release.
**Architecture:** 5 tasks: (1) add renameWindow to TmuxManager, (2) update adapters to use CLI UUID as window name, (3) clean up DB — remove window_name + delete old migrations + simplify schema, (4) remove ActiveSessionInfo.cliSessionId from public API, (5) update bin/codetap.
**Tech Stack:** TypeScript, SQLite, tmux, Shell
**After this plan completes, the session ID system is fully clean:**
- Single ID everywhere: CLI UUID
- tmux window name = CLI UUID
- No translations, no mappings, no deprecated aliases
- DB has minimal schema with no migration chain
---
### Task 1: Add renameWindow to TmuxManager
**Files:**
- Modify: `server/adapters/claude/tmux-manager.ts`
- [ ] **Step 1: Add renameWindow method**
After `killWindow()`, add:
```typescript
async renameWindow(windowId: string, newName: string): Promise<void> {
const target = `${SESSION_NAME}:${windowId}`;
await exec(TMUX, ['rename-window', '-t', target, newName]);
}
```
- [ ] **Step 2: Commit**
```bash
git add server/adapters/claude/tmux-manager.ts
git commit -m "feat: add renameWindow to TmuxManager"
```
---
### Task 2: Use CLI UUID as tmux window name in both adapters
**Files:**
- Modify: `server/adapters/claude/tmux-adapter.ts`
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
**Claude adapter:**
- [ ] **Step 1: startSession — use CLI UUID as window name**
Remove `const windowName = ...`. Pass `sessionId` (CLI UUID) directly:
```typescript
const windowId = await tmuxManager.createWindow(sessionId, cwd, parts.join(' '));
```
Update `dbSessions.upsert` — pass `undefined` for windowName (removed in Task 3):
```typescript
dbSessions.upsert(sessionId, cwd, windowId, undefined, 'claude');
```
- [ ] **Step 2: resumeSession — use CLI UUID as window name**
```typescript
const windowId = await tmuxManager.createWindow(cliUuid, cwd, command);
```
- [ ] **Step 3: attachSession — same pattern**
Remove windowName from upsert calls, pass `undefined`.
- [ ] **Step 4: Update _handleSessionStart discovery**
Replace `w.name.startsWith('claude-')` with:
```typescript
if (w.command.includes('claude') && !this.sessions.has(w.name)) {
```
This works because: CodeTap-created windows have CLI UUID names (which are in the sessions Map if managed). Desktop-started windows have arbitrary names (not in the Map). Either way, checking `!this.sessions.has(w.name)` correctly identifies unmanaged windows.
- [ ] **Step 5: Simplify _findWindowForSession**
```typescript
private async _findWindowForSession(sessionId: string, windowList?: TmuxWindow[]): Promise<string | null> {
const windows = windowList || await tmuxManager.listWindows();
// Primary: check DB for stored window_id
const dbRow = dbSessions.get(sessionId);
if (dbRow?.window_id && windows.some(w => w.id === dbRow.window_id)) {
return dbRow.window_id;
}
// Fallback: match by name (window name = CLI UUID = sessionId)
const match = windows.find(w => w.name === sessionId);
return match?.id || null;
}
```
Note: `listWindows()` is called once and reused for both checks.
**Codex adapter:**
- [ ] **Step 6: startSession — temp name, then rename**
```typescript
const tempName = `codex-${Date.now()}`;
const windowId = await tmuxManager.createWindow(tempName, cwd, parts.join(' '));
// ... _waitForReady, _watchForTranscript ...
const cliUUID = await this._waitForCliUUID(tempName);
// Rename tmux window to CLI UUID
const session = this.sessions.get(cliUUID);
if (session?.windowId) {
await tmuxManager.renameWindow(session.windowId, cliUUID);
}
return { sessionId: cliUUID };
```
- [ ] **Step 7: resumeSession — use CLI UUID as window name**
```typescript
const windowId = await tmuxManager.createWindow(codexUuid, cwd, parts.join(' '));
```
- [ ] **Step 8: Pass undefined for windowName in all upsert calls**
Both adapters: `dbSessions.upsert(id, cwd, windowId, undefined, adapter)`.
- [ ] **Step 9: Commit**
```bash
git add server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts
git commit -m "refactor: use CLI UUID as tmux window name"
```
---
### Task 3: Clean up DB — remove window_name, delete old migrations, simplify
**Files:**
- Modify: `server/db.ts`
This task does 3 things: (a) remove `window_name` column, (b) delete ALL old schema migrations, (c) delete `migrateJsonToSqlite`. Since the app is pre-release, no backward compat needed.
- [ ] **Step 1: Replace entire initDB() migration section with a single clean schema**
Delete ALL migration code in initDB():
- `claude_session → cli_session` rename (~line 83-85)
- `cli_session → id + window_name` table rebuild (~line 93-117)
- Any `PRAGMA table_info` checks
Replace the CREATE TABLE with the FINAL clean schema:
```sql
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
window_id TEXT,
adapter TEXT DEFAULT 'claude',
permission_mode TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now')),
last_activity TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id);
```
No `cli_session`, no `window_name`, no `claude_session`. Just the final schema.
- [ ] **Step 2: Delete migrateJsonToSqlite function**
Remove the entire `migrateJsonToSqlite` function (~line 282-309) and its exported types (`JsonPushSub`, etc.). Also remove its caller — grep for `migrateJsonToSqlite` in `server/index.ts`.
- [ ] **Step 3: Update SessionRow interface**
```typescript
export interface SessionRow {
id: string; // CLI UUID
cwd: string;
window_id: string | null;
adapter: string;
permission_mode: string;
created_at: string;
last_activity: string;
}
```
Remove `window_name` and `cli_session` fields entirely.
- [ ] **Step 4: Update upsert signature and SQL**
```typescript
upsert(id: string, cwd: string, windowId?: string, adapter?: string): void {
stmts().sessionsUpsert.run(id, cwd, windowId || null, adapter || 'claude');
},
```
SQL:
```sql
INSERT INTO sessions (id, cwd, window_id, adapter)
VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
cwd = excluded.cwd,
window_id = excluded.window_id,
last_activity = datetime('now')
```
- [ ] **Step 5: Update ALL dbSessions.upsert callers**
Grep entire codebase. Change from 5-param to 4-param signature. Locations:
- `server/adapters/claude/tmux-adapter.ts` — all upsert calls (~4-5 sites)
- `server/adapters/codex/codex-tmux-adapter.ts` — all upsert calls (~4-5 sites)
- `server/adapters/codex/codex-tmux-adapter.ts``_waitForCliUUID` upsert
- [ ] **Step 6: Handle existing DB with old schema**
Since we deleted all migrations, if an old DB exists with `cli_session` or `window_name` columns, it will be incompatible. Add a simple destructive migration:
```typescript
// If old schema detected, just drop and recreate
const tableInfo = d.prepare("PRAGMA table_info('sessions')").all() as { name: string }[];
const hasOldColumns = tableInfo.some(c => c.name === 'cli_session' || c.name === 'window_name' || c.name === 'claude_session');
if (hasOldColumns) {
d.exec('DROP TABLE sessions');
// Table will be recreated by the CREATE TABLE IF NOT EXISTS above
d.exec(`CREATE TABLE sessions (...final schema...)`);
console.log('[db] Dropped old sessions table (pre-release cleanup)');
}
```
This is safe because the app is pre-release and `clearAll()` deletes all rows on shutdown anyway.
- [ ] **Step 7: Remove migrateJsonToSqlite caller from server/index.ts**
Grep for `migrateJsonToSqlite` in `server/index.ts` and remove the call.
- [ ] **Step 8: Commit**
```bash
git add server/db.ts server/index.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts
git commit -m "refactor: clean DB schema — remove window_name, delete old migrations"
```
---
### Task 4: Remove ActiveSessionInfo.cliSessionId from public API
**Files:**
- Modify: `server/adapters/interface.ts`
- Modify: `server/adapters/claude/tmux-adapter.ts`
- Modify: `server/adapters/codex/codex-tmux-adapter.ts`
- Modify: `src/hooks/useSessions.ts`
- Modify: `src/components/SessionsView.tsx`
- Modify: `server/index.ts`
- [ ] **Step 1: Remove cliSessionId from ActiveSessionInfo**
In `server/adapters/interface.ts`, remove:
```typescript
/** @deprecated Use sessionId instead — same value after unification */
cliSessionId: string;
```
- [ ] **Step 2: Remove cliSessionId from getActiveSessions in both adapters**
In Claude's `getActiveSessions()`: remove `cliSessionId: session.cliSessionId` from the returned object.
In Codex's `getActiveSessions()`: same.
- [ ] **Step 3: Update frontend useSessions.ts**
Change `if (s.cliSessionId) ids.add(s.cliSessionId)` to `if (s.sessionId) ids.add(s.sessionId)`. (May already be done — verify.)
- [ ] **Step 4: Update SessionsView.tsx**
Remove any remaining `session.cliSessionId` references. Use `session.sessionId` everywhere.
- [ ] **Step 5: Update server/index.ts active-sessions endpoint**
The active-sessions handler may still reference `s.cliSessionId` for child filtering. Change to `s.sessionId`.
- [ ] **Step 6: TypeScript compilation check**
`npx tsc --noEmit` — zero errors.
- [ ] **Step 7: Commit**
```bash
git add server/adapters/interface.ts server/adapters/claude/tmux-adapter.ts server/adapters/codex/codex-tmux-adapter.ts src/hooks/useSessions.ts src/components/SessionsView.tsx server/index.ts
git commit -m "refactor: remove deprecated cliSessionId from public API"
```
---
### Task 5: Update bin/codetap
**Files:**
- Modify: `bin/codetap`
- [ ] **Step 1: get_project_sessions() — query id**
```bash
# Before: SELECT window_name FROM sessions WHERE cwd=...
# After: SELECT id FROM sessions WHERE cwd=...
```
tmux window names are now CLI UUIDs = DB `id`.
- [ ] **Step 2: -a listing — match by id**
```bash
# Before: SELECT id, adapter, window_name, cwd FROM sessions WHERE window_name IN (...)
# After: SELECT id, adapter, cwd FROM sessions WHERE id IN (...)
```
- [ ] **Step 3: --resume — simplified**
```bash
# Before: WHERE id='...' OR window_name='...'
# After: WHERE id='...'
```
- [ ] **Step 4: Window name generation for new/continue**
Generate UUID for Claude (use `--session-id` value):
```bash
SESSION_UUID=$(python3 -c 'import uuid; print(uuid.uuid4())')
WINDOW_NAME="$SESSION_UUID"
# For Claude: pass --session-id $SESSION_UUID
```
For Codex: use temp name, server will rename after UUID discovery.
- [ ] **Step 5: Remove any window_name references**
Grep entire script for `window_name` — should be zero after above changes.
- [ ] **Step 6: Commit**
```bash
git add bin/codetap
git commit -m "refactor: bin/codetap uses CLI UUID as tmux window name"
```
---
## Self-Review Checklist
### Compilation Safety
- Task 2 passes `undefined` for windowName param → Task 3 removes the param. Between Tasks 2 and 3, the code compiles because `undefined` is valid for an optional `string?` param. ✅
- Task 4 removes `cliSessionId` from `ActiveSessionInfo`. All consumers updated in same task. ✅
### Codex _waitForCliUUID Flow
- Session starts under temp name `codex-{timestamp}` → stored in Map under temp key
- Hook/watcher sets `session.cliSessionId``_waitForCliUUID` polls and detects it
- `_waitForCliUUID` re-keys Map: delete temp key, set CLI UUID key
- **NEW**: `renameWindow(windowId, cliUUID)` renames the tmux window
- After this: window name = Map key = DB id = CLI UUID ✅
- `session.cliSessionId` field kept in `CodexSessionState` (needed for _waitForCliUUID polling). Not exposed publicly. ✅
### handleReconnect
- User clicks session → `registerClient(conn, sessionId)` where sessionId = CLI UUID
- `hasActiveWindow(sessionId)` checks if tmux window exists for this session
- After window name change: `_findWindowForSession(sessionId)` finds by `w.name === sessionId` (window name = CLI UUID) ✅
- Desktop later opens same session → events broadcast to CLI UUID → mobile receives ✅
### DB Schema Final State
```sql
sessions: id(PK/UUID), cwd, window_id(@N), adapter, permission_mode, created_at, last_activity
session_reviews: id, parent_cli_session_id, child_cli_session_id, child_adapter, ...
```
No `cli_session`, no `window_name`, no `claude_session`. Clean. ✅
### Old DB Handling
- If old DB exists with legacy columns → DROP TABLE + recreate. Data loss is fine (pre-release). ✅
- `session_reviews` table is not touched — it was created with the correct schema. ✅
### bin/codetap
- `-a` mode: tmux window names are now UUIDs, DB `id` is UUID → direct IN clause match ✅
- `--resume`: accepts UUID → `WHERE id='...'`
- `new` mode: generates UUID as window name ✅
- `--continue`: queries most recent session by `id` → resume it ✅
### Things NOT changed (correct to leave alone)
- `SessionState.cliSessionId` in both adapters — needed internally for Codex UUID discovery. Not exposed publicly after Task 4. ✅
- `session_reviews` table column names (`parent_cli_session_id`, `child_cli_session_id`) — these are just column names, not related to the internal ID concept. They store CLI UUIDs. ✅
- `tmux-manager.ts` `TmuxWindow.name` field — still populated from `#{window_name}` tmux format. Now contains CLI UUID. ✅
### Potential Issues
- **UUID as tmux tab name is long (36 chars)** — cosmetic only, tmux truncates display. Not a functional issue.
- **Desktop-started sessions (not via CodeTap)** — their tmux window name won't be a UUID. But `handleSessionStart` uses `w.command.includes('claude')` for discovery, not window name format. Hook body provides the CLI UUID. ✅
- **`python3 -c 'import uuid; print(uuid.uuid4())'` in bin/codetap** — requires Python 3. Could use `uuidgen` instead (available on macOS). Safer: `uuidgen | tr '[:upper:]' '[:lower:]'`
---
## Verification
1. Delete `~/.codetap/codetap.db` to start fresh (or let migration drop old table)
2. `CLAUDE_UI_PASSWORD=test npm run dev` — server starts cleanly
3. `tmux list-windows -t codetap` — windows named with CLI UUIDs
4. Click historical session → history loads immediately
5. New Claude session → window named with UUID, messages work
6. New Codex session → starts with temp name, renamed to UUID
7. `bin/codetap -a` → lists sessions
8. `bin/codetap --resume <UUID>` → works
9. Active sessions tab → shows sessions, no `cliSessionId` references
10. `grep -rn "window_name\|cli_session\|cliSessionId\|claude_session" server/ src/` → zero results (except internal `SessionState.cliSessionId` in adapters)
@@ -0,0 +1,422 @@
# Review Panel UX Fixes 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 Cross-AI Review UX issues: marker leaks, panel minimize/expand, send-back button, icon polish, read-only history, adapter icons.
**Spec:** `docs/superpowers/specs/2026-03-25-review-panel-ux-fixes-design.md`
---
### Task 1: Fix marker bugs (Session List + trailing `\\n`)
**Files:**
- `server/adapters/codex/codex-tmux-adapter.ts`
- `src/lib/content-utils.ts`
- [ ] **Step 1: Fix `stripMarker` regex to handle literal `\\n`**
In `src/lib/content-utils.ts` line 5, change:
```typescript
const CODETAP_REF_REGEX = /^\[CODETAP_REF:[^\]]+\]\n?/;
```
To:
```typescript
const CODETAP_REF_REGEX = /^\[CODETAP_REF:[^\]]+\](?:\\n|\n)?/;
```
This matches both real newline (`\n`) and literal two-char `\\n` (which Codex sendMessage produces).
- [ ] **Step 2: Strip marker from `firstPrompt` in Codex adapter**
In `server/adapters/codex/codex-tmux-adapter.ts` around line 445, after extracting text:
```typescript
if (text) session.firstPrompt = text.substring(0, 200);
```
Change to:
```typescript
if (text) {
// Strip CODETAP_REF marker if present
const stripped = text.replace(/^\[CODETAP_REF:[^\]]+\](?:\\n|\n)?/, '');
session.firstPrompt = stripped.substring(0, 200);
}
```
- [ ] **Step 3: Verify + commit**
```bash
npx tsc --noEmit
git add src/lib/content-utils.ts server/adapters/codex/codex-tmux-adapter.ts
git commit -m "fix: strip CODETAP_REF marker from session list + handle literal \\n"
```
---
### Task 2: Fix send-back button + icon polish + copy feedback
**Files:**
- `src/components/ChatBody.tsx`
- `src/components/MessageBubble.tsx`
- [ ] **Step 1: Fix `showActions` to include `onSendBack` (ChatBody.tsx line 199)**
**ROOT CAUSE:** `showActions` requires `sendTargets` but FloatingReviewPanel only passes `onSendBack`.
Change line 199 from:
```typescript
showActions={msg.role === 'assistant' && !streaming && !!sendTargets && sendTargets.length > 0}
```
To:
```typescript
showActions={msg.role === 'assistant' && !streaming && (!!onSendBack || (!!sendTargets && sendTargets.length > 0))}
```
- [ ] **Step 2: Remove border from icon buttons (MessageBubble.tsx lines 186-210)**
Change copy button className (line 188) from:
```
"flex items-center justify-center w-7 h-7 text-text-dim border border-border rounded-md hover:bg-white/5 transition-colors"
```
To:
```
"flex items-center justify-center w-6 h-6 text-text-dim/40 hover:text-text-dim hover:bg-white/5 rounded transition-colors"
```
Change send-back button className (line 196) from:
```
"flex items-center justify-center w-7 h-7 text-green-400 border border-green-400/30 rounded-md hover:bg-green-400/10 transition-colors"
```
To:
```
"flex items-center justify-center w-6 h-6 text-green-400/40 hover:text-green-400 hover:bg-green-400/10 rounded transition-colors"
```
Apply similar change to the SendDropdown button if it has border.
- [ ] **Step 3: Reduce icon size and stroke width (MessageBubble.tsx lines 34-59)**
In all three icon components (CopyIcon, SendIcon, SendBackIcon), change:
```
width="14" height="14" ... strokeWidth="2"
```
To:
```
width="12" height="12" ... strokeWidth="1.5"
```
- [ ] **Step 4: Add copy feedback — checkmark confirmation**
Add `useState` import. Add state inside `MessageBubble`:
```typescript
const [copied, setCopied] = useState(false);
```
Change the copy button onClick (line 187):
```typescript
onClick={() => {
navigator.clipboard.writeText(extractTextFromBlocks(content));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
```
Change the copy button icon rendering:
```tsx
{copied ? <CheckIcon /> : <CopyIcon />}
```
Add CheckIcon component:
```typescript
function CheckIcon() {
return (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
```
When `copied` is true, change button color to green briefly:
```typescript
className={`flex items-center justify-center w-6 h-6 rounded transition-colors ${
copied ? 'text-green-400' : 'text-text-dim/40 hover:text-text-dim hover:bg-white/5'
}`}
```
- [ ] **Step 5: Verify + commit**
```bash
npx tsc --noEmit
git add src/components/ChatBody.tsx src/components/MessageBubble.tsx
git commit -m "fix: send-back button visible, icon polish (no border, smaller), copy checkmark feedback"
```
---
### Task 3: Panel minimize — thin bar above input
**Files:**
- `src/components/FloatingReviewPanel.tsx`
- `src/components/ChatBody.tsx` (placeholder prop)
- [ ] **Step 1: Minimized bar rendered by ChatView (NOT FloatingReviewPanel)**
The minimized bar must sit between the message scroll area and the input — in the normal document flow. FloatingReviewPanel can't do this because it renders as an overlay. Solution: ChatView renders the bar directly. FloatingReviewPanel returns `null` when `panelState === 'minimized'`.
In FloatingReviewPanel, change the minimized block (lines 57-67) to:
```tsx
if (panelState === 'minimized') return null;
```
In ChatView, add a `ReviewMinimizedBar` inline component (or extract to a small file). Render it between ChatBody and the footer, using `renderAboveInput` slot on ChatBody:
```tsx
// In ChatView's renderAboveInput callback:
renderAboveInput={() => (
<>
{activeReview && reviewPanelState === 'minimized' && (
<div
className="flex items-center justify-between px-3 py-1.5"
style={{ background: `${getBrand(activeReview.childAdapter).color}08`, borderTop: `1px solid ${getBrand(activeReview.childAdapter).color}25` }}
>
<div className="flex items-center gap-2">
<span className="w-1.5 h-1.5 rounded-full animate-pulse" style={{ background: getBrand(activeReview.childAdapter).color }} />
<span className="text-[10px] font-semibold" style={{ color: getBrand(activeReview.childAdapter).color }}>
{getBrand(activeReview.childAdapter).displayName}
</span>
<span className="text-[10px] text-text-dim/50">{activeReview.reviewTitle || 'review'} · active</span>
</div>
<div className="flex items-center gap-3">
<button onClick={() => setReviewPanelState('expanded')} className="text-[10px] transition-colors" style={{ color: `${getBrand(activeReview.childAdapter).color}80` }}>
Expand
</button>
<button onClick={handleEndReview} className="text-[10px] text-red-400/80 hover:text-red-400 transition-colors">
End
</button>
</div>
</div>
)}
<StatusBar ... />
</>
)}
```
Note: no message count shown — parent doesn't have access to child message count. Show "active" instead.
- [ ] **Step 2: Add ▼ Minimize button to expanded panel header (lines 98-113)**
In the expanded panel header, add a minimize button next to End:
```tsx
<button
onClick={() => onPanelStateChange('minimized')}
className="text-xs text-text-dim/50 hover:text-text-dim px-2 py-1 rounded hover:bg-white/5 transition-colors"
></button>
```
- [ ] **Step 3: Update child input placeholder**
In FloatingReviewPanel, pass a custom placeholder to ChatBody. Add `inputPlaceholder` prop to ChatBody:
```typescript
// ChatBody props
inputPlaceholder?: string;
```
In ChatBody, pass to ShimmerInput:
```tsx
<ShimmerInput placeholder={inputPlaceholder || "Send a message..."} ... />
```
FloatingReviewPanel passes:
```tsx
inputPlaceholder={`Reply to ${brand.displayName} review...`}
```
- [ ] **Step 4: Verify + commit**
```bash
npx tsc --noEmit
git add src/components/FloatingReviewPanel.tsx src/components/ChatView.tsx src/components/ChatBody.tsx
git commit -m "feat: review panel minimizes to thin bar above input, custom placeholder"
```
---
### Task 4: CollapsedReviewCard onClick + read-only panel
**Files:**
- `src/components/CollapsedReviewCard.tsx`
- `src/components/ChatView.tsx`
- `src/components/FloatingReviewPanel.tsx`
- [ ] **Step 1: Pass `childSessionId` to CollapsedReviewCard**
In ChatView `renderReviewMarkers` (line 268), the review object has `child_cli_session_id`. Pass it:
```tsx
<CollapsedReviewCard
adapter={review.child_adapter}
title={review.review_title}
messageCount={review.message_count || 0}
summary="Tap to view review conversation"
onClick={() => handleOpenReadOnlyReview(review)}
/>
```
Add handler in ChatView:
```typescript
const handleOpenReadOnlyReview = useCallback((review: any) => {
setActiveReview({
reviewId: review.id,
childSessionId: review.child_cli_session_id,
childCliSessionId: review.child_cli_session_id,
childAdapter: review.child_adapter,
anchorMessageId: review.anchor_message_id,
reviewTitle: review.review_title,
});
setReviewPanelState('expanded');
setReadOnlyReview(true); // NEW state
}, []);
```
Add state:
```typescript
const [readOnlyReview, setReadOnlyReview] = useState(false);
```
- [ ] **Step 2: Add `readOnly` prop to FloatingReviewPanel**
```typescript
interface FloatingReviewPanelProps {
// ... existing props
readOnly?: boolean;
}
```
When `readOnly`:
- Header: gray instead of green, "ended" label, ✕ Close instead of End
- No ShimmerInput — show "Review ended — read only" text
- No send-back action
Pass to ChatBody:
```tsx
<ChatBody
...
disabled={readOnly}
onSendBack={readOnly ? undefined : handleSendBack}
/>
```
If readOnly, don't render ShimmerInput in ChatBody. Add a `hideInput` prop to ChatBody:
```typescript
hideInput?: boolean;
```
- [ ] **Step 3: Update onEnd for read-only panel**
FloatingReviewPanel uses the existing `onEnd` callback. The `readOnly` prop controls what the button says:
```tsx
<button onClick={onEnd}>
{readOnly ? '✕' : 'End'}
</button>
```
In ChatView's `onEnd` handler, check `readOnlyReview`:
```tsx
onEnd={async () => {
if (!readOnlyReview && activeReview.reviewId) {
try { await api.endReview(activeReview.reviewId); } catch {}
}
setActiveReview(null);
setReviewPanelState('hidden');
setReviewInitialPrompt(null);
setReviewCwd(null);
setReadOnlyReview(false);
}}
```
Also reset `readOnlyReview` in `handleReviewSelect` (when opening a new active review):
```typescript
setReadOnlyReview(false);
```
- [ ] **Step 4: Verify + commit**
```bash
npx tsc --noEmit
git add src/components/CollapsedReviewCard.tsx src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx src/components/ChatBody.tsx
git commit -m "feat: collapsed review card opens read-only panel with child session history"
```
---
### Task 5: Adapter icons from thesvg.org
**Files:**
- `src/components/AdapterIcon.tsx`
- [ ] **Step 1: Fetch SVGs from thesvg.org**
Visit https://www.thesvg.org/ and search for:
- "Anthropic" or "Claude" → get the official Anthropic logo SVG
- "OpenAI" → get the official OpenAI logo SVG
- [ ] **Step 2: Update ClaudeIcon and CodexIcon**
Replace the SVG paths in `AdapterIcon.tsx` (lines 10-37) with the official ones from thesvg.org. Keep:
- `fill="currentColor"` for color control
- `viewBox` matching the original SVG
- `width={size} height={size}` props
- [ ] **Step 3: Verify + commit**
```bash
npx tsc --noEmit
git add src/components/AdapterIcon.tsx
git commit -m "feat: use official adapter icons from thesvg.org"
```
---
### Task 6: E2E Verification
- [ ] **Step 1:** Start server, create Codex session → verify no marker in session list
- [ ] **Step 2:** Open Codex session → verify no `\\n` at start of first message
- [ ] **Step 3:** Create Claude session → send message → Click send icon → Direct send → verify panel opens with Codex response
- [ ] **Step 4:** Verify send-back ↩ icon appears on child responses
- [ ] **Step 5:** Verify copy icon → click → ✓ checkmark appears → reverts after 2s
- [ ] **Step 6:** Verify icon buttons have no border, smaller size
- [ ] **Step 7:** Click ▼ minimize → verify thin bar appears above input → parent input usable
- [ ] **Step 8:** Click ▲ Expand → panel opens again
- [ ] **Step 9:** Click End → verify panel closes → review markers appear in history
- [ ] **Step 10:** Click collapsed review card → verify read-only panel opens (no input, gray header)
- [ ] **Step 11:** Close read-only panel → verify return to normal chat
---
## Self-Review
### showActions bug fix
Before: `showActions = assistant && !streaming && !!sendTargets && sendTargets.length > 0`
After: `showActions = assistant && !streaming && (!!onSendBack || (!!sendTargets && sendTargets.length > 0))`
FloatingReviewPanel passes `onSendBack` but not `sendTargets` → now shows action buttons ✅
### Minimized bar placement
Renders as normal flow element (not absolute) between ChatBody and input. Parent chat is fully scrollable and input is fully usable. ✅
### Read-only panel
Uses same FloatingReviewPanel with `readOnly` flag. RECONNECT to child session for history. No input, no send-back. ✅
### Files changed
| File | Changes |
|------|---------|
| `src/lib/content-utils.ts` | Fix stripMarker regex |
| `server/adapters/codex/codex-tmux-adapter.ts` | Strip marker from firstPrompt |
| `src/components/ChatBody.tsx` | Fix showActions, add inputPlaceholder/hideInput |
| `src/components/MessageBubble.tsx` | Icon polish, copy feedback, no border |
| `src/components/FloatingReviewPanel.tsx` | Thin bar minimize, readOnly, custom placeholder |
| `src/components/ChatView.tsx` | Minimized bar, read-only review handler |
| `src/components/CollapsedReviewCard.tsx` | Pass onClick with review data |
| `src/components/AdapterIcon.tsx` | Official SVGs from thesvg.org |
@@ -0,0 +1,271 @@
# Review State Separation + Session List Cleanup 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:** Separate active review and history review states so viewing historical reviews doesn't conflict with active reviews, fix marker in session list, hide child sessions from session list.
**Spec:** `docs/superpowers/specs/2026-03-25-review-state-separation-design.md`
---
### Task 1: Separate activeReview and historyReview states
**Files:**
- `src/hooks/useChat.ts`
- `src/components/ChatView.tsx`
- `src/components/FloatingReviewPanel.tsx`
- [ ] **Step 1: Add `historyReview` state to useChat, rename `reviewPanelState` → `activeReviewPanel`**
In `src/hooks/useChat.ts`:
Change state declarations (around line 147):
```typescript
// OLD:
const [reviewPanelState, setReviewPanelState] = useState<'expanded' | 'minimized'>('expanded');
// NEW:
const [activeReviewPanel, setActiveReviewPanel] = useState<'expanded' | 'minimized'>('expanded');
const [historyReview, setHistoryReview] = useState<any>(null);
```
Export `historyReview`, `setHistoryReview`, `activeReviewPanel`, `setActiveReviewPanel` in the return value. Remove old `reviewPanelState`, `setReviewPanelState` exports.
- [ ] **Step 2: Remove `readOnlyReview` state from ChatView**
In `src/components/ChatView.tsx`, remove:
```typescript
const [readOnlyReview, setReadOnlyReview] = useState(false);
```
Replace all `readOnlyReview` references with `!!historyReview` (from useChat).
Replace all `setReadOnlyReview(...)` calls — remove them (historyReview existence replaces the boolean).
- [ ] **Step 3: Update `handleOpenReadOnlyReview` to use `historyReview`**
Change from:
```typescript
setActiveReview({ ...review data... });
setReviewPanelState('expanded');
setReadOnlyReview(true);
```
To:
```typescript
setHistoryReview({ ...review data... });
if (activeReview) setActiveReviewPanel('minimized'); // minimize active if exists
```
- [ ] **Step 4: Update `closeReview` to clear both states**
```typescript
const closeReview = useCallback(async () => {
if (activeReview?.reviewId) {
try { await api.endReview(activeReview.reviewId); } catch {}
}
setActiveReview(null);
setHistoryReview(null);
setReviewInitialPrompt(null);
setReviewCwd(null);
}, [activeReview]);
```
No more `readOnlyReview` check — `closeReview` always ends the active review.
Add a separate `closeHistoryPanel`:
```typescript
const closeHistoryPanel = useCallback(() => {
setHistoryReview(null);
}, []);
```
- [ ] **Step 5: Update `handleReviewSelect` (start new review)**
Add: `setHistoryReview(null)` to clear any open history panel.
Change: `setReviewPanelState('expanded')``setActiveReviewPanel('expanded')`
- [ ] **Step 6: Update FloatingReviewPanel rendering in ChatView**
Compute panel review outside JSX (not in IIFE):
```typescript
// Near other memos/derived state
const panelReview = historyReview || (activeReviewPanel === 'expanded' ? activeReview : null);
const isHistoryPanel = !!historyReview;
```
Replace the current conditional rendering with:
```tsx
{panelReview && (
<FloatingReviewPanel
reviewId={panelReview.reviewId || panelReview.id || undefined}
childSessionId={panelReview.childSessionId || panelReview.child_cli_session_id || undefined}
childAdapter={panelReview.childAdapter || panelReview.child_adapter}
reviewTitle={panelReview.reviewTitle || panelReview.review_title}
onEnd={isHistoryPanel ? closeHistoryPanel : closeReview}
onMinimize={isHistoryPanel ? undefined : () => setActiveReviewPanel('minimized')}
readOnly={isHistoryPanel}
initialPrompt={!isHistoryPanel ? (reviewInitialPrompt || undefined) : undefined}
cwd={!isHistoryPanel ? (reviewCwd || undefined) : undefined}
onSessionCreated={!isHistoryPanel ? onSessionCreatedCallback : undefined}
/>
)}
```
Note: `panelState` and `onPanelStateChange` props removed (Step 8).
- [ ] **Step 7: Update minimized bar in `renderAboveInput`**
Show minimized bar when: `activeReview && (activeReviewPanel === 'minimized' || historyReview)`
Update ▲ Expand button:
```typescript
onClick={() => { setHistoryReview(null); setActiveReviewPanel('expanded'); }}
```
The minimized bar is for the ACTIVE review only. It always shows active review info, never history info.
```
Bar shows when: activeReview !== null AND (activeReviewPanel === 'minimized' OR historyReview !== null)
Bar content: always shows activeReview info
Bar label: always "active" (not "ended")
Bar buttons: ▲ Expand (closes history + expands active) | End (ends active review)
```
Remove all `readOnlyReview` / `historyReview` checks from the bar rendering — bar is purely about active review.
- [ ] **Step 8: Update FloatingReviewPanel type — remove `panelState` prop**
Since FloatingReviewPanel is only rendered when it should be visible (expanded), the `panelState` prop is no longer needed. The parent (ChatView) controls visibility.
Remove from interface:
```typescript
panelState: 'expanded' | 'minimized';
onPanelStateChange: (state: 'expanded' | 'minimized') => void;
```
Remove the `if (panelState === 'minimized') return null;` check.
Keep the ▼ minimize button in the header — it calls a new `onMinimize` prop:
```typescript
onMinimize?: () => void; // only for active (non-readOnly) panel
```
ChatView passes: `onMinimize={() => setActiveReviewPanel('minimized')}`
- [ ] **Step 9: Verify + commit**
```bash
npx tsc --noEmit
git add src/hooks/useChat.ts src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx
git commit -m "refactor: separate activeReview and historyReview states, mutual exclusion"
```
---
### Task 2: Fix marker in session list (Codex getSessions)
**Files:**
- `server/adapters/codex/jsonl-store.ts`
- [ ] **Step 1: Strip marker in `getSessions` (line 204)**
Change:
```typescript
firstPrompt: entry.text ? entry.text.slice(0, 200) : null,
```
To:
```typescript
firstPrompt: entry.text
? entry.text.replace(/^\[CODETAP_REF:[^\]]+\](?:\\n|\n)?/, '').slice(0, 200)
: null,
```
- [ ] **Step 2: Verify + commit**
```bash
npx tsc --noEmit
git add server/adapters/codex/jsonl-store.ts
git commit -m "fix: strip CODETAP_REF marker from Codex getSessions firstPrompt"
```
---
### Task 3: Hide child sessions from session list
**Files:**
- `server/index.ts`
- [ ] **Step 1: Filter child sessions from project session list**
Find the GET endpoint that returns sessions for a project (search for `getSessions` calls in `server/index.ts`). After getting the sessions array, filter out child session IDs:
```typescript
const childIds = sessionReviews.getAllChildIds();
const filtered = sessions.filter(s => !childIds.has(s.sessionId));
```
`getAllChildIds()` already exists in `server/db.ts` — it returns a `Set<string>` of child CLI session IDs. Verify it includes ALL child IDs (both active and ended reviews), not just active ones. Ended child sessions should also be hidden from the session list.
- [ ] **Step 2: Filter child sessions from active sessions list**
Find the GET endpoint for active sessions. Apply the same filter:
```typescript
const childIds = sessionReviews.getAllChildIds();
const filtered = activeSessions.filter(s => !childIds.has(s.sessionId));
```
- [ ] **Step 3: Verify + commit**
```bash
npx tsc --noEmit
git add server/index.ts
git commit -m "fix: hide child review sessions from project and active session lists"
```
---
### Task 4: E2E Verification
- [ ] **Step 1:** Start server, create Claude session, send message
- [ ] **Step 2:** Send to Codex → verify panel opens with response
- [ ] **Step 3:** Click ▼ minimize → verify thin bar appears, parent input usable
- [ ] **Step 4:** Click ▲ expand → verify panel opens again
- [ ] **Step 5:** Click End → verify panel closes, review markers appear
- [ ] **Step 6:** Click collapsed review card → verify read-only panel opens (history)
- [ ] **Step 7:** Verify minimized bar still shows active review info (if active review exists)
- [ ] **Step 8:** Close read-only panel → verify return to normal
- [ ] **Step 9:** Check session list → verify no CODETAP_REF marker, no child sessions visible
---
## Self-Review
### State model after Task 1
```
activeReview — ongoing review (null if none)
historyReview — historical review being viewed (null if none)
activeReviewPanel — 'expanded' | 'minimized'
Panel: historyReview || (expanded activeReview) || nothing
Bar: activeReview && (minimized || historyReview)
```
### Mutual exclusion
- Open history → minimize active ✅
- Expand active → close history ✅
- End active → close both ✅
- Start new → close history + expand ✅
### Files changed
| File | Changes |
|------|---------|
| `src/hooks/useChat.ts` | Add historyReview state, rename reviewPanelState → activeReviewPanel |
| `src/components/ChatView.tsx` | Remove readOnlyReview, use historyReview, update closeReview/bar/panel rendering |
| `src/components/FloatingReviewPanel.tsx` | Remove panelState prop, add onMinimize |
| `server/adapters/codex/jsonl-store.ts` | Strip marker in getSessions |
| `server/index.ts` | Filter child sessions from session/active lists |
@@ -0,0 +1,532 @@
# Unified Session Creation Path 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:** Unify Cross-AI Review child session creation to use the same WS QUERY path as normal sessions, eliminating the HTTP-creates-session / WS-reconnects split.
**Spec:** `docs/superpowers/specs/2026-03-25-unified-session-path-design.md`
**Architecture:** Merge `sendMessage` and `pasteToSession` in BOTH adapters (Codex + Claude) so QUERY handles any content size. Move session creation from POST /api/reviews to FloatingReviewPanel's useChat QUERY. POST /api/reviews becomes a registration-only endpoint called after the session exists.
---
## Edge Cases & Scenarios
Before reading the tasks, understand all scenarios this plan must handle:
| # | Scenario | Path | Notes |
|---|----------|------|-------|
| A | Normal Codex session from WebUI | QUERY → handleQuery → startSession → registerClient → sendMessage | ✅ Already works |
| B | Cross-AI Review child (same device) | QUERY → handleQuery (same as A) → then POST /api/reviews/register | ✅ New unified path |
| C | Multi-device: other device connects to parent with active review | RECONNECT → handleReconnect loads active reviews → REVIEW_STARTED → FloatingReviewPanel mounts → RECONNECT to child | ⚠️ RECONNECT path must be preserved |
| D | Page refresh: reconnect to parent + active review | Same as C | ⚠️ RECONNECT path must be preserved |
| E | registerReview POST fails after session created | Session exists but no DB record → retry or show error | ⚠️ Error handling needed |
| F | User clicks End before registerReview completes | reviewId is empty → must not call endReview('') | ⚠️ Guard needed |
| G | Send-back to Claude parent | Claude sendMessage must handle large multiline text | ⚠️ Claude merge needed |
| H | Send-back to Codex parent | Codex sendMessage already handles (Task 1) | ✅ |
| I | CODETAP_REF marker injection | handleQuery injects for non-Claude → sendMessage auto-splits | ✅ |
**Key constraint: RECONNECT path must be preserved** for scenarios C and D. FloatingReviewPanel must support BOTH:
- New path: `initialPrompt` provided, no `childSessionId` → useChat QUERY (creates session)
- Reconnect path: `childSessionId` provided, no `initialPrompt` → useChat RECONNECT (joins existing session)
---
### Task 1: Merge sendMessage and pasteToSession in BOTH adapters
**Files:**
- `server/adapters/codex/codex-tmux-adapter.ts`
- `server/adapters/codex/index.ts`
- `server/adapters/claude/tmux-adapter.ts`
- `server/adapters/claude/index.ts`
This task is standalone — makes `sendMessage` handle all content sizes in both adapters without breaking anything.
- [ ] **Step 1: Rewrite Codex `sendMessage()` (lines 204-221)**
Merge the logic from `pasteToSession()` (lines 223-258) into `sendMessage()`:
```typescript
async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
session._promptSenderClientId = options.clientId || null;
session.isProcessing = true;
// Restart pane monitor if it was stopped
if (!session.monitor) {
this._startMonitor(sessionId, session.windowId);
}
// Large or multiline content: use pasteBuffer (fast, handles newlines)
if (text.length > 500 || text.includes('\n')) {
const singleLine = text.replace(/\n/g, '\\n');
// Fresh Codex sessions have TUI placeholder text. If content starts with
// CODETAP_REF marker, send marker via sendKeys first (clears placeholder),
// then pasteBuffer the rest.
const markerMatch = singleLine.match(/^\[CODETAP_REF:[^\]]+\]/);
if (markerMatch) {
const marker = markerMatch[0];
const rest = singleLine.substring(marker.length);
await tmuxManager.sendKeys(session.windowId, marker, false);
await new Promise<void>(r => setTimeout(r, 200));
if (rest) {
await tmuxManager.pasteBuffer(session.windowId, rest, false);
}
} else {
await tmuxManager.pasteBuffer(session.windowId, singleLine, false);
}
await new Promise<void>(r => setTimeout(r, 300));
await tmuxManager.sendControl(session.windowId, 'Enter');
} else {
// Short text: sendKeys (character-by-character)
await tmuxManager.sendKeys(session.windowId, text, false);
await new Promise<void>(r => setTimeout(r, 200));
await tmuxManager.sendControl(session.windowId, 'Enter');
}
// If there are pending hook bodies waiting for marker matching, try now
if (this._pendingHookBodies.size > 0 && session._watcherPending) {
this._tryMatchPending(sessionId);
}
}
```
- [ ] **Step 2: Remove Codex `pasteToSession()` method (lines 223-258)**
Delete the entire method from `CodexTmuxAdapter`.
- [ ] **Step 3: Update `CodexAdapter.pasteToSession` in `server/adapters/codex/index.ts`**
Delegate to sendMessage (keeps public API working until Task 3 removes callers):
```typescript
async pasteToSession(sid: string, content: string): Promise<void> {
return this._tmux.sendMessage(sid, content);
}
```
- [ ] **Step 4: Update Claude `sendMessage()` in `server/adapters/claude/tmux-adapter.ts`**
Currently Claude's `sendMessage` always uses `sendKeys(text, true)`. Add large content handling:
```typescript
async sendMessage(sessionId: string, text: string, options: QueryOptions = {}): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) throw new Error(`Session ${sessionId} not found`);
session._promptSenderClientId = options.clientId || null;
if (!session.monitor) {
this._startMonitor(sessionId, session.windowId);
}
// Large or multiline content: use pasteBuffer (fast)
if (text.length > 500 || text.includes('\n')) {
await tmuxManager.pasteBuffer(session.windowId, text);
} else {
await tmuxManager.sendKeys(session.windowId, text, true);
}
}
```
Note: Claude's `pasteBuffer` already handles Enter (sendEnter defaults to true in tmux-manager). Claude doesn't need `\n``\\n` replacement or CODETAP_REF marker splitting (Claude generates its own UUID upfront, no placeholder issue).
- [ ] **Step 5: Update `ClaudeAdapter.pasteToSession` in `server/adapters/claude/index.ts`**
Delegate to sendMessage:
```typescript
async pasteToSession(sid: string, content: string): Promise<void> {
return this._tmux.sendMessage(sid, content);
}
```
- [ ] **Step 6: Verify TypeScript compilation**
```bash
npx tsc --noEmit
```
- [ ] **Step 7: Commit**
```bash
git add server/adapters/codex/codex-tmux-adapter.ts server/adapters/codex/index.ts server/adapters/claude/tmux-adapter.ts server/adapters/claude/index.ts
git commit -m "refactor: merge sendMessage and pasteToSession in both adapters — auto-detect large content"
```
---
### Task 2: Add registerReview API endpoint + update frontend
**Files:**
- `server/index.ts` — add POST /api/reviews/register
- `src/lib/api.ts` — add `registerReview()` function
- `src/components/ChatView.tsx` — handleReviewSelect uses local state, calls registerReview after session created
- `src/components/FloatingReviewPanel.tsx` — accept `initialPrompt`, auto-send via QUERY, support RECONNECT for multi-device
- `src/hooks/useChat.ts` — support `initialPrompt` for auto-sending first message
All files change together to maintain compilation.
- [ ] **Step 1: Add `registerReview` to `api.ts`**
```typescript
registerReview: (parentCliSessionId: string, childSessionId: string, targetAdapter: string, anchorMessageId: string, prompt: string, title: string) =>
request<{ reviewId: string }>('/api/reviews/register', {
method: 'POST',
body: JSON.stringify({ parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }),
}),
```
- [ ] **Step 2: Add POST /api/reviews/register endpoint in `server/index.ts`**
```typescript
app.post('/api/reviews/register', authMiddleware, async (req: Request, res: Response) => {
try {
const { parentCliSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title } = req.body;
if (!parentCliSessionId || !childSessionId) {
return res.status(400).json({ error: 'parentCliSessionId and childSessionId required' });
}
const parentAdapterName = sessionAdapterMap.get(parentCliSessionId) || DEFAULT_ADAPTER;
const reviewId = crypto.randomUUID();
sessionReviews.create(reviewId, parentCliSessionId, childSessionId, targetAdapter, parentAdapterName, anchorMessageId, prompt, title);
if (!sessionAdapterMap.has(childSessionId)) {
sessionAdapterMap.set(childSessionId, targetAdapter);
}
broadcastReviewStarted(parentCliSessionId, {
reviewId, childSessionId, childCliSessionId: childSessionId,
childAdapter: targetAdapter, anchorMessageId, reviewTitle: title,
});
res.json({ reviewId });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
```
- [ ] **Step 3: Update FloatingReviewPanel — dual-path support**
**File:** `src/components/FloatingReviewPanel.tsx`
Update interface to support both paths:
```typescript
interface FloatingReviewPanelProps {
reviewId?: string; // empty until registerReview completes (new path)
childSessionId?: string; // empty for new session (QUERY), set for reconnect (RECONNECT)
childAdapter: string;
reviewTitle?: string;
panelState: 'expanded' | 'minimized' | 'hidden';
onPanelStateChange: (state: 'expanded' | 'minimized' | 'hidden') => void;
onEnd: () => void;
// New path only:
initialPrompt?: string; // review context to auto-send as first QUERY
cwd?: string;
onSessionCreated?: (childSessionId: string) => void;
}
```
useChat call:
```typescript
const {
messages, streaming, liveStatus, toolStatuses,
sendMessage: chatSendMessage, abort, sessionId: chatSessionId,
} = useChat(
childSessionId || undefined, // undefined → new session (QUERY); set → reconnect
initialPrompt, // auto-send as first message (new path only)
childAdapter,
cwd,
);
// Notify parent when session is created via QUERY (new path)
const notifiedRef = useRef(false);
useEffect(() => {
if (chatSessionId && !childSessionId && onSessionCreated && !notifiedRef.current) {
notifiedRef.current = true;
onSessionCreated(chatSessionId);
}
}, [chatSessionId, childSessionId, onSessionCreated]);
```
- [ ] **Step 4: Update useChat — support `initialPrompt` parameter**
**File:** `src/hooks/useChat.ts`
Update signature:
```typescript
export function useChat(
existingSessionId?: string,
initialPrompt?: string,
adapterOverride?: string,
cwdOverride?: string,
) {
```
Add ref and auto-send in WS onopen:
```typescript
const initialPromptSent = useRef(false);
// In the WS onopen handler, after connection established:
if (initialPrompt && !existingSessionId && !initialPromptSent.current) {
initialPromptSent.current = true;
actualSend(initialPrompt);
}
```
**Important:** `actualSend` must pass `adapter: adapterOverride` and `cwd: cwdOverride` in the QUERY options so handleQuery uses the correct adapter and directory.
- [ ] **Step 5: Update ChatView `handleReviewSelect` — local mount + registerReview**
**File:** `src/components/ChatView.tsx`
Add state:
```typescript
const [reviewInitialPrompt, setReviewInitialPrompt] = useState<string | null>(null);
const [reviewCwd, setReviewCwd] = useState<string | null>(null);
```
Replace `api.createReview()` call in handleReviewSelect:
```typescript
// Instead of api.createReview, set local state to mount panel
setActiveReview({
reviewId: '',
childSessionId: '',
childCliSessionId: '',
childAdapter: targetAdapter,
anchorMessageId: anchorMsgId,
reviewTitle: title,
});
setReviewInitialPrompt(cappedContext);
setReviewCwd(/* parent session's cwd from adapterConfig or session state */);
setReviewPanelState('expanded');
```
Update FloatingReviewPanel props:
```tsx
<FloatingReviewPanel
reviewId={activeReview.reviewId || undefined}
childSessionId={activeReview.childSessionId || undefined}
childAdapter={activeReview.childAdapter}
reviewTitle={activeReview.reviewTitle}
panelState={reviewPanelState}
onPanelStateChange={setReviewPanelState}
onEnd={async () => {
// Guard: only call endReview if reviewId exists (edge case F)
if (activeReview.reviewId) {
try { await api.endReview(activeReview.reviewId); } catch {}
}
// Always destroy child session if it exists
if (activeReview.childSessionId) {
// session cleanup happens server-side when session ends
}
setActiveReview(null);
setReviewPanelState('hidden');
setReviewInitialPrompt(null);
}}
initialPrompt={reviewInitialPrompt || undefined}
cwd={reviewCwd || undefined}
onSessionCreated={async (childSid) => {
try {
const result = await api.registerReview(
sessionId, childSid, activeReview.childAdapter,
activeReview.anchorMessageId, activeReview.reviewTitle || '', ''
);
setActiveReview(prev => prev ? {
...prev,
reviewId: result.reviewId,
childSessionId: childSid,
childCliSessionId: childSid,
} : null);
} catch (err) {
// Edge case E: registerReview failed
console.error('Failed to register review:', err);
// Session exists but no DB record — user can still chat, just won't persist
}
setReviewInitialPrompt(null);
}}
/>
```
- [ ] **Step 6: Verify RECONNECT path still works (scenarios C/D)**
The RECONNECT path is preserved because:
- When `childSessionId` is provided (from REVIEW_STARTED broadcast on reconnect), useChat sends RECONNECT
- When `initialPrompt` is NOT provided, no auto-send happens
- FloatingReviewPanel renders ChatBody normally with messages from HISTORY_LOAD
Verify by checking: `handleReconnect` in session-manager.ts sends active reviews → useChat REVIEW_STARTED handler sets `activeReview` with `childSessionId` → FloatingReviewPanel mounts with childSessionId → useChat RECONNECT.
- [ ] **Step 7: Verify TypeScript compilation**
```bash
npx tsc --noEmit
```
- [ ] **Step 8: Commit**
```bash
git add server/index.ts src/lib/api.ts src/components/ChatView.tsx src/components/FloatingReviewPanel.tsx src/hooks/useChat.ts
git commit -m "feat: unified session path — review child uses QUERY, registerReview after session created"
```
---
### Task 3: Clean up — remove old review session creation + pasteToSession
**Files:**
- `server/index.ts`
- `src/lib/api.ts`
- `server/adapters/interface.ts`
- `server/adapters/codex/index.ts`
- `server/adapters/claude/index.ts`
- `server/adapters/claude/tmux-adapter.ts`
- [ ] **Step 1: Remove old POST /api/reviews session creation logic**
In `server/index.ts` POST /api/reviews handler (lines 249-319):
- Remove `adapter.startSession()` call
- Remove `adapter.pasteToSession()` call
- Remove marker injection logic
- Keep only: DB record creation + broadcast (same as /api/reviews/register)
- Or remove the entire endpoint and redirect to /api/reviews/register
Check frontend callers:
```bash
grep -rn "createReview\|/api/reviews'" src/ --include="*.ts" --include="*.tsx"
```
Remove `createReview` from `api.ts` if no longer called.
- [ ] **Step 2: Update send-back to use sendMessage**
In `POST /api/reviews/:id/send-back` (server/index.ts lines 369-371):
```typescript
// OLD:
await parentAdapter.pasteToSession(parentSessionId, formatted);
// NEW:
await parentAdapter.sendMessage(parentSessionId, formatted);
```
Both Claude and Codex `sendMessage` now handle large content (Task 1).
- [ ] **Step 3: Remove `pasteToSession` from adapter interface**
Check remaining callers:
```bash
grep -rn "pasteToSession" server/ --include="*.ts"
```
If no remaining callers after Steps 1-2, remove from:
- `server/adapters/interface.ts` — base class method
- `server/adapters/codex/index.ts` — delegation
- `server/adapters/codex/codex-tmux-adapter.ts` — if any leftover
- `server/adapters/claude/index.ts` — delegation
- `server/adapters/claude/tmux-adapter.ts` — implementation
- [ ] **Step 4: Verify TypeScript compilation**
```bash
npx tsc --noEmit
```
- [ ] **Step 5: Commit**
```bash
git add server/ src/lib/api.ts
git commit -m "refactor: remove old review session creation and pasteToSession from adapter interface"
```
---
### Task 4: E2E Verification
- [ ] **Step 1: Start server**
```bash
CLAUDE_UI_PASSWORD=TEST npm run dev
```
- [ ] **Step 2: Test normal Codex session (scenario A)**
New Project → code-tap → Codex → send message → verify response + icon buttons.
- [ ] **Step 3: Test normal Claude session**
New Project → code-tap → Claude → send message → verify response.
- [ ] **Step 4: Test Cross-AI Review unified path (scenario B)**
1. Claude session → send message → get response
2. Click send icon → select "Direct send"
3. Verify FloatingReviewPanel opens
4. Verify panel shows Codex response (via QUERY, same as normal)
5. Verify session ID updates to real UUID
- [ ] **Step 5: Test send-back (scenario H)**
In review panel, click send-back icon → verify message appears in parent chat.
- [ ] **Step 6: Test end review**
Click "End" → verify panel closes, markers appear.
- [ ] **Step 7: Test end review before registerReview (scenario F)**
Quick-click End immediately after review starts (before Codex responds) → verify no crash.
- [ ] **Step 8: Test page refresh reconnect (scenario D)**
1. Start a review
2. Refresh page
3. Reconnect to parent session
4. Verify FloatingReviewPanel re-appears with child session (RECONNECT path)
---
## Self-Review Checklist
### Flow comparison after all tasks
```
Normal session (Codex or Claude):
useChat.actualSend("Hi") → WS QUERY → handleQuery → startSession → registerClient → sendMessage
Review child (same device, scenario B):
useChat.actualSend(reviewContext) → WS QUERY → handleQuery → startSession → registerClient → sendMessage
→ SESSION_CREATED → POST /api/reviews/register → DB record + broadcast
Review child (other device/reconnect, scenarios C/D):
REVIEW_STARTED from server → FloatingReviewPanel mounts with childSessionId
→ useChat RECONNECT → handleReconnect → registerClient → HISTORY_LOAD
All three paths work. Scenarios B and normal use IDENTICAL QUERY flow.
```
### Adapter sendMessage unification
| Adapter | Short text | Long/multiline text |
|---------|-----------|-------------------|
| Codex | sendKeys | `\n``\\n` + pasteBuffer (with CODETAP_REF marker split) |
| Claude | sendKeys | pasteBuffer (no `\n` replacement needed, no marker split) |
### Error handling
- registerReview failure → catch, log, session continues (no DB record but chat works) ✅
- End with empty reviewId → guard, skip endReview API call ✅
- initialPrompt double-send → ref guard prevents ✅
### Files changed
| File | Change |
|------|--------|
| `server/adapters/codex/codex-tmux-adapter.ts` | Merge sendMessage + pasteToSession |
| `server/adapters/codex/index.ts` | pasteToSession delegates to sendMessage |
| `server/adapters/claude/tmux-adapter.ts` | sendMessage handles large content |
| `server/adapters/claude/index.ts` | pasteToSession delegates to sendMessage |
| `server/adapters/interface.ts` | Remove pasteToSession (Task 3) |
| `server/index.ts` | Add /api/reviews/register, remove old POST /api/reviews session creation |
| `src/lib/api.ts` | Add registerReview(), remove createReview() |
| `src/components/ChatView.tsx` | handleReviewSelect → local state + registerReview callback |
| `src/components/FloatingReviewPanel.tsx` | Dual-path: initialPrompt (QUERY) or childSessionId (RECONNECT) |
| `src/hooks/useChat.ts` | Support initialPrompt auto-send |
@@ -0,0 +1,688 @@
# CLI Multi-Adapter Support 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:** Make every `codetap` CLI command support `--adapter` filtering and fix all outdated descriptions that only reference Claude.
**Architecture:** Move `--adapter` flag parsing to the top of the script (before any command handlers), so all commands can access `$ADAPTER`. Update `-a`/`-A` to filter by adapter, `--continue` to filter by adapter, and `hooks` to target specific adapters. Fix all help text and comments.
**Tech Stack:** Bash, Node.js (hooks-cli.mjs)
---
## Complete Issue List
| # | Issue | Type |
|---|---|---|
| 1 | `--adapter` parsed AFTER `-a`/`-A` exits — impossible to combine | Bug |
| 2 | `-a`/`-A` can't filter by adapter | Feature gap |
| 3 | `-a`/`-A` adapter detection uses `pane_current_command` → shows `node` not the adapter name | Bug |
| 4 | `--continue` doesn't pass adapter to resume API | Feature gap |
| 5 | `--continue` doesn't filter by adapter (always picks most recent) | Feature gap |
| 6 | `hooks install/uninstall` can't target specific adapter | Feature gap |
| 7 | Help text says "(Claude or Codex)" — missing Gemini | Text |
| 8 | Header comment says "runs Claude/Codex" — missing Gemini | Text |
| 9 | Header comment missing `--adapter` in usage examples | Text |
| 10 | No-args output doesn't mention `--adapter` | Text |
| 11 | Comment "Claude stores sessions by project dir" is outdated | Text |
| 12 | `<any args>` description unclear | Text |
## File Map
| File | Action | Responsibility |
|---|---|---|
| `bin/codetap` | Modify | Move `--adapter` parsing to top, update all commands, fix all text |
| `bin/hooks-cli.mjs` | Modify | Accept optional adapter name argument |
---
### Task 1: Move `--adapter` Parsing Before All Command Handlers
**Files:**
- Modify: `bin/codetap:24-370` (restructure flag parsing order)
The core structural fix: `--adapter` must be parsed BEFORE any command handler (including `-a`, `-A`, `--version`, `--help`), so all commands can access `$ADAPTER`.
- [ ] **Step 1: Move adapter parsing to right after variable declarations (before the `case` block)**
Move the `--adapter` parsing block (currently at lines 336-370) to immediately after line 22 (`PID_FILE=...`), before the `case "$1" in` block at line 25.
The block to move:
```bash
# --- Parse --adapter flag (before any command handlers) ---
set_adapter() {
case "$1" in
claude) ADAPTER="claude"; ADAPTER_CMD="claude"; YOLO="--dangerously-skip-permissions" ;;
codex) ADAPTER="codex"; ADAPTER_CMD="codex"; YOLO="--dangerously-bypass-approvals-and-sandbox" ;;
gemini) ADAPTER="gemini"; ADAPTER_CMD="gemini"; YOLO="--approval-mode yolo" ;;
esac
}
ADAPTER="claude"
ADAPTER_CMD="claude"
ADAPTER_EXPLICIT=false
prev_arg=""
for arg in "$@"; do
if [ "$prev_arg" = "--adapter" ]; then
ADAPTER_EXPLICIT=true
case "$arg" in
claude) set_adapter claude ;;
codex) set_adapter codex ;;
gemini) set_adapter gemini ;;
*) echo "Unknown adapter: $arg"; exit 1 ;;
esac
fi
prev_arg="$arg"
done
# Strip --adapter and its value from positional args
CLEANED_ARGS=()
skip_next=false
for arg in "$@"; do
if $skip_next; then skip_next=false; continue; fi
if [ "$arg" = "--adapter" ]; then skip_next=true; continue; fi
CLEANED_ARGS+=("$arg")
done
set -- "${CLEANED_ARGS[@]}"
```
Delete the old copy of this block from its current location (lines 336-370).
- [ ] **Step 2: Verify `--version` and `--help` still work**
Run:
```bash
codetap --version
codetap --help
codetap --adapter gemini --version
```
Expected: version prints, help prints, `--adapter gemini --version` still prints version (adapter is parsed but irrelevant for --version).
- [ ] **Step 3: Commit**
```bash
git add bin/codetap
git commit -m "refactor(cli): move --adapter parsing before all command handlers"
```
---
### Task 2: `-a`/`-A` Support `--adapter` Filter + Fix Adapter Detection
**Files:**
- Modify: `bin/codetap` (the `-a`/`-A` handler, lines 249-334)
- [ ] **Step 1: Add adapter filter to the session list**
The current adapter detection (lines 302-307) uses `pane_current_command` which shows `node` for all adapters — broken for detection. Fix by querying the server's `/api/active-sessions` API which has accurate adapter info.
**Note:** The `-a`/`-A` handler is already positioned AFTER `ensure_server()` and `get_auth_token()` (line 249 is after line 200/203). After Task 1 moves `--adapter` parsing to the top, `$ADAPTER` and `$ADAPTER_EXPLICIT` will be available here. No handler relocation needed.
**API note:** `/api/active-sessions` supports `?adapter=` query param but NOT `?cwd=`. For project-level filtering (`-a`), fetch all sessions and filter by `cwd` field client-side in Python.
Replace the entire `-a`/`-A` handler (lines 249-334) with:
```bash
# --- List active sessions ---
if [ "$1" = "--attach" ] || [ "$1" = "-a" ] || [ "$1" = "-A" ]; then
ALL_MODE=false
[ "$1" = "-A" ] && ALL_MODE=true
# Get sessions from the server API (has accurate adapter info)
AUTH_TOKEN=$(get_auth_token)
if [ -n "$AUTH_TOKEN" ]; then
SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions" \
-H "Authorization: Bearer $AUTH_TOKEN" 2>/dev/null)
else
SESSIONS_JSON="[]"
fi
# Filter by adapter and/or cwd client-side
SESSIONS_JSON=$(echo "$SESSIONS_JSON" | python3 -c "
import sys, json
sessions = json.load(sys.stdin)
adapter_filter = '$ADAPTER' if '$ADAPTER_EXPLICIT' == 'true' else None
cwd_filter = '$(pwd)' if '$ALL_MODE' == 'false' else None
if adapter_filter:
sessions = [s for s in sessions if s.get('adapter') == adapter_filter]
if cwd_filter:
sessions = [s for s in sessions if s.get('cwd') == cwd_filter]
json.dump(sessions, sys.stdout)
" 2>/dev/null)
# Parse and display
COUNT=$(echo "$SESSIONS_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))" 2>/dev/null)
if [ "$COUNT" = "0" ] || [ -z "$COUNT" ]; then
if [ "$ADAPTER_EXPLICIT" = true ]; then
echo "No active $ADAPTER sessions."
elif [ "$ALL_MODE" = true ]; then
echo "No active sessions."
else
echo "No active sessions for project '$(basename "$(pwd)")'."
echo "Run 'codetap -A' to see all projects, or 'codetap new' to start a new session."
fi
exit 0
fi
if [ "$ALL_MODE" = true ]; then
HEADER="Active sessions (all projects)"
else
HEADER="Active sessions for $(basename "$(pwd)")"
fi
[ "$ADAPTER_EXPLICIT" = true ] && HEADER="$HEADER$ADAPTER only"
echo "$HEADER:"
echo ""
# Render each session
echo "$SESSIONS_JSON" | python3 -c "
import sys, json
sessions = json.load(sys.stdin)
colors = {'claude': '\033[33m', 'codex': '\033[32m', 'gemini': '\033[34m'}
reset = '\033[0m'
home = '$HOME'
for i, s in enumerate(sessions, 1):
adapter = s.get('adapter', '?')
sid = s.get('sessionId', '?')
cwd = s.get('cwd', '')
first = s.get('firstPrompt', '')
color = colors.get(adapter, '\033[90m')
label = f'{color}[{adapter.capitalize()}]{reset}'
print(f' {i}) {label} {sid}')
if $ALL_MODE and cwd:
print(f' Dir: {cwd.replace(home, \"~\")}')
if first:
print(f' {first[:60]}')
print()
" 2>/dev/null
# Interactive selection
read -p "Select (1-$COUNT), or Enter to cancel: " CHOICE
if [ -n "$CHOICE" ]; then
TARGET=$(echo "$SESSIONS_JSON" | python3 -c "
import sys, json
sessions = json.load(sys.stdin)
idx = int('$CHOICE') - 1
if 0 <= idx < len(sessions):
print(sessions[idx]['sessionId'])
" 2>/dev/null)
if [ -n "$TARGET" ]; then
tmux select-window -t "$TMUX_SESSION:$TARGET" 2>/dev/null
tmux attach -t "$TMUX_SESSION"
fi
else
echo "Cancelled."
fi
exit 0
fi
```
- [ ] **Step 2: Verify**
Run:
```bash
# Start a Gemini and Claude session first, then:
codetap -a # Current project sessions
codetap -A # All sessions
codetap -a --adapter gemini # Only Gemini sessions for current project
codetap -A --adapter codex # Only Codex sessions across all projects
```
Expected: Sessions show correct adapter labels from server (not from pane_current_command). Filter works.
- [ ] **Step 3: Commit**
```bash
git add bin/codetap
git commit -m "feat(cli): -a/-A uses server API for accurate adapter info + --adapter filter"
```
---
### Task 3: `--continue` Support `--adapter` Filter
**Files:**
- Modify: `bin/codetap` (`--continue` handler)
- [ ] **Step 1: Update `--continue` to pass adapter to API and filter by adapter**
Replace the `--continue` handler with:
```bash
elif [ "$1" = "--continue" ]; then
shift
# If adapter specified, find most recent session for that adapter
if [ "$ADAPTER_EXPLICIT" = true ]; then
AUTH_TOKEN=$(get_auth_token)
SESSIONS_JSON=$(curl -s $CURL_OPTS "$PROTOCOL://localhost:$PORT/api/active-sessions" \
-H "Authorization: Bearer $AUTH_TOKEN" 2>/dev/null)
LATEST=$(echo "$SESSIONS_JSON" | python3 -c "
import sys, json
sessions = json.load(sys.stdin)
filtered = [s for s in sessions if s.get('adapter') == '$ADAPTER']
if filtered:
# Sort by lastActivity descending
filtered.sort(key=lambda s: s.get('lastActivity', 0), reverse=True)
print(filtered[0]['sessionId'])
" 2>/dev/null)
else
# No adapter specified — find most recent tmux window
LATEST=$(tmux list-windows -t "$TMUX_SESSION" -F '#{window_activity} #{window_name}' 2>/dev/null | grep -v " main$" | sort -rn | head -1 | awk '{print $2}')
fi
if [ -n "$LATEST" ]; then
# Check if the process in the pane is still running
PANE_CMD=$(tmux display -t "$TMUX_SESSION:$LATEST" -p '#{pane_current_command}' 2>/dev/null)
if [ "$PANE_CMD" = "zsh" ] || [ "$PANE_CMD" = "bash" ]; then
# CLI process exited, shell is showing — resume via API
AUTH_TOKEN="${AUTH_TOKEN:-$(get_auth_token)}"
BODY=$(printf '%s\n%s\n%s' "$LATEST" "$ADAPTER" "$(pwd)" | python3 -c 'import sys,json; s,a,c=sys.stdin.read().strip().split("\n"); print(json.dumps({"sessionId":s,"adapter":a,"cwd":c}))' 2>/dev/null)
curl -s $CURL_OPTS -X POST "${PROTOCOL}://localhost:${PORT}/api/sessions/resume" \
-H "Authorization: Bearer $AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" >/dev/null 2>&1
fi
tmux select-window -t "$TMUX_SESSION:$LATEST"
else
if [ "$ADAPTER_EXPLICIT" = true ]; then
echo "No active $ADAPTER sessions to continue"
else
echo "No active sessions to continue"
fi
exit 1
fi
tmux attach -t "$TMUX_SESSION"
exit 0
```
- [ ] **Step 2: Verify**
Run:
```bash
codetap --continue # Resume most recent (any adapter)
codetap --continue --adapter gemini # Resume most recent Gemini
codetap --continue --adapter codex # Resume most recent Codex
```
- [ ] **Step 3: Commit**
```bash
git add bin/codetap
git commit -m "feat(cli): --continue supports --adapter filter + passes adapter to resume API"
```
---
### Task 4: `hooks install/uninstall` Support `--adapter` Filter
**Files:**
- Modify: `bin/codetap:84-86` (hooks handler)
- Modify: `bin/hooks-cli.mjs` (accept adapter argument)
- [ ] **Step 1: Update hooks-cli.mjs to accept optional adapter**
Replace `bin/hooks-cli.mjs`:
```javascript
#!/usr/bin/env node
// Standalone hook management — no server needed.
// Usage: node hooks-cli.mjs install|uninstall [adapter]
// adapter: claude, codex, gemini, or omit for all
import { ClaudeHookConfig } from '../server/adapters/claude/hook-config.js';
import { CodexHookConfig } from '../server/adapters/codex/hook-config.js';
import { GeminiHookConfig } from '../server/adapters/gemini/hook-config.js';
const cmd = process.argv[2];
const adapterArg = process.argv[3]; // optional: claude, codex, gemini
if (!cmd || !['install', 'uninstall'].includes(cmd)) {
console.error('Usage: hooks-cli.mjs install|uninstall [claude|codex|gemini]');
process.exit(1);
}
const adapters = {
claude: new ClaudeHookConfig(),
codex: new CodexHookConfig(),
gemini: new GeminiHookConfig(),
};
const targets = adapterArg ? { [adapterArg]: adapters[adapterArg] } : adapters;
if (adapterArg && !adapters[adapterArg]) {
console.error(`Unknown adapter: ${adapterArg}. Use: claude, codex, gemini`);
process.exit(1);
}
for (const [name, config] of Object.entries(targets)) {
if (cmd === 'install') {
config.install();
} else {
config.uninstall();
}
}
```
- [ ] **Step 2: Update bin/codetap hooks handler to pass adapter**
Replace lines 84-86:
```bash
hooks)
if [ "$ADAPTER_EXPLICIT" = true ]; then
node "$SCRIPT_DIR/hooks-cli.mjs" "$2" "$ADAPTER"
else
node "$SCRIPT_DIR/hooks-cli.mjs" "$2"
fi
exit 0 ;;
```
- [ ] **Step 3: Verify**
Run:
```bash
codetap hooks install # Install all
codetap hooks uninstall # Uninstall all
codetap hooks install --adapter gemini # Install Gemini only
codetap hooks uninstall --adapter claude # Uninstall Claude only
```
Wait — `--adapter` is parsed before `hooks` command, but the `hooks` case is in the early `case "$1" in` block which runs before `ensure_server`. Need to check if `--adapter` parsing happens before the case block after Task 1 moves it.
After Task 1, the parsing order is:
1. `--adapter` parsed and stripped (lines 23-55 after move)
2. `case "$1" in` — now `$1` is `hooks` (not `--adapter`)
So `codetap --adapter gemini hooks install` works. But `codetap hooks install --adapter gemini` needs the adapter parsing to handle args in any order. After Task 1's `set --` cleanup, `$1` would be `hooks` and `$2` would be `install` — correct.
But what about `codetap hooks --adapter gemini install`? The `--adapter` stripping would remove `--adapter gemini`, leaving `hooks install`. That works too.
- [ ] **Step 4: Commit**
```bash
git add bin/codetap bin/hooks-cli.mjs
git commit -m "feat(cli): hooks install/uninstall supports --adapter for single-adapter targeting"
```
---
### Task 5: Fix All Help Text and Comments
**Files:**
- Modify: `bin/codetap` (header comments, help text, no-args output)
- [ ] **Step 1: Fix header comment (lines 1-14)**
Replace with:
```bash
#!/bin/bash
# codetap — CLI wrapper that runs AI coding assistants in tmux for mobile sync
#
# Usage:
# codetap # Start server, show URLs
# codetap new # New session (default: claude)
# codetap new --adapter gemini # New Gemini session
# codetap --resume <session-id> # Resume a specific session
# codetap --continue # Resume the most recent session
# codetap --continue --adapter codex # Resume most recent Codex session
# codetap -a # List active sessions (current project)
# codetap -a --adapter gemini # List Gemini sessions only
# codetap -A # List ALL active sessions (all projects)
# codetap stop # Stop the server (graceful cleanup)
# codetap hooks install # Install hooks for all adapters
# codetap hooks install --adapter gemini # Install hooks for Gemini only
# codetap cert # Generate self-signed HTTPS cert
#
# Adapters: claude (default), codex, gemini
# Sessions run inside tmux session "codetap".
# Mobile app auto-connects for real-time sync.
```
- [ ] **Step 2: Fix help text (lines 30-49)**
Replace with:
```bash
cat << 'HELP'
Usage: codetap [options] [command]
Commands:
new Start a new session (default: Claude)
stop Stop the server (graceful cleanup)
hooks install Install hooks (all adapters, or use --adapter)
hooks uninstall Remove hooks (all adapters, or use --adapter)
cert Generate self-signed HTTPS certificate
Options:
-v, --version Show version
-h, --help Show this help
-a List active sessions (current project)
-A List ALL active sessions (all projects)
--adapter <name> Adapter: claude (default), codex, gemini
--resume <session-id> Resume a specific session
--continue Resume most recent session
Examples:
codetap new --adapter gemini Start a Gemini session
codetap -a --adapter codex List active Codex sessions
codetap --continue --adapter gemini Continue most recent Gemini session
codetap hooks install --adapter claude Install Claude hooks only
HELP
```
- [ ] **Step 3: Fix no-args output (lines 218-229)**
Replace with:
```bash
echo ""
echo "CodeTap server is running on port $PORT"
echo ""
echo " Open on your phone:"
if [ -n "$TS_HOST" ]; then echo " https://${TS_HOST} (Tailscale)"; fi
if [ -n "$LAN_IP" ]; then echo " ${PROTOCOL}://${LAN_IP}:${PORT} (LAN)"; fi
echo " http://localhost:${PORT} (this machine)"
echo ""
echo " New session: codetap new [--adapter codex|gemini]"
echo " Continue: codetap --continue"
echo " List sessions: codetap -a"
echo " Stop server: codetap stop"
echo ""
```
- [ ] **Step 4: Remove outdated comment on line 18**
Delete line 18: `# Claude stores sessions by project dir; Codex uses date-based dirs (handled in get_project_sessions)`
- [ ] **Step 5: Verify all text output**
Run:
```bash
codetap --help
codetap --version
codetap 2>&1 | head -15
```
Verify no mention of "Claude" in contexts where it should say "adapter", and Gemini is listed everywhere.
- [ ] **Step 6: Commit**
```bash
git add bin/codetap
git commit -m "fix(cli): update all help text, comments, and output for multi-adapter support"
```
---
### Task 6: E2E Verification — All Commands × All Flags
**Files:** None (testing only)
- [ ] **Step 1: Restart server fresh**
```bash
lsof -ti :3456 | xargs kill -9 2>/dev/null
tmux kill-session -t codetap 2>/dev/null
sleep 2
export CLAUDE_UI_PASSWORD=test
CLAUDE_UI_PASSWORD=test npx tsx server/index.ts > /tmp/codetap-cli-test.log 2>&1 &
sleep 6
curl -sk https://localhost:3456/health
```
- [ ] **Step 2: Test `--version` and `--help`**
```bash
codetap --version
# Expected: codetap v1.3.2
codetap --help
# Expected: Multi-adapter help text with Examples section
# Verify: "claude (default), codex, gemini" appears
# Verify: No "Claude-only" language
```
- [ ] **Step 3: Test `codetap` (no args)**
```bash
codetap
# Expected: Server URLs + multi-adapter usage hints
# Verify: "codetap new [--adapter codex|gemini]" in output
```
- [ ] **Step 4: Test `codetap new` (all 3 adapters)**
```bash
# Create sessions using the API (non-interactive, can't attach tmux from subagent)
TOKEN=$(curl -sk -X POST "https://localhost:3456/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
# Claude
RESULT=$(curl -sk -X POST "https://localhost:3456/api/sessions/start" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"adapter\":\"claude\",\"cwd\":\"$(pwd)\"}")
echo "Claude: $RESULT"
# Codex
RESULT=$(curl -sk -X POST "https://localhost:3456/api/sessions/start" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"adapter\":\"codex\",\"cwd\":\"$(pwd)\"}")
echo "Codex: $RESULT"
# Gemini
RESULT=$(curl -sk -X POST "https://localhost:3456/api/sessions/start" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"adapter\":\"gemini\",\"cwd\":\"$(pwd)\"}")
echo "Gemini: $RESULT"
# Verify tmux windows
tmux list-windows -t codetap -F '#{window_name}' | grep -v main
```
Expected: 3 session IDs returned, 3 tmux windows created.
- [ ] **Step 5: Test `-a` and `-A` with and without `--adapter`**
```bash
echo "" | codetap -a 2>&1
# Expected: Lists sessions for current project with [Claude], [Codex], [Gemini] labels
echo "" | codetap -A 2>&1
# Expected: Lists ALL sessions with Dir: info
echo "" | codetap -a --adapter gemini 2>&1
# Expected: Only Gemini sessions listed
echo "" | codetap -A --adapter codex 2>&1
# Expected: Only Codex sessions across all projects
echo "" | codetap -a --adapter claude 2>&1
# Expected: Only Claude sessions for current project
```
- [ ] **Step 6: Test `--continue` with and without `--adapter`**
Can't test tmux attach non-interactively, but verify the session selection logic:
```bash
# Check which session --continue would pick
LATEST=$(tmux list-windows -t codetap -F '#{window_activity} #{window_name}' | grep -v " main$" | sort -rn | head -1 | awk '{print $2}')
echo "Most recent (any adapter): $LATEST"
# With --adapter, verify API query works
TOKEN=$(curl -sk -X POST "https://localhost:3456/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
curl -sk "https://localhost:3456/api/active-sessions" \
-H "Authorization: Bearer $TOKEN" | python3 -c "
import sys, json
for s in json.load(sys.stdin):
print(f'{s[\"adapter\"]:8s} {s[\"sessionId\"][:12]}... last={s.get(\"lastActivity\",0)}')
"
```
- [ ] **Step 7: Test `hooks install/uninstall` with and without `--adapter`**
```bash
codetap hooks uninstall
# Expected: All 3 adapters' hooks removed
codetap hooks install --adapter gemini
# Expected: Only Gemini hooks installed
cat ~/.gemini/settings.json | python3 -c "import sys,json; print('hooks' in json.load(sys.stdin))"
# Expected: True
cat ~/.claude/settings.json | python3 -c "import sys,json; d=json.load(sys.stdin); print('hooks' in d and any('codetap' in str(v).lower() for v in d.get('hooks',{}).values()))"
# Expected: False (Claude hooks not installed)
codetap hooks install
# Expected: All 3 adapters' hooks installed
codetap hooks uninstall --adapter claude
# Expected: Only Claude hooks removed
```
- [ ] **Step 8: Test `--resume`**
```bash
# Get a session ID from the list
SID=$(tmux list-windows -t codetap -F '#{window_name}' | grep -v main | head -1)
echo "Resuming: $SID"
# Can't test tmux attach, but verify API:
TOKEN=$(curl -sk -X POST "https://localhost:3456/api/auth/login" \
-H 'Content-Type: application/json' \
-d '{"password":"test"}' | python3 -c 'import sys,json; print(json.load(sys.stdin)["token"])')
curl -sk -X POST "https://localhost:3456/api/sessions/resume" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "{\"sessionId\":\"$SID\",\"adapter\":\"claude\",\"cwd\":\"$(pwd)\"}"
# Expected: {"sessionId":"..."}
```
- [ ] **Step 9: Test `stop` and `cert`**
```bash
codetap stop
# Expected: "Stopping CodeTap server..." → "Server stopped."
# Cert already exists, just verify the command runs
echo "n" | codetap cert
# Expected: "Certificate already exists..." prompt, then exits
```
- [ ] **Step 10: Commit test results as verification log**
```bash
git add -f docs/superpowers/plans/
git commit -m "docs: CLI multi-adapter verification complete"
```
@@ -0,0 +1,782 @@
# 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):
```sql
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:
```typescript
end_anchor_message_id: string | null;
```
- [ ] **Step 3: Update endReview() to accept endAnchorMessageId**
Replace the `endReview` method (lines 325-328) with:
```typescript
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**
```bash
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`:
```typescript
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`:
```typescript
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**
```bash
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:
```typescript
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):
```typescript
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):
```typescript
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**
```bash
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:
```typescript
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):
```typescript
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):
```typescript
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):
```typescript
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:
```typescript
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**
```bash
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`:
```typescript
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`:
```typescript
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 `ReviewTab` component
- Hidden inactive tabs (hooks stay alive)
Key structure:
```typescript
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 === ''):
```typescript
{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:
```typescript
{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**
```bash
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`:
```typescript
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:
```typescript
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:
```typescript
const [sendToMessageId, setSendToMessageId] = useState<string | null>(null);
```
- [ ] **Step 3: Add handlers for send-to-existing and start-new**
```typescript
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:
```typescript
<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**
```bash
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:
```css
/* 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:
```typescript
<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**
```bash
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**
1. Start a Gemini session from UI → send "Hi" → get response
2. Click "↗ Send to" on the response → should show ReviewActionMenu (no active reviews)
3. Select Codex → Direct Send → child session starts → panel shows with single-review header
4. Click "↗ Send to" on another message → should show SendToExistingSheet with "Send to Codex review" option
5. Click "Start new review..." → ReviewActionMenu opens → select Claude → second tab appears in panel
6. Switch between Codex and Claude tabs
7. Click ▼ to minimize → combined bar shows "2 reviews: Codex · Claude"
8. Click bar to expand → tabs restored
9. Click ✕ on Codex tab → Codex review ends, Claude tab remains
10. Click End on Claude → panel disappears
11. Verify "Review ended" markers appear at the correct positions (not at anchor)
12. Verify CollapsedReviewCards appear at the start anchor positions
- [ ] **Step 5: Final commit**
```bash
git add -A
git commit -m "refactor: clean up old single-review references, verify multi-review integration"
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,558 @@
# PWA Optimization 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:** Bring CodeTap's PWA to production-grade quality with proper viewport handling, splash screens, install prompts, SW updates, badge management, draft persistence, navigation history, and social meta tags.
**Architecture:** All changes are additive — native Web APIs with feature detection, no new dependencies. App.tsx gains PWA lifecycle effects. ShimmerInput gains draft persistence. Manifest and HTML get richer metadata.
**Tech Stack:** Vite + vite-plugin-pwa + native Web APIs (beforeinstallprompt, History API, Network Information API)
**Spec:** `docs/superpowers/specs/2026-03-26-pwa-optimization-design.md`
---
## File Structure
| File | Responsibility | Action |
|------|---------------|--------|
| `index.html` | Viewport, splash images, OG tags | Modify |
| `vite.config.ts` | Manifest shortcuts, screenshots | Modify |
| `src/App.tsx` | Install prompt, SW update, badge clear, history API | Modify |
| `src/components/SessionsView.tsx` | Install banner UI | Modify |
| `src/components/ShimmerInput.tsx` | Draft auto-save | Modify |
| `src/components/StatusBar.tsx` | Slow network indicator | Modify |
| `src/index.css` | Safe area utilities | Modify |
| `public/splash/` | iOS splash screen images | Create |
| `public/screenshots/` | Manifest screenshots | Create |
---
## Task 1: Viewport & Safe Areas
**Files:**
- Modify: `index.html`
- Modify: `src/index.css`
- [ ] **Step 1: Add viewport-fit=cover to index.html**
Change the viewport meta tag:
```html
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
```
- [ ] **Step 2: Add safe area utilities to index.css**
After the existing `.safe-bottom` rule:
```css
.safe-top {
padding-top: env(safe-area-inset-top);
}
.safe-x {
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
}
```
- [ ] **Step 3: Verify — open in iOS simulator or DevTools, check notch area**
- [ ] **Step 4: Commit**
```bash
git add index.html src/index.css
git commit -m "feat(pwa): viewport-fit=cover and full safe area utilities"
```
---
## Task 2: iOS Splash Screens
**Files:**
- Create: `public/splash/` directory
- Modify: `index.html`
- [ ] **Step 1: Create splash screen SVG generator script**
Create a simple inline SVG splash as a data URI approach in index.html. This avoids needing to generate multiple PNGs. Use `apple-mobile-web-app-startup-image` with media queries for major iPhone sizes.
Add to `<head>` in index.html, after the apple-touch-icon link:
```html
<!-- iOS Splash Screens -->
<meta name="apple-mobile-web-app-title" content="CodeTap" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1290x2796.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 393px) and (device-height: 852px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1179x2556.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 428px) and (device-height: 926px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1284x2778.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 390px) and (device-height: 844px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1170x2532.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1125x2436.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 414px) and (device-height: 896px) and (-webkit-device-pixel-ratio: 3)"
href="/splash/splash-1242x2688.png" />
<link rel="apple-touch-startup-image"
media="screen and (device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)"
href="/splash/splash-750x1334.png" />
```
- [ ] **Step 2: Generate splash screen PNGs via Node.js canvas script**
Create `scripts/generate-splash.mjs` that uses the built-in `node:canvas` or a simple HTML-to-PNG approach. Simplest method: create a single-use Node script that writes minimal HTML to a temp file and uses `sharp` or pure SVG-to-PNG. Actually, the most practical approach for a CLI tool: use a simple Node script that generates SVG strings and writes them as `.svg` files that Safari can use (Safari accepts SVG for startup images). If SVG doesn't work, use a single high-res PNG and reference it without media queries as a universal fallback.
Practical fallback: Create `public/splash/` with a single `splash.svg` (dark bg + centered "CodeTap" text), referenced without media queries. Remove per-device media queries from Step 1 and use a single universal link tag instead:
```html
<link rel="apple-touch-startup-image" href="/splash/splash.svg" />
```
If SVG is not supported by Safari for startup images (it isn't), generate a single 1290x2796 PNG using a canvas script at build time, or manually create one. The key requirement is: dark background (#09090b), centered CodeTap text or mascot.
- [ ] **Step 3: Verify — add to home screen on iOS, check splash appears**
- [ ] **Step 4: Commit**
```bash
git add -f public/splash/ index.html
git commit -m "feat(pwa): iOS splash screens for major iPhone sizes"
```
---
## Task 3: Android Install Prompt
**Files:**
- Modify: `src/App.tsx`
- Modify: `src/components/SessionsView.tsx`
- [ ] **Step 1: Add install prompt state to App.tsx**
Add state and effect near the top of `App()`:
```tsx
const [installPrompt, setInstallPrompt] = useState<any>(null);
const [installDismissed, setInstallDismissed] = useState(
() => localStorage.getItem('codetap:install-dismissed') === 'true'
);
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
setInstallPrompt(e);
};
window.addEventListener('beforeinstallprompt', handler);
const installedHandler = () => {
setInstallPrompt(null);
setInstallDismissed(true);
localStorage.setItem('codetap:install-dismissed', 'true');
};
window.addEventListener('appinstalled', installedHandler);
return () => {
window.removeEventListener('beforeinstallprompt', handler);
window.removeEventListener('appinstalled', installedHandler);
};
}, []);
```
- [ ] **Step 2: Pass install props to SessionsView**
Update the SessionsView rendering in App.tsx:
```tsx
<SessionsView
onOpenChat={openChat}
onLogout={handleLogout}
onOpenSettings={() => setView({ name: 'settings' })}
installPrompt={!installDismissed ? installPrompt : null}
onInstall={async () => {
if (installPrompt) {
installPrompt.prompt();
const result = await installPrompt.userChoice;
if (result.outcome === 'accepted') {
setInstallPrompt(null);
setInstallDismissed(true);
localStorage.setItem('codetap:install-dismissed', 'true');
}
}
}}
onDismissInstall={() => {
setInstallDismissed(true);
localStorage.setItem('codetap:install-dismissed', 'true');
}}
/>
```
- [ ] **Step 3: Add install banner to SessionsView**
Add to SessionsView props interface and render a banner below the header when `installPrompt` is truthy:
```tsx
// Add to props
installPrompt?: any;
onInstall?: () => void;
onDismissInstall?: () => void;
// Render below the header, before the tab bar
{installPrompt && (
<div className="flex items-center gap-3 px-4 py-2.5 bg-accent/10 border-b border-accent/20">
<span className="text-xs text-text flex-1 font-mono">Install CodeTap for a better experience</span>
<button onClick={onInstall} className="text-xs font-medium text-accent hover:text-accent-light">Install</button>
<button onClick={onDismissInstall} className="text-xs text-text-dim hover:text-text">Dismiss</button>
</div>
)}
```
- [ ] **Step 4: Verify — open in Chrome Android (or DevTools Application panel), check install banner appears**
- [ ] **Step 5: Commit**
```bash
git add src/App.tsx src/components/SessionsView.tsx
git commit -m "feat(pwa): Android install prompt with dismissible banner"
```
---
## Task 4: Service Worker Update Notification
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 1: Add SW update detection and toast state**
Add near other effects in App():
```tsx
const [swUpdateAvailable, setSwUpdateAvailable] = useState(false);
useEffect(() => {
const handleControllerChange = () => setSwUpdateAvailable(true);
navigator.serviceWorker?.addEventListener('controllerchange', handleControllerChange);
return () => navigator.serviceWorker?.removeEventListener('controllerchange', handleControllerChange);
}, []);
```
- [ ] **Step 2: Render update toast**
Add before the closing `</div>` or at the bottom of the main render:
```tsx
{swUpdateAvailable && (
<div className="fixed bottom-6 left-4 right-4 bg-surface border border-accent/30 rounded-md px-4 py-3 flex items-center justify-between z-50 shadow-lg">
<span className="text-sm text-text font-mono">New version available</span>
<div className="flex gap-2">
<button
onClick={() => window.location.reload()}
className="text-sm font-medium text-accent hover:text-accent-light"
>Refresh</button>
<button
onClick={() => setSwUpdateAvailable(false)}
className="text-sm text-text-dim hover:text-text"
>Later</button>
</div>
</div>
)}
```
- [ ] **Step 3: Verify — modify SW file, rebuild, check toast appears**
- [ ] **Step 4: Commit**
```bash
git add src/App.tsx
git commit -m "feat(pwa): service worker update notification toast"
```
---
## Task 5: Badge Clear on Focus
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 1: Add visibility change listener**
Add with other effects in App():
```tsx
useEffect(() => {
const handleVisibility = () => {
if (document.visibilityState === 'visible') {
navigator.clearAppBadge?.();
}
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
```
- [ ] **Step 2: Commit**
```bash
git add src/App.tsx
git commit -m "feat(pwa): clear app badge when app becomes visible"
```
---
## Task 6: Manifest Shortcuts & Screenshots
**Files:**
- Modify: `vite.config.ts`
- Create: `public/screenshots/` (placeholder)
- [ ] **Step 1: Add shortcuts to manifest in vite.config.ts**
Add after the `icons` array in the manifest config:
```ts
shortcuts: [
{
name: 'New Chat',
short_name: 'New',
url: '/?action=newchat',
icons: [{ src: '/pwa-192x192.png', sizes: '192x192' }],
},
],
categories: ['developer-tools', 'productivity'],
```
- [ ] **Step 2: Handle ?action=newchat in App.tsx**
Add after the existing `?session=` URL handler:
```tsx
useEffect(() => {
const params = new URLSearchParams(window.location.search);
if (params.get('action') === 'newchat' && authed) {
window.history.replaceState({}, '', '/');
// Shortcut just brings user to sessions view — they pick a project from there
setView({ name: 'sessions' });
}
}, [authed]);
```
- [ ] **Step 3: Add screenshots placeholder to manifest**
Add to manifest in vite.config.ts:
```ts
screenshots: [
{
src: '/screenshots/narrow.png',
sizes: '1080x1920',
type: 'image/png',
form_factor: 'narrow',
label: 'CodeTap Chat View',
},
{
src: '/screenshots/wide.png',
sizes: '1920x1080',
type: 'image/png',
form_factor: 'wide',
label: 'CodeTap Sessions View',
},
],
```
Create `public/screenshots/` directory with placeholder images (can be actual screenshots later).
- [ ] **Step 4: Commit**
```bash
git add vite.config.ts src/App.tsx
git add -f public/screenshots/ 2>/dev/null || true
git commit -m "feat(pwa): manifest shortcuts, categories, and screenshots config"
```
---
## Task 7: Input Draft Auto-Save
**Files:**
- Modify: `src/components/ShimmerInput.tsx`
- [ ] **Step 1: Add draft persistence to ShimmerInput**
ShimmerInput doesn't receive a sessionId prop, so use a global draft key. Add after the existing state declarations:
```tsx
const DRAFT_KEY = 'codetap:draft';
// Restore draft on mount
useEffect(() => {
if (!initialText) {
const saved = localStorage.getItem(DRAFT_KEY);
if (saved) setText(saved);
}
}, []);
// Debounce-save draft on text change
const saveTimer = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
clearTimeout(saveTimer.current);
if (text.trim()) {
saveTimer.current = setTimeout(() => {
localStorage.setItem(DRAFT_KEY, text);
}, 500);
} else {
localStorage.removeItem(DRAFT_KEY);
}
return () => clearTimeout(saveTimer.current);
}, [text]);
```
- [ ] **Step 2: Clear draft on send**
In the existing send handler, add `localStorage.removeItem(DRAFT_KEY)` after `onSend(...)`:
```tsx
// Find the handleSend function and add after onSend call:
localStorage.removeItem(DRAFT_KEY);
```
- [ ] **Step 3: Verify — type text, close tab, reopen, check draft restored**
- [ ] **Step 4: Commit**
```bash
git add src/components/ShimmerInput.tsx
git commit -m "feat(pwa): auto-save input draft to localStorage with debounce"
```
---
## Task 8: Slow Network Detection
**Files:**
- Modify: `src/components/StatusBar.tsx`
- [ ] **Step 1: Add network quality detection**
Add a hook at the top of StatusBar component:
```tsx
const [slowNetwork, setSlowNetwork] = useState(false);
useEffect(() => {
const conn = (navigator as any).connection;
if (!conn) return;
const check = () => {
setSlowNetwork(conn.effectiveType === '2g' || conn.effectiveType === 'slow-2g');
};
check();
conn.addEventListener('change', check);
return () => conn.removeEventListener('change', check);
}, []);
```
- [ ] **Step 2: Render slow network indicator**
Add inside the status bar, before or after the model display:
```tsx
{slowNetwork && (
<span className="text-warning text-[10px] font-mono">Slow</span>
)}
```
- [ ] **Step 3: Commit**
```bash
git add src/components/StatusBar.tsx
git commit -m "feat(pwa): slow network indicator via Network Information API"
```
---
## Task 9: History API Navigation
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 1: Push state on view changes**
Modify the `saveView` function to also push history state:
```tsx
function saveView(view: View) {
sessionStorage.setItem('currentView', JSON.stringify(view));
const url = view.name === 'chat' && view.sessionId
? `/?view=chat&session=${view.sessionId}`
: view.name === 'settings'
? '/?view=settings'
: '/';
window.history.pushState({ view }, '', url);
}
```
- [ ] **Step 2: Listen for popstate (back button/gesture)**
Add effect in App():
```tsx
useEffect(() => {
const handlePopState = (event: PopStateEvent) => {
if (event.state?.view) {
setView(event.state.view);
sessionStorage.setItem('currentView', JSON.stringify(event.state.view));
} else {
setView({ name: 'sessions' });
sessionStorage.setItem('currentView', JSON.stringify({ name: 'sessions' }));
}
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, []);
```
- [ ] **Step 3: Use replaceState for initial load (avoid double entry)**
In the existing `loadView()` function, after loading the view, replace the current history entry:
```tsx
// At the end of App() initialization, after first render:
useEffect(() => {
window.history.replaceState({ view }, '', window.location.pathname + window.location.search);
}, []); // only on mount
```
- [ ] **Step 4: Verify — navigate sessions → chat → back gesture returns to sessions**
- [ ] **Step 5: Commit**
```bash
git add src/App.tsx
git commit -m "feat(pwa): History API navigation for back gesture support"
```
---
## Task 10: OpenGraph Meta Tags
**Files:**
- Modify: `index.html`
- [ ] **Step 1: Add OG and Twitter meta tags to index.html**
Add before `</head>`:
```html
<!-- OpenGraph -->
<meta property="og:title" content="CodeTap" />
<meta property="og:description" content="Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal." />
<meta property="og:type" content="website" />
<meta property="og:image" content="/pwa-512x512.png" />
<meta name="twitter:card" content="summary" />
<meta name="description" content="Use Claude Code from your phone. Real-time mobile UI synced with your desktop terminal." />
```
- [ ] **Step 2: Commit**
```bash
git add index.html
git commit -m "feat(pwa): OpenGraph and Twitter Card meta tags"
```
---
## Verification
1. Run `npm run dev` in the worktree
2. Open in Chrome DevTools → Application panel:
- Check manifest loads correctly with shortcuts, screenshots, categories
- Check service worker is registered
3. Mobile testing (iOS):
- Add to Home Screen → check splash screen appears
- Verify content extends properly behind notch (viewport-fit=cover)
- Test back gesture navigates correctly
4. Mobile testing (Android / Chrome):
- Check install banner appears in SessionsView
- Dismiss and verify it doesn't reappear
- Install and verify banner disappears
5. Draft persistence:
- Type text in input, close tab, reopen → text should be restored
- Send message → draft should be cleared
6. Badge:
- Receive push notification with badge → switch to app → badge clears
7. Network:
- Throttle to 2G in DevTools → "Slow" indicator appears in StatusBar
@@ -0,0 +1,566 @@
# Send-to Menu Redesign + Settings Page 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:** Redesign the Send-to menu as a two-step bottom sheet with model selection, add a Settings page with saved instructions management and per-adapter preferences.
**Architecture:** Three layers of changes: (1) Server — new `saved_instructions` DB table + API endpoints, (2) Client API — new instruction CRUD methods, (3) UI — rewritten ReviewActionMenu, new SettingsView with sub-pages, updated App routing and SessionsView header.
**Tech Stack:** React + TypeScript + Vite (client), Express + SQLite + better-sqlite3 (server), Tailwind CSS (styling)
**Spec:** `docs/superpowers/specs/2026-03-26-send-to-menu-settings-design.md`
---
## File Map
| File | Action | Responsibility |
|------|--------|---------------|
| `server/db.ts` | Modify | Add `saved_instructions` table + prepared statements + `savedInstructions` operations |
| `server/index.ts` | Modify | Add 3 instruction API endpoints |
| `src/lib/api.ts` | Modify | Add instruction CRUD client methods |
| `src/components/ReviewActionMenu.tsx` | Rewrite | Two-step bottom sheet with adapter/model/instructions |
| `src/components/ChatView.tsx` | Modify | Simplify `handleReviewSelect` — no more context assembly |
| `src/App.tsx` | Modify | Add `settings` view to routing |
| `src/components/SessionsView.tsx` | Modify | Add settings icon in header |
| `src/components/SettingsView.tsx` | Create | Main settings page with sections |
| `src/components/SavedInstructionsView.tsx` | Create | Instruction list with add/delete |
| `src/components/AdapterSettingsSection.tsx` | Create | Per-adapter model/permission/effort dropdowns |
---
## Task 1: Saved Instructions — Server DB + API
**Files:**
- Modify: `server/db.ts`
- Modify: `server/index.ts`
- [ ] **Step 1: Add `saved_instructions` table to DB**
In `server/db.ts`, add to the `db.exec()` block (after `session_reviews` table):
```sql
CREATE TABLE IF NOT EXISTS saved_instructions (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
instruction TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
```
- [ ] **Step 2: Add prepared statements**
In the `PreparedStatements` interface, add:
```typescript
instructionCreate: BetterSqlite3.Statement;
instructionGetAll: BetterSqlite3.Statement;
instructionDelete: BetterSqlite3.Statement;
```
In the `stmts()` function, add:
```typescript
instructionCreate: d.prepare(
`INSERT INTO saved_instructions (id, label, instruction) VALUES (?, ?, ?)`
),
instructionGetAll: d.prepare(
`SELECT * FROM saved_instructions ORDER BY created_at ASC`
),
instructionDelete: d.prepare(
`DELETE FROM saved_instructions WHERE id = ?`
),
```
- [ ] **Step 3: Add `savedInstructions` export object**
After the `sessionReviews` export, add:
```typescript
export const savedInstructions = {
create(id: string, label: string, instruction: string): void {
stmts().instructionCreate.run(id, label, instruction);
},
getAll(): { id: string; label: string; instruction: string; created_at: string }[] {
return stmts().instructionGetAll.all() as any[];
},
delete(id: string): void {
stmts().instructionDelete.run(id);
},
};
```
- [ ] **Step 4: Add API endpoints in `server/index.ts`**
Import `savedInstructions` from `./db.js`. Add 3 routes after the review routes:
```typescript
// --- Saved Instructions API ---
app.get('/api/instructions', authMiddleware, (_req: Request, res: Response) => {
try {
res.json(savedInstructions.getAll());
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.post('/api/instructions', authMiddleware, (req: Request, res: Response) => {
try {
const { label, instruction } = req.body;
if (!label || !instruction) return res.status(400).json({ error: 'label and instruction required' });
const id = randomUUID();
savedInstructions.create(id, label, instruction);
res.json({ id, label, instruction });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
app.delete('/api/instructions/:id', authMiddleware, (req: Request, res: Response) => {
try {
savedInstructions.delete(req.params.id);
res.json({ ok: true });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
```
Note: `randomUUID` is already imported in `server/index.ts`.
- [ ] **Step 5: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 6: Commit**
```bash
git add server/db.ts server/index.ts
git commit -m "feat: add saved_instructions DB table and API endpoints"
```
---
## Task 2: Client API — Instruction Methods
**Files:**
- Modify: `src/lib/api.ts`
- [ ] **Step 1: Add instruction API methods**
Add to the `api` object, following the existing pattern (see `registerReview`, `endReview` for reference):
```typescript
async getInstructions(): Promise<{ id: string; label: string; instruction: string; created_at: string }[]> {
return request('/api/instructions');
},
async createInstruction(label: string, instruction: string): Promise<{ id: string; label: string; instruction: string }> {
return request('/api/instructions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label, instruction }),
});
},
async deleteInstruction(id: string): Promise<void> {
return request(`/api/instructions/${id}`, { method: 'DELETE' });
},
```
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/lib/api.ts
git commit -m "feat: add instruction CRUD methods to client API"
```
---
## Task 3: ReviewActionMenu — Two-Step Bottom Sheet
**Files:**
- Rewrite: `src/components/ReviewActionMenu.tsx`
This is the core UI change. The component goes from a simple template picker to a two-step bottom sheet with adapter selection, model picker, and expandable instructions panel.
- [ ] **Step 1: Rewrite ReviewActionMenu.tsx**
New props interface:
```typescript
interface ReviewActionMenuProps {
visible: boolean;
adapters: { id: string; displayName: string }[];
onDirectSend: (adapter: string, model: string) => void;
onSendWithInstruction: (adapter: string, model: string, instruction: string, isCustom: boolean) => void;
onClose: () => void;
}
```
Component state:
- `step`: `'adapter' | 'action'` — which step is shown
- `selectedAdapter`: `string | null` — chosen adapter ID
- `adapterConfig`: loaded from `api.adapterConfig(selectedAdapter)` when adapter is chosen
- `selectedModel`: `string` — from adapterConfig.models, default to first item
- `instructionsExpanded`: `boolean` — toggle for With Instructions section
- `savedInstructions`: loaded from `api.getInstructions()` on mount
- `customText`: `string` — free text input value
**Step 1 UI** (adapter selection):
- Backdrop overlay (click to close)
- Bottom sheet with drag handle
- "Send to…" title
- Adapter rows: `<AdapterIcon>` + adapter name, no model text, tap → set selectedAdapter + go to step 2
**Step 2 UI** (action selection):
- ` {AdapterName}` header with colored adapter name (back arrow returns to step 1)
- Model: `<select>` with options from `adapterConfig.models`
- "Direct Send" button (icon ↗) — calls `onDirectSend(selectedAdapter, selectedModel)` then `onClose()`
- "With Instructions" button (icon ✎) with ▼/▲ chevron — toggles `instructionsExpanded`
- When expanded:
- Saved instructions list (tap → calls `onSendWithInstruction(adapter, model, item.instruction, false)`)
- Divider "或輸入新的"
- Text input + ↑ send button → calls `onSendWithInstruction(adapter, model, customText, true)` (isCustom=true triggers save toast)
- Text input + ↑ send button → calls `onSendWithInstruction(adapter, model, customText)`
Use `AdapterIcon` component from `./AdapterIcon` for adapter icons.
Use `getBrand` from `@/lib/adapter-brands` for adapter colors.
Reset `step` to `'adapter'` whenever `visible` changes to true.
- [ ] **Step 2: Verify it compiles**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/components/ReviewActionMenu.tsx
git commit -m "feat: rewrite ReviewActionMenu as two-step bottom sheet"
```
---
## Task 4: ChatView — Simplify Review Flow + Save Toast
**Files:**
- Modify: `src/components/ChatView.tsx`
- [ ] **Step 1: Replace handleReviewSelect with two new handlers**
Delete the old `handleReviewSelect` callback, the `promptTemplates` record, and the context assembly code.
Add two new handlers:
```typescript
const handleDirectSend = useCallback((adapter: string, model: string) => {
const anchorId = reviewMenuMessageId;
setReviewMenuMessageId(null);
setReviewTargetAdapter(null);
if (!anchorId) return;
// Save selected model to adapter prefs so child session uses it
patchAdapterPrefs(adapter, { model });
const anchorMsg = messages.find(m => m.id === anchorId);
const rawText = anchorMsg ? extractTextFromBlocks(anchorMsg.content) : '';
setHistoryReview(null);
setActiveReview({
reviewId: '', childSessionId: '', childCliSessionId: '',
childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: 'direct',
});
setReviewInitialPrompt(rawText);
setReviewCwd(cwd || null);
setActiveReviewPanel('expanded');
}, [reviewMenuMessageId, messages, cwd]);
const handleSendWithInstruction = useCallback((adapter: string, model: string, instruction: string, isCustom: boolean) => {
const anchorId = reviewMenuMessageId;
setReviewMenuMessageId(null);
setReviewTargetAdapter(null);
if (!anchorId) return;
// Save selected model to adapter prefs so child session uses it
patchAdapterPrefs(adapter, { model });
const anchorMsg = messages.find(m => m.id === anchorId);
const rawText = anchorMsg ? extractTextFromBlocks(anchorMsg.content) : '';
const prompt = `${instruction}\n\n${rawText}`;
const title = instruction.substring(0, 30);
setHistoryReview(null);
setActiveReview({
reviewId: '', childSessionId: '', childCliSessionId: '',
childAdapter: adapter, anchorMessageId: anchorId, reviewTitle: title,
});
setReviewInitialPrompt(prompt);
setReviewCwd(cwd || null);
setActiveReviewPanel('expanded');
// Show save toast only for custom (typed) instructions, not saved ones
if (isCustom) {
setSaveToast({ instruction, label: title });
setTimeout(() => setSaveToast(null), 3000);
}
}, [reviewMenuMessageId, messages, cwd]);
```
Add state:
```typescript
const [saveToast, setSaveToast] = useState<{ instruction: string; label: string } | null>(null);
```
- [ ] **Step 2: Update ReviewActionMenu JSX**
Replace the current `<ReviewActionMenu>` with:
```tsx
<ReviewActionMenu
visible={!!reviewMenuMessageId}
adapters={sendTargets.map(t => ({ id: t.adapter, displayName: t.label }))}
onDirectSend={handleDirectSend}
onSendWithInstruction={handleSendWithInstruction}
onClose={() => { setReviewMenuMessageId(null); setReviewTargetAdapter(null); }}
/>
```
- [ ] **Step 3: Add save toast UI**
Render at the bottom of the ChatView return, before the closing `</div>`:
```tsx
{saveToast && (
<div className="fixed bottom-20 left-4 right-4 bg-[#1a1a1a] border border-[#333] rounded-xl p-3 flex items-center justify-between z-30">
<span className="text-sm text-[#888]"></span>
<button
className="text-sm text-blue-400 font-medium px-3 py-1 rounded-lg hover:bg-blue-400/10"
onClick={() => {
api.createInstruction(saveToast.label, saveToast.instruction);
setSaveToast(null);
}}
>Save</button>
</div>
)}
```
- [ ] **Step 4: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
- [ ] **Step 5: Commit**
```bash
git add src/components/ChatView.tsx
git commit -m "feat: simplify review flow — direct send raw text, instructions without context"
```
---
## Task 5: App Routing — Add Settings View
**Files:**
- Modify: `src/App.tsx`
- [ ] **Step 1: Add settings to View type and rendering**
Add `| { name: 'settings' }` to the `View` union type.
Add rendering branch before the default `SessionsView`:
```tsx
if (view.name === 'settings') {
return <SettingsView onBack={() => setView({ name: 'sessions' })} />;
}
```
Add import: `import { SettingsView } from './components/SettingsView';`
- [ ] **Step 2: Pass onOpenSettings to SessionsView**
Add prop to the `<SessionsView>` JSX:
```tsx
onOpenSettings={() => setView({ name: 'settings' })}
```
- [ ] **Step 3: Commit**
```bash
git add src/App.tsx
git commit -m "feat: add settings view to app routing"
```
---
## Task 6: SessionsView — Settings Icon in Header
**Files:**
- Modify: `src/components/SessionsView.tsx`
- [ ] **Step 1: Add `onOpenSettings` prop and gear icon**
Add `onOpenSettings: () => void` to the component props.
In the header's button group (the `<div className="flex items-center gap-2">` block), add before the Logout button:
```tsx
<button
onClick={onOpenSettings}
className="p-2 text-text-dim hover:text-text transition-colors"
title="Settings"
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06A1.65 1.65 0 0019.4 9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/>
</svg>
</button>
```
- [ ] **Step 2: Commit**
```bash
git add src/components/SessionsView.tsx
git commit -m "feat: add settings gear icon to project list header"
```
---
## Task 7: SettingsView — Main Settings Page
**Files:**
- Create: `src/components/SettingsView.tsx`
- [ ] **Step 1: Create SettingsView component**
Props: `onBack: () => void`
State:
- `subView`: `'main' | 'instructions' | string` (string = adapter id)
- `adapters`: fetched from `api.adapters()` on mount
- `version`: fetched from server health endpoint or read from a constant
Sub-view routing:
- `subView === 'instructions'` → render `<SavedInstructionsView onBack={() => setSubView('main')} />`
- `subView` matches an adapter id → render `<AdapterSettingsSection adapter={subView} onBack={() => setSubView('main')} />`
- Default → render main list
Main list: dark themed rows with chevrons, each tappable:
- "Saved Instructions" → `setSubView('instructions')`
- One row per adapter (icon + name) → `setSubView(adapterId)`
- "Notifications" → inline push toggle (reuse existing `usePushNotifications` hook)
- "About" → show `CodeTap v{version}` inline
Header: back arrow + "Settings" title, same style as existing headers.
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/components/SettingsView.tsx
git commit -m "feat: create SettingsView with section navigation"
```
---
## Task 8: SavedInstructionsView — Instruction Management
**Files:**
- Create: `src/components/SavedInstructionsView.tsx`
- [ ] **Step 1: Create SavedInstructionsView component**
Props: `onBack: () => void`
State:
- `instructions`: array, fetched from `api.getInstructions()` on mount
- `showAddForm`: boolean
- `newLabel`: string
- `newInstruction`: string
UI:
- Header: back arrow + "Saved Instructions" + `[+ Add]` button
- List: each item shows label (bold) + instruction preview (truncated, dim) + ✕ delete button
- Delete: show `window.confirm('Delete this instruction?')`, then `api.deleteInstruction(id)`, remove from local state
- Add form (when showAddForm): label input + instruction textarea + [Save] + [Cancel] buttons
- Save: `api.createInstruction(label, instruction)`, append to local state, hide form
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/components/SavedInstructionsView.tsx
git commit -m "feat: create SavedInstructionsView with add/delete"
```
---
## Task 9: AdapterSettingsSection — Per-Adapter Preferences
**Files:**
- Create: `src/components/AdapterSettingsSection.tsx`
- [ ] **Step 1: Create AdapterSettingsSection component**
Props: `adapter: string; onBack: () => void`
State:
- `config`: fetched from `api.adapterConfig(adapter)` on mount (contains models, permissionModes, effortLevels, effortLabel)
- `prefs`: loaded from `loadAdapterPrefs(adapter)` (model, permissionMode, effort)
UI:
- Header: back arrow + AdapterIcon + adapter display name (from `getBrand`)
- Three `<select>` dropdowns, each with a label:
- "Model" → options from `config.models` (each has `value` + `label`)
- "Permission Mode" → options from `config.permissionModes`
- Effort label from `config.effortLabel` (e.g. "Thinking" for Claude, "Effort" for Codex) → options from `config.effortLevels`
- On change: `patchAdapterPrefs(adapter, { [field]: value })` to persist to localStorage
Import `loadAdapterPrefs`, `patchAdapterPrefs` from `@/lib/adapter-prefs`.
Import `getBrand` from `@/lib/adapter-brands`.
Import `AdapterIcon` from `./AdapterIcon`.
- [ ] **Step 2: Verify TypeScript compiles**
Run: `npx tsc --noEmit`
- [ ] **Step 3: Commit**
```bash
git add src/components/AdapterSettingsSection.tsx
git commit -m "feat: create AdapterSettingsSection with per-adapter dropdowns"
```
---
## Task 10: E2E Verification
- [ ] **Step 1: Full TypeScript check**
Run: `npx tsc --noEmit`
Expected: No errors
- [ ] **Step 2: Start server (without watch mode) and Vite**
Start server and Vite in separate processes. Ensure tmux session exists first.
- [ ] **Step 3: Visual verification in browser**
Verify the following scenarios:
1. Settings icon visible in project list header
2. Settings page loads with all sections
3. Saved Instructions: add an instruction, verify it appears in list, delete it
4. Adapter settings: all dropdowns populate with correct adapter-specific options
5. In a chat, click send icon → two-step menu opens
6. Step 1 shows adapter icons + names (no model text)
7. Step 2 shows model dropdown + Direct Send + With Instructions
8. With Instructions has expand/collapse chevron
9. Direct Send sends only raw response text
10. With Instructions sends instruction + raw text
11. Save toast appears and works after custom instruction send
- [ ] **Step 4: Final commit if any fixes needed**
@@ -0,0 +1,334 @@
# Cross-AI Review
## Overview
A mechanism to send messages from one CLI session (e.g., Claude) to another adapter's CLI session (e.g., Codex) for review, all within the same ChatView. The child session runs in its own tmux window with full codebase access, and its UI is presented as a floating panel over the parent chat.
## Concepts
### Parent Session
The main CLI session the user is working in. Appears in session list and active sessions as normal.
### Child Session (Review Session)
A secondary CLI session triggered from a specific message in the parent. It:
- Runs a different adapter (e.g., parent is Claude, child is Codex)
- Opens in the same `cwd` as the parent (read from parent's DB row, not from client)
- Is a real tmux window, supports full CLI interaction with codebase access
- Does NOT appear in the session list or active sessions
- Is tracked via a separate `session_reviews` DB table
- Its UI appears as a floating panel inside the parent's ChatView
- Uses its own `useChat` hook instance for independent WebSocket connection and message handling
### Relationship
- One parent can have one active (non-ended) child at a time
- If the user tries to start a second review while one is active, show a confirmation: "End current review to start a new one?"
- Each child has an `anchor_message_id` — the ID of the specific assistant message that triggered the review
- Ended reviews remain as collapsed cards in the history; a parent can have many ended reviews
## Naming Conventions
All adapter references in the UI are **dynamic**, never hardcoded:
- "Send to [Codex]" / "Send to [Claude]" — resolved from available adapters via `/api/adapters`
- In child session: "Send to [Parent Adapter Name]" — resolved from parent's adapter
- The "Send to" button is only shown when at least one other adapter is available (`/api/adapters` filtered to `available: true`)
- Review session title is dynamic based on the prompt template selected: "Code Review", "Suggest Alternatives", "Direct Send", or the user's custom instruction (truncated)
## User Flow
### 1. Triggering a Review
Every assistant message in the parent chat has action buttons:
- **Copy** — copy message content
- **Send to [Adapter]** — triggers the cross-AI review flow (adapter name is dynamic)
When the user taps "Send to [Adapter]", a popup menu appears with prompt template options:
- **Direct send** — send the message as-is
- **Code Review** — attach a "please review this code" instruction
- **Suggest alternatives** — ask for different approaches
- **Custom instruction...** — user types their own prompt
After selection:
1. Server creates a new CLI session for the target adapter in tmux (same `cwd` as parent)
2. Context (parent conversation history + selected message + prompt template) is pasted into the child CLI via `tmux load-buffer` + `paste-buffer` (see "Context Passing" section)
3. A floating panel appears at the bottom of the screen
4. Server broadcasts `REVIEW_STARTED` to all parent session clients
5. A `session_reviews` row is created in the DB
### 2. Interacting with the Child Session
The floating panel has three states:
**Expanded** — takes up ~55% of the screen from the bottom. Shows:
- Header with dynamic title: "[Adapter] [Template Name]" (e.g., "Codex Code Review")
- "End" button to close the review
- Chat messages from the child session (with streaming — uses its own `useChat` hook)
- Each child assistant message has:
- **Copy** button
- **Send to [Parent Adapter]** button — dynamically named based on parent's adapter
- Input field for the user to ask follow-up questions to the child AI
**Minimized (Pill)** — a small floating pill button in the bottom-right corner. Shows adapter name + template name with a pulsing dot. Tap to expand.
**Hidden** — the floating panel is dismissed but the tmux session continues running in the background.
Users can switch between states by tapping the handle bar (to minimize) or the pill (to expand).
### 3. Sending Results Back to Parent
Each assistant message in the child session has a **"Send to [Parent Adapter]"** button. When tapped:
- **Guard**: if the parent session is currently processing (`isProcessing`), show a toast: "Wait for the current turn to complete"
- Otherwise: the message content is prefixed with "[Review feedback from [Child Adapter]:]" and injected into the parent's tmux session via `sendMessage()`
- The parent AI sees it as a normal user message and can respond
- The user continues working with the parent AI, informed by the review
This is a manual, explicit action. There is no automatic injection.
For long messages, use `tmux load-buffer` + `paste-buffer` (see "Context Passing & Message Delivery" section).
### 4. Ending a Review
The user taps the **"End"** button on the floating panel:
- `ended_at` is set in the `session_reviews` table
- The child's tmux window is killed
- The floating panel disappears
- Server broadcasts `REVIEW_ENDED` to all parent session clients
- The child session's JSONL file is preserved for history
### 5. Viewing History
When the user re-opens the parent session and scrolls through message history:
- At the `anchor_message_id` position, a **block-start marker** is rendered: "[Adapter] [Template] started"
- Immediately after the block-start marker, a **collapsed review card** appears showing:
- Adapter name, template name, and message count
- A brief summary (first line of the child AI's first response)
- "Tap to expand" hint — opens the full child conversation in a read-only panel
- The block-end marker renders immediately after the collapsed review card: "Review ended"
- Parent messages that occurred during the review period continue normally in the chat flow (they are NOT inside the collapsed card — they are separate messages below it)
The block markers are NOT stored in JSONL. They are rendered dynamically by ChatView based on `session_reviews` DB metadata, keyed by `anchor_message_id`.
## Context Passing & Message Delivery
### tmux buffer (unified approach)
All text delivery to CLI sessions uses `tmux load-buffer` + `paste-buffer` instead of `send-keys`. This applies to:
- Initial context sent to child session
- Messages sent back from child to parent ("Send to [Parent Adapter]")
- Any other long-form text injection
**Why not `send-keys`:**
- Special characters (quotes, backslashes, newlines) get interpreted by the shell
- `send-keys -l` processes text character-by-character, slow for large content
- Practical input length limits
**Why not file-based (write file + "read /tmp/xxx.md"):**
- Requires an extra tool call round trip (AI has to read the file)
- Requires file cleanup
- Less direct
**Buffer mechanism:**
```bash
# 1. Write content to a temp file
echo "$content" > /tmp/codetap-buf-{id}.txt
# 2. Load into tmux buffer
tmux load-buffer /tmp/codetap-buf-{id}.txt
# 3. Paste into target pane
tmux paste-buffer -t codetap:{windowId}
# 4. Press Enter to submit
tmux send-keys -t codetap:{windowId} Enter
# 5. Clean up temp file
rm /tmp/codetap-buf-{id}.txt
```
The CLI receives the pasted text as a single multi-line prompt. Both Claude Code and Codex handle pasted multi-line input natively.
### Initial context format
When creating a child session, the context pasted as the first prompt:
```
The following is a conversation between a user and [Parent Adapter].
Please review the highlighted response below.
[Conversation History]
User: [message 1]
[Parent Adapter]: [message 2]
...
>>> REVIEW THIS RESPONSE <<<
[Parent Adapter]: [the selected message content]
[Instruction]
[Prompt template text, e.g., "Please perform a code review..."]
```
Maximum context: last 50 messages or 30KB of text (whichever is smaller). If truncated, prepend "[Earlier conversation omitted]".
### Send-back format
When "Send to [Parent Adapter]" is tapped on a child message, the content pasted to the parent:
```
[Review feedback from [Child Adapter]]:
[message content]
```
## Data Model
### New table: `session_reviews`
```sql
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,
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);
```
**All session IDs stored are native CLI UUIDs** (e.g., `019d1956-2941-7360-b313-0610d98ee150` for Codex, `4ac007a8-7b04-4646-8f0c-a74845aa01bf` for Claude), NOT internal IDs (e.g., `codex-1774267440443` or `claude-1774269874387`). Internal IDs change on server restart when sessions are recreated; CLI UUIDs are permanent.
This table is **NOT cleared** by `clearAll()` — it survives server restarts. The `sessions` table continues to be cleared as before.
### Filtering child sessions
Child sessions still appear in the `sessions` table (for tmux window tracking). Filtering is done at the **API layer** in `server/index.ts`, not in adapters or JSONL stores. This is because:
- `getSessions()` in both adapters reads from JSONL files, not the DB -- it has no access to `session_reviews`
- `getActiveSessions()` reads from in-memory Maps -- same issue
- The API endpoints already aggregate across adapters, so filtering here is natural
**Implementation:**
For `/api/sessions` and `/api/active-sessions` endpoints in `server/index.ts`:
1. Query `session_reviews` for all `child_cli_session_id` values
2. Build a `Set<string>` of child CLI UUIDs
3. Filter the results: exclude any session whose `cliSessionId` (CLI UUID) is in the set
This keeps adapter code untouched and centralizes the filtering logic.
### Message IDs
The spec uses `anchor_message_id` to identify which message triggered a review. Currently:
- `ChatMessage` type has an optional `id` field, but it is never populated
- JSONL entries from both Claude and Codex do not have dedicated message IDs
**Solution:** Generate synthetic UUIDs for each message at parse time in `TranscriptParser` (Claude) and `CodexTranscriptParser` (Codex). These IDs are:
- Generated deterministically or at parse time
- Threaded through to `ChatMessage.id` in the React state
- Passed to `MessageBubble` as a prop for action button callbacks
- Stored as `anchor_message_id` in `session_reviews` when a review is triggered
### DB operations
Add a `sessionReviews` operation set to `server/db.ts`:
- `create(id, parentCliId, childCliId, childAdapter, anchorMsgId, prompt, title)` -- insert new review
- `getActiveForParent(parentCliSessionId)` -- active reviews for reconnect
- `getAllChildIds()` -- all child CLI UUIDs for session list filtering
- `endReview(reviewId)` -- sets ended_at
- `getForParent(parentCliSessionId)` -- all reviews including ended (for history rendering)
### TmuxManager changes
Add a `pasteBuffer(windowId, content)` method to `server/adapters/claude/tmux-manager.ts`:
1. Write content to a temp file
2. `tmux load-buffer <tmpFile>`
3. `tmux paste-buffer -t codetap:<windowId>`
4. `tmux send-keys -t codetap:<windowId> Enter`
5. Clean up temp file
Uses `execFile` (not `exec`) for safety, consistent with existing TmuxManager methods.
This replaces `sendKeys()` for all cross-AI review text delivery (initial context + send-back). Regular `sendMessage()` in adapters continues to use `sendKeys()` for short user prompts.
## WebSocket Events
Two new event types for review lifecycle:
```typescript
// server → client: a review session was created
REVIEW_STARTED = 'review-started'
{
type: 'review-started',
reviewId: string,
childSessionId: string, // internal session ID for useChat connection
childCliSessionId: string, // CLI UUID
childAdapter: string,
anchorMessageId: string,
reviewTitle: string,
}
// server → client: a review session was ended
REVIEW_ENDED = 'review-ended'
{
type: 'review-ended',
reviewId: string,
}
```
These are broadcast to all clients connected to the parent session. On receiving `REVIEW_STARTED`, the client creates a `FloatingReviewPanel` with its own `useChat` hook instance pointing to the child session.
## Reconnect / Server Restart
### Reconnecting to a parent session with active child
When a user opens a parent session:
1. Query DB: `SELECT * FROM session_reviews WHERE parent_cli_session_id = ? AND ended_at IS NULL`
2. For each active child (at most one, enforced by the one-active-child rule):
- Resolve the child CLI UUID to an internal session ID
- Check if the tmux window still exists
- If yes: re-attach (start monitoring events), show floating panel
- If no (e.g., server restarted): use `resumeSession` with the child CLI UUID to create a new tmux window, show floating panel
3. For ended children: do nothing at connect time. ChatView renders collapsed review cards when scrolling through history by querying `session_reviews` for this parent.
### clearAll() behavior
`clearAll()` clears the `sessions` table on shutdown. The `session_reviews` table is NOT cleared. On next startup:
- `session_reviews` still has the parent-child relationships (keyed by CLI UUIDs)
- Child session JSONL files still exist on disk
- History view works correctly
## UI Components
### New Components
- **FloatingReviewPanel** — the expandable/minimizable floating panel for child session interaction. Uses its own `useChat` hook instance with the child's session ID.
- **FloatingReviewPill** — the minimized pill button
- **ReviewActionMenu** — the popup menu when "Send to [Adapter]" is tapped (prompt template selection)
- **CollapsedReviewCard** — the folded review card shown in history view
- **BlockMarker** — the "Review started" / "Review ended" divider lines
### Modified Components
- **MessageBubble** — add "Copy" and "Send to [Adapter]" action buttons to each assistant message. Adapter name is dynamic.
- **ChatView** — integrate FloatingReviewPanel, render block markers and collapsed cards in history, manage child session lifecycle
- **useChat hook** — add review state management (active review ID, floating panel visibility). Does NOT handle child session messages — that is delegated to the child's own `useChat` instance inside `FloatingReviewPanel`.
### Removed Components
- **QuickActionCards** — replaced by per-message action buttons
- **CrossAdapterFlow** — replaced by the new review mechanism
- **quick-commands.ts** — prompt templates moved into ReviewActionMenu
- `crossAdapterFlow` state, `startCrossAdapterFlow`, `completeCrossAdapterFlow` in useChat — all removed
## Scope Boundaries (NOT included in v1)
- **Auto N-round debate** — two AIs automatically going back and forth. Deferred due to JSONL duplication complexity.
- **Multi-child sessions** — only one active child at a time. Multiple ended reviews are fine.
- **More than 2 adapters** — the architecture supports it (dynamic naming, adapter list from API), but UI is only tested with Claude + Codex.
## Interactive Mockup
A visual mockup is available at `/tmp/cross-ai-review-mockup.html` showing three views:
1. **Live view** — review in progress with floating panel (expanded + minimized pill states)
2. **History view** — review ended with collapsed card + block markers + interleaved parent messages
3. **Menu view** — the prompt template selection popup
@@ -0,0 +1,136 @@
# InsightBlock — Adapter-Specific Content Rendering
## Problem
Claude Code produces "Insight" blocks in its markdown responses:
```
`★ Insight ─────────────────────────────────────`
[educational content]
`─────────────────────────────────────────────────`
```
These currently render as ugly inline `<code>` elements in ReactMarkdown. Need a dedicated, collapsible UI component — while keeping the architecture extensible for other adapters (Gemini, Codex) that may have their own text patterns.
## Design
### Approach: Frontend Text Transform with Adapter-Scoped Patterns
- **No server changes.** The Insight text flows through the existing pipeline as `{ type: 'text' }` content blocks.
- **Regex patterns** defined per-adapter in adapter-scoped files.
- **Generic splitter** in `src/lib/` accepts patterns as parameters — no coupling to any specific adapter.
- **Collapsible card UI** matching ToolCallCard's expand/collapse pattern.
### File Structure
```
src/
├── lib/
│ └── text-transforms.ts # Generic: splitTextSegments(text, patterns)
├── components/
│ ├── adapters/
│ │ └── claude/
│ │ ├── InsightBlock.tsx # Collapsible insight card
│ │ └── patterns.ts # INSIGHT_RE regex + segment type
│ ├── MessageBubble.tsx # Modified: split → map → render
│ └── ...
```
**Dependency direction:**
```
MessageBubble → text-transforms (generic)
MessageBubble → adapters/claude/patterns (adapter-specific)
MessageBubble → adapters/claude/InsightBlock (adapter-specific)
```
Generic code never imports adapter-specific code. MessageBubble is the composition root.
### `src/components/adapters/claude/patterns.ts`
Exports Claude-specific text patterns:
```typescript
import type { TextPattern } from '@/lib/text-transforms';
export const CLAUDE_PATTERNS: TextPattern[] = [
{
type: 'insight',
regex: /`[★✦]?\s*Insight[─\-\s]*`\n([\s\S]*?)\n`[─\-]+[.。]?`/g,
},
];
```
### `src/lib/text-transforms.ts`
Generic segment splitter — adapter-agnostic:
```typescript
export interface TextPattern {
type: string;
regex: RegExp;
}
export type TextSegment = {
type: string; // 'markdown' | 'insight' | future types
text: string;
};
export function splitTextSegments(text: string, patterns: TextPattern[]): TextSegment[] {
// Fast path: no patterns or no text → return as-is
// For each pattern, find all matches and record their positions
// Split text into alternating markdown/matched segments
// Streaming safety: unmatched opening fence → treat as plain markdown
}
```
### `src/components/adapters/claude/InsightBlock.tsx`
Collapsible card — Style C from brainstorming:
- **Collapsed (default):** `★` icon + "Insight" label + first-line summary (truncated) + `▼` chevron
- **Expanded:** Full content rendered via ReactMarkdown with prose styling
- **Visual:** `bg-surface/30 border border-border/50 rounded-lg` — subtle card with accent star
### `src/components/MessageBubble.tsx` Changes
```diff
+ import { splitTextSegments } from '@/lib/text-transforms';
+ import { CLAUDE_PATTERNS } from './adapters/claude/patterns';
+ import { InsightBlock } from './adapters/claude/InsightBlock';
// In assistant message render:
const textContent = content.filter(...).map(...).join('');
+ const segments = splitTextSegments(textContent, CLAUDE_PATTERNS);
- <ReactMarkdown ...>{textContent}</ReactMarkdown>
+ {segments.map((seg, i) =>
+ seg.type === 'insight'
+ ? <InsightBlock key={i} text={seg.text} />
+ : <ReactMarkdown key={i} components={markdownComponents}>{seg.text}</ReactMarkdown>
+ )}
```
## Extensibility
When Gemini adds "Analysis" blocks:
1. `src/components/adapters/gemini/patterns.ts` — export `GEMINI_PATTERNS`
2. `src/components/adapters/gemini/AnalysisBlock.tsx` — new component
3. `MessageBubble.tsx` — merge patterns: `[...CLAUDE_PATTERNS, ...GEMINI_PATTERNS]`
4. Add one more segment type case in the render map
No server changes. No refactoring. Explicit additions only.
## Edge Cases
- **Streaming:** Partial insight (opening fence without closing) → `splitTextSegments` treats as plain markdown. InsightBlock only renders when both fences are present.
- **No insights:** Fast path — `splitTextSegments` returns `[{ type: 'markdown', text }]`, single ReactMarkdown render. No performance regression.
- **Multiple insights:** Each becomes its own InsightBlock in the segments array, with markdown segments between them.
- **Nested markdown in insight body:** ReactMarkdown inside InsightBlock handles bullet points, code, links.
## Not Changing
- Server pipeline (TranscriptParser, session-manager, IAdapter)
- ChatView.tsx (Insight is text-layer, not content-block-layer)
- useChat.ts
- WS protocol
@@ -0,0 +1,256 @@
# Session ID Unification Design Spec
## Problem
CodeTap's session management has grown organically and now has several issues:
1. **Dual ID system** — Each session has an "internal ID" (`session-{timestamp}` or `desktop-{uuid前8字}`) and a "CLI UUID". The internal ID is meaningless to users but is what the UI displays.
2. **Dual storage**`session-map.json` (file) and SQLite (DB) both store session mappings. The file is written by the SessionStart hook but only read on server startup, causing `codetap new` sessions to not appear in Active Sessions until server restart.
3. **`desktop-` prefix confusion** — Sessions that are "rediscovered" after a non-graceful server restart get a new `desktop-` internal ID, losing their original ID.
4. **No adapter awareness in IDs** — Internal IDs don't indicate which adapter (Claude/Codex/Gemini) the session belongs to.
5. **User can't resume from desktop** — The chat header shows the internal ID which can't be used with `claude --resume`.
## Design Decisions
| Topic | Decision |
|-------|----------|
| Scope | All adapters (Claude, Codex, future) |
| Storage | SQLite only — remove session-map.json |
| SessionStart hook | POST to server API (like all other hooks) |
| Internal ID format | `{adapter}-{timestamp}` (e.g., `claude-1774210269126`) |
| `desktop-` prefix | Removed entirely |
| Non-graceful restart recovery | Read original internal ID from DB |
| User-facing display | Chat header: CLI UUID (primary) + internal ID (secondary) |
| Active Sessions list | Keep showing `firstPrompt` (no change) |
| DB on shutdown | Clear `sessions` table (tmux windows are killed, records are useless) |
| CLI `--adapter` flag | Added to `codetap new` and `codetap --continue` |
| CLI `--resume` | Accepts internal ID or CLI UUID; scans JSONL dirs to detect adapter |
| CLI `--continue` | Pass through to adapter CLI's native continue command |
| Adapter selector UI | Not in scope (future work) |
## Architecture
### Internal ID Format
```
{adapter}-{timestamp}
Examples:
claude-1774210269126
codex-1774210345678
gemini-1774210500000
```
Produced by:
- `startSession()` in each adapter (Web UI new session)
- `bin/codetap` CLI script (`codetap new`, `codetap --resume`, `codetap --continue`)
### Single Source of Truth: SQLite
All session mappings go through SQLite. No file-based storage.
**SessionStart hook flow (unified for all entry points):**
```
Claude/Codex CLI starts → SessionStart hook fires
POST /api/hooks/{adapter}/session-start
body: { session_id: "<CLI UUID>", cwd: "/path", ... }
Server handler:
1. Find tmux window for this session (by window name or DB lookup)
2. If session already in memory → update mapping (e.g., /resume changed UUID)
3. If session NOT in memory → create new entry:
- Internal ID from tmux window name (e.g., claude-1774210269126)
- Map CLI UUID → internal ID
- Write to DB
4. Session appears in Active Sessions immediately
```
**Shutdown flow:**
```
SIGTERM/SIGINT received
1. adapter.destroy() → tmuxManager.killSession() → all tmux windows killed
2. dbSessions.clearAll() → clear sessions table
3. closeDB()
```
### DB Schema
```sql
CREATE TABLE sessions (
id TEXT PRIMARY KEY, -- internal ID (claude-1774210269126)
cli_session TEXT, -- CLI native UUID
adapter TEXT, -- 'claude' / 'codex' / 'gemini'
cwd TEXT,
window_id TEXT,
permission_mode TEXT,
created_at DATETIME DEFAULT (datetime('now')),
last_activity DATETIME DEFAULT (datetime('now'))
);
CREATE INDEX idx_sessions_cli ON sessions(cli_session);
CREATE INDEX idx_sessions_adapter ON sessions(adapter);
```
Migration: rename `claude_session``cli_session`, add `adapter` column (default `'claude'` for existing rows), remove `is_active` column.
### Chat Header Display
```
┌──────────────────────────────────────────────────┐
│ ← code-tap 625c60d0-aedb-4e0b... [copy icon] │
│ claude-1774210269126 │
└──────────────────────────────────────────────────┘
```
- Primary: CLI UUID (truncated), click copy icon to copy full UUID → usable with `claude --resume <uuid>`
- Secondary: internal ID → usable with `tmux select-window -t codetap:claude-1774210269126`
`SESSION_CREATED` message updated to include both `sessionId` (internal) and `cliSessionId` (UUID).
### Hook Config Change
In `hook-config.ts`, SessionStart changes from file-writing script to `fireAndForget` API POST (same pattern as all other hooks):
```typescript
// Before:
SessionStart: [{ hooks: [{ type: 'command', command: hookPath, timeout: 2 }] }]
// After:
SessionStart: [{ hooks: [{ type: 'command', command: fireAndForget('session-start'), timeout: 2 }] }]
```
### SESSION_CREATED Message Payload
```typescript
// Before:
{ type: 'session-created', sessionId: string }
// After:
{ type: 'session-created', sessionId: string, cliSessionId: string }
```
`useChat` hook stores both IDs. `ChatView` header displays `cliSessionId` (primary) and `sessionId` (secondary).
### Recovery from Non-Graceful Shutdown
If the server crashes (kill -9, power loss) without running the shutdown flow:
1. Tmux session `codetap` may still be alive with running CLI instances
2. On next server start, DB still has session records (SQLite persists)
3. When hooks fire from surviving CLI instances → `resolveSessionId`:
- Finds the session in DB by `cli_session` UUID
- Restores the **original** internal ID from DB (e.g., `claude-1774210269126`)
- Re-creates in-memory mapping
- Session reappears in Active Sessions with its original ID
If the CLI session hasn't fired a SessionStart hook yet (e.g., Codex before first interaction):
- Session stays in DB with `cli_session = NULL`
- Once hook fires → DB record updated with CLI UUID
- UI shows UUID after hook fires (brief grace period showing internal ID only)
### CLI Changes (`bin/codetap`)
**`codetap new [--adapter <name>]`**
```bash
codetap new # WINDOW_NAME="claude-$(date +%s)", runs: claude
codetap new --adapter codex # WINDOW_NAME="codex-$(date +%s)", runs: codex
codetap new --adapter gemini # WINDOW_NAME="gemini-$(date +%s)", runs: gemini
```
Default adapter: `claude`. The `--adapter` flag determines both the window name prefix and the CLI command to run.
**`codetap --resume <id>`**
```
Input: internal ID or CLI UUID
Is it internal ID format? ({adapter}-{digits})
├─ Yes → extract adapter from prefix, query DB for CLI UUID
│ ├─ Found → run: {adapter} --resume {uuid}
│ └─ Not found → error
└─ No (UUID format) → query DB by cli_session
├─ Found → get adapter from DB → run: {adapter} --resume {uuid}
└─ Not found → scan JSONL directories per adapter
├─ Found → detected adapter → run: {adapter} --resume {uuid}
└─ Not found → error: "Session not found"
```
**`codetap --continue [--adapter <name>]`**
Pass through to adapter CLI's native continue command:
- `claude --continue`
- `codex resume --last`
Window name: `{adapter}-{timestamp}` (same format as `new`).
SessionStart hook handles the mapping automatically when the CLI starts — even if the continued session was never managed by CodeTap before.
Default adapter: `claude`. With `--adapter codex`, runs codex's native continue command instead.
**`codetap -a / -A`**
Enhanced display:
```
Active sessions for code-tap:
1) claude-1774210269126
UUID: 625c60d0-aedb-4e0b-b78e-c9fbf0405e67
reply pong...
2) codex-1774210345678
UUID: abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx
fix the login bug...
Select (1-2):
```
### Files to Modify
**Server:**
- `server/db.ts` — Schema migration, rename column, add `adapter` field, add `clearAll()`, remove session-map.json migration
- `server/adapters/claude/tmux-adapter.ts` — Remove `desktop-` logic from `resolveSessionId`, change `session-` to `claude-` in `startSession`, add `session-start` handler, restore original ID on recovery
- `server/adapters/claude/hook-config.ts` — Change SessionStart from `hookPath` script to `fireAndForget('session-start')`
- `server/adapters/claude/index.ts` — Add `session-start` hook route
- `server/adapters/codex/codex-tmux-adapter.ts` — Same pattern: `codex-` prefix, unified session-start handling
- `server/adapters/codex/index.ts` — Add `session-start` hook route if missing
- `server/adapters/interface.ts` — Add `adapter` field to `ActiveSessionInfo`
- `server/session-manager.ts` — Pass `cliSessionId` in `SESSION_CREATED` message
- `server/index.ts` — Call `dbSessions.clearAll()` in shutdown
- `server/config.ts` — Remove `sessionMap` path config
**Client:**
- `src/hooks/useChat.ts` — Store `cliSessionId` from `SESSION_CREATED`
- `src/components/ChatView.tsx` — Header shows CLI UUID (primary) + internal ID (secondary)
- `src/components/SessionsView.tsx` — No change (already shows `firstPrompt`)
**CLI:**
- `bin/codetap` — Add `--adapter` flag, change window naming, update resume/continue logic, enhance `-a`/`-A` display
- `bin/codetap-hook` — Delete (replaced by API POST)
### E2E Spec Updates (`tests/e2e-spec.feature`)
The following scenarios need to be updated to reflect the new session ID architecture:
1. **Chat header display** (L247): Update to show CLI UUID (primary) + internal ID (secondary) with copy icon
2. **CLI `--adapter` flag** (L1168-1475): Add scenarios for `codetap new --adapter`, `codetap --continue --adapter`
3. **Active sessions `-a`/`-A` display** (L1212): Update to show UUID + internal ID format
4. **session-map.json references** (L1308): Remove; update to DB-based recovery
5. **Session Deduplication regression** (L1829): Update to reflect Connect button fix (claudeSessionId → sessionId)
6. **SessionStart hook**: Add scenario documenting API POST flow (replaces file-writing script)
7. **tmux window naming** (L1176): Specify `{adapter}-{timestamp}` format
8. **Non-graceful restart recovery** (L1308): Add scenario for restoring original ID from DB
9. **Active session card UUID field** (L1548): Clarify where UUIDs appear (title vs expanded view)
### What Gets Removed
- `bin/codetap-hook` script
- `session-map.json` mechanism (writing, reading, migration)
- `desktop-` prefix logic in `resolveSessionId`
- `is_active` column from sessions table
- `sessionMap` path in config
@@ -0,0 +1,120 @@
# Codex UUID Discovery Fix + Session Architecture Cleanup
## Problems Found
### 1. Deadlock: `_waitForCliUUID` blocks `startSession` (Critical)
`startSession()` calls `_waitForCliUUID()` which polls for `session.cliSessionId` to be set. But the UUID is only set when `handleSessionStart` hook fires, which requires Codex to process a prompt. The prompt is sent AFTER `startSession` returns. Deadlock: 15-second timeout, session creation fails.
Affects both `handleQuery` (new Codex chat from Web UI) and `POST /api/reviews` (Cross-AI Review child session).
### 2. Pending Session Matching by Count (Medium)
`handleSessionStart` matches hook to pending session by checking `pendingSessions.length === 1`. If 0 pending: treated as desktop-started. If 2 pending: neither matches, hook creates a spurious session entry. This is a guess, not a precise match.
### 3. `_findAndAttachWindow` uses `command.includes('codex')` (Medium)
Grabs the first tmux window whose command contains `codex`. If multiple codex windows exist, picks the wrong one. After Session ID Unification, window names are UUIDs, so this method is both incorrect and unnecessary (see solution).
### 4. `_watchForTranscript` matches by recency (Low)
Scans the day directory for JSONL files modified within 120 seconds, picks the first match. If two Codex sessions start simultaneously, can pick the wrong file.
### 5. Server shutdown leaves tmux windows running (Resource waste)
`adapter.destroy()` cleans up monitors and watchers but does NOT kill tmux windows. After server stops, CLI processes continue running in tmux, consuming resources.
### 6. DB sessions table is unnecessary (Complexity)
The `sessions` DB table stores `id`, `cwd`, `window_id`, `adapter`. After the Session ID Unification, all runtime data is in the in-memory `sessions` Map. The DB was used for:
- `_findAndAttachWindow` window recovery after restart: unnecessary if windows are killed on shutdown
- `handleReconnect` cwd lookup for resumeSession: unnecessary if handleReconnect doesn't resume
- Review endpoints cwd lookup: can use in-memory Map instead
## Solution
### A. Remove `_waitForCliUUID` entirely
`startSession()` returns the temp key immediately. UUID discovery happens asynchronously via `handleSessionStart` or `_watchForTranscript`.
### B. CODETAP_REF marker for precise matching
Every first message sent to a new Codex session includes a marker:
```
[CODETAP_REF:codex-1774316492094]
actual prompt or context here...
```
Where `codex-1774316492094` is the temp key (tmux window name at creation time).
**Injection points:**
- `handleQuery` in session-manager.ts: when creating a new session (no existing sessionId), prepend marker to the prompt
- `POST /api/reviews` in index.ts: prepend marker to the context
**Matching in `handleSessionStart`:**
1. Read the JSONL file at `body.transcript_path`
2. Find the first user message
3. Extract `CODETAP_REF:xxx` marker
4. Match `xxx` to a pending session's temp key
5. Call `_rekeyAndRename` to finalize
**Matching in `_watchForTranscript`:**
- After finding a candidate JSONL file, verify it contains `CODETAP_REF:tempKey`
**Frontend filtering:**
- Strip `[CODETAP_REF:...]` from user messages in `convertMessages` (useChat.ts)
### C. `_rekeyAndRename` — finalize UUID discovery
New method called when UUID is discovered (by handleSessionStart or _watchForTranscript):
- Delete temp key from sessions Map
- Set CLI UUID as new key
- Rename tmux window from temp name to CLI UUID
- Update monitor's sessionId
### D. Server shutdown kills all tmux windows
`adapter.destroy()` calls `tmuxManager.killSession()` to kill the entire codetap tmux session. No resource leaks.
### E. Remove `_findAndAttachWindow`
With shutdown killing all windows, no tmux windows survive restart. No need to rediscover windows. Delete the method and all call sites.
### F. Remove DB sessions table
The `sessions` table serves no purpose after changes D and G:
- `_findAndAttachWindow` (deleted in E) was the main consumer
- `handleReconnect` no longer calls `resumeSession` (changed in G)
- Review endpoints get `cwd` from in-memory Map (changed in H)
Delete: CREATE TABLE, prepared statements, SessionRow interface, `sessions` export, all `dbSessions.*` calls across the codebase.
DB retains only `session_reviews` table (for Cross-AI Review).
### G. Simplify `handleReconnect`
Remove the `hasActiveWindow` + `resumeSession` block. After shutdown kills windows, there is no scenario where a session is not in the Map but has an active tmux window.
`handleReconnect` becomes: register client, load JSONL history, replay pending state. Building tmux windows is `handleQuery`'s job (when the user sends a message).
### H. Review endpoints get cwd from Map + add parent_adapter to session_reviews
Replace `dbSessions.get(parentCliSessionId)` with `adapter.getSession(parentCliSessionId)` to get `cwd` from the in-memory Map. The parent session is always active (user is interacting with it) so it is always in the Map.
Add `parent_adapter TEXT NOT NULL` column to `session_reviews` table. Store it when creating a review. This way `send-back` and `delete` endpoints can find the correct adapter directly from the review row, without needing to iterate all adapters or query the sessions DB.
## Files Affected
| File | Changes |
|------|---------|
| `server/adapters/codex/codex-tmux-adapter.ts` | A: remove _waitForCliUUID. B: add _matchByTranscriptMarker. C: add _rekeyAndRename. D: destroy calls killSession. E: remove _findAndAttachWindow. F: remove all dbSessions calls |
| `server/adapters/claude/tmux-adapter.ts` | D: destroy calls killSession. F: remove all dbSessions calls |
| `server/adapters/claude/index.ts` | No change (doesn't use dbSessions directly) |
| `server/adapters/codex/index.ts` | No change |
| `server/session-manager.ts` | B: inject marker in handleQuery. F: remove dbSessions import and calls. G: simplify handleReconnect. H: review restoration uses adapter.getSession for cwd |
| `server/index.ts` | B: inject marker in POST /api/reviews. F: remove dbSessions import, clearAll call in shutdown. H: review endpoints use adapter.getSession for cwd |
| `server/db.ts` | F: delete sessions table schema, SessionRow, prepared statements, sessions export. Keep session_reviews |
| `src/lib/content-utils.ts` | B: add stripMarker function |
| `src/hooks/useChat.ts` | B: strip marker in convertMessages |
| `bin/codetap` | F: remove SQL queries that reference sessions table (get_project_sessions, -a listing, --resume lookup) |
@@ -0,0 +1,104 @@
# Remaining Session Fixes
## Context
Items A, B, C, F, G, H, J, K, L, M were already implemented in earlier commits. The following 4 items remain. CLI internal `/resume` command handling is deferred (Codex doesn't support it, Claude's case is rare).
## D. handleSessionStart — remove pending matching, add _pendingHookBodies
**Current:** `handleSessionStart` has `pendingSessions.length === 1` guessing logic to match a hook to a pending session.
**Problem:** This fails with multiple pending sessions. The marker matching also can't work here because `SessionStart` hook fires at CLI startup, BEFORE the marker is pasted into the JSONL.
**Fix:** `handleSessionStart` does NOT match pending sessions:
```
handleSessionStart(body):
1. sessions.has(uuid) → already managed → update state → return
2. has pending sessions → store hook body in _pendingHookBodies Map → return
3. no pending sessions → ignore → return
```
New `_pendingHookBodies: Map<string, CodexHookBody>` stores hook info (uuid, transcript_path, cwd). When `_watchForTranscript` later matches via marker and calls `_rekeyAndRename`, it reads `_pendingHookBodies.get(uuid)` to get the stored info.
**Cleanup:** `_pendingHookBodies` entries should be cleaned up after 60 seconds if unmatched (timer per entry, or sweep in `_startSessionCleanup`).
**Files:** `server/adapters/codex/codex-tmux-adapter.ts`
## E. Remove desktop-discovery from BOTH adapters
**Current:** Both adapters' `handleSessionStart` create session entries for unknown UUIDs.
- Claude: searches for "unmanaged tmux window running claude" (`w.command.includes('claude')`)
- Codex: creates entry and calls `_findAndAttachWindow` (already removed but fallback path remains)
**Why remove:** With server shutdown killing all tmux windows, and `bin/codetap` moving to API calls, there are no "desktop-started" sessions in the codetap tmux session. Every session should go through `startSession` or `resumeSession`.
**Fix:**
- Claude `handleSessionStart`: remove the "find unmanaged tmux window" block. Keep only `sessions.has(uuid) → update → return`. Unknown UUIDs are ignored.
- Codex `handleSessionStart`: the "desktop-started" branch becomes "ignore" (Task D already handles this).
**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts`
## I. New API endpoints for bin/codetap
**Current:** `bin/codetap` creates tmux windows directly, bypassing the server. Sessions it creates don't appear in the Map.
**Fix:** Add two REST endpoints:
```
POST /api/sessions/start
Body: { adapter, cwd, model?, permissionMode? }
→ adapter.startSession(cwd, options)
→ Returns: { sessionId }
POST /api/sessions/resume
Body: { sessionId, adapter?, cwd }
→ adapter.resumeSession(sessionId, cwd)
→ Returns: { sessionId }
```
Both require `authMiddleware`.
For `/resume`, if `adapter` is not provided, detect from JSONL file location:
- `~/.claude/projects/.../{UUID}.jsonl` → claude
- `~/.codex/sessions/.../*-{UUID}.jsonl` → codex
**Authentication for bin/codetap:** The script needs a token. It can get one via:
```bash
TOKEN=$(curl -sk -X POST https://localhost:$PORT/api/auth/login \
-H "Content-Type: application/json" \
-d "{\"password\":\"$CLAUDE_UI_PASSWORD\"}")
```
`CLAUDE_UI_PASSWORD` is already required as an env var.
**Files:** `server/index.ts`
## N. Update bin/codetap to use API endpoints
**Fix:**
- `bin/codetap new` → authenticate → `POST /api/sessions/start``tmux select-window`
- `bin/codetap --resume UUID` → authenticate → `POST /api/sessions/resume``tmux select-window`
- `bin/codetap --continue` → find most recent window from tmux → resume via API
- `bin/codetap -a``tmux list-windows` directly (adapter detected from `pane_current_command`)
- Remove ALL `sqlite3` references and `CODETAP_DB` variable
**Note for Codex sessions:** `POST /api/sessions/start` returns temp key (`codex-{timestamp}`). The script does `tmux select-window -t codetap:codex-{timestamp}`. The user is in the window. After rekey, the window name changes to UUID, but the user is unaffected (already inside).
**Files:** `bin/codetap`
## Files Affected
| File | Changes |
|------|---------|
| `server/adapters/codex/codex-tmux-adapter.ts` | D: _pendingHookBodies + rewrite handleSessionStart |
| `server/adapters/claude/tmux-adapter.ts` | E: remove desktop-discovery from handleSessionStart |
| `server/index.ts` | I: add session start/resume endpoints |
| `bin/codetap` | N: use API calls, remove sqlite3 |
## Not Included
- CLI internal `/resume` handling — Codex doesn't support it, Claude's case is rare and non-breaking
- Shared `TmuxAdapterBase` class — deferred to future refactor
- `childCliSessionId` removal from WS protocol — deferred (TODO in code)
@@ -0,0 +1,303 @@
# Session ID Unification — CLI UUID as Single Source of Truth
## Problem
The codebase has two session ID systems that create bugs and complexity:
1. **Internal ID** (format `claude-1774300056705` / `codex-1774300056705`): tmux window name, in-memory Map key, DB primary key
2. **CLI UUID** (format `d6d56787-bfaf-4312-ae4d-99683ba45459`): permanent ID from the CLI tool, JSONL filename
These require constant translation via `resolveSessionId()` and `cliToSessionId` Maps. When translation fails:
- Mobile can not receive desktop events (registered under CLI UUID, events broadcast under internal ID)
- handleReconnect creates unwanted tmux windows (lost hasActiveWindow guard)
- SessionsView passes different ID types (project list = CLI UUID, active list = internal ID)
- Latent bug: `_registerCliUUID()` references renamed column `claude_session` (should be `cli_session`)
## Solution
Eliminate internal ID as a session identifier. Use CLI UUID everywhere. Internal ID becomes just a tmux window display name with no programmatic significance.
## Design
### Layer 1: Adapter Internals
**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts`, `server/adapters/codex/pane-monitor.ts`, `server/adapters/interface.ts`
**`this.sessions` Map key**: internal ID changes to CLI UUID.
**Eliminated entirely:**
- `cliToSessionId: Map<string, string>` -- no translation needed
- `resolveSessionId()` method -- no translation needed
- `_registerCliUUID()` -- no mapping to maintain (also fixes `claude_session` column bug)
- `_remapCliSession()` -- no remapping needed
- `_removeCliMapping()` -- no mapping to clean up
**`startSession()` returns CLI UUID.** The tmux window name remains `{adapter}-{timestamp}` for tmux display but is not used as an identifier.
**`resumeSession()` / `attachSession()`** take CLI UUID as parameter.
**All event emits** use CLI UUID as first argument.
**`getActiveSessions()`** returns CLI UUID as `sessionId`. The `cliSessionId` field kept for compatibility (same value).
**`_findWindowForSession()`** looks up `window_id` from DB by CLI UUID instead of matching tmux window names.
**`handleSessionStart()` pattern matching**: `w.name.startsWith('claude-')` still works because tmux window names retain the `{adapter}-{timestamp}` format.
**Codex `startSession()` UUID timing**: Codex CLI generates its own UUID (arrives via SessionStart hook or JSONL filename). Add `_waitForCliUUID()` that waits for `session.cliSessionId` to be populated (max 15 seconds). Session initially stored under a temporary key (the window name), re-keyed once UUID is known.
During the temp-key window:
- Hooks arriving from CLI use the `_watcherPending` scanning pattern (already exists) to find the session
- Events are emitted under the temp key (few clients would be registered under it yet)
- Once UUID arrives: session is re-keyed in the Map, DB is upserted, `sessionAdapterMap` updated
If `_waitForCliUUID` times out:
- Kill the tmux window (`tmuxManager.killWindow`)
- Remove the temp session from the Map
- Throw error (propagates to client as WS error message)
**Codex `CodexPaneMonitor`**: stores `sessionId` for event emission -- changes from internal ID to CLI UUID.
### Layer 2: DB Schema
**Files:** `server/db.ts`
**`sessions` table migration:**
Before:
- `id` (PRIMARY KEY) = internal ID
- `cli_session` = CLI UUID
After:
- `id` (PRIMARY KEY) = CLI UUID
- `cli_session` column removed
- `window_name` column added (stores old internal ID for tmux display/debug)
**`SessionRow` interface:**
```typescript
export interface SessionRow {
id: string; // CLI UUID (was internal ID)
cwd: string;
window_id: string | null; // tmux window ID (@N)
window_name: string | null; // tmux window name for debug
adapter: string;
permission_mode: string;
created_at: string;
last_activity: string;
}
```
**Prepared statements:**
- `sessionsUpsert(id=cliUUID, cwd, windowId, windowName, adapter)` -- `id` is CLI UUID
- `sessionsFindByCliSession` -- REMOVED (use primary key lookup)
- `sessionsFindByWindowId` -- unchanged
- `sessionsRemove(id)` -- `id` is now CLI UUID
- Add `sessionsGet(id)` -- simple primary key lookup
**`session_reviews` table**: No changes (already uses CLI UUIDs).
**`session_stats` table**: Verify `session_id` uses CLI UUID. Migrate if needed.
**Migration SQL** (SQLite table rebuild pattern):
```sql
-- Step 1: Create new table with new schema
CREATE TABLE IF NOT EXISTS sessions_new (
id TEXT PRIMARY KEY,
cwd TEXT NOT NULL,
window_id TEXT,
window_name TEXT,
adapter TEXT DEFAULT 'claude',
permission_mode TEXT DEFAULT 'default',
created_at TEXT DEFAULT (datetime('now')),
last_activity TEXT DEFAULT (datetime('now'))
);
-- Step 2: Copy data, swapping id and cli_session
-- Skip rows where cli_session is empty, equals the internal ID, or matches {adapter}-{timestamp} pattern
INSERT OR IGNORE INTO sessions_new (id, cwd, window_id, window_name, adapter, permission_mode, created_at, last_activity)
SELECT
CASE
WHEN cli_session IS NOT NULL AND cli_session != '' AND cli_session != id THEN cli_session
ELSE id
END,
cwd, window_id, id, adapter, permission_mode, created_at, last_activity
FROM sessions;
-- Step 3: Drop old table and rename
DROP TABLE sessions;
ALTER TABLE sessions_new RENAME TO sessions;
-- Step 4: Recreate indexes
CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id);
```
Migration is wrapped in a transaction and runs inside `initDB()` before any adapter initialization. Detection: check if the old `cli_session` column exists (`PRAGMA table_info(sessions)`).
**Handling rows where `cli_session` = internal ID**: Some Codex sessions store the internal ID as both `id` and `cli_session` (when UUID wasn't yet known). The migration uses `CASE WHEN cli_session != id THEN cli_session ELSE id END` — these rows keep their internal ID as `id`. They will be orphaned (no JSONL match) and cleaned up naturally by `clearAll()` on next shutdown.
**`session_stats` table**: This table exists in the schema but is never written to by any code in the codebase (no INSERT statements found). It can be left as-is or dropped. No migration needed.
### Layer 3: Session Manager
**Files:** `server/session-manager.ts`
**`sessionClients` Map key**: CLI UUID (was internal ID).
**`sessionAdapterMap` Map key**: CLI UUID (was internal ID).
**`broadcast(sessionId, message)`**: `sessionId` is CLI UUID. This is the core fix -- adapter events emitted with CLI UUID now match client registration keys.
**`sendSessionCreated()`**: Sends single `sessionId` (CLI UUID). Remove `cliSessionId` field.
**`handleQuery()`**: No `resolveSessionId` call. `options.sessionId` from client is CLI UUID directly.
**`handleReconnect()`**: Greatly simplified. No `resolveSessionId` needed. All 11 existing steps preserved:
1. ~~Resolve internal ID via resolveSessionId~~ REMOVED (no translation needed)
2. Register client under CLI UUID
3. Clear push pending notifications (keyed by CLI UUID)
4. Send SESSION_CREATED (single ID)
5. Send cached status
6. Resume if not in memory — **with `hasActiveWindow` guard**:
```
if session not in memory:
if tmux window exists: resumeSession (attach to monitor events)
else: do nothing (just load history from JSONL)
```
7. Sync watcher position
8. Load JSONL history (`adapter.getMessages(sessionId)` — CLI UUID directly, no cliSessionId extraction needed)
9. Send streaming state (SESSION_STATE)
10. Replay pending tools and permissions
11. Restore active child reviews (`sessionReviews.getActiveForParent(sessionId)` — CLI UUID directly)
Key simplification in step 11: no longer needs `findByCliSession` to look up parent CWD for child session resume — can use `sessions.get(sessionId)` primary key lookup directly (since `id` is now CLI UUID).
**`triggerPush()`**: Simplified — `sessionId` IS CLI UUID, so child review check uses `sessionId` directly (no need to look up `sessionObj.cliSessionId`). Single `getSession()` call replaces the current two calls with different casts. Uses CLI UUID for sessionClients lookup, push pending, and push payload.
**`session-ended` handler**: `sessionId` parameter IS CLI UUID. The entire convoluted lookup (`findByWindowId` fallback + `getAll().find()`) to extract `cli_session` is eliminated — `sessionId` is already the CLI UUID. Cascade cleanup calls `sessionReviews.getActiveForParent(sessionId)` directly. `sessionClients.delete` and `sessionAdapterMap.delete` remain synchronous (before any async cascade work).
**`server/index.ts` active sessions endpoint**: The dual lookup `getClientCount(s.sessionId) || getClientCount(s.cliSessionId)` simplifies to `getClientCount(s.sessionId)` since both are the same CLI UUID.
**`broadcastReviewStarted/Ended`**: `parentSessionId` is CLI UUID.
### Layer 4: Frontend
**Files:** `src/hooks/useChat.ts`, `src/lib/ws.ts`, `src/components/ChatView.tsx`, `src/components/SessionsView.tsx`, `src/components/FloatingReviewPanel.tsx`, `src/lib/api.ts`
**`useChat.ts`**: Merge `sessionId` and `cliSessionId` states into single `sessionId` (always CLI UUID). `SESSION_CREATED` handler sets one state. All outgoing WS messages (QUERY, ABORT, RECONNECT, SET_MODEL, SET_PERMISSION_MODE, PLAN_RESPONSE) send this single `sessionId`.
**`ws.ts`**: `activeSessionId` stores CLI UUID from `SESSION_CREATED`.
**`ChatView.tsx`**: Header shows single `sessionId` (CLI UUID). Review API calls use `sessionId` directly. Remove `cliSessionId` from ChatHeader props.
**`SessionsView.tsx`**: Both session lists (project + active) now use CLI UUID for `session.sessionId`. `onOpenChat(session.sessionId)` is consistent. `destroySession(session.sessionId)` passes CLI UUID. Push pending lookup `pending[session.sessionId]` uses CLI UUID.
**`FloatingReviewPanel.tsx`**: `childSessionId` prop is CLI UUID.
**`ActiveSessionInfo` interface**: `sessionId` becomes CLI UUID. `cliSessionId` kept as deprecated alias (same value).
### Layer 5: Push Notifications + Permissions
**Files:** `server/push.ts`, `server/permission-manager.ts`, `src/sw.ts`, `src/App.tsx`
**`push.ts`**: `pendingSessions` Map keyed by CLI UUID.
**`permission-manager.ts`**: `PendingPermission.sessionId` is CLI UUID. `sessionPendingIds` Map keyed by CLI UUID.
**`sw.ts`**: Push payload `sessionId` is CLI UUID. Notification click URL `/?session=${cliUUID}`.
**`App.tsx`**: URL parameter `?session=` is CLI UUID. Service worker `OPEN_SESSION` message carries CLI UUID.
### Layer 6: CLI (`bin/codetap`)
**File:** `bin/codetap`
DB queries updated to use new schema (no `cli_session` column, `id` is CLI UUID):
- Line 239 `get_project_sessions()`: Change `SELECT id FROM sessions` to `SELECT window_name FROM sessions` — the function returns IDs to match against tmux window names, which are `{adapter}-{timestamp}` format (stored in `window_name`, not `id`)
- Line 290: Change `SELECT id, adapter, cli_session, cwd` to `SELECT id, adapter, window_name, cwd` — display CLI UUID as `id`, use `window_name` for tmux matching
- Line 260: Match tmux window names against `window_name` column (not `id`)
- Line 382 `--resume`: Simplified to `WHERE id='${SAFE_ID}'` since `id` is now CLI UUID
- Window name generation unchanged (`{adapter}-{timestamp}`)
**Critical**: The `-a` listing mode joins tmux window names against DB session IDs. After migration, this join key changes from `id` to `window_name`. The `IN (...)` SQL clause must query `window_name` column.
## Codex UUID Discovery Flow
```
1. handleQuery() -> adapter.startSession(cwd, options)
2. Codex startSession():
a. Generate window name "codex-{timestamp}"
b. Create tmux window
c. Wait for CLI ready (_waitForReady)
d. Start _watchForTranscript() (sets up FSWatcher)
e. NEW: _waitForCliUUID() -- wait for session.cliSessionId to be populated
- Source 1: handleSessionStart hook fires with session_id
- Source 2: _watchForTranscript detects JSONL file, extracts UUID from filename
f. Once UUID known: set as Map key, upsert DB
g. Return { sessionId: cliUUID }
3. handleQuery() continues with cliUUID
4. registerClient, sendSessionCreated, sendMessage -- all use cliUUID
```
Timeout: 15 seconds. If UUID not discovered, startSession fails with error.
## Files Affected (Complete List)
| File | Change Type | Summary |
|------|------------|---------|
| `server/db.ts` | MODIFY | Schema migration, remove cli_session, add window_name, update SessionRow |
| `server/session-manager.ts` | MODIFY | All Map keys to CLI UUID, simplify handleReconnect, fix triggerPush |
| `server/adapters/claude/tmux-adapter.ts` | MODIFY | sessions Map key, eliminate cliToSessionId/resolveSessionId, events use CLI UUID |
| `server/adapters/codex/codex-tmux-adapter.ts` | MODIFY | Same as Claude adapter, add _waitForCliUUID |
| `server/adapters/codex/pane-monitor.ts` | MODIFY | sessionId is CLI UUID |
| `server/adapters/interface.ts` | MODIFY | Remove resolveSessionId, update ActiveSessionInfo |
| `server/adapters/claude/index.ts` | MODIFY | Remove resolveSessionId delegation |
| `server/adapters/codex/index.ts` | MODIFY | Remove resolveSessionId delegation |
| `server/push.ts` | MODIFY | pendingSessions keyed by CLI UUID |
| `server/permission-manager.ts` | MODIFY | All session indices use CLI UUID |
| `server/transport/client-connection.ts` | NO CHANGE | sessionId field semantics change (now CLI UUID) |
| `server/index.ts` | MODIFY | Remove dual-ID lookups in active-sessions, simplify review endpoints |
| `server/types/messages.ts` | MODIFY | QueryOptions.sessionId is CLI UUID |
| `server/types/adapter.ts` | MODIFY | SessionInfo.sessionId is CLI UUID (already was) |
| `server/ws-types.ts` | NO CHANGE | Just message type constants |
| `src/hooks/useChat.ts` | MODIFY | Merge sessionId + cliSessionId into one |
| `src/lib/ws.ts` | MODIFY | activeSessionId stores CLI UUID |
| `src/lib/api.ts` | MODIFY | destroySession passes CLI UUID |
| `src/components/ChatView.tsx` | MODIFY | Single ID in header, remove cliSessionId usage |
| `src/components/SessionsView.tsx` | MODIFY | Consistent CLI UUID for both lists |
| `src/components/FloatingReviewPanel.tsx` | MODIFY | childSessionId is CLI UUID |
| `src/sw.ts` | MODIFY | Push sessionId is CLI UUID |
| `src/App.tsx` | MODIFY | URL param and SW message use CLI UUID |
| `bin/codetap` | MODIFY | DB queries use new schema |
| `server/adapters/claude/jsonl-store.ts` | NO CHANGE | Already uses CLI UUID |
| `server/adapters/codex/jsonl-store.ts` | NO CHANGE | Already uses CLI UUID |
| `server/adapters/claude/pane-monitor.ts` | NO CHANGE | Uses windowId (tmux ID), not session ID |
| `server/adapters/claude/hook-config.ts` | NO CHANGE | No session IDs |
| `server/adapters/codex/hook-config.ts` | NO CHANGE | No session IDs |
| `server/adapters/registry.ts` | NO CHANGE | No session IDs |
| `server/config.ts` | NO CHANGE | No session IDs |
| `server/stores/jsonl-watcher.ts` | NO CHANGE | File path based |
| `src/hooks/useSessions.ts` | MODIFY | `s.cliSessionId` for green dots → `s.sessionId` (same value after unification) |
| `tests/e2e-spec.feature` | MODIFY | Remove references to `resolveSessionId`, `cliSessionId` dual-ID |
## What This Fixes
1. Mobile receives desktop events in real-time (same broadcast key)
2. handleReconnect does not create unwanted tmux windows (hasActiveWindow guard)
3. SessionsView uses consistent IDs (both lists pass CLI UUID)
4. No more resolveSessionId translation failures
5. Fixes _registerCliUUID bug (references renamed column)
6. Eliminates ~200 lines of translation/mapping code
7. Push notifications navigate to correct session
8. Active session client count lookup simplified (no dual-ID check)
## Scope Boundaries
NOT included in this refactor:
- Changing tmux window names (they remain `{adapter}-{timestamp}` for display)
- Changing JSONL file paths (already CLI UUID based)
- Changing the Cross-AI Review feature (already uses CLI UUIDs)
- Auto N-round debate or other deferred features
@@ -0,0 +1,119 @@
# Cross-AI Review Panel UX Fixes
## Context
E2E testing revealed several UX issues with Cross-AI Review: marker text leaking into UI, panel blocking parent interaction, and incomplete features (collapsed card onClick, read-only mode).
## Issues & Fixes
### A. Marker Bugs
**A1. Session List shows marker**
`firstPrompt` in Codex adapter extracts raw text from JSONL without stripping `[CODETAP_REF:xxx]`. Session list displays it.
Fix: Strip marker when setting `firstPrompt` in Codex adapter's `_processWatcherEntries`.
**A2. Marker trailing `\\n` residue**
`handleQuery` injects `[CODETAP_REF:xxx]\n{prompt}`. Codex `sendMessage` replaces `\n``\\n`. JSONL stores `[CODETAP_REF:xxx]\\nHello`. `stripMarker` regex `\n?` matches real newline but not literal `\\n`.
Fix: Update `stripMarker` regex to `^\[CODETAP_REF:[^\]]+\](?:\\\\n|\n)?` — matches both real newline and literal `\\n`.
**Files:** `server/adapters/codex/codex-tmux-adapter.ts`, `src/lib/content-utils.ts`
### B. Panel Minimize / Expand UX
**B1. Minimized state: thin bar above input**
When minimized, show a full-width bar between the message area and parent input:
- Left: pulsing green dot + adapter badge ("Codex") + status ("review in progress · 3 messages")
- Right: ▲ Expand button + End button
- Bar has subtle green top border
**B2. Expanded state: clear minimize button**
Panel header gets a ▼ Minimize icon button (in addition to handle bar). Header shows: adapter badge + review title + ▼ Minimize + End.
**B3. Input distinction**
When panel is expanded, the child input shows:
- Adapter badge (small Codex icon) to the left of the input
- Placeholder: "Reply to Codex review..." (not generic "Send a message...")
- Panel has green top border separating it from parent chat
**B4. Panel covers parent input**
When expanded, parent input is hidden (covered by panel). Only child input visible. This is intentional — user must minimize to chat with parent.
**Files:** `src/components/FloatingReviewPanel.tsx`, `src/components/ChatBody.tsx` (placeholder prop)
### C. Review History Markers
**C1. Start/End markers wrap all content**
Review Start and End markers appear in parent chat history. Everything between them (including parent messages exchanged while review was minimized) is wrapped. This shows "review was happening during this time period."
```
[parent message]
──── Codex review started ────
[collapsed review card: "5 messages · tap to view"]
[parent message during review]
[parent message during review]
──── Codex review ended ────
[parent message after review]
```
**C2. CollapsedReviewCard onClick → read-only panel**
Currently onClick is a TODO. Implement: clicking opens FloatingReviewPanel in read-only mode with child session's history via RECONNECT.
Card receives `childSessionId` from the review record. Click sets a `readOnlyReview` state in ChatView → mounts FloatingReviewPanel with `readOnly` flag.
**C3. Read-only panel**
Same layout as active panel but:
- Gray header (not green) — "Codex | code review · ended"
- ✕ Close button (not End)
- No input — bottom shows "Review ended — read only"
- Messages loaded via RECONNECT + HISTORY_LOAD
**Files:** `src/components/CollapsedReviewCard.tsx`, `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx`
### D. Send-back Button Missing in Child Panel
**Problem:** Child session's assistant responses should show a ↩ send-back icon, but it's not visible. After the ChatBody refactor, `onSendBack` is passed from FloatingReviewPanel → ChatBody → MessageBubble. But `showActions` may not be correctly evaluated, or the prop chain is broken.
**Fix:** Verify and fix the prop chain:
1. FloatingReviewPanel passes `onSendBack` to ChatBody ✓ (confirmed in code)
2. ChatBody passes `onSendBack` to MessageBubble — check `showActions` logic
3. MessageBubble renders ↩ icon when `onSendBack` is provided and `showActions` is true
If `showActions` is computed inside ChatBody (not passed as prop), verify it evaluates to `true` for assistant messages when not streaming.
**Files:** `src/components/ChatBody.tsx`, `src/components/MessageBubble.tsx`
### E. Message Action Icons Polish
**E1. Icons too large / too bold / have border**
Current icon buttons have `border border-border rounded-md` (visible outline box), `w-7 h-7` (28px), and SVG `strokeWidth="2"`.
Fix:
- Remove `border` from button — no outline box, just the icon
- Reduce button size from `w-7 h-7` to `w-6 h-6` (24px)
- Reduce SVG from `width/height="14"` to `"12"`
- Reduce SVG `strokeWidth` from `"2"` to `"1.5"`
- Keep hover background (`hover:bg-white/5`) for touch feedback
**E2. Copy feedback — checkmark confirmation**
Copy icon should show a ✓ checkmark for ~2 seconds after clicking, then revert to the copy icon. Confirms the clipboard action succeeded.
Implementation: `useState` for `copied` state, `setTimeout` to reset after 2s.
**Files:** `src/components/MessageBubble.tsx`
### F. Adapter Icons — Use SVGs from thesvg.org
Current `AdapterIcon.tsx` has hand-drawn SVG paths for Claude (Anthropic "A") and Codex (OpenAI knot). Replace with official SVGs from https://www.thesvg.org/ for better accuracy.
- Search for "Anthropic" / "Claude" → get official Anthropic logo SVG
- Search for "OpenAI" / "Codex" → get official OpenAI logo SVG
- Update `ClaudeIcon` and `CodexIcon` components in `src/components/AdapterIcon.tsx`
- Keep the same `size` prop interface and `fill="currentColor"` for color control
**Files:** `src/components/AdapterIcon.tsx`
## Not Changed
- Review session creation flow (already unified via QUERY in previous spec)
- Server-side review lifecycle
@@ -0,0 +1,105 @@
# Review State Separation + Session List Cleanup
## Context
Three issues to fix together:
1. **activeReview / historyReview state conflict** — Viewing a historical review overwrites the active review state, losing its panel
2. **Session list shows CODETAP_REF marker** — firstPrompt not stripped (screenshot confirms markers visible in session list)
3. **Child sessions visible in session list** — review child sessions should not appear in project session list or active sessions
## A. Separate activeReview and historyReview States
### Problem
`activeReview` state serves double duty (active + read-only viewing). Viewing history replaces active review.
### Design
**State model:**
```typescript
activeReview: ReviewInfo | null // ongoing active review
historyReview: ReviewInfo | null // historical review being viewed (read-only)
activeReviewPanel: 'expanded' | 'minimized' // renamed from reviewPanelState
```
**Remove:** `readOnlyReview: boolean` — replaced by `historyReview !== null`
**Panel display (mutual exclusion — only one panel at a time):**
```
historyReview !== null → read-only panel
activeReview && activeReviewPanel === 'expanded' → active panel
otherwise → no panel
```
**Minimized bar shows when:**
```
activeReview !== null AND (activeReviewPanel === 'minimized' OR historyReview !== null)
```
**Interactions:**
| Action | Effect |
|--------|--------|
| Click collapsed card (history) | `setHistoryReview(review)` + `setActiveReviewPanel('minimized')` |
| ✕ Close history panel | `setHistoryReview(null)` |
| ▲ Expand minimized bar | `setHistoryReview(null)` + `setActiveReviewPanel('expanded')` |
| ▼ Minimize active | `setActiveReviewPanel('minimized')` |
| End active review | `setActiveReview(null)` + `setHistoryReview(null)` |
| Start new review | `setActiveReview(...)` + `setActiveReviewPanel('expanded')` + `setHistoryReview(null)` |
**FloatingReviewPanel receives:**
```typescript
const panelReview = historyReview || (activeReviewPanel === 'expanded' ? activeReview : null);
const isReadOnly = !!historyReview;
{panelReview && (
<FloatingReviewPanel
review={panelReview}
readOnly={isReadOnly}
onEnd={isReadOnly ? () => setHistoryReview(null) : closeReview}
...
/>
)}
```
**Files:** `src/hooks/useChat.ts`, `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx`
## B. Session List Marker Strip
### Problem
Screenshot shows `[CODETAP_REF:codex-1774412730686]\nHi` in session list. The earlier fix (strip marker in `firstPrompt`) may not have been applied in all code paths, or the sessions were created before the fix.
### Design
Marker stripping is Codex-specific behavior (Codex's `sendMessage` does `\n``\\n` replacement). Fix in the Codex adapter only — not client-side.
**Two Codex-side locations to strip:**
1. **`codex/jsonl-store.ts` `getSessions()` line 204** — `firstPrompt` from `history.jsonl` entry. This is the session list source for ALL sessions (including historical). Strip `[CODETAP_REF:...](\\n|\n)?` from `entry.text` before slicing.
2. **`codex/codex-tmux-adapter.ts` `_processWatcherEntries()`** — `firstPrompt` for active sessions (already fixed in earlier commit, but verify it covers all paths).
**Files:** `server/adapters/codex/jsonl-store.ts`, `server/adapters/codex/codex-tmux-adapter.ts`
## C. Hide Child Sessions from Session List
### Problem
Cross-AI Review child sessions appear in the project session list and active sessions list. They should be hidden — they're child sessions owned by a parent.
### Design
**Server-side filtering:** When returning sessions (both project sessions and active sessions), exclude sessions whose ID appears as `child_cli_session_id` in the `session_reviews` table.
- `GET /api/sessions/:dir` — filter out child session IDs
- Active sessions list — filter out child session IDs from `getActiveSessions()`
**How to identify child sessions:**
- `sessionReviews.getAllChildIds()` already exists (returns Set of child CLI session IDs)
- Use this to filter in both endpoints
**Files:** `server/index.ts` (session endpoints), `server/db.ts` (getAllChildIds)
## Not Changed
- Review creation flow (already unified via QUERY)
- Send-back mechanism
- FloatingReviewPanel component structure (still uses ChatBody)
@@ -0,0 +1,126 @@
# Unified Session Creation Path for Cross-AI Review
## Context
Cross-AI Review child sessions currently use a different creation path than normal sessions:
- **Normal session**: WebUI sends WS `QUERY``handleQuery``startSession` + `registerClient` + `sendMessage` — all in one handler, atomically.
- **Review child**: HTTP `POST /api/reviews``startSession` + `pasteToSession` on server → broadcast `REVIEW_STARTED` → FloatingReviewPanel mounts → `useChat` sends WS `RECONNECT``registerClient` — split across HTTP and WS.
This split causes race conditions (rekey happens before WS client connects) and requires defensive mechanisms (`rekeyAliases`, `session-rekeyed` event forwarding) that wouldn't be needed if both paths were the same.
**Insight**: A review child session IS a normal new session. The only difference is the first message is review context instead of user-typed text. It should go through the same QUERY flow.
## Design
### 1. Codex `sendMessage` — auto-handle large/multiline content
Currently Codex adapter has two methods:
- `sendMessage` — uses `sendKeys` (character-by-character, doesn't handle newlines)
- `pasteToSession` — uses `pasteBuffer` (bulk paste, replaces `\n` with `\\n`)
Review context is large (30KB+) and multiline. If it goes through `sendMessage` via QUERY, `sendKeys` would be extremely slow and newlines would be treated as separate message submissions.
**Fix**: Make `sendMessage` auto-detect and use `pasteBuffer` for large/multiline content. Transparent to all callers.
**Important**: Fresh Codex sessions have TUI placeholder text (e.g., "Use /skills to list available skills"). Pasting via `pasteBuffer` appends to the placeholder, truncating the first ~20 chars. The existing fix (from this session) splits the paste: send the `[CODETAP_REF:...]` marker via `sendKeys` first (triggers TUI to clear placeholder), wait 200ms, then `pasteBuffer` the rest. The unified `sendMessage` must preserve this behavior.
```
sendMessage(sessionId, text):
if text.length > 500 || text.includes('\n'):
singleLine = text.replace(/\n/g, '\\n')
// Check for CODETAP_REF marker at start (fresh session with placeholder)
markerMatch = singleLine.match(/^\[CODETAP_REF:[^\]]+\]/)
if markerMatch:
sendKeys(marker) // clears TUI placeholder
wait 200ms
pasteBuffer(rest) // fast, placeholder already cleared
else:
pasteBuffer(singleLine) // existing session, no placeholder issue
wait 300ms
sendControl('Enter')
else:
sendKeys(text) // character-by-character, fine for short text
wait 200ms
sendControl('Enter')
```
This merges `sendMessage` and `pasteToSession` into one method that handles all cases.
**Files**: `server/adapters/codex/codex-tmux-adapter.ts`
### 2. Frontend — review child uses QUERY, not RECONNECT
**Current flow**:
```
POST /api/reviews → server creates session + DB record → broadcast REVIEW_STARTED
→ parent useChat sets activeReview → FloatingReviewPanel mounts
→ useChat(childSessionId) → WS RECONNECT → handleReconnect
```
**New flow**:
```
User clicks "Send to Codex" → selects template
→ ChatView locally sets activeReview state (no server call)
→ FloatingReviewPanel mounts with { context, targetAdapter, cwd }
→ FloatingReviewPanel's useChat auto-sends context as first WS QUERY
→ handleQuery → startSession → registerClient → sendMessage (same as normal!)
→ SESSION_CREATED received → useChat has childSessionId
→ POST /api/reviews { parentSessionId, childSessionId, ... } → DB record created
```
Key changes:
- `ChatView.handleReviewSelect`: instead of calling `api.createReview()`, locally mount FloatingReviewPanel with review props
- `FloatingReviewPanel`: receives `initialPrompt` prop, useChat auto-sends it as first QUERY
- After `SESSION_CREATED`, call `api.registerReview()` to persist the DB record
**Files**: `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx`, `src/hooks/useChat.ts`, `src/lib/api.ts`
### 3. Server — POST /api/reviews simplified
From:
- `adapter.startSession(cwd)` — REMOVE
- `adapter.pasteToSession(childSessionId, markerContext)` — REMOVE
- `sessionReviews.create(...)` — KEEP
- `broadcastReviewStarted(...)` — KEEP (for multi-device sync)
- Returns `{ reviewId, childSessionId }` — childSessionId now comes from client
To:
```
POST /api/reviews (renamed or new endpoint: POST /api/reviews/register)
Body: { parentSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }
→ sessionReviews.create(...)
→ broadcastReviewStarted(parentSessionId, { reviewId, childSessionId, ... })
→ Returns { reviewId }
```
**Files**: `server/index.ts`
### 4. CODETAP_REF marker — already handled
`handleQuery` in `session-manager.ts` already injects `[CODETAP_REF:tempKey]` for non-Claude new sessions. No change needed — the marker injection works naturally through the QUERY flow.
### 5. `pasteToSession` — can be removed from Codex adapter public API
After `sendMessage` handles all content sizes, `pasteToSession` is no longer needed as a separate public method. It can be:
- Removed from the adapter interface
- Or kept as internal helper called by `sendMessage`
The only remaining caller is `POST /api/reviews/:id/send-back` (sends feedback to parent). This also goes through `sendMessage` if we update it.
**Files**: `server/adapters/codex/codex-tmux-adapter.ts`, `server/adapters/codex/index.ts`, `server/adapters/interface.ts`
## Not Changed
- `POST /api/reviews/:id/send-back` — still HTTP (different concern: sending message to an existing session)
- `POST /api/reviews/:id/end` — still HTTP
- `rekeyAliases` — kept as defensive mechanism (handleQuery's registerClient vs hook timing)
- `session-rekeyed` forwarding — kept (still needed for handleQuery flow)
## Verification
1. New Codex session from WebUI — send message, verify response appears
2. Cross-AI Review: click "Send to Codex" → panel opens → Codex responds in panel (same QUERY flow)
3. Send back to parent — verify message appears
4. End review — verify markers appear
5. Reconnect — verify active review restored
@@ -0,0 +1,130 @@
# Cross-AI Review v2 — Multi-Review, Marker Position, Send-To UX
**Date**: 2026-03-26
**Status**: Approved
## Problem
Three issues with the current cross-AI review system:
1. **"Review ended" marker position** — rendered at the anchor message (where "Send to" was clicked), not at the bottom of the chat where the user actually pressed End. Misleading timeline.
2. **"Send to" ignores active reviews** — always opens the full adapter/model selection flow, even when there's already an active review that should receive the message.
3. **Single active review limit** — frontend state (`activeReview`) is a single object. Cannot run multiple reviews simultaneously (e.g., send one message to Codex and another to Claude).
Additionally: **textarea placeholder 16px override** — global CSS `input, textarea, select { font-size: 16px }` (iOS zoom prevention) overrides Tailwind `text-sm` in the review panel, making the placeholder disproportionately large.
## Design
### 1. Review Ended Marker Position
**Current**: "started", "in progress", and "ended" markers are all rendered by `renderReviewMarkers`, keyed by `anchor_message_id`. They all appear after the anchor message.
**New**: Split markers into two locations:
- **"started" + CollapsedReviewCard** — at anchor message (shows where the review was initiated; card lets user tap to view the review conversation)
- **"in progress"** — at anchor message (only for active reviews, replaces CollapsedReviewCard)
- **"ended"** — rendered after the last message in parent chat at the time End was pressed (shows where in the timeline the review concluded)
**Implementation**:
- Add `end_anchor_message_id TEXT` column to `session_reviews` table
- When `endReview()` is called, set `end_anchor_message_id` to the ID of the last message currently in the parent session's message history
- Server: `GET /api/reviews` response already returns all review columns — no API change needed
- Frontend: `renderReviewMarkers` uses two maps:
- `startMarkersByAnchor` — keyed by `anchor_message_id`:
- Active review: "started" marker + "in progress" marker
- Ended review: "started" marker + CollapsedReviewCard (tap to view)
- `endMarkersByAnchor` — keyed by `end_anchor_message_id`:
- Ended review only: "Review ended" marker
### 2. Send-To with Active Review
**Current**: Clicking "↗ Send to" always sets `reviewMenuMessageId` → opens `ReviewActionMenu` bottom sheet → full adapter/model flow.
**New**: Two paths based on whether active reviews exist:
**Path A — No active reviews**: Same as current. Full adapter/model selection → create new child session.
**Path B — Active review(s) exist**: Show a simplified bottom sheet with options:
- One button per active review: **"Send to {Adapter} review"** (with adapter badge + color). Clicking sends the message text directly to that child session as a new prompt.
- A divider line
- **"Start new review..."** button at the bottom → opens the current full flow
**Sending to existing review**:
- Extract text from the clicked message
- Call `childChat.sendMessage(text)` on the corresponding review's `useChat` instance
- Auto-expand and switch tab to that review's tab
- No new DB row, no new session — just a follow-up message in the existing child session
### 3. Multi-Review UI (Design D)
**State change**: `activeReview` (single object) → `activeReviews` (array of review objects). Each entry has: `{ reviewId, childSessionId, childCliSessionId, childAdapter, anchorMessageId, reviewTitle }`.
**Minimized state** (all reviews collapsed):
- Single compact bar above the input: colored dots for each review + "{N} reviews: Codex · Claude" + "▲ Expand"
- Clicking the bar expands to the tabbed panel
**Expanded state** (panel visible):
- 50% height bottom panel with:
- **Handle bar** at top (drag/click to minimize)
- **Tab bar**: one tab per active review, each showing adapter color dot + name. Active tab underlined with adapter color. Each tab has ✕ to end that review. Right side has ▼ minimize button.
- **Chat area**: messages for the focused tab's child session
- **Input**: "Reply to {Adapter} review..." placeholder
**Single review special case**: When only 1 active review, show header (badge + title + ▼ + End) instead of tab bar. Same as current design.
**Each tab is an independent `useChat` hook**. The `FloatingReviewPanel` component manages an array of child chat instances, renders only the active tab's messages, but keeps all hooks alive for background message receipt.
**Tab lifecycle**:
- New review → push to `activeReviews`, add tab, auto-focus it
- End review (✕ or "End" button) → call `api.endReview(reviewId)`, remove from `activeReviews`, focus adjacent tab
- All reviews ended → panel disappears, minimized bar disappears
### 4. Placeholder Font Size Fix
**Root cause**: `src/index.css` line 83: `input, textarea, select { font-size: 16px }` overrides `text-sm` (14px).
**Fix**: Keep the 16px rule for iOS zoom prevention, but add a specific override for the review panel textarea:
```css
.review-panel-input textarea { font-size: 14px !important; }
```
Or use Tailwind's `!text-sm` on the textarea in FloatingReviewPanel. The main chat input stays at 16px (looks fine at full width); only the cramped review panel gets the smaller size.
## Data Flow Changes
### End Review (updated)
```
User taps "End" on tab / End button
→ Frontend: get last message ID from parent chat messages array
→ api.endReview(reviewId, { endAnchorMessageId: lastMsgId })
→ Server: UPDATE session_reviews SET ended_at=NOW(), end_anchor_message_id=?
→ Server: broadcast WS REVIEW_ENDED { reviewId }
→ Server: destroySession(childCliSessionId)
→ Frontend: remove from activeReviews array
→ Frontend: reviews re-fetched → endMarkersByAnchor updated
→ "ended" marker + CollapsedReviewCard appear after the correct message
```
### Send-To Existing Review
```
User taps "↗ Send to" on assistant message (with active reviews present)
→ Simplified bottom sheet: [Send to Codex review] [Send to Claude review] [Start new...]
→ User taps "Send to Codex review"
→ Extract text from the anchor message
→ Find the Codex review's useChat sendMessage function
→ sendMessage(text) → message sent to child session
→ Auto-expand panel, switch to Codex tab
```
## Migration
- New column `end_anchor_message_id` on `session_reviews`: nullable, no migration needed for existing rows (they will show "ended" at anchor position as fallback)
## Scope Exclusions
- Drag-to-reorder tabs: not needed
- Resize panel height: not needed (fixed 50%)
- Review notifications/badges on minimized bar: nice-to-have, not in v2
- Persist expanded/minimized state across page refreshes: not needed
@@ -0,0 +1,421 @@
# Gemini CLI Adapter Design
**Date:** 2026-03-26
**Status:** Draft
**Approach:** B — Shared layer extraction + Gemini adapter
## Overview
Add a third adapter to code-tap for Google's Gemini CLI (v0.34.0+), providing full bidirectional control from the mobile PWA — identical feature parity with the existing Claude and Codex adapters.
## Scope
- Full Gemini adapter: tmux session management, prompt sending, streaming, tool tracking, permission approval, thinking display, model/permission mode switching
- New `JsonWatcher` for Gemini's single-JSON session format
- Bridge script for Gemini's stdin/stdout hook protocol
- Shared layer: move `tmux-manager.ts` to `server/adapters/shared/`
- CLI, registry, and frontend integration
## Research Findings
### Gemini CLI Architecture
| Aspect | Detail |
|---|---|
| **Version** | 0.34.0 |
| **Config dir** | `~/.gemini/` |
| **Settings** | `~/.gemini/settings.json` |
| **Session files** | `~/.gemini/tmp/<project-name>/chats/session-*.json` (single JSON, not JSONL) |
| **Project mapping** | `~/.gemini/projects.json` maps abs paths to project names |
| **Project root** | `~/.gemini/tmp/<project-name>/.project_root` contains abs path |
| **Hook protocol** | stdin/stdout JSON (not HTTP like Claude) |
| **Hook events** | BeforeTool, AfterTool, BeforeAgent, AfterAgent, SessionStart, SessionEnd, + more |
| **Models** | auto, pro (2.5 Pro), flash (2.5 Flash), flash-lite |
| **Permission modes** | default, auto_edit, yolo, plan |
| **Resume** | `gemini --resume <id-or-index>` |
| **GEMINI.md** | Yes, analogous to CLAUDE.md |
### Session File Format (JSON, not JSONL)
```json
{
"sessionId": "uuid",
"projectHash": "sha256",
"startTime": "ISO 8601",
"lastUpdated": "ISO 8601",
"messages": [
{
"id": "uuid",
"timestamp": "ISO 8601",
"type": "user",
"content": [{ "text": "..." }]
},
{
"id": "uuid",
"timestamp": "ISO 8601",
"type": "gemini",
"content": "markdown string",
"thoughts": [{ "subject": "...", "description": "...", "timestamp": "..." }],
"tokens": { "input": N, "output": N, "cached": N, "thoughts": N, "tool": N, "total": N },
"model": "gemini-3.1-pro-preview",
"toolCalls": [{
"id": "string",
"name": "tool_name",
"args": {},
"result": [{ "functionResponse": { "id": "...", "name": "...", "response": { "output": "..." } } }],
"status": "success|cancelled",
"timestamp": "ISO 8601",
"displayName": "Human-readable name",
"description": "Tool description"
}]
},
{
"id": "uuid",
"type": "error",
"content": "error string"
},
{
"id": "uuid",
"type": "info",
"content": "info string"
}
],
"kind": "main",
"summary": "Session summary"
}
```
### Key Differences from Claude/Codex
| Aspect | Claude | Codex | Gemini |
|---|---|---|---|
| Session format | JSONL (append-only) | JSONL (append-only) | Single JSON (rewritten) |
| Watcher strategy | Byte offset tracking | Byte offset tracking | File size guard + message ID tracking |
| Hook protocol | HTTP POST (url-based) | HTTP POST (command curl) | stdin/stdout JSON (needs bridge script) |
| Tool tracking | Separate tool_use/tool_result entries | JSONL entries | Embedded in gemini message as toolCalls[] |
| Thinking | Pane monitor detection | Pane monitor detection | In JSON (thoughts[]) + pane monitor |
| Token/model info | statusLine hook | JSONL entries | In JSON (tokens{}, model field) |
| Session ID | Pre-assigned via --session-id | Discovered from SessionStart hook | Discovered from SessionStart hook |
| Permission toggle | Shift+Tab cycles 4 modes | N/A | Ctrl+Y toggles YOLO on/off |
| Model switch | /model slash command | N/A | /model slash command |
## File Structure
### New Files
```
server/adapters/shared/
tmux-manager.ts # Moved from claude/ (shared by all 3 adapters)
server/adapters/gemini/
index.ts # GeminiAdapter (extends IAdapter)
gemini-tmux-adapter.ts # Session lifecycle, hook handling
pane-monitor.ts # Gemini TUI streaming/thinking detection
transcript-parser.ts # JSON session -> ParsedMessage[]
json-store.ts # Session discovery from ~/.gemini/tmp/
message-utils.ts # Gemini content block extraction
hook-config.ts # GeminiHookConfig (install/uninstall hooks)
bridge.sh # stdin JSON -> curl POST bridge script
server/stores/
json-watcher.ts # New: JSON file watcher (alongside existing jsonl-watcher.ts)
```
### Modified Files
```
server/adapters/shared/tmux-manager.ts # Moved from server/adapters/claude/tmux-manager.ts
server/adapters/claude/tmux-adapter.ts # Update import path -> ../shared/tmux-manager.js
server/adapters/codex/codex-tmux-adapter.ts # Update import path -> ../shared/tmux-manager.js
server/adapters/init.ts # Add gemini loader
server/adapters/registry.ts # Add 'gemini' to default enabled list
bin/hooks-cli.mjs # Add GeminiHookConfig
bin/codetap # Add gemini to set_adapter, detection, labels, validation
src/lib/adapter-brands.ts # Add gemini brand + extend iconType union to include 'gemini'
src/components/AdapterIcon.tsx # Add GeminiIcon (SVG from thesvg.org), refactor to switch/map
```
## Component Designs
### 1. Bridge Script (`bridge.sh`)
Gemini hooks communicate via stdin JSON / stdout JSON. The bridge reads stdin and POSTs to the code-tap server, matching the existing HTTP-based pattern.
```bash
#!/bin/bash
# Reads JSON from stdin (Gemini hook protocol), POSTs to code-tap server.
#
# IMPORTANT: Gemini hooks expect a JSON response on stdout. We must write
# a response BEFORE backgrounding the curl POST, or Gemini will hang.
# Exit code 0 = allow (continue), exit code 2 = block.
#
# Shell compatibility: Uses #!/bin/bash for /dev/tcp port check.
# If Gemini executes hooks with zsh (which lacks /dev/tcp), fall back to
# curl's --connect-timeout instead. Validated against Gemini CLI v0.34.0.
ENDPOINT="$1"
PORT="${CODETAP_PORT:-3456}"
PROTOCOL="${CODETAP_PROTOCOL:-http}"
CURL_K=""
[ "$PROTOCOL" = "https" ] && CURL_K="-k"
# Read stdin (Gemini hook JSON payload)
input=$(cat)
# Respond to Gemini immediately — must happen BEFORE backgrounding curl.
# Empty JSON object = "no modifications, continue normally".
printf '{}'
# Port check: skip curl if server isn't listening (fail-fast <1ms)
(echo >/dev/tcp/localhost/$PORT) 2>/dev/null || exit 0
# Forward payload to code-tap server asynchronously
printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \
-X POST -H 'Content-Type:application/json' -d @- \
"${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null &
```
### 2. GeminiHookConfig (`hook-config.ts`)
Installs hooks into `~/.gemini/settings.json` under the `hooks` key. Follows the same wrap pattern as Claude/Codex — preserves existing hooks, identifies our entries by portTag for clean uninstall.
**Hook mapping:**
| Gemini Event | Bridge Endpoint | Purpose |
|---|---|---|
| `BeforeTool` | `before-tool` | tool-start event |
| `AfterTool` | `after-tool` | tool-done event |
| `BeforeAgent` | `before-agent` | processing-started |
| `AfterAgent` | `after-agent` | session-idle (stop) |
| `SessionStart` | `session-start` | Session registration, watcher setup |
| `SessionEnd` | `session-end` | Cleanup |
**Hook command format:**
```json
{
"hooks": {
"BeforeTool": [{
"matcher": "*",
"hooks": [{
"type": "command",
"command": "/abs/path/to/bridge.sh before-tool",
"timeout": 2
}]
}]
}
}
```
Environment variables `CODETAP_PORT` and `CODETAP_PROTOCOL` are set in the command string so the bridge knows where to POST.
### 3. JsonWatcher (`server/stores/json-watcher.ts`)
Watches a single JSON session file for new messages. Cannot use byte-offset tracking (file is rewritten entirely on each update), so uses file-size guard + message ID tracking.
**Algorithm:**
1. `fs.watch()` triggers on file change (+ fallback polling every 2s)
2. `stat()` checks if file size changed — skip if same (filters false positives)
3. Read entire file, `JSON.parse()`
4. Compare `messages.length` vs `_lastMessageCount`
5. Find new messages by scanning from `_lastMessageCount` index
6. Verify with `_lastMessageId` (guard against message deletion/modification edge case)
7. Emit only new messages via `onNewMessages()` callback
8. Update `_lastSize`, `_lastMessageCount`, `_lastMessageId`
**Debounce:** 50ms after `fs.watch` fires before polling. Chosen to balance latency (streaming UX) vs coalescing (Gemini rewrites the file on each message). The existing `JsonlWatcher` uses no debounce because JSONL appends are atomic; JSON rewrites are not.
**Performance:** Observed session files up to ~34KB in practice. `JSON.parse()` of 34KB takes <1ms. As a safeguard: if file size exceeds 2MB, log a warning. The in-memory parsed result is NOT cached between polls (file is always re-read on size change) — this keeps the watcher stateless and avoids stale-cache bugs.
**API (consistent with JsonlWatcher):**
```typescript
start(options?: { skipExisting?: boolean }): void
stop(): void
pollNow(): void
onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void
onError(cb: (err: Error) => void): void
```
### 4. GeminiTranscriptParser
Converts Gemini JSON messages to the shared `ParsedMessage` format used by the frontend.
**Type mapping:**
- `type: "user"` -> `role: "user"`, content normalized to `ContentBlock[]`
- `type: "gemini"` -> `role: "assistant"`, content + toolCalls merged into `ContentBlock[]`
- `type: "error"` -> emitted as `session-error` event (visible to user — rate limits, API key issues, etc.)
- `type: "info"` -> skipped (internal CLI messages like "Press F12 for diagnostics")
**Tool call conversion:**
Gemini embeds tool calls in the gemini message as `toolCalls[]`. Each tool call has `id`, `name`, `args`, `result`, `status`. These are converted to standard `tool_use` + `tool_result` ContentBlocks to match the Claude adapter's output format.
**Thinking extraction:**
Gemini includes `thoughts[]` in the JSON. These are emitted as `thinking` events and optionally included in the message content as thinking blocks.
**Token/model extraction:**
`tokens` and `model` fields are extracted and emitted as `status-update` events, providing context%, model, and cost info without needing a statusLine hook.
### 5. GeminiJsonStore (`json-store.ts`)
Session discovery for Gemini's file structure. Maps to `SessionInfo` interface.
**Discovery algorithm:**
1. Read `~/.gemini/projects.json` to get `{ projects: { "/abs/path": "project-name" } }`
2. For a given `dir` (cwd), find matching project name from the mapping
3. List `~/.gemini/tmp/<project-name>/chats/session-*.json` files
4. For each file: read JSON, extract `sessionId`, `startTime`, `lastUpdated`, `summary`, first user message text, model from latest gemini message
5. Return `SessionInfo[]` sorted by `lastUpdated` descending
**Key functions:**
- `getSessions(dir?, limit?)` — List sessions for a project (or all projects)
- `getMessages(sessionId, dir?)` — Read and parse a session file, return `ParsedMessage[]`
- `findSessionFile(sessionId)` — Scan all project dirs to locate a session file by UUID
- `getProjectName(dir)` — Look up project name from `projects.json`
**Project root resolution:**
Each `~/.gemini/tmp/<project-name>/.project_root` file contains the absolute path. Use this to map back from project-name to cwd for display.
### 6. GeminiAdapter Capabilities
```typescript
{
supportsPlanMode: true, // --approval-mode plan
supportsPermissionModes: true, // default, auto_edit, yolo, plan
supportsInterrupt: true, // Ctrl+C in tmux
supportsResume: true, // gemini --resume
supportsAttach: false, // TBD
supportsStatusLine: false, // No statusLine hook (token info from JSON)
supportsImages: true,
supportsStreaming: true,
maxContextWindow: 1000000, // 1M tokens
permissionModeType: 'toggle', // Ctrl+Y toggles YOLO (not cycle like Claude)
}
```
**Effort levels:** Gemini CLI does not expose a reasoning effort parameter. `getEffortLevels()` returns `[]`.
**Permission mode runtime behavior:**
- `auto_edit` and `plan` can only be set at session launch via `--approval-mode`
- At runtime, Ctrl+Y is a binary toggle: `default` <-> `yolo`
- `switchPermissionMode()` for `auto_edit`/`plan` mid-session: not supported, returns `false`
```
**Models:**
- `auto` — Dynamic resolution (default)
- `pro` — Gemini 2.5 Pro (complex reasoning)
- `flash` — Gemini 2.5 Flash (fast, balanced)
- `flash-lite` — Gemini 2.5 Flash Lite (fastest)
**Permission modes:**
- `default` — Prompts for each tool call
- `auto_edit` — Auto-approves file edits
- `yolo` — Auto-approves everything
- `plan` — Read-only (experimental)
### 7. Session Lifecycle
**Start:**
```
gemini --approval-mode <mode> -m <model> -i "<prompt>"
```
- Session ID discovered from SessionStart hook's `session_id` field
- Uses `_pendingHookBodies` pattern (same as Codex) to handle race condition
- Must emit `'session-rekeyed'` event when temp session key is replaced with real UUID from hook (same as Codex's `session-rekeyed` pattern — SessionManager re-registers WS clients under new ID)
**Resume:**
```
gemini --resume <session-id>
```
**Permission mode switch:**
- Ctrl+Y in tmux toggles YOLO on/off
- Only binary toggle (not 4-way cycle like Claude's Shift+Tab)
**Model switch:**
- `/model <name>` slash command via tmux sendKeys
### 8. CLI & Frontend Changes
**`bin/codetap`:**
- `set_adapter()`: add `gemini` case with `YOLO="--approval-mode yolo"`
- Adapter detection: add `*gemini*` pattern
- ANSI label: `\033[34m[Gemini]\033[0m` (blue)
- `--adapter` validation: add `gemini` case
**`bin/hooks-cli.mjs`:**
- Import and instantiate `GeminiHookConfig`
- Add to install/uninstall calls
**`server/adapters/init.ts` + `server/adapters/registry.ts`** (atomic — must land together):
- `init.ts`: Add `gemini` loader in `LOADERS` map
- `registry.ts`: Add `'gemini'` to default `enabledAdapters` list
- If one changes without the other, the adapter either loads but isn't enabled, or is enabled but fails to load
**`src/lib/adapter-brands.ts`:**
```typescript
gemini: {
id: 'gemini',
displayName: 'Gemini',
provider: 'Google',
color: '#4285f4',
colorBg: '#4285f422',
gradient: 'linear-gradient(135deg, #4285f4, #1a73e8)',
glow: 'rgba(66,133,244,0.3)',
iconType: 'gemini',
}
```
**`src/components/AdapterIcon.tsx`:**
- Add `GeminiIcon` component with official Google Gemini SVG from thesvg.org
- Add `'gemini'` case to iconType switch
### 9. Shared Layer Refactor
**Move `tmux-manager.ts`:**
- From: `server/adapters/claude/tmux-manager.ts`
- To: `server/adapters/shared/tmux-manager.ts`
- Update imports in:
- `server/adapters/claude/tmux-adapter.ts`
- `server/adapters/claude/pane-monitor.ts`
- `server/adapters/codex/codex-tmux-adapter.ts`
- `server/adapters/gemini/gemini-tmux-adapter.ts`
No logic changes — pure file move + import path updates.
## Data Flow
```
Gemini CLI (tmux)
|
+-- Hook (stdin JSON) --> bridge.sh --> POST /api/hooks/gemini/<event>
| |
| GeminiTmuxAdapter.handle{Event}()
| |
| emit('tool-start', 'tool-done', 'session-idle', etc.)
|
+-- Session JSON file (~/.gemini/tmp/<project>/chats/session-*.json)
| |
| JsonWatcher detects file change (fs.watch + polling)
| |
| Reads JSON, diffs messages by count + ID
| |
| GeminiTranscriptParser.parse(newMessages)
| |
| emit('new-messages', messages[])
| emit('status-update', { model, tokens })
| emit('thinking', { thoughts[] })
|
+-- tmux pane output (streaming)
|
GeminiPaneMonitor detects changes
|
emit('streaming-text')
All events --> SessionManager --> WebSocket --> React frontend
```
## Testing Strategy
- Unit tests for `GeminiTranscriptParser` (convert JSON messages to ParsedMessage[])
- Unit tests for `JsonWatcher` (file size guard, message ID tracking, debounce)
- Unit tests for `GeminiHookConfig` (install/uninstall preserves existing hooks)
- Integration test: start Gemini session via API, verify WebSocket events
- Manual test: full flow on phone (start, send prompt, see streaming, approve tool, resume)
@@ -0,0 +1,163 @@
# PWA Optimization Design Spec
**Date:** 2026-03-26
**Goal:** Bring CodeTap's PWA to production-grade quality — proper viewport handling, splash screens, install prompts, SW updates, badge management, draft persistence, and navigation history.
---
## Current State
CodeTap already has solid PWA foundations:
- Service Worker with Workbox precaching (vite-plugin-pwa, injectManifest)
- Web App Manifest (standalone, portrait, dark theme)
- Push notifications with badge support
- iOS meta tags (capable, black-translucent status bar)
- Offline detection + OfflineView
- overscroll-behavior: none, 16px inputs, h-dvh, safe-bottom
## What's Missing
### High Priority
#### 1. Viewport & Safe Areas
**Problem:** Missing `viewport-fit=cover`. Only bottom safe area handled — notch/Dynamic Island area not accounted for.
**Solution:**
- Add `viewport-fit=cover` to viewport meta tag in `index.html`
- Add CSS for top safe area: headers get `padding-top: env(safe-area-inset-top)`
- The standalone PWA mode on iOS with `black-translucent` status bar needs the content to extend behind the status bar — `viewport-fit=cover` enables this
**Files:** `index.html`, `src/index.css`
#### 2. Splash Screen / Launch Images
**Problem:** White flash on app startup — no branded loading experience.
**Solution:**
- Add `apple-mobile-web-app-startup-image` meta tags covering major iPhone sizes
- Use `media` attribute with `device-width`, `device-height`, and `device-pixel-ratio` queries
- Background: `#09090b` (matches theme), centered CodeTap mascot/logo
- Generate splash images as data URIs or static PNGs in `/public/splash/`
- Minimum coverage: iPhone SE, iPhone 14/15, iPhone 14/15 Pro Max, iPhone 16 Pro Max
**Files:** `index.html`, `public/splash/` (new directory)
#### 3. Android Install Prompt
**Problem:** No handling of `beforeinstallprompt` event — Android users never see install prompt.
**Solution:**
- Listen for `beforeinstallprompt` in App.tsx, store the event in state
- Show a dismissible install banner in SessionsView (below header)
- Banner text: "Install CodeTap for a better experience" with Install/Dismiss buttons
- On Install click: call `event.prompt()`, hide banner
- On Dismiss: hide banner, store dismissal in `localStorage` so it doesn't reappear
- After successful install (`appinstalled` event): hide banner permanently
**Files:** `src/App.tsx`, `src/components/SessionsView.tsx`
#### 4. Service Worker Update Notification
**Problem:** SW updates silently — user doesn't know a new version is available.
**Solution:**
- Listen for `controllerchange` on `navigator.serviceWorker` in App.tsx
- When detected, show a toast at bottom: "New version available" with Refresh button
- On click: `window.location.reload()`
- Toast auto-dismisses after 10s but can be manually dismissed
**Files:** `src/App.tsx`
### Medium Priority
#### 5. Badge Clear on Focus
**Problem:** App badge persists even when user is actively looking at the app.
**Solution:**
- In App.tsx, listen for `visibilitychange` event
- When `document.visibilityState === 'visible'`: call `navigator.clearAppBadge()` (with feature check)
- This ensures badge is cleared whenever user switches back to the app
**Files:** `src/App.tsx`
#### 6. Manifest Shortcuts
**Problem:** No quick actions from home screen long-press.
**Solution:**
- Add `shortcuts` array to manifest in vite.config.ts:
- "New Chat" — url: `/?action=newchat`, icon: `chat-bubble-icon`
- In App.tsx, check for `?action=newchat` param and navigate accordingly
**Files:** `vite.config.ts`, `src/App.tsx`
#### 7. Input Draft Auto-Save
**Problem:** Typed text lost if app is backgrounded or crashes.
**Solution:**
- In ShimmerInput: on every input change, debounce-save to `localStorage` with key `codetap:draft:{sessionId}`
- On mount: restore draft from localStorage if present
- On successful send or explicit clear: delete the draft
- Debounce: 500ms to avoid excessive writes
**Files:** `src/components/ShimmerInput.tsx`
#### 8. Manifest Screenshots
**Problem:** Missing screenshots for app stores and install prompts.
**Solution:**
- Add `screenshots` array to manifest in vite.config.ts
- Provide at minimum:
- 1 narrow screenshot (phone, 1080x1920) — chat view
- 1 wide screenshot (tablet/desktop, 1920x1080) — sessions view
- Store in `public/screenshots/`
**Files:** `vite.config.ts`, `public/screenshots/` (new directory)
### Low Priority
#### 9. Slow Network Detection
**Problem:** No feedback when on slow connection.
**Solution:**
- Check `navigator.connection?.effectiveType` (with feature detection)
- When `2g` or `slow-2g`: show a subtle indicator in StatusBar ("Slow connection")
- Re-check on `change` event of `navigator.connection`
**Files:** `src/components/StatusBar.tsx`
#### 10. History API Navigation
**Problem:** Browser back gesture doesn't work — app uses `sessionStorage` for view state, no history stack.
**Solution:**
- In App.tsx, use `history.pushState()` when navigating between views
- Listen for `popstate` event to handle back navigation
- Map each view to a history entry: `sessions`, `chat/{sessionId}`, `settings`, `newchat/{cwd}`
- This enables iOS swipe-back gesture and Android back button in standalone mode
**Files:** `src/App.tsx`
#### 11. OpenGraph Meta Tags
**Problem:** No social sharing metadata.
**Solution:**
- Add to `index.html`:
- `og:title`: "CodeTap"
- `og:description`: "Use Claude Code from your phone"
- `og:image`: link to a social card image
- `og:type`: "website"
- `twitter:card`: "summary"
**Files:** `index.html`
---
## Design Principles
1. **Progressive enhancement** — All PWA features use feature detection. App works fine without them.
2. **No new dependencies** — Everything is native Web APIs or existing vite-plugin-pwa config.
3. **Minimal UI additions** — Install banner and SW update toast are the only new UI elements.
4. **Respect user choice** — Install prompt is dismissible and remembers dismissal.
## Out of Scope
- Full offline-first with background sync (current offline detection is sufficient)
- Push notification permission prompt UI (current flow works)
- Image compression before upload
- Orientation lock via Screen Orientation API
@@ -0,0 +1,174 @@
# Send-to Menu Redesign + Settings Page
## Overview
Redesign the "Send to Other AI" menu for cross-AI review, add a Settings page for managing preferences and saved instructions.
Three deliverables:
1. **Send-to Menu** — Two-step bottom sheet with adapter selection, model picker, Direct Send / With Instructions
2. **Settings Page** — Centralized preferences: saved instructions, per-adapter defaults, notifications, about
3. **Saved Instructions DB** — Server-side storage for reusable instruction templates
## Part 1: Send-to Menu
### Layout: Two-Step Bottom Sheet
**Step 1 — Adapter Selection:**
- Bottom sheet titled "Send to…"
- Lists all available adapters (excluding current)
- Each row: adapter icon (official SVG from AdapterIcon.tsx) + adapter name
- No model shown here (model is selected in step 2)
- Tap row → navigates to step 2
**Step 2 — Action Selection:**
- Header: ` {AdapterName}` (back arrow + colored adapter name)
- Model dropdown: `Model: [gpt-5.4 ▾]` — uses native `<select>`, options from `GET /api/adapter/:name/config`
- Two action buttons:
- **Direct Send** — icon ↗, subtitle "直接送出,不加 instructions"
- **With Instructions** — icon ✎, subtitle "自訂或使用已儲存的", has ▼/▲ chevron for expand/collapse
**With Instructions (expandable section):**
- Saved instructions list (from DB) — tap to select and send immediately
- Divider: "或輸入新的"
- Text input + send button
- Tapping ▼/▲ toggles the section open/closed
### Behavior
**Direct Send:**
- Sends ONLY the raw response text (the message the user clicked ↗ on)
- No context wrapper, no conversation history, no instructions
- Immediately opens FloatingReviewPanel
**With Instructions (saved):**
- Sends: `{instruction}\n\n{response_text}`
- Immediately opens FloatingReviewPanel
**With Instructions (new):**
- Same format as saved
- After sending, show a toast at bottom: "存成常用?" + [Save] button
- Toast auto-dismisses after 3 seconds
- Tapping Save → `POST /api/instructions` with auto-generated label (first 30 chars of instruction)
- Saved instructions then appear in the list for future use
### Components Changed
| Component | Change |
|-----------|--------|
| `ReviewActionMenu.tsx` | Complete rewrite — two-step bottom sheet |
| `MessageBubble.tsx` / `SendDropdown` | Unchanged (still triggers `onSendTo`) |
| `ChatView.tsx` `handleReviewSelect` | Simplify — remove promptTemplates, context assembly. Direct Send = raw text, Instructions = instruction + raw text |
| `AdapterIcon.tsx` | Unchanged (already uses official SVGs) |
## Part 2: Settings Page
### Entry Point
- New ⚙️ icon button in the project list header (next to Logout)
- Navigates to Settings view
### Settings Structure
```
Settings
├── Saved Instructions
│ └── List with Add/Delete, tap to edit
├── Claude (per-adapter)
│ ├── Model: [dropdown]
│ ├── Permission Mode: [dropdown] (label from adapter)
│ └── Thinking: [dropdown] (label from adapter, "Thinking" for Claude)
├── Codex (per-adapter)
│ ├── Model: [dropdown]
│ ├── Permission Mode: [dropdown]
│ └── Effort: [dropdown] (label from adapter, "Effort" for Codex)
├── Notifications
│ └── Push Notifications: [toggle]
└── About
└── CodeTap v{version}
```
### Per-Adapter Settings
- Options fetched dynamically from `GET /api/adapter/:name/config`
- Each adapter has its own models, permission modes, effort levels, and effort label
- Changes saved to `localStorage` via existing `adapter-prefs.ts` (same as current cycle buttons)
- NewChat page cycle buttons remain as shortcuts
### Saved Instructions Sub-page
```
Saved Instructions [+ Add]
──────────────────────────────────────────
Code Review ✕
Review for correctness, edge cases…
Suggest Alternatives ✕
Suggest 3 alternative approaches…
```
- `+ Add` → inline input or modal to enter label + instruction text
- `✕` → delete with confirmation
- v1: no edit — delete and recreate
### Version Number
- Read from `/api/health` endpoint (already returns version from `package.json`)
### Components
| Component | Type |
|-----------|------|
| `SettingsView.tsx` | New — main settings page |
| `SavedInstructionsView.tsx` | New — instruction management sub-page |
| `AdapterSettingsSection.tsx` | New — per-adapter dropdown settings |
| `SessionsView.tsx` | Modified — add ⚙️ icon in header |
## Part 3: Saved Instructions DB
### Schema
```sql
CREATE TABLE saved_instructions (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
instruction TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
```
### API Endpoints
| Method | Path | Body | Description |
|--------|------|------|-------------|
| GET | `/api/instructions` | — | List all, ordered by created_at |
| POST | `/api/instructions` | `{label, instruction}` | Create new |
| DELETE | `/api/instructions/:id` | — | Delete |
No update endpoint — delete and recreate. Keeps it simple.
### Client API
Add to `src/lib/api.ts`:
- `api.getInstructions(): Promise<Instruction[]>`
- `api.createInstruction(label, instruction): Promise<Instruction>`
- `api.deleteInstruction(id): Promise<void>`
## Data Flow Summary
```
User clicks ↗ on message
→ ReviewActionMenu opens (Step 1: pick adapter)
→ Step 2: pick model + Direct Send or With Instructions
→ Direct Send: sendMessage(rawText) to child adapter
→ With Instructions: sendMessage(instruction + rawText) to child adapter
→ FloatingReviewPanel opens (same as current QUERY path)
→ If new instruction used, toast offers to save
```
## Out of Scope
- Instruction editing (v1: delete + recreate)
- Instruction reordering (v1: created_at order)
- Per-adapter instructions (v1: global list)
- Theme/appearance settings
- Server-side config management