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**