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:
@@ -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**
|
||||
Reference in New Issue
Block a user