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,334 @@
|
||||
# Cross-AI Review
|
||||
|
||||
## Overview
|
||||
|
||||
A mechanism to send messages from one CLI session (e.g., Claude) to another adapter's CLI session (e.g., Codex) for review, all within the same ChatView. The child session runs in its own tmux window with full codebase access, and its UI is presented as a floating panel over the parent chat.
|
||||
|
||||
## Concepts
|
||||
|
||||
### Parent Session
|
||||
The main CLI session the user is working in. Appears in session list and active sessions as normal.
|
||||
|
||||
### Child Session (Review Session)
|
||||
A secondary CLI session triggered from a specific message in the parent. It:
|
||||
- Runs a different adapter (e.g., parent is Claude, child is Codex)
|
||||
- Opens in the same `cwd` as the parent (read from parent's DB row, not from client)
|
||||
- Is a real tmux window, supports full CLI interaction with codebase access
|
||||
- Does NOT appear in the session list or active sessions
|
||||
- Is tracked via a separate `session_reviews` DB table
|
||||
- Its UI appears as a floating panel inside the parent's ChatView
|
||||
- Uses its own `useChat` hook instance for independent WebSocket connection and message handling
|
||||
|
||||
### Relationship
|
||||
- One parent can have one active (non-ended) child at a time
|
||||
- If the user tries to start a second review while one is active, show a confirmation: "End current review to start a new one?"
|
||||
- Each child has an `anchor_message_id` — the ID of the specific assistant message that triggered the review
|
||||
- Ended reviews remain as collapsed cards in the history; a parent can have many ended reviews
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
All adapter references in the UI are **dynamic**, never hardcoded:
|
||||
- "Send to [Codex]" / "Send to [Claude]" — resolved from available adapters via `/api/adapters`
|
||||
- In child session: "Send to [Parent Adapter Name]" — resolved from parent's adapter
|
||||
- The "Send to" button is only shown when at least one other adapter is available (`/api/adapters` filtered to `available: true`)
|
||||
- Review session title is dynamic based on the prompt template selected: "Code Review", "Suggest Alternatives", "Direct Send", or the user's custom instruction (truncated)
|
||||
|
||||
## User Flow
|
||||
|
||||
### 1. Triggering a Review
|
||||
|
||||
Every assistant message in the parent chat has action buttons:
|
||||
- **Copy** — copy message content
|
||||
- **Send to [Adapter]** — triggers the cross-AI review flow (adapter name is dynamic)
|
||||
|
||||
When the user taps "Send to [Adapter]", a popup menu appears with prompt template options:
|
||||
- **Direct send** — send the message as-is
|
||||
- **Code Review** — attach a "please review this code" instruction
|
||||
- **Suggest alternatives** — ask for different approaches
|
||||
- **Custom instruction...** — user types their own prompt
|
||||
|
||||
After selection:
|
||||
1. Server creates a new CLI session for the target adapter in tmux (same `cwd` as parent)
|
||||
2. Context (parent conversation history + selected message + prompt template) is pasted into the child CLI via `tmux load-buffer` + `paste-buffer` (see "Context Passing" section)
|
||||
3. A floating panel appears at the bottom of the screen
|
||||
4. Server broadcasts `REVIEW_STARTED` to all parent session clients
|
||||
5. A `session_reviews` row is created in the DB
|
||||
|
||||
### 2. Interacting with the Child Session
|
||||
|
||||
The floating panel has three states:
|
||||
|
||||
**Expanded** — takes up ~55% of the screen from the bottom. Shows:
|
||||
- Header with dynamic title: "[Adapter] [Template Name]" (e.g., "Codex Code Review")
|
||||
- "End" button to close the review
|
||||
- Chat messages from the child session (with streaming — uses its own `useChat` hook)
|
||||
- Each child assistant message has:
|
||||
- **Copy** button
|
||||
- **Send to [Parent Adapter]** button — dynamically named based on parent's adapter
|
||||
- Input field for the user to ask follow-up questions to the child AI
|
||||
|
||||
**Minimized (Pill)** — a small floating pill button in the bottom-right corner. Shows adapter name + template name with a pulsing dot. Tap to expand.
|
||||
|
||||
**Hidden** — the floating panel is dismissed but the tmux session continues running in the background.
|
||||
|
||||
Users can switch between states by tapping the handle bar (to minimize) or the pill (to expand).
|
||||
|
||||
### 3. Sending Results Back to Parent
|
||||
|
||||
Each assistant message in the child session has a **"Send to [Parent Adapter]"** button. When tapped:
|
||||
- **Guard**: if the parent session is currently processing (`isProcessing`), show a toast: "Wait for the current turn to complete"
|
||||
- Otherwise: the message content is prefixed with "[Review feedback from [Child Adapter]:]" and injected into the parent's tmux session via `sendMessage()`
|
||||
- The parent AI sees it as a normal user message and can respond
|
||||
- The user continues working with the parent AI, informed by the review
|
||||
|
||||
This is a manual, explicit action. There is no automatic injection.
|
||||
|
||||
For long messages, use `tmux load-buffer` + `paste-buffer` (see "Context Passing & Message Delivery" section).
|
||||
|
||||
### 4. Ending a Review
|
||||
|
||||
The user taps the **"End"** button on the floating panel:
|
||||
- `ended_at` is set in the `session_reviews` table
|
||||
- The child's tmux window is killed
|
||||
- The floating panel disappears
|
||||
- Server broadcasts `REVIEW_ENDED` to all parent session clients
|
||||
- The child session's JSONL file is preserved for history
|
||||
|
||||
### 5. Viewing History
|
||||
|
||||
When the user re-opens the parent session and scrolls through message history:
|
||||
- At the `anchor_message_id` position, a **block-start marker** is rendered: "[Adapter] [Template] started"
|
||||
- Immediately after the block-start marker, a **collapsed review card** appears showing:
|
||||
- Adapter name, template name, and message count
|
||||
- A brief summary (first line of the child AI's first response)
|
||||
- "Tap to expand" hint — opens the full child conversation in a read-only panel
|
||||
- The block-end marker renders immediately after the collapsed review card: "Review ended"
|
||||
- Parent messages that occurred during the review period continue normally in the chat flow (they are NOT inside the collapsed card — they are separate messages below it)
|
||||
|
||||
The block markers are NOT stored in JSONL. They are rendered dynamically by ChatView based on `session_reviews` DB metadata, keyed by `anchor_message_id`.
|
||||
|
||||
## Context Passing & Message Delivery
|
||||
|
||||
### tmux buffer (unified approach)
|
||||
|
||||
All text delivery to CLI sessions uses `tmux load-buffer` + `paste-buffer` instead of `send-keys`. This applies to:
|
||||
- Initial context sent to child session
|
||||
- Messages sent back from child to parent ("Send to [Parent Adapter]")
|
||||
- Any other long-form text injection
|
||||
|
||||
**Why not `send-keys`:**
|
||||
- Special characters (quotes, backslashes, newlines) get interpreted by the shell
|
||||
- `send-keys -l` processes text character-by-character, slow for large content
|
||||
- Practical input length limits
|
||||
|
||||
**Why not file-based (write file + "read /tmp/xxx.md"):**
|
||||
- Requires an extra tool call round trip (AI has to read the file)
|
||||
- Requires file cleanup
|
||||
- Less direct
|
||||
|
||||
**Buffer mechanism:**
|
||||
|
||||
```bash
|
||||
# 1. Write content to a temp file
|
||||
echo "$content" > /tmp/codetap-buf-{id}.txt
|
||||
|
||||
# 2. Load into tmux buffer
|
||||
tmux load-buffer /tmp/codetap-buf-{id}.txt
|
||||
|
||||
# 3. Paste into target pane
|
||||
tmux paste-buffer -t codetap:{windowId}
|
||||
|
||||
# 4. Press Enter to submit
|
||||
tmux send-keys -t codetap:{windowId} Enter
|
||||
|
||||
# 5. Clean up temp file
|
||||
rm /tmp/codetap-buf-{id}.txt
|
||||
```
|
||||
|
||||
The CLI receives the pasted text as a single multi-line prompt. Both Claude Code and Codex handle pasted multi-line input natively.
|
||||
|
||||
### Initial context format
|
||||
|
||||
When creating a child session, the context pasted as the first prompt:
|
||||
|
||||
```
|
||||
The following is a conversation between a user and [Parent Adapter].
|
||||
Please review the highlighted response below.
|
||||
|
||||
[Conversation History]
|
||||
User: [message 1]
|
||||
[Parent Adapter]: [message 2]
|
||||
...
|
||||
|
||||
>>> REVIEW THIS RESPONSE <<<
|
||||
[Parent Adapter]: [the selected message content]
|
||||
|
||||
[Instruction]
|
||||
[Prompt template text, e.g., "Please perform a code review..."]
|
||||
```
|
||||
|
||||
Maximum context: last 50 messages or 30KB of text (whichever is smaller). If truncated, prepend "[Earlier conversation omitted]".
|
||||
|
||||
### Send-back format
|
||||
|
||||
When "Send to [Parent Adapter]" is tapped on a child message, the content pasted to the parent:
|
||||
|
||||
```
|
||||
[Review feedback from [Child Adapter]]:
|
||||
[message content]
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### New table: `session_reviews`
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS session_reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_cli_session_id TEXT NOT NULL,
|
||||
child_cli_session_id TEXT NOT NULL,
|
||||
child_adapter TEXT NOT NULL,
|
||||
anchor_message_id TEXT,
|
||||
review_prompt TEXT,
|
||||
review_title TEXT,
|
||||
started_at TEXT DEFAULT (datetime('now')),
|
||||
ended_at TEXT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_parent ON session_reviews(parent_cli_session_id);
|
||||
```
|
||||
|
||||
**All session IDs stored are native CLI UUIDs** (e.g., `019d1956-2941-7360-b313-0610d98ee150` for Codex, `4ac007a8-7b04-4646-8f0c-a74845aa01bf` for Claude), NOT internal IDs (e.g., `codex-1774267440443` or `claude-1774269874387`). Internal IDs change on server restart when sessions are recreated; CLI UUIDs are permanent.
|
||||
|
||||
This table is **NOT cleared** by `clearAll()` — it survives server restarts. The `sessions` table continues to be cleared as before.
|
||||
|
||||
### Filtering child sessions
|
||||
|
||||
Child sessions still appear in the `sessions` table (for tmux window tracking). Filtering is done at the **API layer** in `server/index.ts`, not in adapters or JSONL stores. This is because:
|
||||
- `getSessions()` in both adapters reads from JSONL files, not the DB -- it has no access to `session_reviews`
|
||||
- `getActiveSessions()` reads from in-memory Maps -- same issue
|
||||
- The API endpoints already aggregate across adapters, so filtering here is natural
|
||||
|
||||
**Implementation:**
|
||||
|
||||
For `/api/sessions` and `/api/active-sessions` endpoints in `server/index.ts`:
|
||||
1. Query `session_reviews` for all `child_cli_session_id` values
|
||||
2. Build a `Set<string>` of child CLI UUIDs
|
||||
3. Filter the results: exclude any session whose `cliSessionId` (CLI UUID) is in the set
|
||||
|
||||
This keeps adapter code untouched and centralizes the filtering logic.
|
||||
|
||||
### Message IDs
|
||||
|
||||
The spec uses `anchor_message_id` to identify which message triggered a review. Currently:
|
||||
- `ChatMessage` type has an optional `id` field, but it is never populated
|
||||
- JSONL entries from both Claude and Codex do not have dedicated message IDs
|
||||
|
||||
**Solution:** Generate synthetic UUIDs for each message at parse time in `TranscriptParser` (Claude) and `CodexTranscriptParser` (Codex). These IDs are:
|
||||
- Generated deterministically or at parse time
|
||||
- Threaded through to `ChatMessage.id` in the React state
|
||||
- Passed to `MessageBubble` as a prop for action button callbacks
|
||||
- Stored as `anchor_message_id` in `session_reviews` when a review is triggered
|
||||
|
||||
### DB operations
|
||||
|
||||
Add a `sessionReviews` operation set to `server/db.ts`:
|
||||
|
||||
- `create(id, parentCliId, childCliId, childAdapter, anchorMsgId, prompt, title)` -- insert new review
|
||||
- `getActiveForParent(parentCliSessionId)` -- active reviews for reconnect
|
||||
- `getAllChildIds()` -- all child CLI UUIDs for session list filtering
|
||||
- `endReview(reviewId)` -- sets ended_at
|
||||
- `getForParent(parentCliSessionId)` -- all reviews including ended (for history rendering)
|
||||
|
||||
### TmuxManager changes
|
||||
|
||||
Add a `pasteBuffer(windowId, content)` method to `server/adapters/claude/tmux-manager.ts`:
|
||||
1. Write content to a temp file
|
||||
2. `tmux load-buffer <tmpFile>`
|
||||
3. `tmux paste-buffer -t codetap:<windowId>`
|
||||
4. `tmux send-keys -t codetap:<windowId> Enter`
|
||||
5. Clean up temp file
|
||||
|
||||
Uses `execFile` (not `exec`) for safety, consistent with existing TmuxManager methods.
|
||||
|
||||
This replaces `sendKeys()` for all cross-AI review text delivery (initial context + send-back). Regular `sendMessage()` in adapters continues to use `sendKeys()` for short user prompts.
|
||||
|
||||
## WebSocket Events
|
||||
|
||||
Two new event types for review lifecycle:
|
||||
|
||||
```typescript
|
||||
// server → client: a review session was created
|
||||
REVIEW_STARTED = 'review-started'
|
||||
{
|
||||
type: 'review-started',
|
||||
reviewId: string,
|
||||
childSessionId: string, // internal session ID for useChat connection
|
||||
childCliSessionId: string, // CLI UUID
|
||||
childAdapter: string,
|
||||
anchorMessageId: string,
|
||||
reviewTitle: string,
|
||||
}
|
||||
|
||||
// server → client: a review session was ended
|
||||
REVIEW_ENDED = 'review-ended'
|
||||
{
|
||||
type: 'review-ended',
|
||||
reviewId: string,
|
||||
}
|
||||
```
|
||||
|
||||
These are broadcast to all clients connected to the parent session. On receiving `REVIEW_STARTED`, the client creates a `FloatingReviewPanel` with its own `useChat` hook instance pointing to the child session.
|
||||
|
||||
## Reconnect / Server Restart
|
||||
|
||||
### Reconnecting to a parent session with active child
|
||||
|
||||
When a user opens a parent session:
|
||||
1. Query DB: `SELECT * FROM session_reviews WHERE parent_cli_session_id = ? AND ended_at IS NULL`
|
||||
2. For each active child (at most one, enforced by the one-active-child rule):
|
||||
- Resolve the child CLI UUID to an internal session ID
|
||||
- Check if the tmux window still exists
|
||||
- If yes: re-attach (start monitoring events), show floating panel
|
||||
- If no (e.g., server restarted): use `resumeSession` with the child CLI UUID to create a new tmux window, show floating panel
|
||||
3. For ended children: do nothing at connect time. ChatView renders collapsed review cards when scrolling through history by querying `session_reviews` for this parent.
|
||||
|
||||
### clearAll() behavior
|
||||
|
||||
`clearAll()` clears the `sessions` table on shutdown. The `session_reviews` table is NOT cleared. On next startup:
|
||||
- `session_reviews` still has the parent-child relationships (keyed by CLI UUIDs)
|
||||
- Child session JSONL files still exist on disk
|
||||
- History view works correctly
|
||||
|
||||
## UI Components
|
||||
|
||||
### New Components
|
||||
- **FloatingReviewPanel** — the expandable/minimizable floating panel for child session interaction. Uses its own `useChat` hook instance with the child's session ID.
|
||||
- **FloatingReviewPill** — the minimized pill button
|
||||
- **ReviewActionMenu** — the popup menu when "Send to [Adapter]" is tapped (prompt template selection)
|
||||
- **CollapsedReviewCard** — the folded review card shown in history view
|
||||
- **BlockMarker** — the "Review started" / "Review ended" divider lines
|
||||
|
||||
### Modified Components
|
||||
- **MessageBubble** — add "Copy" and "Send to [Adapter]" action buttons to each assistant message. Adapter name is dynamic.
|
||||
- **ChatView** — integrate FloatingReviewPanel, render block markers and collapsed cards in history, manage child session lifecycle
|
||||
- **useChat hook** — add review state management (active review ID, floating panel visibility). Does NOT handle child session messages — that is delegated to the child's own `useChat` instance inside `FloatingReviewPanel`.
|
||||
|
||||
### Removed Components
|
||||
- **QuickActionCards** — replaced by per-message action buttons
|
||||
- **CrossAdapterFlow** — replaced by the new review mechanism
|
||||
- **quick-commands.ts** — prompt templates moved into ReviewActionMenu
|
||||
- `crossAdapterFlow` state, `startCrossAdapterFlow`, `completeCrossAdapterFlow` in useChat — all removed
|
||||
|
||||
## Scope Boundaries (NOT included in v1)
|
||||
|
||||
- **Auto N-round debate** — two AIs automatically going back and forth. Deferred due to JSONL duplication complexity.
|
||||
- **Multi-child sessions** — only one active child at a time. Multiple ended reviews are fine.
|
||||
- **More than 2 adapters** — the architecture supports it (dynamic naming, adapter list from API), but UI is only tested with Claude + Codex.
|
||||
|
||||
## Interactive Mockup
|
||||
|
||||
A visual mockup is available at `/tmp/cross-ai-review-mockup.html` showing three views:
|
||||
1. **Live view** — review in progress with floating panel (expanded + minimized pill states)
|
||||
2. **History view** — review ended with collapsed card + block markers + interleaved parent messages
|
||||
3. **Menu view** — the prompt template selection popup
|
||||
@@ -0,0 +1,136 @@
|
||||
# InsightBlock — Adapter-Specific Content Rendering
|
||||
|
||||
## Problem
|
||||
|
||||
Claude Code produces "Insight" blocks in its markdown responses:
|
||||
|
||||
```
|
||||
`★ Insight ─────────────────────────────────────`
|
||||
[educational content]
|
||||
`─────────────────────────────────────────────────`
|
||||
```
|
||||
|
||||
These currently render as ugly inline `<code>` elements in ReactMarkdown. Need a dedicated, collapsible UI component — while keeping the architecture extensible for other adapters (Gemini, Codex) that may have their own text patterns.
|
||||
|
||||
## Design
|
||||
|
||||
### Approach: Frontend Text Transform with Adapter-Scoped Patterns
|
||||
|
||||
- **No server changes.** The Insight text flows through the existing pipeline as `{ type: 'text' }` content blocks.
|
||||
- **Regex patterns** defined per-adapter in adapter-scoped files.
|
||||
- **Generic splitter** in `src/lib/` accepts patterns as parameters — no coupling to any specific adapter.
|
||||
- **Collapsible card UI** matching ToolCallCard's expand/collapse pattern.
|
||||
|
||||
### File Structure
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ └── text-transforms.ts # Generic: splitTextSegments(text, patterns)
|
||||
├── components/
|
||||
│ ├── adapters/
|
||||
│ │ └── claude/
|
||||
│ │ ├── InsightBlock.tsx # Collapsible insight card
|
||||
│ │ └── patterns.ts # INSIGHT_RE regex + segment type
|
||||
│ ├── MessageBubble.tsx # Modified: split → map → render
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
**Dependency direction:**
|
||||
```
|
||||
MessageBubble → text-transforms (generic)
|
||||
MessageBubble → adapters/claude/patterns (adapter-specific)
|
||||
MessageBubble → adapters/claude/InsightBlock (adapter-specific)
|
||||
```
|
||||
|
||||
Generic code never imports adapter-specific code. MessageBubble is the composition root.
|
||||
|
||||
### `src/components/adapters/claude/patterns.ts`
|
||||
|
||||
Exports Claude-specific text patterns:
|
||||
|
||||
```typescript
|
||||
import type { TextPattern } from '@/lib/text-transforms';
|
||||
|
||||
export const CLAUDE_PATTERNS: TextPattern[] = [
|
||||
{
|
||||
type: 'insight',
|
||||
regex: /`[★✦]?\s*Insight[─\-\s]*`\n([\s\S]*?)\n`[─\-]+[.。]?`/g,
|
||||
},
|
||||
];
|
||||
```
|
||||
|
||||
### `src/lib/text-transforms.ts`
|
||||
|
||||
Generic segment splitter — adapter-agnostic:
|
||||
|
||||
```typescript
|
||||
export interface TextPattern {
|
||||
type: string;
|
||||
regex: RegExp;
|
||||
}
|
||||
|
||||
export type TextSegment = {
|
||||
type: string; // 'markdown' | 'insight' | future types
|
||||
text: string;
|
||||
};
|
||||
|
||||
export function splitTextSegments(text: string, patterns: TextPattern[]): TextSegment[] {
|
||||
// Fast path: no patterns or no text → return as-is
|
||||
// For each pattern, find all matches and record their positions
|
||||
// Split text into alternating markdown/matched segments
|
||||
// Streaming safety: unmatched opening fence → treat as plain markdown
|
||||
}
|
||||
```
|
||||
|
||||
### `src/components/adapters/claude/InsightBlock.tsx`
|
||||
|
||||
Collapsible card — Style C from brainstorming:
|
||||
|
||||
- **Collapsed (default):** `★` icon + "Insight" label + first-line summary (truncated) + `▼` chevron
|
||||
- **Expanded:** Full content rendered via ReactMarkdown with prose styling
|
||||
- **Visual:** `bg-surface/30 border border-border/50 rounded-lg` — subtle card with accent star
|
||||
|
||||
### `src/components/MessageBubble.tsx` Changes
|
||||
|
||||
```diff
|
||||
+ import { splitTextSegments } from '@/lib/text-transforms';
|
||||
+ import { CLAUDE_PATTERNS } from './adapters/claude/patterns';
|
||||
+ import { InsightBlock } from './adapters/claude/InsightBlock';
|
||||
|
||||
// In assistant message render:
|
||||
const textContent = content.filter(...).map(...).join('');
|
||||
+ const segments = splitTextSegments(textContent, CLAUDE_PATTERNS);
|
||||
|
||||
- <ReactMarkdown ...>{textContent}</ReactMarkdown>
|
||||
+ {segments.map((seg, i) =>
|
||||
+ seg.type === 'insight'
|
||||
+ ? <InsightBlock key={i} text={seg.text} />
|
||||
+ : <ReactMarkdown key={i} components={markdownComponents}>{seg.text}</ReactMarkdown>
|
||||
+ )}
|
||||
```
|
||||
|
||||
## Extensibility
|
||||
|
||||
When Gemini adds "Analysis" blocks:
|
||||
|
||||
1. `src/components/adapters/gemini/patterns.ts` — export `GEMINI_PATTERNS`
|
||||
2. `src/components/adapters/gemini/AnalysisBlock.tsx` — new component
|
||||
3. `MessageBubble.tsx` — merge patterns: `[...CLAUDE_PATTERNS, ...GEMINI_PATTERNS]`
|
||||
4. Add one more segment type case in the render map
|
||||
|
||||
No server changes. No refactoring. Explicit additions only.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- **Streaming:** Partial insight (opening fence without closing) → `splitTextSegments` treats as plain markdown. InsightBlock only renders when both fences are present.
|
||||
- **No insights:** Fast path — `splitTextSegments` returns `[{ type: 'markdown', text }]`, single ReactMarkdown render. No performance regression.
|
||||
- **Multiple insights:** Each becomes its own InsightBlock in the segments array, with markdown segments between them.
|
||||
- **Nested markdown in insight body:** ReactMarkdown inside InsightBlock handles bullet points, code, links.
|
||||
|
||||
## Not Changing
|
||||
|
||||
- Server pipeline (TranscriptParser, session-manager, IAdapter)
|
||||
- ChatView.tsx (Insight is text-layer, not content-block-layer)
|
||||
- useChat.ts
|
||||
- WS protocol
|
||||
@@ -0,0 +1,256 @@
|
||||
# Session ID Unification Design Spec
|
||||
|
||||
## Problem
|
||||
|
||||
CodeTap's session management has grown organically and now has several issues:
|
||||
|
||||
1. **Dual ID system** — Each session has an "internal ID" (`session-{timestamp}` or `desktop-{uuid前8字}`) and a "CLI UUID". The internal ID is meaningless to users but is what the UI displays.
|
||||
2. **Dual storage** — `session-map.json` (file) and SQLite (DB) both store session mappings. The file is written by the SessionStart hook but only read on server startup, causing `codetap new` sessions to not appear in Active Sessions until server restart.
|
||||
3. **`desktop-` prefix confusion** — Sessions that are "rediscovered" after a non-graceful server restart get a new `desktop-` internal ID, losing their original ID.
|
||||
4. **No adapter awareness in IDs** — Internal IDs don't indicate which adapter (Claude/Codex/Gemini) the session belongs to.
|
||||
5. **User can't resume from desktop** — The chat header shows the internal ID which can't be used with `claude --resume`.
|
||||
|
||||
## Design Decisions
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Scope | All adapters (Claude, Codex, future) |
|
||||
| Storage | SQLite only — remove session-map.json |
|
||||
| SessionStart hook | POST to server API (like all other hooks) |
|
||||
| Internal ID format | `{adapter}-{timestamp}` (e.g., `claude-1774210269126`) |
|
||||
| `desktop-` prefix | Removed entirely |
|
||||
| Non-graceful restart recovery | Read original internal ID from DB |
|
||||
| User-facing display | Chat header: CLI UUID (primary) + internal ID (secondary) |
|
||||
| Active Sessions list | Keep showing `firstPrompt` (no change) |
|
||||
| DB on shutdown | Clear `sessions` table (tmux windows are killed, records are useless) |
|
||||
| CLI `--adapter` flag | Added to `codetap new` and `codetap --continue` |
|
||||
| CLI `--resume` | Accepts internal ID or CLI UUID; scans JSONL dirs to detect adapter |
|
||||
| CLI `--continue` | Pass through to adapter CLI's native continue command |
|
||||
| Adapter selector UI | Not in scope (future work) |
|
||||
|
||||
## Architecture
|
||||
|
||||
### Internal ID Format
|
||||
|
||||
```
|
||||
{adapter}-{timestamp}
|
||||
|
||||
Examples:
|
||||
claude-1774210269126
|
||||
codex-1774210345678
|
||||
gemini-1774210500000
|
||||
```
|
||||
|
||||
Produced by:
|
||||
- `startSession()` in each adapter (Web UI new session)
|
||||
- `bin/codetap` CLI script (`codetap new`, `codetap --resume`, `codetap --continue`)
|
||||
|
||||
### Single Source of Truth: SQLite
|
||||
|
||||
All session mappings go through SQLite. No file-based storage.
|
||||
|
||||
**SessionStart hook flow (unified for all entry points):**
|
||||
|
||||
```
|
||||
Claude/Codex CLI starts → SessionStart hook fires
|
||||
↓
|
||||
POST /api/hooks/{adapter}/session-start
|
||||
body: { session_id: "<CLI UUID>", cwd: "/path", ... }
|
||||
↓
|
||||
Server handler:
|
||||
1. Find tmux window for this session (by window name or DB lookup)
|
||||
2. If session already in memory → update mapping (e.g., /resume changed UUID)
|
||||
3. If session NOT in memory → create new entry:
|
||||
- Internal ID from tmux window name (e.g., claude-1774210269126)
|
||||
- Map CLI UUID → internal ID
|
||||
- Write to DB
|
||||
4. Session appears in Active Sessions immediately
|
||||
```
|
||||
|
||||
**Shutdown flow:**
|
||||
|
||||
```
|
||||
SIGTERM/SIGINT received
|
||||
↓
|
||||
1. adapter.destroy() → tmuxManager.killSession() → all tmux windows killed
|
||||
2. dbSessions.clearAll() → clear sessions table
|
||||
3. closeDB()
|
||||
```
|
||||
|
||||
### DB Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY, -- internal ID (claude-1774210269126)
|
||||
cli_session TEXT, -- CLI native UUID
|
||||
adapter TEXT, -- 'claude' / 'codex' / 'gemini'
|
||||
cwd TEXT,
|
||||
window_id TEXT,
|
||||
permission_mode TEXT,
|
||||
created_at DATETIME DEFAULT (datetime('now')),
|
||||
last_activity DATETIME DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sessions_cli ON sessions(cli_session);
|
||||
CREATE INDEX idx_sessions_adapter ON sessions(adapter);
|
||||
```
|
||||
|
||||
Migration: rename `claude_session` → `cli_session`, add `adapter` column (default `'claude'` for existing rows), remove `is_active` column.
|
||||
|
||||
### Chat Header Display
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ ← code-tap 625c60d0-aedb-4e0b... [copy icon] │
|
||||
│ claude-1774210269126 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Primary: CLI UUID (truncated), click copy icon to copy full UUID → usable with `claude --resume <uuid>`
|
||||
- Secondary: internal ID → usable with `tmux select-window -t codetap:claude-1774210269126`
|
||||
|
||||
`SESSION_CREATED` message updated to include both `sessionId` (internal) and `cliSessionId` (UUID).
|
||||
|
||||
### Hook Config Change
|
||||
|
||||
In `hook-config.ts`, SessionStart changes from file-writing script to `fireAndForget` API POST (same pattern as all other hooks):
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: hookPath, timeout: 2 }] }]
|
||||
|
||||
// After:
|
||||
SessionStart: [{ hooks: [{ type: 'command', command: fireAndForget('session-start'), timeout: 2 }] }]
|
||||
```
|
||||
|
||||
### SESSION_CREATED Message Payload
|
||||
|
||||
```typescript
|
||||
// Before:
|
||||
{ type: 'session-created', sessionId: string }
|
||||
|
||||
// After:
|
||||
{ type: 'session-created', sessionId: string, cliSessionId: string }
|
||||
```
|
||||
|
||||
`useChat` hook stores both IDs. `ChatView` header displays `cliSessionId` (primary) and `sessionId` (secondary).
|
||||
|
||||
### Recovery from Non-Graceful Shutdown
|
||||
|
||||
If the server crashes (kill -9, power loss) without running the shutdown flow:
|
||||
|
||||
1. Tmux session `codetap` may still be alive with running CLI instances
|
||||
2. On next server start, DB still has session records (SQLite persists)
|
||||
3. When hooks fire from surviving CLI instances → `resolveSessionId`:
|
||||
- Finds the session in DB by `cli_session` UUID
|
||||
- Restores the **original** internal ID from DB (e.g., `claude-1774210269126`)
|
||||
- Re-creates in-memory mapping
|
||||
- Session reappears in Active Sessions with its original ID
|
||||
|
||||
If the CLI session hasn't fired a SessionStart hook yet (e.g., Codex before first interaction):
|
||||
- Session stays in DB with `cli_session = NULL`
|
||||
- Once hook fires → DB record updated with CLI UUID
|
||||
- UI shows UUID after hook fires (brief grace period showing internal ID only)
|
||||
|
||||
### CLI Changes (`bin/codetap`)
|
||||
|
||||
**`codetap new [--adapter <name>]`**
|
||||
|
||||
```bash
|
||||
codetap new # WINDOW_NAME="claude-$(date +%s)", runs: claude
|
||||
codetap new --adapter codex # WINDOW_NAME="codex-$(date +%s)", runs: codex
|
||||
codetap new --adapter gemini # WINDOW_NAME="gemini-$(date +%s)", runs: gemini
|
||||
```
|
||||
|
||||
Default adapter: `claude`. The `--adapter` flag determines both the window name prefix and the CLI command to run.
|
||||
|
||||
**`codetap --resume <id>`**
|
||||
|
||||
```
|
||||
Input: internal ID or CLI UUID
|
||||
↓
|
||||
Is it internal ID format? ({adapter}-{digits})
|
||||
├─ Yes → extract adapter from prefix, query DB for CLI UUID
|
||||
│ ├─ Found → run: {adapter} --resume {uuid}
|
||||
│ └─ Not found → error
|
||||
└─ No (UUID format) → query DB by cli_session
|
||||
├─ Found → get adapter from DB → run: {adapter} --resume {uuid}
|
||||
└─ Not found → scan JSONL directories per adapter
|
||||
├─ Found → detected adapter → run: {adapter} --resume {uuid}
|
||||
└─ Not found → error: "Session not found"
|
||||
```
|
||||
|
||||
**`codetap --continue [--adapter <name>]`**
|
||||
|
||||
Pass through to adapter CLI's native continue command:
|
||||
- `claude --continue`
|
||||
- `codex resume --last`
|
||||
|
||||
Window name: `{adapter}-{timestamp}` (same format as `new`).
|
||||
|
||||
SessionStart hook handles the mapping automatically when the CLI starts — even if the continued session was never managed by CodeTap before.
|
||||
|
||||
Default adapter: `claude`. With `--adapter codex`, runs codex's native continue command instead.
|
||||
|
||||
**`codetap -a / -A`**
|
||||
|
||||
Enhanced display:
|
||||
|
||||
```
|
||||
Active sessions for code-tap:
|
||||
|
||||
1) claude-1774210269126
|
||||
UUID: 625c60d0-aedb-4e0b-b78e-c9fbf0405e67
|
||||
reply pong...
|
||||
|
||||
2) codex-1774210345678
|
||||
UUID: abc12345-xxxx-xxxx-xxxx-xxxxxxxxxxxx
|
||||
fix the login bug...
|
||||
|
||||
Select (1-2):
|
||||
```
|
||||
|
||||
### Files to Modify
|
||||
|
||||
**Server:**
|
||||
- `server/db.ts` — Schema migration, rename column, add `adapter` field, add `clearAll()`, remove session-map.json migration
|
||||
- `server/adapters/claude/tmux-adapter.ts` — Remove `desktop-` logic from `resolveSessionId`, change `session-` to `claude-` in `startSession`, add `session-start` handler, restore original ID on recovery
|
||||
- `server/adapters/claude/hook-config.ts` — Change SessionStart from `hookPath` script to `fireAndForget('session-start')`
|
||||
- `server/adapters/claude/index.ts` — Add `session-start` hook route
|
||||
- `server/adapters/codex/codex-tmux-adapter.ts` — Same pattern: `codex-` prefix, unified session-start handling
|
||||
- `server/adapters/codex/index.ts` — Add `session-start` hook route if missing
|
||||
- `server/adapters/interface.ts` — Add `adapter` field to `ActiveSessionInfo`
|
||||
- `server/session-manager.ts` — Pass `cliSessionId` in `SESSION_CREATED` message
|
||||
- `server/index.ts` — Call `dbSessions.clearAll()` in shutdown
|
||||
- `server/config.ts` — Remove `sessionMap` path config
|
||||
|
||||
**Client:**
|
||||
- `src/hooks/useChat.ts` — Store `cliSessionId` from `SESSION_CREATED`
|
||||
- `src/components/ChatView.tsx` — Header shows CLI UUID (primary) + internal ID (secondary)
|
||||
- `src/components/SessionsView.tsx` — No change (already shows `firstPrompt`)
|
||||
|
||||
**CLI:**
|
||||
- `bin/codetap` — Add `--adapter` flag, change window naming, update resume/continue logic, enhance `-a`/`-A` display
|
||||
- `bin/codetap-hook` — Delete (replaced by API POST)
|
||||
|
||||
### E2E Spec Updates (`tests/e2e-spec.feature`)
|
||||
|
||||
The following scenarios need to be updated to reflect the new session ID architecture:
|
||||
|
||||
1. **Chat header display** (L247): Update to show CLI UUID (primary) + internal ID (secondary) with copy icon
|
||||
2. **CLI `--adapter` flag** (L1168-1475): Add scenarios for `codetap new --adapter`, `codetap --continue --adapter`
|
||||
3. **Active sessions `-a`/`-A` display** (L1212): Update to show UUID + internal ID format
|
||||
4. **session-map.json references** (L1308): Remove; update to DB-based recovery
|
||||
5. **Session Deduplication regression** (L1829): Update to reflect Connect button fix (claudeSessionId → sessionId)
|
||||
6. **SessionStart hook**: Add scenario documenting API POST flow (replaces file-writing script)
|
||||
7. **tmux window naming** (L1176): Specify `{adapter}-{timestamp}` format
|
||||
8. **Non-graceful restart recovery** (L1308): Add scenario for restoring original ID from DB
|
||||
9. **Active session card UUID field** (L1548): Clarify where UUIDs appear (title vs expanded view)
|
||||
|
||||
### What Gets Removed
|
||||
|
||||
- `bin/codetap-hook` script
|
||||
- `session-map.json` mechanism (writing, reading, migration)
|
||||
- `desktop-` prefix logic in `resolveSessionId`
|
||||
- `is_active` column from sessions table
|
||||
- `sessionMap` path in config
|
||||
@@ -0,0 +1,120 @@
|
||||
# Codex UUID Discovery Fix + Session Architecture Cleanup
|
||||
|
||||
## Problems Found
|
||||
|
||||
### 1. Deadlock: `_waitForCliUUID` blocks `startSession` (Critical)
|
||||
|
||||
`startSession()` calls `_waitForCliUUID()` which polls for `session.cliSessionId` to be set. But the UUID is only set when `handleSessionStart` hook fires, which requires Codex to process a prompt. The prompt is sent AFTER `startSession` returns. Deadlock: 15-second timeout, session creation fails.
|
||||
|
||||
Affects both `handleQuery` (new Codex chat from Web UI) and `POST /api/reviews` (Cross-AI Review child session).
|
||||
|
||||
### 2. Pending Session Matching by Count (Medium)
|
||||
|
||||
`handleSessionStart` matches hook to pending session by checking `pendingSessions.length === 1`. If 0 pending: treated as desktop-started. If 2 pending: neither matches, hook creates a spurious session entry. This is a guess, not a precise match.
|
||||
|
||||
### 3. `_findAndAttachWindow` uses `command.includes('codex')` (Medium)
|
||||
|
||||
Grabs the first tmux window whose command contains `codex`. If multiple codex windows exist, picks the wrong one. After Session ID Unification, window names are UUIDs, so this method is both incorrect and unnecessary (see solution).
|
||||
|
||||
### 4. `_watchForTranscript` matches by recency (Low)
|
||||
|
||||
Scans the day directory for JSONL files modified within 120 seconds, picks the first match. If two Codex sessions start simultaneously, can pick the wrong file.
|
||||
|
||||
### 5. Server shutdown leaves tmux windows running (Resource waste)
|
||||
|
||||
`adapter.destroy()` cleans up monitors and watchers but does NOT kill tmux windows. After server stops, CLI processes continue running in tmux, consuming resources.
|
||||
|
||||
### 6. DB sessions table is unnecessary (Complexity)
|
||||
|
||||
The `sessions` DB table stores `id`, `cwd`, `window_id`, `adapter`. After the Session ID Unification, all runtime data is in the in-memory `sessions` Map. The DB was used for:
|
||||
- `_findAndAttachWindow` window recovery after restart: unnecessary if windows are killed on shutdown
|
||||
- `handleReconnect` cwd lookup for resumeSession: unnecessary if handleReconnect doesn't resume
|
||||
- Review endpoints cwd lookup: can use in-memory Map instead
|
||||
|
||||
## Solution
|
||||
|
||||
### A. Remove `_waitForCliUUID` entirely
|
||||
|
||||
`startSession()` returns the temp key immediately. UUID discovery happens asynchronously via `handleSessionStart` or `_watchForTranscript`.
|
||||
|
||||
### B. CODETAP_REF marker for precise matching
|
||||
|
||||
Every first message sent to a new Codex session includes a marker:
|
||||
|
||||
```
|
||||
[CODETAP_REF:codex-1774316492094]
|
||||
actual prompt or context here...
|
||||
```
|
||||
|
||||
Where `codex-1774316492094` is the temp key (tmux window name at creation time).
|
||||
|
||||
**Injection points:**
|
||||
- `handleQuery` in session-manager.ts: when creating a new session (no existing sessionId), prepend marker to the prompt
|
||||
- `POST /api/reviews` in index.ts: prepend marker to the context
|
||||
|
||||
**Matching in `handleSessionStart`:**
|
||||
1. Read the JSONL file at `body.transcript_path`
|
||||
2. Find the first user message
|
||||
3. Extract `CODETAP_REF:xxx` marker
|
||||
4. Match `xxx` to a pending session's temp key
|
||||
5. Call `_rekeyAndRename` to finalize
|
||||
|
||||
**Matching in `_watchForTranscript`:**
|
||||
- After finding a candidate JSONL file, verify it contains `CODETAP_REF:tempKey`
|
||||
|
||||
**Frontend filtering:**
|
||||
- Strip `[CODETAP_REF:...]` from user messages in `convertMessages` (useChat.ts)
|
||||
|
||||
### C. `_rekeyAndRename` — finalize UUID discovery
|
||||
|
||||
New method called when UUID is discovered (by handleSessionStart or _watchForTranscript):
|
||||
- Delete temp key from sessions Map
|
||||
- Set CLI UUID as new key
|
||||
- Rename tmux window from temp name to CLI UUID
|
||||
- Update monitor's sessionId
|
||||
|
||||
### D. Server shutdown kills all tmux windows
|
||||
|
||||
`adapter.destroy()` calls `tmuxManager.killSession()` to kill the entire codetap tmux session. No resource leaks.
|
||||
|
||||
### E. Remove `_findAndAttachWindow`
|
||||
|
||||
With shutdown killing all windows, no tmux windows survive restart. No need to rediscover windows. Delete the method and all call sites.
|
||||
|
||||
### F. Remove DB sessions table
|
||||
|
||||
The `sessions` table serves no purpose after changes D and G:
|
||||
- `_findAndAttachWindow` (deleted in E) was the main consumer
|
||||
- `handleReconnect` no longer calls `resumeSession` (changed in G)
|
||||
- Review endpoints get `cwd` from in-memory Map (changed in H)
|
||||
|
||||
Delete: CREATE TABLE, prepared statements, SessionRow interface, `sessions` export, all `dbSessions.*` calls across the codebase.
|
||||
|
||||
DB retains only `session_reviews` table (for Cross-AI Review).
|
||||
|
||||
### G. Simplify `handleReconnect`
|
||||
|
||||
Remove the `hasActiveWindow` + `resumeSession` block. After shutdown kills windows, there is no scenario where a session is not in the Map but has an active tmux window.
|
||||
|
||||
`handleReconnect` becomes: register client, load JSONL history, replay pending state. Building tmux windows is `handleQuery`'s job (when the user sends a message).
|
||||
|
||||
### H. Review endpoints get cwd from Map + add parent_adapter to session_reviews
|
||||
|
||||
Replace `dbSessions.get(parentCliSessionId)` with `adapter.getSession(parentCliSessionId)` to get `cwd` from the in-memory Map. The parent session is always active (user is interacting with it) so it is always in the Map.
|
||||
|
||||
Add `parent_adapter TEXT NOT NULL` column to `session_reviews` table. Store it when creating a review. This way `send-back` and `delete` endpoints can find the correct adapter directly from the review row, without needing to iterate all adapters or query the sessions DB.
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `server/adapters/codex/codex-tmux-adapter.ts` | A: remove _waitForCliUUID. B: add _matchByTranscriptMarker. C: add _rekeyAndRename. D: destroy calls killSession. E: remove _findAndAttachWindow. F: remove all dbSessions calls |
|
||||
| `server/adapters/claude/tmux-adapter.ts` | D: destroy calls killSession. F: remove all dbSessions calls |
|
||||
| `server/adapters/claude/index.ts` | No change (doesn't use dbSessions directly) |
|
||||
| `server/adapters/codex/index.ts` | No change |
|
||||
| `server/session-manager.ts` | B: inject marker in handleQuery. F: remove dbSessions import and calls. G: simplify handleReconnect. H: review restoration uses adapter.getSession for cwd |
|
||||
| `server/index.ts` | B: inject marker in POST /api/reviews. F: remove dbSessions import, clearAll call in shutdown. H: review endpoints use adapter.getSession for cwd |
|
||||
| `server/db.ts` | F: delete sessions table schema, SessionRow, prepared statements, sessions export. Keep session_reviews |
|
||||
| `src/lib/content-utils.ts` | B: add stripMarker function |
|
||||
| `src/hooks/useChat.ts` | B: strip marker in convertMessages |
|
||||
| `bin/codetap` | F: remove SQL queries that reference sessions table (get_project_sessions, -a listing, --resume lookup) |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Remaining Session Fixes
|
||||
|
||||
## Context
|
||||
|
||||
Items A, B, C, F, G, H, J, K, L, M were already implemented in earlier commits. The following 4 items remain. CLI internal `/resume` command handling is deferred (Codex doesn't support it, Claude's case is rare).
|
||||
|
||||
## D. handleSessionStart — remove pending matching, add _pendingHookBodies
|
||||
|
||||
**Current:** `handleSessionStart` has `pendingSessions.length === 1` guessing logic to match a hook to a pending session.
|
||||
|
||||
**Problem:** This fails with multiple pending sessions. The marker matching also can't work here because `SessionStart` hook fires at CLI startup, BEFORE the marker is pasted into the JSONL.
|
||||
|
||||
**Fix:** `handleSessionStart` does NOT match pending sessions:
|
||||
|
||||
```
|
||||
handleSessionStart(body):
|
||||
1. sessions.has(uuid) → already managed → update state → return
|
||||
2. has pending sessions → store hook body in _pendingHookBodies Map → return
|
||||
3. no pending sessions → ignore → return
|
||||
```
|
||||
|
||||
New `_pendingHookBodies: Map<string, CodexHookBody>` stores hook info (uuid, transcript_path, cwd). When `_watchForTranscript` later matches via marker and calls `_rekeyAndRename`, it reads `_pendingHookBodies.get(uuid)` to get the stored info.
|
||||
|
||||
**Cleanup:** `_pendingHookBodies` entries should be cleaned up after 60 seconds if unmatched (timer per entry, or sweep in `_startSessionCleanup`).
|
||||
|
||||
**Files:** `server/adapters/codex/codex-tmux-adapter.ts`
|
||||
|
||||
## E. Remove desktop-discovery from BOTH adapters
|
||||
|
||||
**Current:** Both adapters' `handleSessionStart` create session entries for unknown UUIDs.
|
||||
- Claude: searches for "unmanaged tmux window running claude" (`w.command.includes('claude')`)
|
||||
- Codex: creates entry and calls `_findAndAttachWindow` (already removed but fallback path remains)
|
||||
|
||||
**Why remove:** With server shutdown killing all tmux windows, and `bin/codetap` moving to API calls, there are no "desktop-started" sessions in the codetap tmux session. Every session should go through `startSession` or `resumeSession`.
|
||||
|
||||
**Fix:**
|
||||
- Claude `handleSessionStart`: remove the "find unmanaged tmux window" block. Keep only `sessions.has(uuid) → update → return`. Unknown UUIDs are ignored.
|
||||
- Codex `handleSessionStart`: the "desktop-started" branch becomes "ignore" (Task D already handles this).
|
||||
|
||||
**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts`
|
||||
|
||||
## I. New API endpoints for bin/codetap
|
||||
|
||||
**Current:** `bin/codetap` creates tmux windows directly, bypassing the server. Sessions it creates don't appear in the Map.
|
||||
|
||||
**Fix:** Add two REST endpoints:
|
||||
|
||||
```
|
||||
POST /api/sessions/start
|
||||
Body: { adapter, cwd, model?, permissionMode? }
|
||||
→ adapter.startSession(cwd, options)
|
||||
→ Returns: { sessionId }
|
||||
|
||||
POST /api/sessions/resume
|
||||
Body: { sessionId, adapter?, cwd }
|
||||
→ adapter.resumeSession(sessionId, cwd)
|
||||
→ Returns: { sessionId }
|
||||
```
|
||||
|
||||
Both require `authMiddleware`.
|
||||
|
||||
For `/resume`, if `adapter` is not provided, detect from JSONL file location:
|
||||
- `~/.claude/projects/.../{UUID}.jsonl` → claude
|
||||
- `~/.codex/sessions/.../*-{UUID}.jsonl` → codex
|
||||
|
||||
**Authentication for bin/codetap:** The script needs a token. It can get one via:
|
||||
```bash
|
||||
TOKEN=$(curl -sk -X POST https://localhost:$PORT/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"password\":\"$CLAUDE_UI_PASSWORD\"}")
|
||||
```
|
||||
|
||||
`CLAUDE_UI_PASSWORD` is already required as an env var.
|
||||
|
||||
**Files:** `server/index.ts`
|
||||
|
||||
## N. Update bin/codetap to use API endpoints
|
||||
|
||||
**Fix:**
|
||||
|
||||
- `bin/codetap new` → authenticate → `POST /api/sessions/start` → `tmux select-window`
|
||||
- `bin/codetap --resume UUID` → authenticate → `POST /api/sessions/resume` → `tmux select-window`
|
||||
- `bin/codetap --continue` → find most recent window from tmux → resume via API
|
||||
- `bin/codetap -a` → `tmux list-windows` directly (adapter detected from `pane_current_command`)
|
||||
- Remove ALL `sqlite3` references and `CODETAP_DB` variable
|
||||
|
||||
**Note for Codex sessions:** `POST /api/sessions/start` returns temp key (`codex-{timestamp}`). The script does `tmux select-window -t codetap:codex-{timestamp}`. The user is in the window. After rekey, the window name changes to UUID, but the user is unaffected (already inside).
|
||||
|
||||
**Files:** `bin/codetap`
|
||||
|
||||
## Files Affected
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `server/adapters/codex/codex-tmux-adapter.ts` | D: _pendingHookBodies + rewrite handleSessionStart |
|
||||
| `server/adapters/claude/tmux-adapter.ts` | E: remove desktop-discovery from handleSessionStart |
|
||||
| `server/index.ts` | I: add session start/resume endpoints |
|
||||
| `bin/codetap` | N: use API calls, remove sqlite3 |
|
||||
|
||||
## Not Included
|
||||
|
||||
- CLI internal `/resume` handling — Codex doesn't support it, Claude's case is rare and non-breaking
|
||||
- Shared `TmuxAdapterBase` class — deferred to future refactor
|
||||
- `childCliSessionId` removal from WS protocol — deferred (TODO in code)
|
||||
@@ -0,0 +1,303 @@
|
||||
# Session ID Unification — CLI UUID as Single Source of Truth
|
||||
|
||||
## Problem
|
||||
|
||||
The codebase has two session ID systems that create bugs and complexity:
|
||||
|
||||
1. **Internal ID** (format `claude-1774300056705` / `codex-1774300056705`): tmux window name, in-memory Map key, DB primary key
|
||||
2. **CLI UUID** (format `d6d56787-bfaf-4312-ae4d-99683ba45459`): permanent ID from the CLI tool, JSONL filename
|
||||
|
||||
These require constant translation via `resolveSessionId()` and `cliToSessionId` Maps. When translation fails:
|
||||
|
||||
- Mobile can not receive desktop events (registered under CLI UUID, events broadcast under internal ID)
|
||||
- handleReconnect creates unwanted tmux windows (lost hasActiveWindow guard)
|
||||
- SessionsView passes different ID types (project list = CLI UUID, active list = internal ID)
|
||||
- Latent bug: `_registerCliUUID()` references renamed column `claude_session` (should be `cli_session`)
|
||||
|
||||
## Solution
|
||||
|
||||
Eliminate internal ID as a session identifier. Use CLI UUID everywhere. Internal ID becomes just a tmux window display name with no programmatic significance.
|
||||
|
||||
## Design
|
||||
|
||||
### Layer 1: Adapter Internals
|
||||
|
||||
**Files:** `server/adapters/claude/tmux-adapter.ts`, `server/adapters/codex/codex-tmux-adapter.ts`, `server/adapters/codex/pane-monitor.ts`, `server/adapters/interface.ts`
|
||||
|
||||
**`this.sessions` Map key**: internal ID changes to CLI UUID.
|
||||
|
||||
**Eliminated entirely:**
|
||||
- `cliToSessionId: Map<string, string>` -- no translation needed
|
||||
- `resolveSessionId()` method -- no translation needed
|
||||
- `_registerCliUUID()` -- no mapping to maintain (also fixes `claude_session` column bug)
|
||||
- `_remapCliSession()` -- no remapping needed
|
||||
- `_removeCliMapping()` -- no mapping to clean up
|
||||
|
||||
**`startSession()` returns CLI UUID.** The tmux window name remains `{adapter}-{timestamp}` for tmux display but is not used as an identifier.
|
||||
|
||||
**`resumeSession()` / `attachSession()`** take CLI UUID as parameter.
|
||||
|
||||
**All event emits** use CLI UUID as first argument.
|
||||
|
||||
**`getActiveSessions()`** returns CLI UUID as `sessionId`. The `cliSessionId` field kept for compatibility (same value).
|
||||
|
||||
**`_findWindowForSession()`** looks up `window_id` from DB by CLI UUID instead of matching tmux window names.
|
||||
|
||||
**`handleSessionStart()` pattern matching**: `w.name.startsWith('claude-')` still works because tmux window names retain the `{adapter}-{timestamp}` format.
|
||||
|
||||
**Codex `startSession()` UUID timing**: Codex CLI generates its own UUID (arrives via SessionStart hook or JSONL filename). Add `_waitForCliUUID()` that waits for `session.cliSessionId` to be populated (max 15 seconds). Session initially stored under a temporary key (the window name), re-keyed once UUID is known.
|
||||
|
||||
During the temp-key window:
|
||||
- Hooks arriving from CLI use the `_watcherPending` scanning pattern (already exists) to find the session
|
||||
- Events are emitted under the temp key (few clients would be registered under it yet)
|
||||
- Once UUID arrives: session is re-keyed in the Map, DB is upserted, `sessionAdapterMap` updated
|
||||
|
||||
If `_waitForCliUUID` times out:
|
||||
- Kill the tmux window (`tmuxManager.killWindow`)
|
||||
- Remove the temp session from the Map
|
||||
- Throw error (propagates to client as WS error message)
|
||||
|
||||
**Codex `CodexPaneMonitor`**: stores `sessionId` for event emission -- changes from internal ID to CLI UUID.
|
||||
|
||||
### Layer 2: DB Schema
|
||||
|
||||
**Files:** `server/db.ts`
|
||||
|
||||
**`sessions` table migration:**
|
||||
|
||||
Before:
|
||||
- `id` (PRIMARY KEY) = internal ID
|
||||
- `cli_session` = CLI UUID
|
||||
|
||||
After:
|
||||
- `id` (PRIMARY KEY) = CLI UUID
|
||||
- `cli_session` column removed
|
||||
- `window_name` column added (stores old internal ID for tmux display/debug)
|
||||
|
||||
**`SessionRow` interface:**
|
||||
|
||||
```typescript
|
||||
export interface SessionRow {
|
||||
id: string; // CLI UUID (was internal ID)
|
||||
cwd: string;
|
||||
window_id: string | null; // tmux window ID (@N)
|
||||
window_name: string | null; // tmux window name for debug
|
||||
adapter: string;
|
||||
permission_mode: string;
|
||||
created_at: string;
|
||||
last_activity: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Prepared statements:**
|
||||
- `sessionsUpsert(id=cliUUID, cwd, windowId, windowName, adapter)` -- `id` is CLI UUID
|
||||
- `sessionsFindByCliSession` -- REMOVED (use primary key lookup)
|
||||
- `sessionsFindByWindowId` -- unchanged
|
||||
- `sessionsRemove(id)` -- `id` is now CLI UUID
|
||||
- Add `sessionsGet(id)` -- simple primary key lookup
|
||||
|
||||
**`session_reviews` table**: No changes (already uses CLI UUIDs).
|
||||
|
||||
**`session_stats` table**: Verify `session_id` uses CLI UUID. Migrate if needed.
|
||||
|
||||
**Migration SQL** (SQLite table rebuild pattern):
|
||||
|
||||
```sql
|
||||
-- Step 1: Create new table with new schema
|
||||
CREATE TABLE IF NOT EXISTS sessions_new (
|
||||
id TEXT PRIMARY KEY,
|
||||
cwd TEXT NOT NULL,
|
||||
window_id TEXT,
|
||||
window_name TEXT,
|
||||
adapter TEXT DEFAULT 'claude',
|
||||
permission_mode TEXT DEFAULT 'default',
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
last_activity TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Step 2: Copy data, swapping id and cli_session
|
||||
-- Skip rows where cli_session is empty, equals the internal ID, or matches {adapter}-{timestamp} pattern
|
||||
INSERT OR IGNORE INTO sessions_new (id, cwd, window_id, window_name, adapter, permission_mode, created_at, last_activity)
|
||||
SELECT
|
||||
CASE
|
||||
WHEN cli_session IS NOT NULL AND cli_session != '' AND cli_session != id THEN cli_session
|
||||
ELSE id
|
||||
END,
|
||||
cwd, window_id, id, adapter, permission_mode, created_at, last_activity
|
||||
FROM sessions;
|
||||
|
||||
-- Step 3: Drop old table and rename
|
||||
DROP TABLE sessions;
|
||||
ALTER TABLE sessions_new RENAME TO sessions;
|
||||
|
||||
-- Step 4: Recreate indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_window ON sessions(window_id);
|
||||
```
|
||||
|
||||
Migration is wrapped in a transaction and runs inside `initDB()` before any adapter initialization. Detection: check if the old `cli_session` column exists (`PRAGMA table_info(sessions)`).
|
||||
|
||||
**Handling rows where `cli_session` = internal ID**: Some Codex sessions store the internal ID as both `id` and `cli_session` (when UUID wasn't yet known). The migration uses `CASE WHEN cli_session != id THEN cli_session ELSE id END` — these rows keep their internal ID as `id`. They will be orphaned (no JSONL match) and cleaned up naturally by `clearAll()` on next shutdown.
|
||||
|
||||
**`session_stats` table**: This table exists in the schema but is never written to by any code in the codebase (no INSERT statements found). It can be left as-is or dropped. No migration needed.
|
||||
|
||||
### Layer 3: Session Manager
|
||||
|
||||
**Files:** `server/session-manager.ts`
|
||||
|
||||
**`sessionClients` Map key**: CLI UUID (was internal ID).
|
||||
**`sessionAdapterMap` Map key**: CLI UUID (was internal ID).
|
||||
|
||||
**`broadcast(sessionId, message)`**: `sessionId` is CLI UUID. This is the core fix -- adapter events emitted with CLI UUID now match client registration keys.
|
||||
|
||||
**`sendSessionCreated()`**: Sends single `sessionId` (CLI UUID). Remove `cliSessionId` field.
|
||||
|
||||
**`handleQuery()`**: No `resolveSessionId` call. `options.sessionId` from client is CLI UUID directly.
|
||||
|
||||
**`handleReconnect()`**: Greatly simplified. No `resolveSessionId` needed. All 11 existing steps preserved:
|
||||
|
||||
1. ~~Resolve internal ID via resolveSessionId~~ REMOVED (no translation needed)
|
||||
2. Register client under CLI UUID
|
||||
3. Clear push pending notifications (keyed by CLI UUID)
|
||||
4. Send SESSION_CREATED (single ID)
|
||||
5. Send cached status
|
||||
6. Resume if not in memory — **with `hasActiveWindow` guard**:
|
||||
```
|
||||
if session not in memory:
|
||||
if tmux window exists: resumeSession (attach to monitor events)
|
||||
else: do nothing (just load history from JSONL)
|
||||
```
|
||||
7. Sync watcher position
|
||||
8. Load JSONL history (`adapter.getMessages(sessionId)` — CLI UUID directly, no cliSessionId extraction needed)
|
||||
9. Send streaming state (SESSION_STATE)
|
||||
10. Replay pending tools and permissions
|
||||
11. Restore active child reviews (`sessionReviews.getActiveForParent(sessionId)` — CLI UUID directly)
|
||||
|
||||
Key simplification in step 11: no longer needs `findByCliSession` to look up parent CWD for child session resume — can use `sessions.get(sessionId)` primary key lookup directly (since `id` is now CLI UUID).
|
||||
|
||||
**`triggerPush()`**: Simplified — `sessionId` IS CLI UUID, so child review check uses `sessionId` directly (no need to look up `sessionObj.cliSessionId`). Single `getSession()` call replaces the current two calls with different casts. Uses CLI UUID for sessionClients lookup, push pending, and push payload.
|
||||
|
||||
**`session-ended` handler**: `sessionId` parameter IS CLI UUID. The entire convoluted lookup (`findByWindowId` fallback + `getAll().find()`) to extract `cli_session` is eliminated — `sessionId` is already the CLI UUID. Cascade cleanup calls `sessionReviews.getActiveForParent(sessionId)` directly. `sessionClients.delete` and `sessionAdapterMap.delete` remain synchronous (before any async cascade work).
|
||||
|
||||
**`server/index.ts` active sessions endpoint**: The dual lookup `getClientCount(s.sessionId) || getClientCount(s.cliSessionId)` simplifies to `getClientCount(s.sessionId)` since both are the same CLI UUID.
|
||||
|
||||
**`broadcastReviewStarted/Ended`**: `parentSessionId` is CLI UUID.
|
||||
|
||||
### Layer 4: Frontend
|
||||
|
||||
**Files:** `src/hooks/useChat.ts`, `src/lib/ws.ts`, `src/components/ChatView.tsx`, `src/components/SessionsView.tsx`, `src/components/FloatingReviewPanel.tsx`, `src/lib/api.ts`
|
||||
|
||||
**`useChat.ts`**: Merge `sessionId` and `cliSessionId` states into single `sessionId` (always CLI UUID). `SESSION_CREATED` handler sets one state. All outgoing WS messages (QUERY, ABORT, RECONNECT, SET_MODEL, SET_PERMISSION_MODE, PLAN_RESPONSE) send this single `sessionId`.
|
||||
|
||||
**`ws.ts`**: `activeSessionId` stores CLI UUID from `SESSION_CREATED`.
|
||||
|
||||
**`ChatView.tsx`**: Header shows single `sessionId` (CLI UUID). Review API calls use `sessionId` directly. Remove `cliSessionId` from ChatHeader props.
|
||||
|
||||
**`SessionsView.tsx`**: Both session lists (project + active) now use CLI UUID for `session.sessionId`. `onOpenChat(session.sessionId)` is consistent. `destroySession(session.sessionId)` passes CLI UUID. Push pending lookup `pending[session.sessionId]` uses CLI UUID.
|
||||
|
||||
**`FloatingReviewPanel.tsx`**: `childSessionId` prop is CLI UUID.
|
||||
|
||||
**`ActiveSessionInfo` interface**: `sessionId` becomes CLI UUID. `cliSessionId` kept as deprecated alias (same value).
|
||||
|
||||
### Layer 5: Push Notifications + Permissions
|
||||
|
||||
**Files:** `server/push.ts`, `server/permission-manager.ts`, `src/sw.ts`, `src/App.tsx`
|
||||
|
||||
**`push.ts`**: `pendingSessions` Map keyed by CLI UUID.
|
||||
|
||||
**`permission-manager.ts`**: `PendingPermission.sessionId` is CLI UUID. `sessionPendingIds` Map keyed by CLI UUID.
|
||||
|
||||
**`sw.ts`**: Push payload `sessionId` is CLI UUID. Notification click URL `/?session=${cliUUID}`.
|
||||
|
||||
**`App.tsx`**: URL parameter `?session=` is CLI UUID. Service worker `OPEN_SESSION` message carries CLI UUID.
|
||||
|
||||
### Layer 6: CLI (`bin/codetap`)
|
||||
|
||||
**File:** `bin/codetap`
|
||||
|
||||
DB queries updated to use new schema (no `cli_session` column, `id` is CLI UUID):
|
||||
|
||||
- Line 239 `get_project_sessions()`: Change `SELECT id FROM sessions` to `SELECT window_name FROM sessions` — the function returns IDs to match against tmux window names, which are `{adapter}-{timestamp}` format (stored in `window_name`, not `id`)
|
||||
- Line 290: Change `SELECT id, adapter, cli_session, cwd` to `SELECT id, adapter, window_name, cwd` — display CLI UUID as `id`, use `window_name` for tmux matching
|
||||
- Line 260: Match tmux window names against `window_name` column (not `id`)
|
||||
- Line 382 `--resume`: Simplified to `WHERE id='${SAFE_ID}'` since `id` is now CLI UUID
|
||||
- Window name generation unchanged (`{adapter}-{timestamp}`)
|
||||
|
||||
**Critical**: The `-a` listing mode joins tmux window names against DB session IDs. After migration, this join key changes from `id` to `window_name`. The `IN (...)` SQL clause must query `window_name` column.
|
||||
|
||||
## Codex UUID Discovery Flow
|
||||
|
||||
```
|
||||
1. handleQuery() -> adapter.startSession(cwd, options)
|
||||
2. Codex startSession():
|
||||
a. Generate window name "codex-{timestamp}"
|
||||
b. Create tmux window
|
||||
c. Wait for CLI ready (_waitForReady)
|
||||
d. Start _watchForTranscript() (sets up FSWatcher)
|
||||
e. NEW: _waitForCliUUID() -- wait for session.cliSessionId to be populated
|
||||
- Source 1: handleSessionStart hook fires with session_id
|
||||
- Source 2: _watchForTranscript detects JSONL file, extracts UUID from filename
|
||||
f. Once UUID known: set as Map key, upsert DB
|
||||
g. Return { sessionId: cliUUID }
|
||||
3. handleQuery() continues with cliUUID
|
||||
4. registerClient, sendSessionCreated, sendMessage -- all use cliUUID
|
||||
```
|
||||
|
||||
Timeout: 15 seconds. If UUID not discovered, startSession fails with error.
|
||||
|
||||
## Files Affected (Complete List)
|
||||
|
||||
| File | Change Type | Summary |
|
||||
|------|------------|---------|
|
||||
| `server/db.ts` | MODIFY | Schema migration, remove cli_session, add window_name, update SessionRow |
|
||||
| `server/session-manager.ts` | MODIFY | All Map keys to CLI UUID, simplify handleReconnect, fix triggerPush |
|
||||
| `server/adapters/claude/tmux-adapter.ts` | MODIFY | sessions Map key, eliminate cliToSessionId/resolveSessionId, events use CLI UUID |
|
||||
| `server/adapters/codex/codex-tmux-adapter.ts` | MODIFY | Same as Claude adapter, add _waitForCliUUID |
|
||||
| `server/adapters/codex/pane-monitor.ts` | MODIFY | sessionId is CLI UUID |
|
||||
| `server/adapters/interface.ts` | MODIFY | Remove resolveSessionId, update ActiveSessionInfo |
|
||||
| `server/adapters/claude/index.ts` | MODIFY | Remove resolveSessionId delegation |
|
||||
| `server/adapters/codex/index.ts` | MODIFY | Remove resolveSessionId delegation |
|
||||
| `server/push.ts` | MODIFY | pendingSessions keyed by CLI UUID |
|
||||
| `server/permission-manager.ts` | MODIFY | All session indices use CLI UUID |
|
||||
| `server/transport/client-connection.ts` | NO CHANGE | sessionId field semantics change (now CLI UUID) |
|
||||
| `server/index.ts` | MODIFY | Remove dual-ID lookups in active-sessions, simplify review endpoints |
|
||||
| `server/types/messages.ts` | MODIFY | QueryOptions.sessionId is CLI UUID |
|
||||
| `server/types/adapter.ts` | MODIFY | SessionInfo.sessionId is CLI UUID (already was) |
|
||||
| `server/ws-types.ts` | NO CHANGE | Just message type constants |
|
||||
| `src/hooks/useChat.ts` | MODIFY | Merge sessionId + cliSessionId into one |
|
||||
| `src/lib/ws.ts` | MODIFY | activeSessionId stores CLI UUID |
|
||||
| `src/lib/api.ts` | MODIFY | destroySession passes CLI UUID |
|
||||
| `src/components/ChatView.tsx` | MODIFY | Single ID in header, remove cliSessionId usage |
|
||||
| `src/components/SessionsView.tsx` | MODIFY | Consistent CLI UUID for both lists |
|
||||
| `src/components/FloatingReviewPanel.tsx` | MODIFY | childSessionId is CLI UUID |
|
||||
| `src/sw.ts` | MODIFY | Push sessionId is CLI UUID |
|
||||
| `src/App.tsx` | MODIFY | URL param and SW message use CLI UUID |
|
||||
| `bin/codetap` | MODIFY | DB queries use new schema |
|
||||
| `server/adapters/claude/jsonl-store.ts` | NO CHANGE | Already uses CLI UUID |
|
||||
| `server/adapters/codex/jsonl-store.ts` | NO CHANGE | Already uses CLI UUID |
|
||||
| `server/adapters/claude/pane-monitor.ts` | NO CHANGE | Uses windowId (tmux ID), not session ID |
|
||||
| `server/adapters/claude/hook-config.ts` | NO CHANGE | No session IDs |
|
||||
| `server/adapters/codex/hook-config.ts` | NO CHANGE | No session IDs |
|
||||
| `server/adapters/registry.ts` | NO CHANGE | No session IDs |
|
||||
| `server/config.ts` | NO CHANGE | No session IDs |
|
||||
| `server/stores/jsonl-watcher.ts` | NO CHANGE | File path based |
|
||||
| `src/hooks/useSessions.ts` | MODIFY | `s.cliSessionId` for green dots → `s.sessionId` (same value after unification) |
|
||||
| `tests/e2e-spec.feature` | MODIFY | Remove references to `resolveSessionId`, `cliSessionId` dual-ID |
|
||||
|
||||
## What This Fixes
|
||||
|
||||
1. Mobile receives desktop events in real-time (same broadcast key)
|
||||
2. handleReconnect does not create unwanted tmux windows (hasActiveWindow guard)
|
||||
3. SessionsView uses consistent IDs (both lists pass CLI UUID)
|
||||
4. No more resolveSessionId translation failures
|
||||
5. Fixes _registerCliUUID bug (references renamed column)
|
||||
6. Eliminates ~200 lines of translation/mapping code
|
||||
7. Push notifications navigate to correct session
|
||||
8. Active session client count lookup simplified (no dual-ID check)
|
||||
|
||||
## Scope Boundaries
|
||||
|
||||
NOT included in this refactor:
|
||||
- Changing tmux window names (they remain `{adapter}-{timestamp}` for display)
|
||||
- Changing JSONL file paths (already CLI UUID based)
|
||||
- Changing the Cross-AI Review feature (already uses CLI UUIDs)
|
||||
- Auto N-round debate or other deferred features
|
||||
@@ -0,0 +1,119 @@
|
||||
# Cross-AI Review Panel UX Fixes
|
||||
|
||||
## Context
|
||||
|
||||
E2E testing revealed several UX issues with Cross-AI Review: marker text leaking into UI, panel blocking parent interaction, and incomplete features (collapsed card onClick, read-only mode).
|
||||
|
||||
## Issues & Fixes
|
||||
|
||||
### A. Marker Bugs
|
||||
|
||||
**A1. Session List shows marker**
|
||||
`firstPrompt` in Codex adapter extracts raw text from JSONL without stripping `[CODETAP_REF:xxx]`. Session list displays it.
|
||||
|
||||
Fix: Strip marker when setting `firstPrompt` in Codex adapter's `_processWatcherEntries`.
|
||||
|
||||
**A2. Marker trailing `\\n` residue**
|
||||
`handleQuery` injects `[CODETAP_REF:xxx]\n{prompt}`. Codex `sendMessage` replaces `\n` → `\\n`. JSONL stores `[CODETAP_REF:xxx]\\nHello`. `stripMarker` regex `\n?` matches real newline but not literal `\\n`.
|
||||
|
||||
Fix: Update `stripMarker` regex to `^\[CODETAP_REF:[^\]]+\](?:\\\\n|\n)?` — matches both real newline and literal `\\n`.
|
||||
|
||||
**Files:** `server/adapters/codex/codex-tmux-adapter.ts`, `src/lib/content-utils.ts`
|
||||
|
||||
### B. Panel Minimize / Expand UX
|
||||
|
||||
**B1. Minimized state: thin bar above input**
|
||||
When minimized, show a full-width bar between the message area and parent input:
|
||||
- Left: pulsing green dot + adapter badge ("Codex") + status ("review in progress · 3 messages")
|
||||
- Right: ▲ Expand button + End button
|
||||
- Bar has subtle green top border
|
||||
|
||||
**B2. Expanded state: clear minimize button**
|
||||
Panel header gets a ▼ Minimize icon button (in addition to handle bar). Header shows: adapter badge + review title + ▼ Minimize + End.
|
||||
|
||||
**B3. Input distinction**
|
||||
When panel is expanded, the child input shows:
|
||||
- Adapter badge (small Codex icon) to the left of the input
|
||||
- Placeholder: "Reply to Codex review..." (not generic "Send a message...")
|
||||
- Panel has green top border separating it from parent chat
|
||||
|
||||
**B4. Panel covers parent input**
|
||||
When expanded, parent input is hidden (covered by panel). Only child input visible. This is intentional — user must minimize to chat with parent.
|
||||
|
||||
**Files:** `src/components/FloatingReviewPanel.tsx`, `src/components/ChatBody.tsx` (placeholder prop)
|
||||
|
||||
### C. Review History Markers
|
||||
|
||||
**C1. Start/End markers wrap all content**
|
||||
Review Start and End markers appear in parent chat history. Everything between them (including parent messages exchanged while review was minimized) is wrapped. This shows "review was happening during this time period."
|
||||
|
||||
```
|
||||
[parent message]
|
||||
──── Codex review started ────
|
||||
[collapsed review card: "5 messages · tap to view"]
|
||||
[parent message during review]
|
||||
[parent message during review]
|
||||
──── Codex review ended ────
|
||||
[parent message after review]
|
||||
```
|
||||
|
||||
**C2. CollapsedReviewCard onClick → read-only panel**
|
||||
Currently onClick is a TODO. Implement: clicking opens FloatingReviewPanel in read-only mode with child session's history via RECONNECT.
|
||||
|
||||
Card receives `childSessionId` from the review record. Click sets a `readOnlyReview` state in ChatView → mounts FloatingReviewPanel with `readOnly` flag.
|
||||
|
||||
**C3. Read-only panel**
|
||||
Same layout as active panel but:
|
||||
- Gray header (not green) — "Codex | code review · ended"
|
||||
- ✕ Close button (not End)
|
||||
- No input — bottom shows "Review ended — read only"
|
||||
- Messages loaded via RECONNECT + HISTORY_LOAD
|
||||
|
||||
**Files:** `src/components/CollapsedReviewCard.tsx`, `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx`
|
||||
|
||||
### D. Send-back Button Missing in Child Panel
|
||||
|
||||
**Problem:** Child session's assistant responses should show a ↩ send-back icon, but it's not visible. After the ChatBody refactor, `onSendBack` is passed from FloatingReviewPanel → ChatBody → MessageBubble. But `showActions` may not be correctly evaluated, or the prop chain is broken.
|
||||
|
||||
**Fix:** Verify and fix the prop chain:
|
||||
1. FloatingReviewPanel passes `onSendBack` to ChatBody ✓ (confirmed in code)
|
||||
2. ChatBody passes `onSendBack` to MessageBubble — check `showActions` logic
|
||||
3. MessageBubble renders ↩ icon when `onSendBack` is provided and `showActions` is true
|
||||
|
||||
If `showActions` is computed inside ChatBody (not passed as prop), verify it evaluates to `true` for assistant messages when not streaming.
|
||||
|
||||
**Files:** `src/components/ChatBody.tsx`, `src/components/MessageBubble.tsx`
|
||||
|
||||
### E. Message Action Icons Polish
|
||||
|
||||
**E1. Icons too large / too bold / have border**
|
||||
Current icon buttons have `border border-border rounded-md` (visible outline box), `w-7 h-7` (28px), and SVG `strokeWidth="2"`.
|
||||
|
||||
Fix:
|
||||
- Remove `border` from button — no outline box, just the icon
|
||||
- Reduce button size from `w-7 h-7` to `w-6 h-6` (24px)
|
||||
- Reduce SVG from `width/height="14"` to `"12"`
|
||||
- Reduce SVG `strokeWidth` from `"2"` to `"1.5"`
|
||||
- Keep hover background (`hover:bg-white/5`) for touch feedback
|
||||
|
||||
**E2. Copy feedback — checkmark confirmation**
|
||||
Copy icon should show a ✓ checkmark for ~2 seconds after clicking, then revert to the copy icon. Confirms the clipboard action succeeded.
|
||||
|
||||
Implementation: `useState` for `copied` state, `setTimeout` to reset after 2s.
|
||||
|
||||
**Files:** `src/components/MessageBubble.tsx`
|
||||
|
||||
### F. Adapter Icons — Use SVGs from thesvg.org
|
||||
|
||||
Current `AdapterIcon.tsx` has hand-drawn SVG paths for Claude (Anthropic "A") and Codex (OpenAI knot). Replace with official SVGs from https://www.thesvg.org/ for better accuracy.
|
||||
|
||||
- Search for "Anthropic" / "Claude" → get official Anthropic logo SVG
|
||||
- Search for "OpenAI" / "Codex" → get official OpenAI logo SVG
|
||||
- Update `ClaudeIcon` and `CodexIcon` components in `src/components/AdapterIcon.tsx`
|
||||
- Keep the same `size` prop interface and `fill="currentColor"` for color control
|
||||
|
||||
**Files:** `src/components/AdapterIcon.tsx`
|
||||
|
||||
## Not Changed
|
||||
- Review session creation flow (already unified via QUERY in previous spec)
|
||||
- Server-side review lifecycle
|
||||
@@ -0,0 +1,105 @@
|
||||
# Review State Separation + Session List Cleanup
|
||||
|
||||
## Context
|
||||
|
||||
Three issues to fix together:
|
||||
|
||||
1. **activeReview / historyReview state conflict** — Viewing a historical review overwrites the active review state, losing its panel
|
||||
2. **Session list shows CODETAP_REF marker** — firstPrompt not stripped (screenshot confirms markers visible in session list)
|
||||
3. **Child sessions visible in session list** — review child sessions should not appear in project session list or active sessions
|
||||
|
||||
## A. Separate activeReview and historyReview States
|
||||
|
||||
### Problem
|
||||
`activeReview` state serves double duty (active + read-only viewing). Viewing history replaces active review.
|
||||
|
||||
### Design
|
||||
|
||||
**State model:**
|
||||
```typescript
|
||||
activeReview: ReviewInfo | null // ongoing active review
|
||||
historyReview: ReviewInfo | null // historical review being viewed (read-only)
|
||||
activeReviewPanel: 'expanded' | 'minimized' // renamed from reviewPanelState
|
||||
```
|
||||
|
||||
**Remove:** `readOnlyReview: boolean` — replaced by `historyReview !== null`
|
||||
|
||||
**Panel display (mutual exclusion — only one panel at a time):**
|
||||
```
|
||||
historyReview !== null → read-only panel
|
||||
activeReview && activeReviewPanel === 'expanded' → active panel
|
||||
otherwise → no panel
|
||||
```
|
||||
|
||||
**Minimized bar shows when:**
|
||||
```
|
||||
activeReview !== null AND (activeReviewPanel === 'minimized' OR historyReview !== null)
|
||||
```
|
||||
|
||||
**Interactions:**
|
||||
|
||||
| Action | Effect |
|
||||
|--------|--------|
|
||||
| Click collapsed card (history) | `setHistoryReview(review)` + `setActiveReviewPanel('minimized')` |
|
||||
| ✕ Close history panel | `setHistoryReview(null)` |
|
||||
| ▲ Expand minimized bar | `setHistoryReview(null)` + `setActiveReviewPanel('expanded')` |
|
||||
| ▼ Minimize active | `setActiveReviewPanel('minimized')` |
|
||||
| End active review | `setActiveReview(null)` + `setHistoryReview(null)` |
|
||||
| Start new review | `setActiveReview(...)` + `setActiveReviewPanel('expanded')` + `setHistoryReview(null)` |
|
||||
|
||||
**FloatingReviewPanel receives:**
|
||||
```typescript
|
||||
const panelReview = historyReview || (activeReviewPanel === 'expanded' ? activeReview : null);
|
||||
const isReadOnly = !!historyReview;
|
||||
|
||||
{panelReview && (
|
||||
<FloatingReviewPanel
|
||||
review={panelReview}
|
||||
readOnly={isReadOnly}
|
||||
onEnd={isReadOnly ? () => setHistoryReview(null) : closeReview}
|
||||
...
|
||||
/>
|
||||
)}
|
||||
```
|
||||
|
||||
**Files:** `src/hooks/useChat.ts`, `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx`
|
||||
|
||||
## B. Session List Marker Strip
|
||||
|
||||
### Problem
|
||||
Screenshot shows `[CODETAP_REF:codex-1774412730686]\nHi` in session list. The earlier fix (strip marker in `firstPrompt`) may not have been applied in all code paths, or the sessions were created before the fix.
|
||||
|
||||
### Design
|
||||
|
||||
Marker stripping is Codex-specific behavior (Codex's `sendMessage` does `\n` → `\\n` replacement). Fix in the Codex adapter only — not client-side.
|
||||
|
||||
**Two Codex-side locations to strip:**
|
||||
|
||||
1. **`codex/jsonl-store.ts` `getSessions()` line 204** — `firstPrompt` from `history.jsonl` entry. This is the session list source for ALL sessions (including historical). Strip `[CODETAP_REF:...](\\n|\n)?` from `entry.text` before slicing.
|
||||
|
||||
2. **`codex/codex-tmux-adapter.ts` `_processWatcherEntries()`** — `firstPrompt` for active sessions (already fixed in earlier commit, but verify it covers all paths).
|
||||
|
||||
**Files:** `server/adapters/codex/jsonl-store.ts`, `server/adapters/codex/codex-tmux-adapter.ts`
|
||||
|
||||
## C. Hide Child Sessions from Session List
|
||||
|
||||
### Problem
|
||||
Cross-AI Review child sessions appear in the project session list and active sessions list. They should be hidden — they're child sessions owned by a parent.
|
||||
|
||||
### Design
|
||||
|
||||
**Server-side filtering:** When returning sessions (both project sessions and active sessions), exclude sessions whose ID appears as `child_cli_session_id` in the `session_reviews` table.
|
||||
|
||||
- `GET /api/sessions/:dir` — filter out child session IDs
|
||||
- Active sessions list — filter out child session IDs from `getActiveSessions()`
|
||||
|
||||
**How to identify child sessions:**
|
||||
- `sessionReviews.getAllChildIds()` already exists (returns Set of child CLI session IDs)
|
||||
- Use this to filter in both endpoints
|
||||
|
||||
**Files:** `server/index.ts` (session endpoints), `server/db.ts` (getAllChildIds)
|
||||
|
||||
## Not Changed
|
||||
- Review creation flow (already unified via QUERY)
|
||||
- Send-back mechanism
|
||||
- FloatingReviewPanel component structure (still uses ChatBody)
|
||||
@@ -0,0 +1,126 @@
|
||||
# Unified Session Creation Path for Cross-AI Review
|
||||
|
||||
## Context
|
||||
|
||||
Cross-AI Review child sessions currently use a different creation path than normal sessions:
|
||||
|
||||
- **Normal session**: WebUI sends WS `QUERY` → `handleQuery` → `startSession` + `registerClient` + `sendMessage` — all in one handler, atomically.
|
||||
- **Review child**: HTTP `POST /api/reviews` → `startSession` + `pasteToSession` on server → broadcast `REVIEW_STARTED` → FloatingReviewPanel mounts → `useChat` sends WS `RECONNECT` → `registerClient` — split across HTTP and WS.
|
||||
|
||||
This split causes race conditions (rekey happens before WS client connects) and requires defensive mechanisms (`rekeyAliases`, `session-rekeyed` event forwarding) that wouldn't be needed if both paths were the same.
|
||||
|
||||
**Insight**: A review child session IS a normal new session. The only difference is the first message is review context instead of user-typed text. It should go through the same QUERY flow.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Codex `sendMessage` — auto-handle large/multiline content
|
||||
|
||||
Currently Codex adapter has two methods:
|
||||
- `sendMessage` — uses `sendKeys` (character-by-character, doesn't handle newlines)
|
||||
- `pasteToSession` — uses `pasteBuffer` (bulk paste, replaces `\n` with `\\n`)
|
||||
|
||||
Review context is large (30KB+) and multiline. If it goes through `sendMessage` via QUERY, `sendKeys` would be extremely slow and newlines would be treated as separate message submissions.
|
||||
|
||||
**Fix**: Make `sendMessage` auto-detect and use `pasteBuffer` for large/multiline content. Transparent to all callers.
|
||||
|
||||
**Important**: Fresh Codex sessions have TUI placeholder text (e.g., "Use /skills to list available skills"). Pasting via `pasteBuffer` appends to the placeholder, truncating the first ~20 chars. The existing fix (from this session) splits the paste: send the `[CODETAP_REF:...]` marker via `sendKeys` first (triggers TUI to clear placeholder), wait 200ms, then `pasteBuffer` the rest. The unified `sendMessage` must preserve this behavior.
|
||||
|
||||
```
|
||||
sendMessage(sessionId, text):
|
||||
if text.length > 500 || text.includes('\n'):
|
||||
singleLine = text.replace(/\n/g, '\\n')
|
||||
// Check for CODETAP_REF marker at start (fresh session with placeholder)
|
||||
markerMatch = singleLine.match(/^\[CODETAP_REF:[^\]]+\]/)
|
||||
if markerMatch:
|
||||
sendKeys(marker) // clears TUI placeholder
|
||||
wait 200ms
|
||||
pasteBuffer(rest) // fast, placeholder already cleared
|
||||
else:
|
||||
pasteBuffer(singleLine) // existing session, no placeholder issue
|
||||
wait 300ms
|
||||
sendControl('Enter')
|
||||
else:
|
||||
sendKeys(text) // character-by-character, fine for short text
|
||||
wait 200ms
|
||||
sendControl('Enter')
|
||||
```
|
||||
|
||||
This merges `sendMessage` and `pasteToSession` into one method that handles all cases.
|
||||
|
||||
**Files**: `server/adapters/codex/codex-tmux-adapter.ts`
|
||||
|
||||
### 2. Frontend — review child uses QUERY, not RECONNECT
|
||||
|
||||
**Current flow**:
|
||||
```
|
||||
POST /api/reviews → server creates session + DB record → broadcast REVIEW_STARTED
|
||||
→ parent useChat sets activeReview → FloatingReviewPanel mounts
|
||||
→ useChat(childSessionId) → WS RECONNECT → handleReconnect
|
||||
```
|
||||
|
||||
**New flow**:
|
||||
```
|
||||
User clicks "Send to Codex" → selects template
|
||||
→ ChatView locally sets activeReview state (no server call)
|
||||
→ FloatingReviewPanel mounts with { context, targetAdapter, cwd }
|
||||
→ FloatingReviewPanel's useChat auto-sends context as first WS QUERY
|
||||
→ handleQuery → startSession → registerClient → sendMessage (same as normal!)
|
||||
→ SESSION_CREATED received → useChat has childSessionId
|
||||
→ POST /api/reviews { parentSessionId, childSessionId, ... } → DB record created
|
||||
```
|
||||
|
||||
Key changes:
|
||||
- `ChatView.handleReviewSelect`: instead of calling `api.createReview()`, locally mount FloatingReviewPanel with review props
|
||||
- `FloatingReviewPanel`: receives `initialPrompt` prop, useChat auto-sends it as first QUERY
|
||||
- After `SESSION_CREATED`, call `api.registerReview()` to persist the DB record
|
||||
|
||||
**Files**: `src/components/ChatView.tsx`, `src/components/FloatingReviewPanel.tsx`, `src/hooks/useChat.ts`, `src/lib/api.ts`
|
||||
|
||||
### 3. Server — POST /api/reviews simplified
|
||||
|
||||
From:
|
||||
- `adapter.startSession(cwd)` — REMOVE
|
||||
- `adapter.pasteToSession(childSessionId, markerContext)` — REMOVE
|
||||
- `sessionReviews.create(...)` — KEEP
|
||||
- `broadcastReviewStarted(...)` — KEEP (for multi-device sync)
|
||||
- Returns `{ reviewId, childSessionId }` — childSessionId now comes from client
|
||||
|
||||
To:
|
||||
```
|
||||
POST /api/reviews (renamed or new endpoint: POST /api/reviews/register)
|
||||
Body: { parentSessionId, childSessionId, targetAdapter, anchorMessageId, prompt, title }
|
||||
→ sessionReviews.create(...)
|
||||
→ broadcastReviewStarted(parentSessionId, { reviewId, childSessionId, ... })
|
||||
→ Returns { reviewId }
|
||||
```
|
||||
|
||||
**Files**: `server/index.ts`
|
||||
|
||||
### 4. CODETAP_REF marker — already handled
|
||||
|
||||
`handleQuery` in `session-manager.ts` already injects `[CODETAP_REF:tempKey]` for non-Claude new sessions. No change needed — the marker injection works naturally through the QUERY flow.
|
||||
|
||||
### 5. `pasteToSession` — can be removed from Codex adapter public API
|
||||
|
||||
After `sendMessage` handles all content sizes, `pasteToSession` is no longer needed as a separate public method. It can be:
|
||||
- Removed from the adapter interface
|
||||
- Or kept as internal helper called by `sendMessage`
|
||||
|
||||
The only remaining caller is `POST /api/reviews/:id/send-back` (sends feedback to parent). This also goes through `sendMessage` if we update it.
|
||||
|
||||
**Files**: `server/adapters/codex/codex-tmux-adapter.ts`, `server/adapters/codex/index.ts`, `server/adapters/interface.ts`
|
||||
|
||||
## Not Changed
|
||||
|
||||
- `POST /api/reviews/:id/send-back` — still HTTP (different concern: sending message to an existing session)
|
||||
- `POST /api/reviews/:id/end` — still HTTP
|
||||
- `rekeyAliases` — kept as defensive mechanism (handleQuery's registerClient vs hook timing)
|
||||
- `session-rekeyed` forwarding — kept (still needed for handleQuery flow)
|
||||
|
||||
## Verification
|
||||
|
||||
1. New Codex session from WebUI — send message, verify response appears
|
||||
2. Cross-AI Review: click "Send to Codex" → panel opens → Codex responds in panel (same QUERY flow)
|
||||
3. Send back to parent — verify message appears
|
||||
4. End review — verify markers appear
|
||||
5. Reconnect — verify active review restored
|
||||
@@ -0,0 +1,130 @@
|
||||
# Cross-AI Review v2 — Multi-Review, Marker Position, Send-To UX
|
||||
|
||||
**Date**: 2026-03-26
|
||||
**Status**: Approved
|
||||
|
||||
## Problem
|
||||
|
||||
Three issues with the current cross-AI review system:
|
||||
|
||||
1. **"Review ended" marker position** — rendered at the anchor message (where "Send to" was clicked), not at the bottom of the chat where the user actually pressed End. Misleading timeline.
|
||||
2. **"Send to" ignores active reviews** — always opens the full adapter/model selection flow, even when there's already an active review that should receive the message.
|
||||
3. **Single active review limit** — frontend state (`activeReview`) is a single object. Cannot run multiple reviews simultaneously (e.g., send one message to Codex and another to Claude).
|
||||
|
||||
Additionally: **textarea placeholder 16px override** — global CSS `input, textarea, select { font-size: 16px }` (iOS zoom prevention) overrides Tailwind `text-sm` in the review panel, making the placeholder disproportionately large.
|
||||
|
||||
## Design
|
||||
|
||||
### 1. Review Ended Marker Position
|
||||
|
||||
**Current**: "started", "in progress", and "ended" markers are all rendered by `renderReviewMarkers`, keyed by `anchor_message_id`. They all appear after the anchor message.
|
||||
|
||||
**New**: Split markers into two locations:
|
||||
- **"started" + CollapsedReviewCard** — at anchor message (shows where the review was initiated; card lets user tap to view the review conversation)
|
||||
- **"in progress"** — at anchor message (only for active reviews, replaces CollapsedReviewCard)
|
||||
- **"ended"** — rendered after the last message in parent chat at the time End was pressed (shows where in the timeline the review concluded)
|
||||
|
||||
**Implementation**:
|
||||
- Add `end_anchor_message_id TEXT` column to `session_reviews` table
|
||||
- When `endReview()` is called, set `end_anchor_message_id` to the ID of the last message currently in the parent session's message history
|
||||
- Server: `GET /api/reviews` response already returns all review columns — no API change needed
|
||||
- Frontend: `renderReviewMarkers` uses two maps:
|
||||
- `startMarkersByAnchor` — keyed by `anchor_message_id`:
|
||||
- Active review: "started" marker + "in progress" marker
|
||||
- Ended review: "started" marker + CollapsedReviewCard (tap to view)
|
||||
- `endMarkersByAnchor` — keyed by `end_anchor_message_id`:
|
||||
- Ended review only: "Review ended" marker
|
||||
|
||||
### 2. Send-To with Active Review
|
||||
|
||||
**Current**: Clicking "↗ Send to" always sets `reviewMenuMessageId` → opens `ReviewActionMenu` bottom sheet → full adapter/model flow.
|
||||
|
||||
**New**: Two paths based on whether active reviews exist:
|
||||
|
||||
**Path A — No active reviews**: Same as current. Full adapter/model selection → create new child session.
|
||||
|
||||
**Path B — Active review(s) exist**: Show a simplified bottom sheet with options:
|
||||
- One button per active review: **"Send to {Adapter} review"** (with adapter badge + color). Clicking sends the message text directly to that child session as a new prompt.
|
||||
- A divider line
|
||||
- **"Start new review..."** button at the bottom → opens the current full flow
|
||||
|
||||
**Sending to existing review**:
|
||||
- Extract text from the clicked message
|
||||
- Call `childChat.sendMessage(text)` on the corresponding review's `useChat` instance
|
||||
- Auto-expand and switch tab to that review's tab
|
||||
- No new DB row, no new session — just a follow-up message in the existing child session
|
||||
|
||||
### 3. Multi-Review UI (Design D)
|
||||
|
||||
**State change**: `activeReview` (single object) → `activeReviews` (array of review objects). Each entry has: `{ reviewId, childSessionId, childCliSessionId, childAdapter, anchorMessageId, reviewTitle }`.
|
||||
|
||||
**Minimized state** (all reviews collapsed):
|
||||
- Single compact bar above the input: colored dots for each review + "{N} reviews: Codex · Claude" + "▲ Expand"
|
||||
- Clicking the bar expands to the tabbed panel
|
||||
|
||||
**Expanded state** (panel visible):
|
||||
- 50% height bottom panel with:
|
||||
- **Handle bar** at top (drag/click to minimize)
|
||||
- **Tab bar**: one tab per active review, each showing adapter color dot + name. Active tab underlined with adapter color. Each tab has ✕ to end that review. Right side has ▼ minimize button.
|
||||
- **Chat area**: messages for the focused tab's child session
|
||||
- **Input**: "Reply to {Adapter} review..." placeholder
|
||||
|
||||
**Single review special case**: When only 1 active review, show header (badge + title + ▼ + End) instead of tab bar. Same as current design.
|
||||
|
||||
**Each tab is an independent `useChat` hook**. The `FloatingReviewPanel` component manages an array of child chat instances, renders only the active tab's messages, but keeps all hooks alive for background message receipt.
|
||||
|
||||
**Tab lifecycle**:
|
||||
- New review → push to `activeReviews`, add tab, auto-focus it
|
||||
- End review (✕ or "End" button) → call `api.endReview(reviewId)`, remove from `activeReviews`, focus adjacent tab
|
||||
- All reviews ended → panel disappears, minimized bar disappears
|
||||
|
||||
### 4. Placeholder Font Size Fix
|
||||
|
||||
**Root cause**: `src/index.css` line 83: `input, textarea, select { font-size: 16px }` overrides `text-sm` (14px).
|
||||
|
||||
**Fix**: Keep the 16px rule for iOS zoom prevention, but add a specific override for the review panel textarea:
|
||||
|
||||
```css
|
||||
.review-panel-input textarea { font-size: 14px !important; }
|
||||
```
|
||||
|
||||
Or use Tailwind's `!text-sm` on the textarea in FloatingReviewPanel. The main chat input stays at 16px (looks fine at full width); only the cramped review panel gets the smaller size.
|
||||
|
||||
## Data Flow Changes
|
||||
|
||||
### End Review (updated)
|
||||
|
||||
```
|
||||
User taps "End" on tab / End button
|
||||
→ Frontend: get last message ID from parent chat messages array
|
||||
→ api.endReview(reviewId, { endAnchorMessageId: lastMsgId })
|
||||
→ Server: UPDATE session_reviews SET ended_at=NOW(), end_anchor_message_id=?
|
||||
→ Server: broadcast WS REVIEW_ENDED { reviewId }
|
||||
→ Server: destroySession(childCliSessionId)
|
||||
→ Frontend: remove from activeReviews array
|
||||
→ Frontend: reviews re-fetched → endMarkersByAnchor updated
|
||||
→ "ended" marker + CollapsedReviewCard appear after the correct message
|
||||
```
|
||||
|
||||
### Send-To Existing Review
|
||||
|
||||
```
|
||||
User taps "↗ Send to" on assistant message (with active reviews present)
|
||||
→ Simplified bottom sheet: [Send to Codex review] [Send to Claude review] [Start new...]
|
||||
→ User taps "Send to Codex review"
|
||||
→ Extract text from the anchor message
|
||||
→ Find the Codex review's useChat sendMessage function
|
||||
→ sendMessage(text) → message sent to child session
|
||||
→ Auto-expand panel, switch to Codex tab
|
||||
```
|
||||
|
||||
## Migration
|
||||
|
||||
- New column `end_anchor_message_id` on `session_reviews`: nullable, no migration needed for existing rows (they will show "ended" at anchor position as fallback)
|
||||
|
||||
## Scope Exclusions
|
||||
|
||||
- Drag-to-reorder tabs: not needed
|
||||
- Resize panel height: not needed (fixed 50%)
|
||||
- Review notifications/badges on minimized bar: nice-to-have, not in v2
|
||||
- Persist expanded/minimized state across page refreshes: not needed
|
||||
@@ -0,0 +1,421 @@
|
||||
# Gemini CLI Adapter Design
|
||||
|
||||
**Date:** 2026-03-26
|
||||
**Status:** Draft
|
||||
**Approach:** B — Shared layer extraction + Gemini adapter
|
||||
|
||||
## Overview
|
||||
|
||||
Add a third adapter to code-tap for Google's Gemini CLI (v0.34.0+), providing full bidirectional control from the mobile PWA — identical feature parity with the existing Claude and Codex adapters.
|
||||
|
||||
## Scope
|
||||
|
||||
- Full Gemini adapter: tmux session management, prompt sending, streaming, tool tracking, permission approval, thinking display, model/permission mode switching
|
||||
- New `JsonWatcher` for Gemini's single-JSON session format
|
||||
- Bridge script for Gemini's stdin/stdout hook protocol
|
||||
- Shared layer: move `tmux-manager.ts` to `server/adapters/shared/`
|
||||
- CLI, registry, and frontend integration
|
||||
|
||||
## Research Findings
|
||||
|
||||
### Gemini CLI Architecture
|
||||
|
||||
| Aspect | Detail |
|
||||
|---|---|
|
||||
| **Version** | 0.34.0 |
|
||||
| **Config dir** | `~/.gemini/` |
|
||||
| **Settings** | `~/.gemini/settings.json` |
|
||||
| **Session files** | `~/.gemini/tmp/<project-name>/chats/session-*.json` (single JSON, not JSONL) |
|
||||
| **Project mapping** | `~/.gemini/projects.json` maps abs paths to project names |
|
||||
| **Project root** | `~/.gemini/tmp/<project-name>/.project_root` contains abs path |
|
||||
| **Hook protocol** | stdin/stdout JSON (not HTTP like Claude) |
|
||||
| **Hook events** | BeforeTool, AfterTool, BeforeAgent, AfterAgent, SessionStart, SessionEnd, + more |
|
||||
| **Models** | auto, pro (2.5 Pro), flash (2.5 Flash), flash-lite |
|
||||
| **Permission modes** | default, auto_edit, yolo, plan |
|
||||
| **Resume** | `gemini --resume <id-or-index>` |
|
||||
| **GEMINI.md** | Yes, analogous to CLAUDE.md |
|
||||
|
||||
### Session File Format (JSON, not JSONL)
|
||||
|
||||
```json
|
||||
{
|
||||
"sessionId": "uuid",
|
||||
"projectHash": "sha256",
|
||||
"startTime": "ISO 8601",
|
||||
"lastUpdated": "ISO 8601",
|
||||
"messages": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"timestamp": "ISO 8601",
|
||||
"type": "user",
|
||||
"content": [{ "text": "..." }]
|
||||
},
|
||||
{
|
||||
"id": "uuid",
|
||||
"timestamp": "ISO 8601",
|
||||
"type": "gemini",
|
||||
"content": "markdown string",
|
||||
"thoughts": [{ "subject": "...", "description": "...", "timestamp": "..." }],
|
||||
"tokens": { "input": N, "output": N, "cached": N, "thoughts": N, "tool": N, "total": N },
|
||||
"model": "gemini-3.1-pro-preview",
|
||||
"toolCalls": [{
|
||||
"id": "string",
|
||||
"name": "tool_name",
|
||||
"args": {},
|
||||
"result": [{ "functionResponse": { "id": "...", "name": "...", "response": { "output": "..." } } }],
|
||||
"status": "success|cancelled",
|
||||
"timestamp": "ISO 8601",
|
||||
"displayName": "Human-readable name",
|
||||
"description": "Tool description"
|
||||
}]
|
||||
},
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "error",
|
||||
"content": "error string"
|
||||
},
|
||||
{
|
||||
"id": "uuid",
|
||||
"type": "info",
|
||||
"content": "info string"
|
||||
}
|
||||
],
|
||||
"kind": "main",
|
||||
"summary": "Session summary"
|
||||
}
|
||||
```
|
||||
|
||||
### Key Differences from Claude/Codex
|
||||
|
||||
| Aspect | Claude | Codex | Gemini |
|
||||
|---|---|---|---|
|
||||
| Session format | JSONL (append-only) | JSONL (append-only) | Single JSON (rewritten) |
|
||||
| Watcher strategy | Byte offset tracking | Byte offset tracking | File size guard + message ID tracking |
|
||||
| Hook protocol | HTTP POST (url-based) | HTTP POST (command curl) | stdin/stdout JSON (needs bridge script) |
|
||||
| Tool tracking | Separate tool_use/tool_result entries | JSONL entries | Embedded in gemini message as toolCalls[] |
|
||||
| Thinking | Pane monitor detection | Pane monitor detection | In JSON (thoughts[]) + pane monitor |
|
||||
| Token/model info | statusLine hook | JSONL entries | In JSON (tokens{}, model field) |
|
||||
| Session ID | Pre-assigned via --session-id | Discovered from SessionStart hook | Discovered from SessionStart hook |
|
||||
| Permission toggle | Shift+Tab cycles 4 modes | N/A | Ctrl+Y toggles YOLO on/off |
|
||||
| Model switch | /model slash command | N/A | /model slash command |
|
||||
|
||||
## File Structure
|
||||
|
||||
### New Files
|
||||
|
||||
```
|
||||
server/adapters/shared/
|
||||
tmux-manager.ts # Moved from claude/ (shared by all 3 adapters)
|
||||
|
||||
server/adapters/gemini/
|
||||
index.ts # GeminiAdapter (extends IAdapter)
|
||||
gemini-tmux-adapter.ts # Session lifecycle, hook handling
|
||||
pane-monitor.ts # Gemini TUI streaming/thinking detection
|
||||
transcript-parser.ts # JSON session -> ParsedMessage[]
|
||||
json-store.ts # Session discovery from ~/.gemini/tmp/
|
||||
message-utils.ts # Gemini content block extraction
|
||||
hook-config.ts # GeminiHookConfig (install/uninstall hooks)
|
||||
bridge.sh # stdin JSON -> curl POST bridge script
|
||||
|
||||
server/stores/
|
||||
json-watcher.ts # New: JSON file watcher (alongside existing jsonl-watcher.ts)
|
||||
```
|
||||
|
||||
### Modified Files
|
||||
|
||||
```
|
||||
server/adapters/shared/tmux-manager.ts # Moved from server/adapters/claude/tmux-manager.ts
|
||||
server/adapters/claude/tmux-adapter.ts # Update import path -> ../shared/tmux-manager.js
|
||||
server/adapters/codex/codex-tmux-adapter.ts # Update import path -> ../shared/tmux-manager.js
|
||||
server/adapters/init.ts # Add gemini loader
|
||||
server/adapters/registry.ts # Add 'gemini' to default enabled list
|
||||
bin/hooks-cli.mjs # Add GeminiHookConfig
|
||||
bin/codetap # Add gemini to set_adapter, detection, labels, validation
|
||||
src/lib/adapter-brands.ts # Add gemini brand + extend iconType union to include 'gemini'
|
||||
src/components/AdapterIcon.tsx # Add GeminiIcon (SVG from thesvg.org), refactor to switch/map
|
||||
```
|
||||
|
||||
## Component Designs
|
||||
|
||||
### 1. Bridge Script (`bridge.sh`)
|
||||
|
||||
Gemini hooks communicate via stdin JSON / stdout JSON. The bridge reads stdin and POSTs to the code-tap server, matching the existing HTTP-based pattern.
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# Reads JSON from stdin (Gemini hook protocol), POSTs to code-tap server.
|
||||
#
|
||||
# IMPORTANT: Gemini hooks expect a JSON response on stdout. We must write
|
||||
# a response BEFORE backgrounding the curl POST, or Gemini will hang.
|
||||
# Exit code 0 = allow (continue), exit code 2 = block.
|
||||
#
|
||||
# Shell compatibility: Uses #!/bin/bash for /dev/tcp port check.
|
||||
# If Gemini executes hooks with zsh (which lacks /dev/tcp), fall back to
|
||||
# curl's --connect-timeout instead. Validated against Gemini CLI v0.34.0.
|
||||
ENDPOINT="$1"
|
||||
PORT="${CODETAP_PORT:-3456}"
|
||||
PROTOCOL="${CODETAP_PROTOCOL:-http}"
|
||||
CURL_K=""
|
||||
[ "$PROTOCOL" = "https" ] && CURL_K="-k"
|
||||
|
||||
# Read stdin (Gemini hook JSON payload)
|
||||
input=$(cat)
|
||||
|
||||
# Respond to Gemini immediately — must happen BEFORE backgrounding curl.
|
||||
# Empty JSON object = "no modifications, continue normally".
|
||||
printf '{}'
|
||||
|
||||
# Port check: skip curl if server isn't listening (fail-fast <1ms)
|
||||
(echo >/dev/tcp/localhost/$PORT) 2>/dev/null || exit 0
|
||||
|
||||
# Forward payload to code-tap server asynchronously
|
||||
printf '%s' "$input" | curl -sf $CURL_K --connect-timeout 2 --max-time 5 \
|
||||
-X POST -H 'Content-Type:application/json' -d @- \
|
||||
"${PROTOCOL}://localhost:${PORT}/api/hooks/gemini/${ENDPOINT}" &>/dev/null &
|
||||
```
|
||||
|
||||
### 2. GeminiHookConfig (`hook-config.ts`)
|
||||
|
||||
Installs hooks into `~/.gemini/settings.json` under the `hooks` key. Follows the same wrap pattern as Claude/Codex — preserves existing hooks, identifies our entries by portTag for clean uninstall.
|
||||
|
||||
**Hook mapping:**
|
||||
|
||||
| Gemini Event | Bridge Endpoint | Purpose |
|
||||
|---|---|---|
|
||||
| `BeforeTool` | `before-tool` | tool-start event |
|
||||
| `AfterTool` | `after-tool` | tool-done event |
|
||||
| `BeforeAgent` | `before-agent` | processing-started |
|
||||
| `AfterAgent` | `after-agent` | session-idle (stop) |
|
||||
| `SessionStart` | `session-start` | Session registration, watcher setup |
|
||||
| `SessionEnd` | `session-end` | Cleanup |
|
||||
|
||||
**Hook command format:**
|
||||
```json
|
||||
{
|
||||
"hooks": {
|
||||
"BeforeTool": [{
|
||||
"matcher": "*",
|
||||
"hooks": [{
|
||||
"type": "command",
|
||||
"command": "/abs/path/to/bridge.sh before-tool",
|
||||
"timeout": 2
|
||||
}]
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Environment variables `CODETAP_PORT` and `CODETAP_PROTOCOL` are set in the command string so the bridge knows where to POST.
|
||||
|
||||
### 3. JsonWatcher (`server/stores/json-watcher.ts`)
|
||||
|
||||
Watches a single JSON session file for new messages. Cannot use byte-offset tracking (file is rewritten entirely on each update), so uses file-size guard + message ID tracking.
|
||||
|
||||
**Algorithm:**
|
||||
1. `fs.watch()` triggers on file change (+ fallback polling every 2s)
|
||||
2. `stat()` checks if file size changed — skip if same (filters false positives)
|
||||
3. Read entire file, `JSON.parse()`
|
||||
4. Compare `messages.length` vs `_lastMessageCount`
|
||||
5. Find new messages by scanning from `_lastMessageCount` index
|
||||
6. Verify with `_lastMessageId` (guard against message deletion/modification edge case)
|
||||
7. Emit only new messages via `onNewMessages()` callback
|
||||
8. Update `_lastSize`, `_lastMessageCount`, `_lastMessageId`
|
||||
|
||||
**Debounce:** 50ms after `fs.watch` fires before polling. Chosen to balance latency (streaming UX) vs coalescing (Gemini rewrites the file on each message). The existing `JsonlWatcher` uses no debounce because JSONL appends are atomic; JSON rewrites are not.
|
||||
|
||||
**Performance:** Observed session files up to ~34KB in practice. `JSON.parse()` of 34KB takes <1ms. As a safeguard: if file size exceeds 2MB, log a warning. The in-memory parsed result is NOT cached between polls (file is always re-read on size change) — this keeps the watcher stateless and avoids stale-cache bugs.
|
||||
|
||||
**API (consistent with JsonlWatcher):**
|
||||
```typescript
|
||||
start(options?: { skipExisting?: boolean }): void
|
||||
stop(): void
|
||||
pollNow(): void
|
||||
onNewMessages(cb: (messages: GeminiSessionMessage[]) => void): void
|
||||
onError(cb: (err: Error) => void): void
|
||||
```
|
||||
|
||||
### 4. GeminiTranscriptParser
|
||||
|
||||
Converts Gemini JSON messages to the shared `ParsedMessage` format used by the frontend.
|
||||
|
||||
**Type mapping:**
|
||||
- `type: "user"` -> `role: "user"`, content normalized to `ContentBlock[]`
|
||||
- `type: "gemini"` -> `role: "assistant"`, content + toolCalls merged into `ContentBlock[]`
|
||||
- `type: "error"` -> emitted as `session-error` event (visible to user — rate limits, API key issues, etc.)
|
||||
- `type: "info"` -> skipped (internal CLI messages like "Press F12 for diagnostics")
|
||||
|
||||
**Tool call conversion:**
|
||||
Gemini embeds tool calls in the gemini message as `toolCalls[]`. Each tool call has `id`, `name`, `args`, `result`, `status`. These are converted to standard `tool_use` + `tool_result` ContentBlocks to match the Claude adapter's output format.
|
||||
|
||||
**Thinking extraction:**
|
||||
Gemini includes `thoughts[]` in the JSON. These are emitted as `thinking` events and optionally included in the message content as thinking blocks.
|
||||
|
||||
**Token/model extraction:**
|
||||
`tokens` and `model` fields are extracted and emitted as `status-update` events, providing context%, model, and cost info without needing a statusLine hook.
|
||||
|
||||
### 5. GeminiJsonStore (`json-store.ts`)
|
||||
|
||||
Session discovery for Gemini's file structure. Maps to `SessionInfo` interface.
|
||||
|
||||
**Discovery algorithm:**
|
||||
1. Read `~/.gemini/projects.json` to get `{ projects: { "/abs/path": "project-name" } }`
|
||||
2. For a given `dir` (cwd), find matching project name from the mapping
|
||||
3. List `~/.gemini/tmp/<project-name>/chats/session-*.json` files
|
||||
4. For each file: read JSON, extract `sessionId`, `startTime`, `lastUpdated`, `summary`, first user message text, model from latest gemini message
|
||||
5. Return `SessionInfo[]` sorted by `lastUpdated` descending
|
||||
|
||||
**Key functions:**
|
||||
- `getSessions(dir?, limit?)` — List sessions for a project (or all projects)
|
||||
- `getMessages(sessionId, dir?)` — Read and parse a session file, return `ParsedMessage[]`
|
||||
- `findSessionFile(sessionId)` — Scan all project dirs to locate a session file by UUID
|
||||
- `getProjectName(dir)` — Look up project name from `projects.json`
|
||||
|
||||
**Project root resolution:**
|
||||
Each `~/.gemini/tmp/<project-name>/.project_root` file contains the absolute path. Use this to map back from project-name to cwd for display.
|
||||
|
||||
### 6. GeminiAdapter Capabilities
|
||||
|
||||
```typescript
|
||||
{
|
||||
supportsPlanMode: true, // --approval-mode plan
|
||||
supportsPermissionModes: true, // default, auto_edit, yolo, plan
|
||||
supportsInterrupt: true, // Ctrl+C in tmux
|
||||
supportsResume: true, // gemini --resume
|
||||
supportsAttach: false, // TBD
|
||||
supportsStatusLine: false, // No statusLine hook (token info from JSON)
|
||||
supportsImages: true,
|
||||
supportsStreaming: true,
|
||||
maxContextWindow: 1000000, // 1M tokens
|
||||
permissionModeType: 'toggle', // Ctrl+Y toggles YOLO (not cycle like Claude)
|
||||
}
|
||||
```
|
||||
|
||||
**Effort levels:** Gemini CLI does not expose a reasoning effort parameter. `getEffortLevels()` returns `[]`.
|
||||
|
||||
**Permission mode runtime behavior:**
|
||||
- `auto_edit` and `plan` can only be set at session launch via `--approval-mode`
|
||||
- At runtime, Ctrl+Y is a binary toggle: `default` <-> `yolo`
|
||||
- `switchPermissionMode()` for `auto_edit`/`plan` mid-session: not supported, returns `false`
|
||||
```
|
||||
|
||||
**Models:**
|
||||
- `auto` — Dynamic resolution (default)
|
||||
- `pro` — Gemini 2.5 Pro (complex reasoning)
|
||||
- `flash` — Gemini 2.5 Flash (fast, balanced)
|
||||
- `flash-lite` — Gemini 2.5 Flash Lite (fastest)
|
||||
|
||||
**Permission modes:**
|
||||
- `default` — Prompts for each tool call
|
||||
- `auto_edit` — Auto-approves file edits
|
||||
- `yolo` — Auto-approves everything
|
||||
- `plan` — Read-only (experimental)
|
||||
|
||||
### 7. Session Lifecycle
|
||||
|
||||
**Start:**
|
||||
```
|
||||
gemini --approval-mode <mode> -m <model> -i "<prompt>"
|
||||
```
|
||||
- Session ID discovered from SessionStart hook's `session_id` field
|
||||
- Uses `_pendingHookBodies` pattern (same as Codex) to handle race condition
|
||||
- Must emit `'session-rekeyed'` event when temp session key is replaced with real UUID from hook (same as Codex's `session-rekeyed` pattern — SessionManager re-registers WS clients under new ID)
|
||||
|
||||
**Resume:**
|
||||
```
|
||||
gemini --resume <session-id>
|
||||
```
|
||||
|
||||
**Permission mode switch:**
|
||||
- Ctrl+Y in tmux toggles YOLO on/off
|
||||
- Only binary toggle (not 4-way cycle like Claude's Shift+Tab)
|
||||
|
||||
**Model switch:**
|
||||
- `/model <name>` slash command via tmux sendKeys
|
||||
|
||||
### 8. CLI & Frontend Changes
|
||||
|
||||
**`bin/codetap`:**
|
||||
- `set_adapter()`: add `gemini` case with `YOLO="--approval-mode yolo"`
|
||||
- Adapter detection: add `*gemini*` pattern
|
||||
- ANSI label: `\033[34m[Gemini]\033[0m` (blue)
|
||||
- `--adapter` validation: add `gemini` case
|
||||
|
||||
**`bin/hooks-cli.mjs`:**
|
||||
- Import and instantiate `GeminiHookConfig`
|
||||
- Add to install/uninstall calls
|
||||
|
||||
**`server/adapters/init.ts` + `server/adapters/registry.ts`** (atomic — must land together):
|
||||
- `init.ts`: Add `gemini` loader in `LOADERS` map
|
||||
- `registry.ts`: Add `'gemini'` to default `enabledAdapters` list
|
||||
- If one changes without the other, the adapter either loads but isn't enabled, or is enabled but fails to load
|
||||
|
||||
**`src/lib/adapter-brands.ts`:**
|
||||
```typescript
|
||||
gemini: {
|
||||
id: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
provider: 'Google',
|
||||
color: '#4285f4',
|
||||
colorBg: '#4285f422',
|
||||
gradient: 'linear-gradient(135deg, #4285f4, #1a73e8)',
|
||||
glow: 'rgba(66,133,244,0.3)',
|
||||
iconType: 'gemini',
|
||||
}
|
||||
```
|
||||
|
||||
**`src/components/AdapterIcon.tsx`:**
|
||||
- Add `GeminiIcon` component with official Google Gemini SVG from thesvg.org
|
||||
- Add `'gemini'` case to iconType switch
|
||||
|
||||
### 9. Shared Layer Refactor
|
||||
|
||||
**Move `tmux-manager.ts`:**
|
||||
- From: `server/adapters/claude/tmux-manager.ts`
|
||||
- To: `server/adapters/shared/tmux-manager.ts`
|
||||
- Update imports in:
|
||||
- `server/adapters/claude/tmux-adapter.ts`
|
||||
- `server/adapters/claude/pane-monitor.ts`
|
||||
- `server/adapters/codex/codex-tmux-adapter.ts`
|
||||
- `server/adapters/gemini/gemini-tmux-adapter.ts`
|
||||
|
||||
No logic changes — pure file move + import path updates.
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
Gemini CLI (tmux)
|
||||
|
|
||||
+-- Hook (stdin JSON) --> bridge.sh --> POST /api/hooks/gemini/<event>
|
||||
| |
|
||||
| GeminiTmuxAdapter.handle{Event}()
|
||||
| |
|
||||
| emit('tool-start', 'tool-done', 'session-idle', etc.)
|
||||
|
|
||||
+-- Session JSON file (~/.gemini/tmp/<project>/chats/session-*.json)
|
||||
| |
|
||||
| JsonWatcher detects file change (fs.watch + polling)
|
||||
| |
|
||||
| Reads JSON, diffs messages by count + ID
|
||||
| |
|
||||
| GeminiTranscriptParser.parse(newMessages)
|
||||
| |
|
||||
| emit('new-messages', messages[])
|
||||
| emit('status-update', { model, tokens })
|
||||
| emit('thinking', { thoughts[] })
|
||||
|
|
||||
+-- tmux pane output (streaming)
|
||||
|
|
||||
GeminiPaneMonitor detects changes
|
||||
|
|
||||
emit('streaming-text')
|
||||
|
||||
All events --> SessionManager --> WebSocket --> React frontend
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Unit tests for `GeminiTranscriptParser` (convert JSON messages to ParsedMessage[])
|
||||
- Unit tests for `JsonWatcher` (file size guard, message ID tracking, debounce)
|
||||
- Unit tests for `GeminiHookConfig` (install/uninstall preserves existing hooks)
|
||||
- Integration test: start Gemini session via API, verify WebSocket events
|
||||
- Manual test: full flow on phone (start, send prompt, see streaming, approve tool, resume)
|
||||
@@ -0,0 +1,163 @@
|
||||
# PWA Optimization Design Spec
|
||||
|
||||
**Date:** 2026-03-26
|
||||
**Goal:** Bring CodeTap's PWA to production-grade quality — proper viewport handling, splash screens, install prompts, SW updates, badge management, draft persistence, and navigation history.
|
||||
|
||||
---
|
||||
|
||||
## Current State
|
||||
|
||||
CodeTap already has solid PWA foundations:
|
||||
- Service Worker with Workbox precaching (vite-plugin-pwa, injectManifest)
|
||||
- Web App Manifest (standalone, portrait, dark theme)
|
||||
- Push notifications with badge support
|
||||
- iOS meta tags (capable, black-translucent status bar)
|
||||
- Offline detection + OfflineView
|
||||
- overscroll-behavior: none, 16px inputs, h-dvh, safe-bottom
|
||||
|
||||
## What's Missing
|
||||
|
||||
### High Priority
|
||||
|
||||
#### 1. Viewport & Safe Areas
|
||||
**Problem:** Missing `viewport-fit=cover`. Only bottom safe area handled — notch/Dynamic Island area not accounted for.
|
||||
|
||||
**Solution:**
|
||||
- Add `viewport-fit=cover` to viewport meta tag in `index.html`
|
||||
- Add CSS for top safe area: headers get `padding-top: env(safe-area-inset-top)`
|
||||
- The standalone PWA mode on iOS with `black-translucent` status bar needs the content to extend behind the status bar — `viewport-fit=cover` enables this
|
||||
|
||||
**Files:** `index.html`, `src/index.css`
|
||||
|
||||
#### 2. Splash Screen / Launch Images
|
||||
**Problem:** White flash on app startup — no branded loading experience.
|
||||
|
||||
**Solution:**
|
||||
- Add `apple-mobile-web-app-startup-image` meta tags covering major iPhone sizes
|
||||
- Use `media` attribute with `device-width`, `device-height`, and `device-pixel-ratio` queries
|
||||
- Background: `#09090b` (matches theme), centered CodeTap mascot/logo
|
||||
- Generate splash images as data URIs or static PNGs in `/public/splash/`
|
||||
- Minimum coverage: iPhone SE, iPhone 14/15, iPhone 14/15 Pro Max, iPhone 16 Pro Max
|
||||
|
||||
**Files:** `index.html`, `public/splash/` (new directory)
|
||||
|
||||
#### 3. Android Install Prompt
|
||||
**Problem:** No handling of `beforeinstallprompt` event — Android users never see install prompt.
|
||||
|
||||
**Solution:**
|
||||
- Listen for `beforeinstallprompt` in App.tsx, store the event in state
|
||||
- Show a dismissible install banner in SessionsView (below header)
|
||||
- Banner text: "Install CodeTap for a better experience" with Install/Dismiss buttons
|
||||
- On Install click: call `event.prompt()`, hide banner
|
||||
- On Dismiss: hide banner, store dismissal in `localStorage` so it doesn't reappear
|
||||
- After successful install (`appinstalled` event): hide banner permanently
|
||||
|
||||
**Files:** `src/App.tsx`, `src/components/SessionsView.tsx`
|
||||
|
||||
#### 4. Service Worker Update Notification
|
||||
**Problem:** SW updates silently — user doesn't know a new version is available.
|
||||
|
||||
**Solution:**
|
||||
- Listen for `controllerchange` on `navigator.serviceWorker` in App.tsx
|
||||
- When detected, show a toast at bottom: "New version available" with Refresh button
|
||||
- On click: `window.location.reload()`
|
||||
- Toast auto-dismisses after 10s but can be manually dismissed
|
||||
|
||||
**Files:** `src/App.tsx`
|
||||
|
||||
### Medium Priority
|
||||
|
||||
#### 5. Badge Clear on Focus
|
||||
**Problem:** App badge persists even when user is actively looking at the app.
|
||||
|
||||
**Solution:**
|
||||
- In App.tsx, listen for `visibilitychange` event
|
||||
- When `document.visibilityState === 'visible'`: call `navigator.clearAppBadge()` (with feature check)
|
||||
- This ensures badge is cleared whenever user switches back to the app
|
||||
|
||||
**Files:** `src/App.tsx`
|
||||
|
||||
#### 6. Manifest Shortcuts
|
||||
**Problem:** No quick actions from home screen long-press.
|
||||
|
||||
**Solution:**
|
||||
- Add `shortcuts` array to manifest in vite.config.ts:
|
||||
- "New Chat" — url: `/?action=newchat`, icon: `chat-bubble-icon`
|
||||
- In App.tsx, check for `?action=newchat` param and navigate accordingly
|
||||
|
||||
**Files:** `vite.config.ts`, `src/App.tsx`
|
||||
|
||||
#### 7. Input Draft Auto-Save
|
||||
**Problem:** Typed text lost if app is backgrounded or crashes.
|
||||
|
||||
**Solution:**
|
||||
- In ShimmerInput: on every input change, debounce-save to `localStorage` with key `codetap:draft:{sessionId}`
|
||||
- On mount: restore draft from localStorage if present
|
||||
- On successful send or explicit clear: delete the draft
|
||||
- Debounce: 500ms to avoid excessive writes
|
||||
|
||||
**Files:** `src/components/ShimmerInput.tsx`
|
||||
|
||||
#### 8. Manifest Screenshots
|
||||
**Problem:** Missing screenshots for app stores and install prompts.
|
||||
|
||||
**Solution:**
|
||||
- Add `screenshots` array to manifest in vite.config.ts
|
||||
- Provide at minimum:
|
||||
- 1 narrow screenshot (phone, 1080x1920) — chat view
|
||||
- 1 wide screenshot (tablet/desktop, 1920x1080) — sessions view
|
||||
- Store in `public/screenshots/`
|
||||
|
||||
**Files:** `vite.config.ts`, `public/screenshots/` (new directory)
|
||||
|
||||
### Low Priority
|
||||
|
||||
#### 9. Slow Network Detection
|
||||
**Problem:** No feedback when on slow connection.
|
||||
|
||||
**Solution:**
|
||||
- Check `navigator.connection?.effectiveType` (with feature detection)
|
||||
- When `2g` or `slow-2g`: show a subtle indicator in StatusBar ("Slow connection")
|
||||
- Re-check on `change` event of `navigator.connection`
|
||||
|
||||
**Files:** `src/components/StatusBar.tsx`
|
||||
|
||||
#### 10. History API Navigation
|
||||
**Problem:** Browser back gesture doesn't work — app uses `sessionStorage` for view state, no history stack.
|
||||
|
||||
**Solution:**
|
||||
- In App.tsx, use `history.pushState()` when navigating between views
|
||||
- Listen for `popstate` event to handle back navigation
|
||||
- Map each view to a history entry: `sessions`, `chat/{sessionId}`, `settings`, `newchat/{cwd}`
|
||||
- This enables iOS swipe-back gesture and Android back button in standalone mode
|
||||
|
||||
**Files:** `src/App.tsx`
|
||||
|
||||
#### 11. OpenGraph Meta Tags
|
||||
**Problem:** No social sharing metadata.
|
||||
|
||||
**Solution:**
|
||||
- Add to `index.html`:
|
||||
- `og:title`: "CodeTap"
|
||||
- `og:description`: "Use Claude Code from your phone"
|
||||
- `og:image`: link to a social card image
|
||||
- `og:type`: "website"
|
||||
- `twitter:card`: "summary"
|
||||
|
||||
**Files:** `index.html`
|
||||
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
1. **Progressive enhancement** — All PWA features use feature detection. App works fine without them.
|
||||
2. **No new dependencies** — Everything is native Web APIs or existing vite-plugin-pwa config.
|
||||
3. **Minimal UI additions** — Install banner and SW update toast are the only new UI elements.
|
||||
4. **Respect user choice** — Install prompt is dismissible and remembers dismissal.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Full offline-first with background sync (current offline detection is sufficient)
|
||||
- Push notification permission prompt UI (current flow works)
|
||||
- Image compression before upload
|
||||
- Orientation lock via Screen Orientation API
|
||||
@@ -0,0 +1,174 @@
|
||||
# Send-to Menu Redesign + Settings Page
|
||||
|
||||
## Overview
|
||||
|
||||
Redesign the "Send to Other AI" menu for cross-AI review, add a Settings page for managing preferences and saved instructions.
|
||||
|
||||
Three deliverables:
|
||||
1. **Send-to Menu** — Two-step bottom sheet with adapter selection, model picker, Direct Send / With Instructions
|
||||
2. **Settings Page** — Centralized preferences: saved instructions, per-adapter defaults, notifications, about
|
||||
3. **Saved Instructions DB** — Server-side storage for reusable instruction templates
|
||||
|
||||
## Part 1: Send-to Menu
|
||||
|
||||
### Layout: Two-Step Bottom Sheet
|
||||
|
||||
**Step 1 — Adapter Selection:**
|
||||
- Bottom sheet titled "Send to…"
|
||||
- Lists all available adapters (excluding current)
|
||||
- Each row: adapter icon (official SVG from AdapterIcon.tsx) + adapter name
|
||||
- No model shown here (model is selected in step 2)
|
||||
- Tap row → navigates to step 2
|
||||
|
||||
**Step 2 — Action Selection:**
|
||||
- Header: `‹ {AdapterName}` (back arrow + colored adapter name)
|
||||
- Model dropdown: `Model: [gpt-5.4 ▾]` — uses native `<select>`, options from `GET /api/adapter/:name/config`
|
||||
- Two action buttons:
|
||||
- **Direct Send** — icon ↗, subtitle "直接送出,不加 instructions"
|
||||
- **With Instructions** — icon ✎, subtitle "自訂或使用已儲存的", has ▼/▲ chevron for expand/collapse
|
||||
|
||||
**With Instructions (expandable section):**
|
||||
- Saved instructions list (from DB) — tap to select and send immediately
|
||||
- Divider: "或輸入新的"
|
||||
- Text input + send button
|
||||
- Tapping ▼/▲ toggles the section open/closed
|
||||
|
||||
### Behavior
|
||||
|
||||
**Direct Send:**
|
||||
- Sends ONLY the raw response text (the message the user clicked ↗ on)
|
||||
- No context wrapper, no conversation history, no instructions
|
||||
- Immediately opens FloatingReviewPanel
|
||||
|
||||
**With Instructions (saved):**
|
||||
- Sends: `{instruction}\n\n{response_text}`
|
||||
- Immediately opens FloatingReviewPanel
|
||||
|
||||
**With Instructions (new):**
|
||||
- Same format as saved
|
||||
- After sending, show a toast at bottom: "存成常用?" + [Save] button
|
||||
- Toast auto-dismisses after 3 seconds
|
||||
- Tapping Save → `POST /api/instructions` with auto-generated label (first 30 chars of instruction)
|
||||
- Saved instructions then appear in the list for future use
|
||||
|
||||
### Components Changed
|
||||
|
||||
| Component | Change |
|
||||
|-----------|--------|
|
||||
| `ReviewActionMenu.tsx` | Complete rewrite — two-step bottom sheet |
|
||||
| `MessageBubble.tsx` / `SendDropdown` | Unchanged (still triggers `onSendTo`) |
|
||||
| `ChatView.tsx` `handleReviewSelect` | Simplify — remove promptTemplates, context assembly. Direct Send = raw text, Instructions = instruction + raw text |
|
||||
| `AdapterIcon.tsx` | Unchanged (already uses official SVGs) |
|
||||
|
||||
## Part 2: Settings Page
|
||||
|
||||
### Entry Point
|
||||
|
||||
- New ⚙️ icon button in the project list header (next to Logout)
|
||||
- Navigates to Settings view
|
||||
|
||||
### Settings Structure
|
||||
|
||||
```
|
||||
Settings
|
||||
├── Saved Instructions
|
||||
│ └── List with Add/Delete, tap to edit
|
||||
├── Claude (per-adapter)
|
||||
│ ├── Model: [dropdown]
|
||||
│ ├── Permission Mode: [dropdown] (label from adapter)
|
||||
│ └── Thinking: [dropdown] (label from adapter, "Thinking" for Claude)
|
||||
├── Codex (per-adapter)
|
||||
│ ├── Model: [dropdown]
|
||||
│ ├── Permission Mode: [dropdown]
|
||||
│ └── Effort: [dropdown] (label from adapter, "Effort" for Codex)
|
||||
├── Notifications
|
||||
│ └── Push Notifications: [toggle]
|
||||
└── About
|
||||
└── CodeTap v{version}
|
||||
```
|
||||
|
||||
### Per-Adapter Settings
|
||||
|
||||
- Options fetched dynamically from `GET /api/adapter/:name/config`
|
||||
- Each adapter has its own models, permission modes, effort levels, and effort label
|
||||
- Changes saved to `localStorage` via existing `adapter-prefs.ts` (same as current cycle buttons)
|
||||
- NewChat page cycle buttons remain as shortcuts
|
||||
|
||||
### Saved Instructions Sub-page
|
||||
|
||||
```
|
||||
‹ Saved Instructions [+ Add]
|
||||
──────────────────────────────────────────
|
||||
Code Review ✕
|
||||
Review for correctness, edge cases…
|
||||
|
||||
Suggest Alternatives ✕
|
||||
Suggest 3 alternative approaches…
|
||||
```
|
||||
|
||||
- `+ Add` → inline input or modal to enter label + instruction text
|
||||
- `✕` → delete with confirmation
|
||||
- v1: no edit — delete and recreate
|
||||
|
||||
### Version Number
|
||||
|
||||
- Read from `/api/health` endpoint (already returns version from `package.json`)
|
||||
|
||||
### Components
|
||||
|
||||
| Component | Type |
|
||||
|-----------|------|
|
||||
| `SettingsView.tsx` | New — main settings page |
|
||||
| `SavedInstructionsView.tsx` | New — instruction management sub-page |
|
||||
| `AdapterSettingsSection.tsx` | New — per-adapter dropdown settings |
|
||||
| `SessionsView.tsx` | Modified — add ⚙️ icon in header |
|
||||
|
||||
## Part 3: Saved Instructions DB
|
||||
|
||||
### Schema
|
||||
|
||||
```sql
|
||||
CREATE TABLE saved_instructions (
|
||||
id TEXT PRIMARY KEY,
|
||||
label TEXT NOT NULL,
|
||||
instruction TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
```
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Method | Path | Body | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/instructions` | — | List all, ordered by created_at |
|
||||
| POST | `/api/instructions` | `{label, instruction}` | Create new |
|
||||
| DELETE | `/api/instructions/:id` | — | Delete |
|
||||
|
||||
No update endpoint — delete and recreate. Keeps it simple.
|
||||
|
||||
### Client API
|
||||
|
||||
Add to `src/lib/api.ts`:
|
||||
- `api.getInstructions(): Promise<Instruction[]>`
|
||||
- `api.createInstruction(label, instruction): Promise<Instruction>`
|
||||
- `api.deleteInstruction(id): Promise<void>`
|
||||
|
||||
## Data Flow Summary
|
||||
|
||||
```
|
||||
User clicks ↗ on message
|
||||
→ ReviewActionMenu opens (Step 1: pick adapter)
|
||||
→ Step 2: pick model + Direct Send or With Instructions
|
||||
→ Direct Send: sendMessage(rawText) to child adapter
|
||||
→ With Instructions: sendMessage(instruction + rawText) to child adapter
|
||||
→ FloatingReviewPanel opens (same as current QUERY path)
|
||||
→ If new instruction used, toast offers to save
|
||||
```
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Instruction editing (v1: delete + recreate)
|
||||
- Instruction reordering (v1: created_at order)
|
||||
- Per-adapter instructions (v1: global list)
|
||||
- Theme/appearance settings
|
||||
- Server-side config management
|
||||
Reference in New Issue
Block a user