From b0cb1f9ce233a11a997e95480ec03a508a5ace56 Mon Sep 17 00:00:00 2001 From: Ronald Roy Date: Tue, 20 Jan 2026 09:44:16 -0800 Subject: [PATCH 01/19] feat: Add Pair Writing Mode specification with AI-assisted text revision --- .../2026-01-20-pair-writing-mode.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 .sdd/specs/memory-loop/2026-01-20-pair-writing-mode.md diff --git a/.sdd/specs/memory-loop/2026-01-20-pair-writing-mode.md b/.sdd/specs/memory-loop/2026-01-20-pair-writing-mode.md new file mode 100644 index 00000000..0fe62250 --- /dev/null +++ b/.sdd/specs/memory-loop/2026-01-20-pair-writing-mode.md @@ -0,0 +1,167 @@ +--- +version: 1.1.0 +status: Approved +created: 2026-01-20 +last_updated: 2026-01-20 +authored_by: + - Ronald Roy +--- + +# Pair Writing Mode Specification + +## Executive Summary + +This feature adds AI-assisted text revision to Memory Loop through two complementary capabilities: + +1. **Quick Actions** (all platforms): Four transformative actions (Tighten, Embellish, Correct, Polish) available via context menu when editing any markdown file. AI directly replaces the selected text with the improved version. + +2. **Pair Writing Mode** (desktop only): A split-screen editor with conversation pane for advisory actions (Validate, Critique), freeform chat about selections, and "What Changed?" comparisons. + +Quick Actions reduce friction for common revision tasks. Pair Writing Mode enables deeper collaboration where the user reads AI feedback and manually applies changes, preserving the learning loop. + +## User Story + +As a writer using Memory Loop, I want AI to help me revise text in place for quick edits, and provide advisory feedback in a side-by-side view for deeper revision, so that I can choose the right level of AI involvement for each situation. + +## Stakeholders + +- **Primary**: Writers using Memory Loop for knowledge work and drafting +- **Secondary**: Memory Loop maintainers (new component patterns, state management) + +## Success Criteria + +1. User can invoke Quick Actions on selected text in any markdown file (desktop and mobile) +2. AI directly replaces selected text for transformative actions +3. User can enter Pair Writing Mode for advisory actions and freeform chat (desktop) +4. User can snapshot and compare edits in Pair Writing Mode + +## Functional Requirements + +### Quick Actions (All Platforms) + +- **REQ-F-1**: When editing a markdown file in Browse mode, provide context menu with four transformative actions: + - **Tighten**: Make more concise without losing meaning + - **Embellish**: Add detail, nuance, or context + - **Correct**: Fix typos and grammar only + - **Polish**: Correct + improve prose (controlled improvements) +- **REQ-F-2**: On desktop, context menu appears on right-click when text is selected +- **REQ-F-3**: On mobile, context menu appears on long-press when text is selected (prevent system context menu) +- **REQ-F-4**: Invoking a Quick Action sends selection + surrounding context to Claude. Context includes the paragraph containing the selection plus one paragraph before and after (paragraphs delimited by blank lines) +- **REQ-F-5**: Claude returns revised text; system replaces the selection with the revised text in-place +- **REQ-F-6**: If Claude includes commentary, display as toast notification +- **REQ-F-7**: Show loading indicator on the selection while AI is processing +- **REQ-F-8**: Quick Action edits are part of the document's unsaved changes (user must save to persist) + +### Pair Writing Mode Entry and Layout (Desktop Only) + +- **REQ-F-9**: When viewing a markdown file in Browse mode on desktop, display "Pair Writing" button to enter the mode +- **REQ-F-10**: Hide "Pair Writing" button on mobile/touch devices +- **REQ-F-11**: Pair Writing Mode displays split-screen layout: left pane (editor), right pane (conversation) +- **REQ-F-12**: Left pane contains an editable markdown editor with the current file content +- **REQ-F-13**: Right pane contains a conversation log and chat input +- **REQ-F-14**: Provide "Exit" button to return to standard Browse view, with unsaved changes warning + +### Advisory Actions (Desktop Only, Pair Writing Mode) + +- **REQ-F-15**: In Pair Writing Mode, context menu includes two additional advisory actions: + - **Validate**: Fact-check the claim + - **Critique**: Analyze clarity, voice, structure +- **REQ-F-16**: Advisory actions send selection + context to Claude; response appears in conversation pane +- **REQ-F-17**: User reads feedback and manually applies changes by editing the document +- **REQ-F-18**: Quick Actions (Tighten, Embellish, Correct, Polish) also available in Pair Writing Mode context menu, behaving the same as in Browse mode (direct replacement) + +### Freeform Chat (Desktop Only, Pair Writing Mode) + +- **REQ-F-19**: User can highlight text and press a hotkey to jump to chat input +- **REQ-F-20**: When chat input is focused with a selection, display the selection as a quoted block above the input +- **REQ-F-21**: User types a freeform question; Claude responds with selection context attached +- **REQ-F-22**: Conversation history persists for the session (lost on exit or file switch) + +### Shadow Versioning (Desktop Only, Pair Writing Mode) + +- **REQ-F-23**: Provide "Snapshot" button to manually capture the current document state +- **REQ-F-24**: Only one snapshot exists at a time; new snapshot replaces previous +- **REQ-F-25**: When a snapshot exists and text is selected, enable "Compare to snapshot" action in context menu +- **REQ-F-26**: "Compare to snapshot" displays a diff in the conversation pane showing: + - BEFORE (snapshot): original text at that location + - AFTER (current): user's current text + - ANALYSIS: Claude's description of what changed (objective, not judgmental) + - If selection location has no corresponding text in snapshot (new content), display "No corresponding text in snapshot" message +- **REQ-F-27**: Snapshot is session-scoped (cleared on exit or file switch) + +### Document Persistence + +- **REQ-F-28**: Changes to the document (including Quick Action edits) are not auto-saved +- **REQ-F-29**: Provide "Save" button to write changes back to the vault file (last write wins; no conflict detection in v1) +- **REQ-F-30**: Warn user if exiting with unsaved changes + +## Non-Functional Requirements + +- **REQ-NF-1** (Platform): Quick Actions work on desktop and mobile; Pair Writing Mode is desktop-only +- **REQ-NF-2** (Performance): Editor must maintain 60fps (16ms per frame) when typing in files up to 50KB +- **REQ-NF-3** (Responsiveness): Context menu appears within 100ms of trigger (right-click or long-press) +- **REQ-NF-4** (Consistency): Conversation pane styling matches existing Discussion mode +- **REQ-NF-5** (Accessibility): Context menu is keyboard-navigable after opening +- **REQ-NF-6** (Mobile UX): Long-press duration matches platform convention (~500ms) + +## Explicit Constraints (DO NOT) + +- Do NOT auto-apply advisory action responses (Validate, Critique) to the document +- Do NOT persist conversation history to the vault (session-only) +- Do NOT show Pair Writing Mode entry point on mobile/touch devices +- Do NOT allow custom user-defined actions in v1 (fixed set of 6) +- Do NOT auto-save document changes (explicit save only) +- Do NOT support multiple simultaneous snapshots (one snapshot at a time) + +## Technical Context + +- **Existing Stack**: React 19 frontend, Hono backend, WebSocket communication, Claude Agent SDK +- **Integration Points**: + - Browse mode adjust/edit flow (entry point for Quick Actions) + - FileTree context menu pattern (long-press implementation reference) + - WebSocket protocol (new message types for pair writing actions) + - Session management (conversation history uses existing patterns) +- **Patterns to Respect**: + - State management via SessionContext (useReducer pattern) + - Zod-validated message schemas in shared/ + - CSS modules for component styling + +## Acceptance Tests + +### Quick Actions (All Platforms) +1. **Desktop Quick Action**: Edit markdown file → select text → right-click → choose "Tighten" → selection replaced with tightened text +2. **Mobile Quick Action**: Edit markdown file → select text → long-press → choose "Polish" → selection replaced with polished text +3. **Quick Action Toast**: Invoke "Correct" → AI includes commentary → toast appears with commentary +4. **Quick Action Loading**: Invoke action → loading indicator visible on selection → indicator clears when complete + +### Pair Writing Mode (Desktop Only) +5. **Enter Pair Writing**: Open markdown file on desktop → click "Pair Writing" → split-screen layout appears +6. **Advisory Action**: In Pair Writing Mode → select text → right-click → choose "Validate" → response appears in conversation pane (not inline) +7. **Freeform Chat**: Select text → press hotkey → cursor in chat with selection quoted → type question → response includes selection context +8. **Snapshot + Compare**: Click "Snapshot" → edit text → select edited region → "Compare to snapshot" → diff with analysis appears in conversation pane + +### Document Persistence +9. **Save Flow**: Edit document (via typing or Quick Action) → click "Save" → changes written to vault +10. **Exit Warning**: Edit document without saving → click "Exit" or navigate away → confirmation dialog appears + +### Platform Behavior +11. **Desktop-Only Pair Writing**: Load Memory Loop on touch device → "Pair Writing" button not visible +12. **Mobile Quick Actions Available**: Load Memory Loop on touch device → edit markdown → select text → long-press → context menu with Quick Actions appears + +## Open Questions + +- [ ] Exact hotkey for "jump to chat" in Pair Writing Mode (Tab vs Cmd+Enter vs configurable) +- [ ] Should the split be resizable by dragging the divider? + +## Out of Scope + +- Custom user-defined actions +- Persistent conversation history (stored in vault) +- Multiple snapshots / full version history +- Rich text / WYSIWYG editing (markdown source only) +- Collaborative multi-user editing +- Undo/redo for Quick Action replacements (uses standard editor undo) + +--- + +**Next Phase**: Once approved, use `/spiral-grove:plan-generation` to create technical implementation plan. From f821271c98ef755a56bc9d0632d537a7dc9ab9e9 Mon Sep 17 00:00:00 2001 From: Ronald Roy Date: Tue, 20 Jan 2026 09:45:33 -0800 Subject: [PATCH 02/19] docs: marked specs completed --- .sdd/specs/memory-loop/{ => completed}/2025-12-26-chat.md | 0 .sdd/specs/memory-loop/{ => completed}/2025-12-26-home.md | 0 .../memory-loop/{ => completed}/2025-12-26-navigation-bar.md | 0 .sdd/specs/memory-loop/{ => completed}/2025-12-26-note-capture.md | 0 .../memory-loop/{ => completed}/2025-12-26-vault-selection.md | 0 .sdd/specs/memory-loop/{ => completed}/2025-12-26-view.md | 0 .sdd/specs/memory-loop/{ => completed}/2025-12-31-task-list.md | 0 .../memory-loop/{ => completed}/2026-01-04-slashcommand-ux.md | 0 .sdd/specs/memory-loop/{ => completed}/2026-01-05-vault-setup.md | 0 .../specs/memory-loop/{ => completed}/2026-01-07-recall-search.md | 0 .../{ => completed}/2026-01-12-dag-dependency-resolution.md | 0 .../memory-loop/{ => completed}/2026-01-14-vault-config-editor.md | 0 .../memory-loop/{ => completed}/2026-01-18-memory-extraction.md | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename .sdd/specs/memory-loop/{ => completed}/2025-12-26-chat.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2025-12-26-home.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2025-12-26-navigation-bar.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2025-12-26-note-capture.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2025-12-26-vault-selection.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2025-12-26-view.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2025-12-31-task-list.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2026-01-04-slashcommand-ux.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2026-01-05-vault-setup.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2026-01-07-recall-search.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2026-01-12-dag-dependency-resolution.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2026-01-14-vault-config-editor.md (100%) rename .sdd/specs/memory-loop/{ => completed}/2026-01-18-memory-extraction.md (100%) diff --git a/.sdd/specs/memory-loop/2025-12-26-chat.md b/.sdd/specs/memory-loop/completed/2025-12-26-chat.md similarity index 100% rename from .sdd/specs/memory-loop/2025-12-26-chat.md rename to .sdd/specs/memory-loop/completed/2025-12-26-chat.md diff --git a/.sdd/specs/memory-loop/2025-12-26-home.md b/.sdd/specs/memory-loop/completed/2025-12-26-home.md similarity index 100% rename from .sdd/specs/memory-loop/2025-12-26-home.md rename to .sdd/specs/memory-loop/completed/2025-12-26-home.md diff --git a/.sdd/specs/memory-loop/2025-12-26-navigation-bar.md b/.sdd/specs/memory-loop/completed/2025-12-26-navigation-bar.md similarity index 100% rename from .sdd/specs/memory-loop/2025-12-26-navigation-bar.md rename to .sdd/specs/memory-loop/completed/2025-12-26-navigation-bar.md diff --git a/.sdd/specs/memory-loop/2025-12-26-note-capture.md b/.sdd/specs/memory-loop/completed/2025-12-26-note-capture.md similarity index 100% rename from .sdd/specs/memory-loop/2025-12-26-note-capture.md rename to .sdd/specs/memory-loop/completed/2025-12-26-note-capture.md diff --git a/.sdd/specs/memory-loop/2025-12-26-vault-selection.md b/.sdd/specs/memory-loop/completed/2025-12-26-vault-selection.md similarity index 100% rename from .sdd/specs/memory-loop/2025-12-26-vault-selection.md rename to .sdd/specs/memory-loop/completed/2025-12-26-vault-selection.md diff --git a/.sdd/specs/memory-loop/2025-12-26-view.md b/.sdd/specs/memory-loop/completed/2025-12-26-view.md similarity index 100% rename from .sdd/specs/memory-loop/2025-12-26-view.md rename to .sdd/specs/memory-loop/completed/2025-12-26-view.md diff --git a/.sdd/specs/memory-loop/2025-12-31-task-list.md b/.sdd/specs/memory-loop/completed/2025-12-31-task-list.md similarity index 100% rename from .sdd/specs/memory-loop/2025-12-31-task-list.md rename to .sdd/specs/memory-loop/completed/2025-12-31-task-list.md diff --git a/.sdd/specs/memory-loop/2026-01-04-slashcommand-ux.md b/.sdd/specs/memory-loop/completed/2026-01-04-slashcommand-ux.md similarity index 100% rename from .sdd/specs/memory-loop/2026-01-04-slashcommand-ux.md rename to .sdd/specs/memory-loop/completed/2026-01-04-slashcommand-ux.md diff --git a/.sdd/specs/memory-loop/2026-01-05-vault-setup.md b/.sdd/specs/memory-loop/completed/2026-01-05-vault-setup.md similarity index 100% rename from .sdd/specs/memory-loop/2026-01-05-vault-setup.md rename to .sdd/specs/memory-loop/completed/2026-01-05-vault-setup.md diff --git a/.sdd/specs/memory-loop/2026-01-07-recall-search.md b/.sdd/specs/memory-loop/completed/2026-01-07-recall-search.md similarity index 100% rename from .sdd/specs/memory-loop/2026-01-07-recall-search.md rename to .sdd/specs/memory-loop/completed/2026-01-07-recall-search.md diff --git a/.sdd/specs/memory-loop/2026-01-12-dag-dependency-resolution.md b/.sdd/specs/memory-loop/completed/2026-01-12-dag-dependency-resolution.md similarity index 100% rename from .sdd/specs/memory-loop/2026-01-12-dag-dependency-resolution.md rename to .sdd/specs/memory-loop/completed/2026-01-12-dag-dependency-resolution.md diff --git a/.sdd/specs/memory-loop/2026-01-14-vault-config-editor.md b/.sdd/specs/memory-loop/completed/2026-01-14-vault-config-editor.md similarity index 100% rename from .sdd/specs/memory-loop/2026-01-14-vault-config-editor.md rename to .sdd/specs/memory-loop/completed/2026-01-14-vault-config-editor.md diff --git a/.sdd/specs/memory-loop/2026-01-18-memory-extraction.md b/.sdd/specs/memory-loop/completed/2026-01-18-memory-extraction.md similarity index 100% rename from .sdd/specs/memory-loop/2026-01-18-memory-extraction.md rename to .sdd/specs/memory-loop/completed/2026-01-18-memory-extraction.md From 3a477271c1a52dbba57cd7df57822b19c48a6f43 Mon Sep 17 00:00:00 2001 From: Ronald Roy Date: Tue, 20 Jan 2026 10:12:29 -0800 Subject: [PATCH 03/19] feat: Add technical plan for Pair Writing Mode with Quick Actions and advisory features --- .../2026-01-20-pair-writing-mode-plan.md | 660 ++++++++++++++++++ 1 file changed, 660 insertions(+) create mode 100644 .sdd/plans/memory-loop/2026-01-20-pair-writing-mode-plan.md diff --git a/.sdd/plans/memory-loop/2026-01-20-pair-writing-mode-plan.md b/.sdd/plans/memory-loop/2026-01-20-pair-writing-mode-plan.md new file mode 100644 index 00000000..2d5115ee --- /dev/null +++ b/.sdd/plans/memory-loop/2026-01-20-pair-writing-mode-plan.md @@ -0,0 +1,660 @@ +--- +version: 1.1.0 +status: Approved +created: 2026-01-20 +last_updated: 2026-01-20 +authored_by: + - Ronald Roy +spec: .sdd/specs/memory-loop/2026-01-20-pair-writing-mode.md +--- + +# Pair Writing Mode Technical Plan + +## Overview + +This plan addresses spec v1.1.0 requirements for two complementary capabilities: + +1. **Quick Actions**: Transformative text actions (Tighten, Embellish, Correct, Polish) available in Browse mode's editor, working on both desktop and mobile via context menu +2. **Pair Writing Mode**: Desktop-only split-screen with advisory actions (Validate, Critique), freeform chat, and snapshot comparison + +## Architecture + +### High-Level Component Structure + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ BrowseMode │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ MemoryEditor │ │ +│ │ ┌─────────────────────────────────────────────────────┐ │ │ +│ │ │ EditorContextMenu │ │ │ +│ │ │ (Quick Actions: Tighten, Embellish, Correct, Polish)│ │ │ +│ │ └─────────────────────────────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +│ │ │ +│ [Enter Pair Writing] │ +│ ▼ │ +│ ┌───────────────────────────────────────────────────────────┐ │ +│ │ PairWritingMode (Desktop Only) │ │ +│ │ ┌─────────────────┐ ┌─────────────────────────────┐ │ │ +│ │ │ Editor Pane │ │ Conversation Pane │ │ │ +│ │ │ (MemoryEditor) │ │ (Reuses Discussion UI) │ │ │ +│ │ │ + Snapshot btn │ │ + Freeform chat input │ │ │ +│ │ │ + Full context │ │ + Selection context │ │ │ +│ │ │ menu (6 items)│ │ │ │ │ +│ │ └─────────────────┘ └─────────────────────────────┘ │ │ +│ └───────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Data Flow: Quick Actions + +``` +User selects text → Right-click/Long-press → Context menu appears + │ + ▼ + Select action (e.g., "Tighten") + │ + ▼ + Frontend extracts: + - File path, selection text, line numbers + - Paragraph context (before/after) + │ + ▼ + Send `quick_action_request` via WebSocket + │ + ▼ + Backend creates task-scoped Claude session: + - System prompt: Action-specific instructions (be efficient) + - Read + Edit tools available (scoped to vault) + │ + ▼ + Claude executes (typically 3-5 turns): + Turn 1: "I'll tighten this." → Read tool + Turn 2: "I see the text." → Edit tool + Turn 3: "Done. Removed 3 filler phrases." + │ + ▼ + Backend streams events to frontend: + - tool_start/tool_end (Read) + - tool_start/tool_end (Edit - file written) + - response_chunk (brief confirmation) + - response_end + │ + ▼ + File is already updated on disk + Frontend reloads file content, shows toast with confirmation +``` + +### Data Flow: Advisory Actions (Pair Writing Mode) + +``` +User selects text → Right-click → Choose "Validate" or "Critique" + │ + ▼ + Frontend extracts selection + context + │ + ▼ + Send `advisory_action_request` via WebSocket + │ + ▼ + Backend creates Claude request (streamed): + - System prompt: Advisory instructions + - User message: Selection + context + │ + ▼ + Stream response chunks to conversation pane + │ + ▼ + User reads feedback, manually edits document +``` + +## Technical Decisions + +### TD-1: Context Menu Implementation + +**Decision**: Create a new `EditorContextMenu` component rather than extending FileTree's menu. + +**Rationale**: FileTree's menu operates on file paths; editor menu operates on text selections. Different data models and positioning logic. The long-press timer pattern from FileTree (500ms timeout, touch event handling) will be extracted into a reusable hook. + +**Implementation**: +- New `useLongPress` hook in `frontend/src/hooks/useLongPress.ts` +- New `EditorContextMenu.tsx` component with portal rendering +- Position calculated from Selection API's `getBoundingClientRect()` + +*Addresses*: REQ-F-2, REQ-F-3, REQ-NF-3, REQ-NF-6 + +### TD-2: Quick Actions via Claude Tool Use + +**Decision**: Quick Actions use Claude's Read and Edit tools to modify the file directly, leveraging the existing SDK streaming infrastructure. + +**Rationale**: +- Claude already knows how to use file editing tools +- Tool use pattern is already implemented in the Discussion flow +- Claude handles the complexity of determining exact edit boundaries +- Consistent mental model: "Claude edits your file" (not "Claude suggests, you apply") +- File is immediately persisted - no "unsaved changes" state for Quick Actions + +**Expected turn sequence** (typically 3-5 turns): +1. User message with prompt + context +2. Claude acknowledges task, uses Read tool to see current file +3. Claude confirms what it sees, uses Edit tool to make change +4. Claude confirms completion (brief) + +**Backend Flow**: +1. Receive `quick_action_request` with action type, file path, selection, line numbers +2. Create task-scoped Claude session with Read/Edit tools available +3. System prompt instructs Claude to work efficiently (read → edit → confirm) +4. Stream tool_start/tool_end events to frontend (reuse existing infrastructure) +5. Claude's Edit tool invocation writes directly to vault file +6. Session ends; optional commentary shown as toast + +**Why not one-shot text-in/text-out**: +- Would require backend to apply the edit (duplicating Edit tool logic) +- Loses Claude's reasoning about edit boundaries and context +- Misses opportunity to show tool use in UI (transparency) + +*Addresses*: REQ-F-4, REQ-F-5, REQ-F-6 + +### TD-3: Action-Specific System Prompts + +**Decision**: Store action prompts in a configuration object on the backend, not in the vault. + +**Rationale**: +- Spec requires fixed actions (no user customization in v1) +- Backend control ensures consistent behavior across vaults +- Easy to tune prompts without user intervention +- Future: could expose in vault config when customization is added + +**Prompt Structure** (example for "Tighten"): +``` +You are a writing assistant performing a Quick Action. Be efficient: read the file, make the edit, confirm briefly. + +Task: Tighten the selected text in "{filePath}" (lines {startLine}-{endLine}). + +Rules for "Tighten": +- Preserve the core meaning +- Remove filler words, redundant phrases, unnecessary qualifiers +- Maintain the author's voice and the document's tone + +Selected text to revise: +{selectedText} + +Surrounding context (for tone matching - do not modify this): +{contextBefore} +[SELECTION TO EDIT] +{contextAfter} + +Workflow: +1. Read the file to see current state +2. Use Edit tool to replace the selection with tightened version +3. Confirm with one sentence (e.g., "Removed 3 filler phrases.") + +Keep responses brief. No lengthy explanations. +``` + +**Efficiency guidance in prompt**: +- Explicitly states "be efficient" and "keep responses brief" +- Provides workflow steps to minimize wandering +- Asks for one-sentence confirmation, not paragraphs + +**Position hint logic**: Based on line numbers relative to document length: +- Lines 1-20% → "near the beginning of" +- Lines 20-80% → "in the middle of" +- Lines 80-100% → "near the end of" + +**Tool availability**: Quick Action requests provide Claude with Read and Edit tools scoped to the current vault. + +*Addresses*: REQ-F-1, REQ-F-4 + +### TD-4: Selection Context Extraction + +**Decision**: Extract one paragraph before and after the selection, where paragraphs are delimited by double newlines (`\n\n`). + +**Rationale**: +- Spec explicitly defines paragraph boundaries as blank lines (REQ-F-4) +- One paragraph provides sufficient context for tone/style matching +- Prevents sending entire document to Claude (cost, latency, privacy) +- Consistent behavior regardless of document size + +**Implementation**: +```typescript +interface SelectionContext { + before: string; // Paragraph before selection + selection: string; // Selected text + after: string; // Paragraph after selection + startLine: number; // 1-indexed line number of selection start + endLine: number; // 1-indexed line number of selection end + totalLines: number; // Total lines in document +} + +function extractContext(content: string, selectionStart: number, selectionEnd: number): SelectionContext { + // Find paragraph boundaries using \n\n as delimiter + // Count newlines to determine line numbers + // Return context with text and line metadata +} +``` + +*Addresses*: REQ-F-4, REQ-F-8 + +### TD-5: Pair Writing Mode State Management + +**Decision**: Pair Writing Mode state managed in BrowseMode component, not in SessionContext. + +**Rationale**: +- Pair Writing is a Browse sub-mode, not a top-level mode +- Session-scoped state (conversation, snapshot) fits naturally in component state +- SessionContext manages cross-mode concerns (vault, session ID); Pair Writing is single-mode +- Simplifies cleanup: unmounting PairWritingMode clears all state + +**State Shape**: +```typescript +interface PairWritingState { + isActive: boolean; + content: string; // Current editor content + snapshot: string | null; // Manual snapshot for comparison + conversation: Message[]; // Session-scoped chat history + selection: Selection | null; // Current text selection + hasUnsavedChanges: boolean; +} +``` + +*Addresses*: REQ-F-22, REQ-F-23, REQ-F-24, REQ-F-27 + +### TD-6: Split-Screen Layout + +**Decision**: CSS Grid with two columns; no drag-to-resize in v1. + +**Rationale**: +- Simpler implementation than draggable divider +- Open question in spec ("Should split be resizable?") leaves room for v1 simplicity +- 50/50 split is reasonable default for editor + conversation +- Can add resize handle in future iteration + +**CSS**: +```css +.pair-writing { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1rem; + height: 100%; +} +``` + +*Addresses*: REQ-F-11 + +### TD-7: Conversation Pane Reuse + +**Decision**: Extract conversation display logic from Discussion.tsx into a shared `ConversationPane` component. + +**Rationale**: +- REQ-NF-4 requires conversation pane styling to match Discussion mode +- Discussion.tsx currently bundles conversation display with mode-specific logic +- Extracting shared component ensures visual consistency and reduces duplication +- Pair Writing conversation is simpler (no tool invocations display needed for advisory actions) + +**Extraction**: +``` +Discussion.tsx + ├── uses → ConversationPane (new shared component) + │ ├── MessageList + │ ├── ChatInput + │ └── Streaming indicator + └── mode-specific logic (slash commands, tool permissions) + +PairWritingConversation.tsx + └── uses → ConversationPane (same shared component) +``` + +*Addresses*: REQ-F-13, REQ-NF-4 + +### TD-8: Snapshot Implementation + +**Decision**: Store snapshot as a string in PairWritingState; comparison performed client-side with diff sent to Claude for analysis. + +**Rationale**: +- Single snapshot (REQ-F-24) simplifies to one string field +- Client has both current and snapshot content; can compute diff without backend +- Send diff (not full documents) to Claude for "what changed" analysis +- Keeps snapshot ephemeral (session-scoped per REQ-F-27) + +**Flow**: +1. User clicks "Snapshot" → `snapshot = content` +2. User edits document → `content` changes +3. User selects text, clicks "Compare to snapshot" +4. Frontend finds corresponding region in snapshot (by line numbers or fuzzy match) +5. If found: send before/after to backend for Claude analysis +6. If not found: show "No corresponding text in snapshot" (REQ-F-26) + +*Addresses*: REQ-F-23, REQ-F-24, REQ-F-25, REQ-F-26, REQ-F-27 + +### TD-9: Desktop-Only Detection + +**Decision**: Use CSS media query combined with touch event detection. + +**Rationale**: +- `@media (hover: hover) and (pointer: fine)` targets devices with mouse +- Additional runtime check for `'ontouchstart' in window` catches edge cases +- Hides "Pair Writing" button via CSS on touch devices (REQ-F-10) +- Touch detection also used to show/hide context menu items appropriately + +**Implementation**: +```css +@media (hover: none), (pointer: coarse) { + .pair-writing-button { display: none; } + .advisory-actions { display: none; } +} +``` + +*Addresses*: REQ-F-10, REQ-NF-1 + +### TD-10: Loading State During Quick Actions + +**Decision**: Apply visual indicator (opacity reduction + spinner) to the selected text range while Claude processes. + +**Rationale**: +- REQ-F-7 requires loading indicator on selection +- Can't use global spinner (user might think whole app is frozen) +- Selection highlight with reduced opacity + small spinner near cursor provides clear feedback +- Indicator shows during Claude's reasoning + tool execution (typically 2-5 seconds) + +**Implementation**: +- On `quick_action_request` send: apply loading state to selection +- Show spinner near selection while streaming (tool_start → tool_end) +- On `response_end`: remove loading state, reload file content from disk +- If error: remove loading state, show error toast + +*Addresses*: REQ-F-7 + +### TD-11: Document Save Flow + +**Decision**: Quick Actions are immediately persisted (Claude uses Edit tool). Pair Writing Mode manual edits use existing `write_file` for saving. + +**Rationale**: +- Quick Actions: Claude's Edit tool writes directly to disk - no "unsaved" state +- Manual edits in Pair Writing Mode: User types in editor, needs explicit save +- `hasUnsavedChanges` only tracks manual edits, not Quick Action results +- Last-write-wins per spec (REQ-F-29); no conflict detection needed + +**Implementation**: +- Quick Actions: File updated by Claude's tool invocation; frontend reloads content +- Manual edits: PairWritingToolbar Save button calls existing `write_file` handler +- Exit button checks `hasUnsavedChanges` for manual edits only +- After Quick Action completes, editor reloads file from disk (not from response) + +**Spec note**: REQ-F-8 states "Quick Action edits are part of unsaved changes" but this conflicts with tool-based approach. Recommend updating spec to clarify Quick Actions persist immediately. + +*Addresses*: REQ-F-28, REQ-F-29, REQ-F-30 (REQ-F-8 needs spec revision) + +### TD-12: Keyboard Accessibility + +**Decision**: EditorContextMenu supports keyboard navigation via arrow keys and Enter/Escape. + +**Rationale**: +- REQ-NF-5 requires keyboard-navigable context menu +- Pattern matches native browser context menus and accessibility expectations +- Focus trap while menu is open; Escape dismisses + +**Implementation**: +- Menu items are focusable ` -
- {messages.length === 0 ? ( -
-

Start a conversation about your vault.

-

- Try asking questions about your notes or use slash commands. -

-
- ) : ( - messages.map((message) => ( - - )) - )} - + } + className="discussion__messages" + ariaLabel="Conversation" + />
{ + cleanup(); +}); + +function createMessage( + overrides: Partial = {} +): ConversationMessage { + return { + id: `msg-${Math.random().toString(36).slice(2)}`, + role: "assistant", + content: "Test content", + timestamp: new Date(), + isStreaming: false, + ...overrides, + }; +} + +describe("ConversationPane", () => { + describe("rendering", () => { + it("renders message list with proper role attribute", () => { + const messages = [ + createMessage({ id: "1", content: "Hello" }), + createMessage({ id: "2", content: "World" }), + ]; + + const { container } = render(); + + const messageList = container.querySelector('[role="list"]'); + expect(messageList).not.toBeNull(); + }); + + it("renders messages using MessageBubble component", () => { + const messages = [ + createMessage({ id: "1", role: "user", content: "User message" }), + createMessage({ id: "2", role: "assistant", content: "Assistant message" }), + ]; + + const { container } = render(); + + // MessageBubble renders with message-bubble class + const bubbles = container.querySelectorAll(".message-bubble"); + expect(bubbles.length).toBe(2); + + // Check user and assistant classes + expect( + container.querySelector(".message-bubble--user") + ).not.toBeNull(); + expect( + container.querySelector(".message-bubble--assistant") + ).not.toBeNull(); + }); + + it("passes vaultId to MessageBubble for image display", () => { + const messages = [ + createMessage({ + id: "1", + role: "assistant", + content: "Check this: ![[05_Attachments/image.png]]", + }), + ]; + + const { container } = render( + + ); + + // MessageBubble with vaultId transforms image paths to img elements + const img = container.querySelector("img:not(.message-bubble__hr)"); + expect(img).not.toBeNull(); + expect(img?.getAttribute("src")).toBe( + "/vault/test-vault/assets/05_Attachments/image.png" + ); + }); + + it("applies custom className when provided", () => { + const { container } = render( + + ); + + const pane = container.querySelector(".conversation-pane"); + expect(pane?.classList.contains("custom-class")).toBe(true); + }); + + it("uses custom ariaLabel when provided", () => { + const { container } = render( + + ); + + const pane = container.querySelector('[aria-label="Custom conversation"]'); + expect(pane).not.toBeNull(); + }); + + it("uses default ariaLabel when not provided", () => { + const { container } = render(); + + const pane = container.querySelector('[aria-label="Conversation"]'); + expect(pane).not.toBeNull(); + }); + }); + + describe("empty state", () => { + it("renders default DiscussionEmptyState when no messages and no custom emptyState", () => { + const { container } = render(); + + // Default empty state shows "Start a conversation" text + expect(container.textContent).toContain("Start a conversation"); + }); + + it("renders custom emptyState when provided and no messages", () => { + const customEmpty =
Custom empty state
; + + const { container } = render( + + ); + + expect( + container.querySelector('[data-testid="custom-empty"]') + ).not.toBeNull(); + expect(container.textContent).toContain("Custom empty state"); + }); + + it("does not render empty state when messages exist", () => { + const messages = [createMessage({ id: "1", content: "Hello" })]; + + const { container } = render( + } /> + ); + + expect(container.querySelector('[data-testid="custom-empty"]')).toBeNull(); + }); + }); + + describe("DiscussionEmptyState", () => { + it("renders with expected text content", () => { + const { container } = render(); + + expect(container.textContent).toContain("Start a conversation about your vault"); + expect(container.textContent).toContain("slash commands"); + }); + + it("has proper CSS class for styling", () => { + const { container } = render(); + + expect( + container.querySelector(".conversation-pane__empty") + ).not.toBeNull(); + }); + }); + + describe("streaming indicator", () => { + it("renders streaming indicator when message is streaming", () => { + const messages = [ + createMessage({ + id: "1", + role: "assistant", + content: "Streaming...", + isStreaming: true, + }), + ]; + + const { container } = render(); + + // Streaming indicator is an img with alt="Typing" + const cursor = container.querySelector('img[alt="Typing"]'); + expect(cursor).not.toBeNull(); + }); + + it("does not render streaming indicator for completed messages", () => { + const messages = [ + createMessage({ + id: "1", + role: "assistant", + content: "Complete message", + isStreaming: false, + }), + ]; + + const { container } = render(); + + const cursor = container.querySelector('img[alt="Typing"]'); + expect(cursor).toBeNull(); + }); + }); + + describe("scroll behavior", () => { + it("includes scroll anchor element at end", () => { + const messages = [createMessage({ id: "1", content: "Message" })]; + + const { container } = render(); + + // The scroll anchor is a div with aria-hidden="true" at the end + const anchor = container.querySelector('[aria-hidden="true"]'); + expect(anchor).not.toBeNull(); + }); + }); + + describe("multiple messages", () => { + it("renders messages in order", () => { + const messages = [ + createMessage({ id: "1", role: "user", content: "First message" }), + createMessage({ id: "2", role: "assistant", content: "Second message" }), + createMessage({ id: "3", role: "user", content: "Third message" }), + ]; + + const { container } = render(); + + const textContent = container.textContent ?? ""; + const firstIndex = textContent.indexOf("First message"); + const secondIndex = textContent.indexOf("Second message"); + const thirdIndex = textContent.indexOf("Third message"); + + expect(firstIndex).toBeLessThan(secondIndex); + expect(secondIndex).toBeLessThan(thirdIndex); + }); + + it("uses message id as key for stable rendering", () => { + const messages = [ + createMessage({ id: "stable-id-1", content: "Message 1" }), + createMessage({ id: "stable-id-2", content: "Message 2" }), + ]; + + const { rerender, container } = render( + + ); + + // Get initial render count + const initialBubbles = container.querySelectorAll(".message-bubble"); + expect(initialBubbles.length).toBe(2); + + // Add a new message + const updatedMessages = [ + ...messages, + createMessage({ id: "stable-id-3", content: "Message 3" }), + ]; + + rerender(); + + const updatedBubbles = container.querySelectorAll(".message-bubble"); + expect(updatedBubbles.length).toBe(3); + }); + }); + + describe("tool invocations", () => { + it("renders tool invocations in assistant messages", () => { + const messages = [ + createMessage({ + id: "1", + role: "assistant", + content: "Using a tool...", + toolInvocations: [ + { + toolUseId: "tool-1", + toolName: "Read", + input: { path: "/test.txt" }, + output: "file content", + status: "complete" as const, + }, + ], + }), + ]; + + const { container } = render(); + + // Tool display component should render + const toolDisplay = container.querySelector(".message-bubble__tools"); + expect(toolDisplay).not.toBeNull(); + }); + }); +}); diff --git a/frontend/src/hooks/__tests__/useTextSelection.test.ts b/frontend/src/hooks/__tests__/useTextSelection.test.ts new file mode 100644 index 00000000..69da22a4 --- /dev/null +++ b/frontend/src/hooks/__tests__/useTextSelection.test.ts @@ -0,0 +1,430 @@ +/** + * useTextSelection Hook Tests + * + * Tests line counting, paragraph extraction, and hook behavior. + */ + +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { renderHook, act } from "@testing-library/react"; +import { + useTextSelection, + getLineNumber, + countTotalLines, + extractParagraphBefore, + extractParagraphAfter, + extractSelectionContext, +} from "../useTextSelection"; +import { createRef } from "react"; + +describe("getLineNumber", () => { + it("returns 1 for position 0", () => { + expect(getLineNumber("hello\nworld", 0)).toBe(1); + }); + + it("returns 1 for positions on the first line", () => { + expect(getLineNumber("hello\nworld", 3)).toBe(1); + expect(getLineNumber("hello\nworld", 5)).toBe(1); + }); + + it("returns 2 for positions after first newline", () => { + expect(getLineNumber("hello\nworld", 6)).toBe(2); + expect(getLineNumber("hello\nworld", 10)).toBe(2); + }); + + it("handles multiple lines correctly", () => { + const text = "line1\nline2\nline3\nline4"; + expect(getLineNumber(text, 0)).toBe(1); // start of line1 + expect(getLineNumber(text, 6)).toBe(2); // start of line2 + expect(getLineNumber(text, 12)).toBe(3); // start of line3 + expect(getLineNumber(text, 18)).toBe(4); // start of line4 + }); + + it("handles empty string", () => { + expect(getLineNumber("", 0)).toBe(1); + }); + + it("clamps position to text length", () => { + expect(getLineNumber("hello", 100)).toBe(1); + expect(getLineNumber("hello\nworld", 100)).toBe(2); + }); + + it("handles consecutive newlines", () => { + const text = "a\n\nb"; + expect(getLineNumber(text, 0)).toBe(1); // a + expect(getLineNumber(text, 2)).toBe(2); // empty line + expect(getLineNumber(text, 3)).toBe(3); // b + }); +}); + +describe("countTotalLines", () => { + it("returns 1 for empty string", () => { + expect(countTotalLines("")).toBe(1); + }); + + it("returns 1 for single line without newline", () => { + expect(countTotalLines("hello")).toBe(1); + }); + + it("returns 2 for text with one newline", () => { + expect(countTotalLines("hello\nworld")).toBe(2); + }); + + it("counts multiple lines correctly", () => { + expect(countTotalLines("a\nb\nc\nd")).toBe(4); + }); + + it("counts trailing newline as extra line", () => { + expect(countTotalLines("hello\n")).toBe(2); + }); + + it("counts blank lines", () => { + expect(countTotalLines("a\n\nb")).toBe(3); + expect(countTotalLines("a\n\n\nb")).toBe(4); + }); +}); + +describe("extractParagraphBefore", () => { + it("returns empty string when selection is at start", () => { + expect(extractParagraphBefore("hello world", 0)).toBe(""); + }); + + it("returns empty string when no paragraph delimiter exists before", () => { + expect(extractParagraphBefore("hello world", 5)).toBe(""); + }); + + it("extracts paragraph before when delimiter exists", () => { + const text = "First paragraph.\n\nSecond paragraph."; + // Selection at start of "Second" + expect(extractParagraphBefore(text, 18)).toBe("First paragraph."); + }); + + it("extracts correct paragraph with multiple paragraphs", () => { + const text = "Para one.\n\nPara two.\n\nPara three."; + // Selection at "Para three" + expect(extractParagraphBefore(text, 22)).toBe("Para two."); + }); + + it("handles paragraph at document start", () => { + const text = "Very first.\n\nSecond.\n\nThird."; + // Selection at "Second" + expect(extractParagraphBefore(text, 13)).toBe("Very first."); + }); + + it("handles extra whitespace in paragraphs", () => { + const text = " Para one. \n\n Para two. \n\nPara three."; + // Selection at "Para three" + expect(extractParagraphBefore(text, 31)).toBe("Para two."); + }); + + it("returns empty for single paragraph document", () => { + const text = "Just one paragraph here."; + expect(extractParagraphBefore(text, 10)).toBe(""); + }); +}); + +describe("extractParagraphAfter", () => { + it("returns empty string when selection is at end", () => { + const text = "hello world"; + expect(extractParagraphAfter(text, text.length)).toBe(""); + }); + + it("returns empty string when no paragraph delimiter exists after", () => { + expect(extractParagraphAfter("hello world", 5)).toBe(""); + }); + + it("extracts paragraph after when delimiter exists", () => { + const text = "First paragraph.\n\nSecond paragraph."; + // Selection ends at "First paragraph." + expect(extractParagraphAfter(text, 16)).toBe("Second paragraph."); + }); + + it("extracts correct paragraph with multiple paragraphs", () => { + const text = "Para one.\n\nPara two.\n\nPara three."; + // Selection ends at "Para one." + expect(extractParagraphAfter(text, 9)).toBe("Para two."); + }); + + it("handles paragraph at document end", () => { + const text = "First.\n\nSecond.\n\nVery last."; + // Selection ends at "Second." + expect(extractParagraphAfter(text, 15)).toBe("Very last."); + }); + + it("handles extra whitespace in paragraphs", () => { + const text = "Para one.\n\n Para two. \n\n Para three. "; + // Selection ends at "Para one." + expect(extractParagraphAfter(text, 9)).toBe("Para two."); + }); + + it("returns empty for single paragraph document", () => { + const text = "Just one paragraph here."; + expect(extractParagraphAfter(text, 10)).toBe(""); + }); +}); + +describe("extractSelectionContext", () => { + const sampleDoc = `# Introduction + +This is the first paragraph with some content. + +This is the second paragraph. + +And this is the third paragraph at the end.`; + + it("returns null for empty selection (start equals end)", () => { + expect(extractSelectionContext(sampleDoc, 5, 5)).toBeNull(); + }); + + it("returns null for whitespace-only selection", () => { + expect(extractSelectionContext("hello world", 5, 8)).toBeNull(); + }); + + it("returns null for invalid start position", () => { + expect(extractSelectionContext(sampleDoc, -1, 10)).toBeNull(); + }); + + it("returns null for invalid end position", () => { + expect(extractSelectionContext(sampleDoc, 0, -1)).toBeNull(); + }); + + it("returns null when start > end", () => { + expect(extractSelectionContext(sampleDoc, 10, 5)).toBeNull(); + }); + + it("returns null when positions exceed content length", () => { + expect(extractSelectionContext("short", 0, 100)).toBeNull(); + expect(extractSelectionContext("short", 100, 105)).toBeNull(); + }); + + it("extracts selection text correctly", () => { + const result = extractSelectionContext("hello world", 0, 5); + expect(result?.text).toBe("hello"); + }); + + it("calculates line numbers correctly for single line selection", () => { + const text = "line1\nline2\nline3"; + // Select "line2" + const result = extractSelectionContext(text, 6, 11); + expect(result?.startLine).toBe(2); + expect(result?.endLine).toBe(2); + }); + + it("calculates line numbers correctly for multi-line selection", () => { + const text = "line1\nline2\nline3\nline4"; + // Select "line2\nline3" + const result = extractSelectionContext(text, 6, 17); + expect(result?.startLine).toBe(2); + expect(result?.endLine).toBe(3); + }); + + it("calculates total lines correctly", () => { + const text = "line1\nline2\nline3\nline4"; + const result = extractSelectionContext(text, 0, 5); + expect(result?.totalLines).toBe(4); + }); + + it("extracts paragraph context correctly", () => { + const text = "Para 1.\n\nPara 2 selected text here.\n\nPara 3."; + // Select "selected" in para 2 + const result = extractSelectionContext(text, 16, 24); + expect(result?.contextBefore).toBe("Para 1."); + expect(result?.contextAfter).toBe("Para 3."); + }); + + it("handles selection in first paragraph", () => { + const text = "First paragraph.\n\nSecond paragraph."; + const result = extractSelectionContext(text, 0, 5); + expect(result?.contextBefore).toBe(""); + expect(result?.contextAfter).toBe("Second paragraph."); + }); + + it("handles selection in last paragraph", () => { + const text = "First paragraph.\n\nLast paragraph."; + const result = extractSelectionContext(text, 18, 22); + expect(result?.contextBefore).toBe("First paragraph."); + expect(result?.contextAfter).toBe(""); + }); + + it("handles document with no paragraph breaks", () => { + const text = "Single paragraph document with no breaks."; + const result = extractSelectionContext(text, 7, 16); + expect(result?.contextBefore).toBe(""); + expect(result?.contextAfter).toBe(""); + expect(result?.text).toBe("paragraph"); + }); +}); + +describe("useTextSelection hook", () => { + // Mock textarea for testing + let mockTextarea: HTMLTextAreaElement; + + beforeEach(() => { + mockTextarea = document.createElement("textarea"); + mockTextarea.value = "Hello\n\nWorld\n\nEnd."; + document.body.appendChild(mockTextarea); + }); + + afterEach(() => { + document.body.removeChild(mockTextarea); + }); + + it("returns null selection initially", () => { + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result } = renderHook(() => + useTextSelection(ref, mockTextarea.value) + ); + + expect(result.current.selection).toBeNull(); + }); + + it("provides clearSelection function", () => { + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result } = renderHook(() => + useTextSelection(ref, mockTextarea.value) + ); + + expect(typeof result.current.clearSelection).toBe("function"); + }); + + it("updates selection on select event", () => { + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result } = renderHook(() => + useTextSelection(ref, mockTextarea.value) + ); + + // Simulate selection + act(() => { + mockTextarea.selectionStart = 0; + mockTextarea.selectionEnd = 5; + mockTextarea.dispatchEvent(new Event("select")); + }); + + expect(result.current.selection).not.toBeNull(); + expect(result.current.selection?.text).toBe("Hello"); + }); + + it("updates selection on mouseup", () => { + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result } = renderHook(() => + useTextSelection(ref, mockTextarea.value) + ); + + // Simulate selection via mouseup + act(() => { + mockTextarea.selectionStart = 7; + mockTextarea.selectionEnd = 12; + mockTextarea.dispatchEvent(new MouseEvent("mouseup")); + }); + + expect(result.current.selection).not.toBeNull(); + expect(result.current.selection?.text).toBe("World"); + }); + + it("clears selection when content changes", () => { + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result, rerender } = renderHook( + ({ content }) => useTextSelection(ref, content), + { initialProps: { content: mockTextarea.value } } + ); + + // Set up a selection + act(() => { + mockTextarea.selectionStart = 0; + mockTextarea.selectionEnd = 5; + mockTextarea.dispatchEvent(new Event("select")); + }); + + expect(result.current.selection).not.toBeNull(); + + // Change content + rerender({ content: "Different content entirely" }); + + expect(result.current.selection).toBeNull(); + }); + + it("clearSelection sets selection to null", () => { + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result } = renderHook(() => + useTextSelection(ref, mockTextarea.value) + ); + + // Set up a selection + act(() => { + mockTextarea.selectionStart = 0; + mockTextarea.selectionEnd = 5; + mockTextarea.dispatchEvent(new Event("select")); + }); + + expect(result.current.selection).not.toBeNull(); + + // Clear it + act(() => { + result.current.clearSelection(); + }); + + expect(result.current.selection).toBeNull(); + }); + + it("calculates correct context for middle selection", () => { + const content = "First para.\n\nMiddle para has selected text.\n\nLast para."; + mockTextarea.value = content; + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result } = renderHook(() => useTextSelection(ref, content)); + + // Select "selected" in middle paragraph + // "First para.\n\nMiddle para has selected text.\n\nLast para." + // "selected" starts at index 29 and ends at 37 + act(() => { + mockTextarea.selectionStart = 29; + mockTextarea.selectionEnd = 37; + mockTextarea.dispatchEvent(new Event("select")); + }); + + expect(result.current.selection?.text).toBe("selected"); + expect(result.current.selection?.contextBefore).toBe("First para."); + expect(result.current.selection?.contextAfter).toBe("Last para."); + }); + + it("returns null for null ref", () => { + const ref = createRef(); + // ref.current is null + + const { result } = renderHook(() => useTextSelection(ref, "some content")); + + expect(result.current.selection).toBeNull(); + }); + + it("returns correct line numbers", () => { + const content = "Line 1\nLine 2\nLine 3\nLine 4"; + mockTextarea.value = content; + const ref = createRef(); + (ref as { current: HTMLTextAreaElement }).current = mockTextarea; + + const { result } = renderHook(() => useTextSelection(ref, content)); + + // Select "Line 2\nLine 3" + act(() => { + mockTextarea.selectionStart = 7; + mockTextarea.selectionEnd = 20; + mockTextarea.dispatchEvent(new Event("select")); + }); + + expect(result.current.selection?.startLine).toBe(2); + expect(result.current.selection?.endLine).toBe(3); + expect(result.current.selection?.totalLines).toBe(4); + }); +}); diff --git a/frontend/src/hooks/useLongPress.test.ts b/frontend/src/hooks/useLongPress.test.ts new file mode 100644 index 00000000..222850eb --- /dev/null +++ b/frontend/src/hooks/useLongPress.test.ts @@ -0,0 +1,276 @@ +import { describe, test, expect, mock } from "bun:test"; +import { renderHook, act } from "@testing-library/react"; +import { useLongPress, DEFAULT_LONG_PRESS_DURATION } from "./useLongPress"; + +describe("useLongPress", () => { + + /** + * Helper to create a mock touch event + */ + function createTouchEvent(type: "start" | "move" | "end"): React.TouchEvent { + const prevented = { current: false }; + return { + type: `touch${type}`, + preventDefault: () => { + prevented.current = true; + }, + touches: [{ clientX: 100, clientY: 200 }], + changedTouches: [{ clientX: 100, clientY: 200 }], + // Expose for test assertions + _prevented: prevented, + } as unknown as React.TouchEvent & { _prevented: { current: boolean } }; + } + + describe("default duration", () => { + test("exports DEFAULT_LONG_PRESS_DURATION as 500ms", () => { + expect(DEFAULT_LONG_PRESS_DURATION).toBe(500); + }); + + test("uses 500ms duration by default", async () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback)); + + const event = createTouchEvent("start"); + act(() => { + result.current.onTouchStart(event); + }); + + // Not called immediately + expect(callback).not.toHaveBeenCalled(); + + // Wait less than 500ms + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(callback).not.toHaveBeenCalled(); + + // Wait past 500ms + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("custom duration", () => { + test("respects custom duration option", async () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback, { duration: 200 })); + + const event = createTouchEvent("start"); + act(() => { + result.current.onTouchStart(event); + }); + + // Not called at 150ms + await new Promise((resolve) => setTimeout(resolve, 150)); + expect(callback).not.toHaveBeenCalled(); + + // Called after 200ms + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("callback invocation", () => { + test("calls callback with the touch event", async () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback, { duration: 50 })); + + const event = createTouchEvent("start"); + act(() => { + result.current.onTouchStart(event); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callback).toHaveBeenCalledTimes(1); + // The callback receives the event + expect(callback).toHaveBeenCalledWith(event); + }); + + test("does not call callback if undefined", async () => { + const { result } = renderHook(() => useLongPress(undefined, { duration: 50 })); + + const event = createTouchEvent("start"); + act(() => { + result.current.onTouchStart(event); + }); + + await new Promise((resolve) => setTimeout(resolve, 100)); + // No error thrown, just no callback + }); + }); + + describe("preventDefault", () => { + test("calls preventDefault on touchstart to suppress system context menu", () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback)); + + const event = createTouchEvent("start") as React.TouchEvent & { + _prevented: { current: boolean }; + }; + act(() => { + result.current.onTouchStart(event); + }); + + expect(event._prevented.current).toBe(true); + }); + }); + + describe("cancellation on move", () => { + test("cancels timer when touch moves", async () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback, { duration: 100 })); + + act(() => { + result.current.onTouchStart(createTouchEvent("start")); + }); + + // Move before timer fires + await new Promise((resolve) => setTimeout(resolve, 50)); + act(() => { + result.current.onTouchMove(createTouchEvent("move")); + }); + + // Wait past original timer + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("cancellation on end", () => { + test("cancels timer when touch ends", async () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback, { duration: 100 })); + + act(() => { + result.current.onTouchStart(createTouchEvent("start")); + }); + + // End before timer fires + await new Promise((resolve) => setTimeout(resolve, 50)); + act(() => { + result.current.onTouchEnd(createTouchEvent("end")); + }); + + // Wait past original timer + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("cleanup", () => { + test("cleans up timer on unmount", async () => { + const callback = mock(() => {}); + const { result, unmount } = renderHook(() => useLongPress(callback, { duration: 100 })); + + act(() => { + result.current.onTouchStart(createTouchEvent("start")); + }); + + // Unmount before timer fires + await new Promise((resolve) => setTimeout(resolve, 50)); + unmount(); + + // Wait past original timer + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callback).not.toHaveBeenCalled(); + }); + }); + + describe("multiple interactions", () => { + test("can trigger multiple long presses sequentially", async () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback, { duration: 50 })); + + // First long press + act(() => { + result.current.onTouchStart(createTouchEvent("start")); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callback).toHaveBeenCalledTimes(1); + + // End first touch + act(() => { + result.current.onTouchEnd(createTouchEvent("end")); + }); + + // Second long press + act(() => { + result.current.onTouchStart(createTouchEvent("start")); + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(callback).toHaveBeenCalledTimes(2); + }); + + test("restarting touch before timer fires resets the timer", async () => { + const callback = mock(() => {}); + const { result } = renderHook(() => useLongPress(callback, { duration: 100 })); + + // Start first touch + act(() => { + result.current.onTouchStart(createTouchEvent("start")); + }); + + // Wait 60ms, then end + await new Promise((resolve) => setTimeout(resolve, 60)); + act(() => { + result.current.onTouchEnd(createTouchEvent("end")); + }); + + // Start new touch immediately + act(() => { + result.current.onTouchStart(createTouchEvent("start")); + }); + + // Wait another 60ms (total 120ms from first start, but only 60ms from second) + await new Promise((resolve) => setTimeout(resolve, 60)); + expect(callback).not.toHaveBeenCalled(); + + // Wait for second timer to complete + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe("return value stability", () => { + test("returns consistent handler references when dependencies unchanged", () => { + const callback = mock(() => {}); + const { result, rerender } = renderHook(() => useLongPress(callback, { duration: 500 })); + + const firstHandlers = result.current; + rerender(); + const secondHandlers = result.current; + + expect(secondHandlers.onTouchStart).toBe(firstHandlers.onTouchStart); + expect(secondHandlers.onTouchMove).toBe(firstHandlers.onTouchMove); + expect(secondHandlers.onTouchEnd).toBe(firstHandlers.onTouchEnd); + }); + + test("updates handlers when callback changes", () => { + const callback1 = mock(() => {}); + const callback2 = mock(() => {}); + const { result, rerender } = renderHook( + ({ cb }) => useLongPress(cb, { duration: 500 }), + { initialProps: { cb: callback1 } } + ); + + const firstStart = result.current.onTouchStart; + rerender({ cb: callback2 }); + const secondStart = result.current.onTouchStart; + + // onTouchStart should be different because callback changed + expect(secondStart).not.toBe(firstStart); + }); + + test("updates handlers when duration changes", () => { + const callback = mock(() => {}); + const { result, rerender } = renderHook( + ({ dur }) => useLongPress(callback, { duration: dur }), + { initialProps: { dur: 500 } } + ); + + const firstStart = result.current.onTouchStart; + rerender({ dur: 1000 }); + const secondStart = result.current.onTouchStart; + + expect(secondStart).not.toBe(firstStart); + }); + }); +}); diff --git a/frontend/src/hooks/useLongPress.ts b/frontend/src/hooks/useLongPress.ts new file mode 100644 index 00000000..66079137 --- /dev/null +++ b/frontend/src/hooks/useLongPress.ts @@ -0,0 +1,114 @@ +/** + * useLongPress Hook + * + * Provides touch event handlers for detecting long-press gestures. + * Used for triggering context menus and other actions on mobile devices. + */ + +import { useRef, useCallback, useEffect } from "react"; + +/** + * Touch event handlers returned by the useLongPress hook. + */ +export interface LongPressHandlers { + onTouchStart: (e: React.TouchEvent) => void; + onTouchMove: (e: React.TouchEvent) => void; + onTouchEnd: (e: React.TouchEvent) => void; +} + +/** + * Options for configuring long press behavior. + */ +export interface UseLongPressOptions { + /** Duration in milliseconds before triggering the callback. Default: 500ms */ + duration?: number; +} + +/** Default long press duration in milliseconds */ +export const DEFAULT_LONG_PRESS_DURATION = 500; + +/** + * React hook for detecting long-press gestures on touch devices. + * + * Starts a timer on touch start, cancels on move or end, and fires + * the callback if the touch is held for the specified duration. + * + * @param callback - Function to call when long press is detected + * @param options - Configuration options (duration) + * @returns Touch event handlers to attach to an element + * + * @example + * ```tsx + * const handlers = useLongPress( + * (e) => showContextMenu(e), + * { duration: 500 } + * ); + * + * return ( + * + * ); + * ``` + */ +export function useLongPress( + callback: ((e: React.TouchEvent) => void) | undefined, + options: UseLongPressOptions = {} +): LongPressHandlers { + const { duration = DEFAULT_LONG_PRESS_DURATION } = options; + + const timerRef = useRef | null>(null); + const eventRef = useRef(null); + + // Cleanup timer on unmount + useEffect(() => { + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, []); + + const clearTimer = useCallback(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + eventRef.current = null; + }, []); + + const onTouchStart = useCallback( + (e: React.TouchEvent) => { + if (!callback) return; + + // Prevent system context menu from appearing + e.preventDefault(); + + // Store event for callback + eventRef.current = e; + + timerRef.current = setTimeout(() => { + if (eventRef.current) { + callback(eventRef.current); + } + timerRef.current = null; + eventRef.current = null; + }, duration); + }, + [callback, duration] + ); + + const onTouchMove = useCallback(() => { + clearTimer(); + }, [clearTimer]); + + const onTouchEnd = useCallback(() => { + clearTimer(); + }, [clearTimer]); + + return { + onTouchStart, + onTouchMove, + onTouchEnd, + }; +} diff --git a/frontend/src/hooks/usePairWritingState.test.ts b/frontend/src/hooks/usePairWritingState.test.ts new file mode 100644 index 00000000..bbb0d295 --- /dev/null +++ b/frontend/src/hooks/usePairWritingState.test.ts @@ -0,0 +1,583 @@ +/** + * Tests for usePairWritingState hook. + * + * Covers all state transitions and ensures session-scoped behavior (REQ-F-27). + */ + +import { describe, test, expect } from "bun:test"; +import { renderHook, act } from "@testing-library/react"; +import { usePairWritingState, type TextSelection } from "./usePairWritingState"; + +describe("usePairWritingState", () => { + describe("initial state", () => { + test("starts inactive with empty state", () => { + const { result } = renderHook(() => usePairWritingState()); + + expect(result.current.state).toEqual({ + isActive: false, + content: "", + snapshot: null, + conversation: [], + selection: null, + hasUnsavedChanges: false, + }); + }); + }); + + describe("activate", () => { + test("sets isActive to true and initializes content", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("# Hello World"); + }); + + expect(result.current.state.isActive).toBe(true); + expect(result.current.state.content).toBe("# Hello World"); + expect(result.current.state.hasUnsavedChanges).toBe(false); + }); + + test("clears previous state when reactivating", () => { + const { result } = renderHook(() => usePairWritingState()); + + // Set up some state + act(() => { + result.current.actions.activate("initial content"); + result.current.actions.takeSnapshot(); + result.current.actions.addMessage({ role: "user", content: "hello" }); + result.current.actions.setContent("modified content"); + }); + + // Reactivate with new content + act(() => { + result.current.actions.activate("new content"); + }); + + expect(result.current.state.content).toBe("new content"); + expect(result.current.state.snapshot).toBeNull(); + expect(result.current.state.conversation).toEqual([]); + expect(result.current.state.hasUnsavedChanges).toBe(false); + }); + }); + + describe("deactivate", () => { + test("clears all state (REQ-F-27: session-scoped)", () => { + const { result } = renderHook(() => usePairWritingState()); + + // Set up some state + act(() => { + result.current.actions.activate("content"); + result.current.actions.takeSnapshot(); + result.current.actions.addMessage({ role: "user", content: "hello" }); + result.current.actions.setSelection({ + text: "test", + start: 0, + end: 4, + startLine: 1, + endLine: 1, + }); + }); + + // Deactivate + act(() => { + result.current.actions.deactivate(); + }); + + expect(result.current.state).toEqual({ + isActive: false, + content: "", + snapshot: null, + conversation: [], + selection: null, + hasUnsavedChanges: false, + }); + }); + }); + + describe("setContent", () => { + test("updates content and sets hasUnsavedChanges", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("initial"); + }); + + expect(result.current.state.hasUnsavedChanges).toBe(false); + + act(() => { + result.current.actions.setContent("modified"); + }); + + expect(result.current.state.content).toBe("modified"); + expect(result.current.state.hasUnsavedChanges).toBe(true); + }); + + test("marks unsaved even if content is same (intentional simplicity)", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("same"); + result.current.actions.setContent("same"); + }); + + // This is intentional: we don't track original content for comparison + expect(result.current.state.hasUnsavedChanges).toBe(true); + }); + }); + + describe("takeSnapshot (REQ-F-23, REQ-F-24)", () => { + test("captures current content as snapshot", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("snapshot this"); + result.current.actions.takeSnapshot(); + }); + + expect(result.current.state.snapshot).toBe("snapshot this"); + }); + + test("new snapshot replaces previous (REQ-F-24: only one at a time)", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("first snapshot"); + result.current.actions.takeSnapshot(); + }); + + expect(result.current.state.snapshot).toBe("first snapshot"); + + act(() => { + result.current.actions.setContent("second snapshot"); + result.current.actions.takeSnapshot(); + }); + + expect(result.current.state.snapshot).toBe("second snapshot"); + }); + }); + + describe("clearSnapshot", () => { + test("clears the snapshot", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.takeSnapshot(); + }); + + expect(result.current.state.snapshot).toBe("content"); + + act(() => { + result.current.actions.clearSnapshot(); + }); + + expect(result.current.state.snapshot).toBeNull(); + }); + }); + + describe("addMessage", () => { + test("adds message with generated id and timestamp", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ role: "user", content: "hello" }); + }); + + expect(result.current.state.conversation).toHaveLength(1); + const msg = result.current.state.conversation[0]; + expect(msg.role).toBe("user"); + expect(msg.content).toBe("hello"); + expect(msg.id).toMatch(/^pw-msg-/); + expect(msg.timestamp).toBeInstanceOf(Date); + }); + + test("adds multiple messages in order", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ role: "user", content: "question" }); + result.current.actions.addMessage({ + role: "assistant", + content: "answer", + isStreaming: false, + }); + }); + + expect(result.current.state.conversation).toHaveLength(2); + expect(result.current.state.conversation[0].content).toBe("question"); + expect(result.current.state.conversation[1].content).toBe("answer"); + }); + + test("preserves isStreaming flag", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ + role: "assistant", + content: "", + isStreaming: true, + }); + }); + + expect(result.current.state.conversation[0].isStreaming).toBe(true); + }); + }); + + describe("updateLastMessage", () => { + test("appends content to last assistant message", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ + role: "assistant", + content: "Hello", + isStreaming: true, + }); + }); + + act(() => { + result.current.actions.updateLastMessage(" world"); + }); + + expect(result.current.state.conversation[0].content).toBe("Hello world"); + }); + + test("updates isStreaming flag when provided", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ + role: "assistant", + content: "streaming", + isStreaming: true, + }); + }); + + expect(result.current.state.conversation[0].isStreaming).toBe(true); + + act(() => { + result.current.actions.updateLastMessage("", false); + }); + + expect(result.current.state.conversation[0].isStreaming).toBe(false); + }); + + test("preserves isStreaming when not provided", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ + role: "assistant", + content: "", + isStreaming: true, + }); + result.current.actions.updateLastMessage("chunk"); + }); + + expect(result.current.state.conversation[0].isStreaming).toBe(true); + }); + + test("ignores update if conversation is empty", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.updateLastMessage("orphan"); + }); + + expect(result.current.state.conversation).toHaveLength(0); + }); + + test("ignores update if last message is not assistant", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ role: "user", content: "question" }); + result.current.actions.updateLastMessage(" extra"); + }); + + // User message should be unchanged + expect(result.current.state.conversation[0].content).toBe("question"); + }); + }); + + describe("setSelection", () => { + test("sets text selection", () => { + const { result } = renderHook(() => usePairWritingState()); + + const selection: TextSelection = { + text: "selected text", + start: 10, + end: 23, + startLine: 2, + endLine: 2, + }; + + act(() => { + result.current.actions.activate("content"); + result.current.actions.setSelection(selection); + }); + + expect(result.current.state.selection).toEqual(selection); + }); + + test("clears selection when set to null", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.setSelection({ + text: "test", + start: 0, + end: 4, + startLine: 1, + endLine: 1, + }); + }); + + expect(result.current.state.selection).not.toBeNull(); + + act(() => { + result.current.actions.setSelection(null); + }); + + expect(result.current.state.selection).toBeNull(); + }); + }); + + describe("clearAll", () => { + test("is an alias for deactivate (REQ-F-27)", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.takeSnapshot(); + result.current.actions.addMessage({ role: "user", content: "msg" }); + }); + + act(() => { + result.current.actions.clearAll(); + }); + + expect(result.current.state.isActive).toBe(false); + expect(result.current.state.content).toBe(""); + expect(result.current.state.snapshot).toBeNull(); + expect(result.current.state.conversation).toEqual([]); + }); + }); + + describe("markSaved", () => { + test("clears hasUnsavedChanges flag", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("initial"); + result.current.actions.setContent("modified"); + }); + + expect(result.current.state.hasUnsavedChanges).toBe(true); + + act(() => { + result.current.actions.markSaved(); + }); + + expect(result.current.state.hasUnsavedChanges).toBe(false); + }); + }); + + describe("reloadContent", () => { + test("updates content without marking as unsaved", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("initial"); + result.current.actions.setContent("user edits"); + }); + + expect(result.current.state.hasUnsavedChanges).toBe(true); + + // Simulate Quick Action completing and reloading file from disk + act(() => { + result.current.actions.reloadContent("claude edited this"); + }); + + expect(result.current.state.content).toBe("claude edited this"); + expect(result.current.state.hasUnsavedChanges).toBe(false); + }); + + test("preserves other state (snapshot, conversation, selection)", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("initial"); + result.current.actions.takeSnapshot(); + result.current.actions.addMessage({ role: "user", content: "msg" }); + result.current.actions.setSelection({ + text: "test", + start: 0, + end: 4, + startLine: 1, + endLine: 1, + }); + }); + + act(() => { + result.current.actions.reloadContent("reloaded"); + }); + + expect(result.current.state.content).toBe("reloaded"); + expect(result.current.state.snapshot).toBe("initial"); + expect(result.current.state.conversation).toHaveLength(1); + expect(result.current.state.selection).not.toBeNull(); + }); + }); + + describe("action reference stability", () => { + test("actions are stable across re-renders", () => { + const { result, rerender } = renderHook(() => usePairWritingState()); + + const firstActions = result.current.actions; + rerender(); + const secondActions = result.current.actions; + + // All action references should be stable (useCallback) + expect(secondActions.activate).toBe(firstActions.activate); + expect(secondActions.deactivate).toBe(firstActions.deactivate); + expect(secondActions.setContent).toBe(firstActions.setContent); + expect(secondActions.takeSnapshot).toBe(firstActions.takeSnapshot); + expect(secondActions.clearSnapshot).toBe(firstActions.clearSnapshot); + expect(secondActions.addMessage).toBe(firstActions.addMessage); + expect(secondActions.updateLastMessage).toBe(firstActions.updateLastMessage); + expect(secondActions.setSelection).toBe(firstActions.setSelection); + expect(secondActions.clearAll).toBe(firstActions.clearAll); + expect(secondActions.markSaved).toBe(firstActions.markSaved); + expect(secondActions.reloadContent).toBe(firstActions.reloadContent); + }); + }); + + describe("session-scoped behavior (REQ-F-27)", () => { + test("conversation is cleared on exit", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.addMessage({ role: "user", content: "q1" }); + result.current.actions.addMessage({ role: "assistant", content: "a1" }); + result.current.actions.addMessage({ role: "user", content: "q2" }); + }); + + expect(result.current.state.conversation).toHaveLength(3); + + // Exit Pair Writing Mode + act(() => { + result.current.actions.clearAll(); + }); + + expect(result.current.state.conversation).toEqual([]); + }); + + test("snapshot is cleared on exit", () => { + const { result } = renderHook(() => usePairWritingState()); + + act(() => { + result.current.actions.activate("content"); + result.current.actions.takeSnapshot(); + }); + + expect(result.current.state.snapshot).toBe("content"); + + act(() => { + result.current.actions.clearAll(); + }); + + expect(result.current.state.snapshot).toBeNull(); + }); + }); + + describe("typical workflow", () => { + test("edit-snapshot-compare workflow", () => { + const { result } = renderHook(() => usePairWritingState()); + + // 1. Enter Pair Writing Mode with file content + act(() => { + result.current.actions.activate("# Original Title\n\nSome content here."); + }); + + expect(result.current.state.isActive).toBe(true); + expect(result.current.state.hasUnsavedChanges).toBe(false); + + // 2. Take a snapshot before making changes + act(() => { + result.current.actions.takeSnapshot(); + }); + + expect(result.current.state.snapshot).toBe("# Original Title\n\nSome content here."); + + // 3. User edits the document + act(() => { + result.current.actions.setContent("# Better Title\n\nRevised content here."); + }); + + expect(result.current.state.hasUnsavedChanges).toBe(true); + + // 4. User selects text for comparison + act(() => { + result.current.actions.setSelection({ + text: "Better Title", + start: 2, + end: 14, + startLine: 1, + endLine: 1, + }); + }); + + // 5. User asks for comparison, conversation updates + act(() => { + result.current.actions.addMessage({ + role: "user", + content: "Compare to snapshot", + }); + result.current.actions.addMessage({ + role: "assistant", + content: "", + isStreaming: true, + }); + }); + + // 6. Stream in response + act(() => { + result.current.actions.updateLastMessage("The title changed from 'Original Title' to 'Better Title'."); + result.current.actions.updateLastMessage("", false); + }); + + expect(result.current.state.conversation).toHaveLength(2); + expect(result.current.state.conversation[1].content).toBe( + "The title changed from 'Original Title' to 'Better Title'." + ); + expect(result.current.state.conversation[1].isStreaming).toBe(false); + + // 7. User saves + act(() => { + result.current.actions.markSaved(); + }); + + expect(result.current.state.hasUnsavedChanges).toBe(false); + + // 8. Exit clears session state + act(() => { + result.current.actions.clearAll(); + }); + + expect(result.current.state.isActive).toBe(false); + expect(result.current.state.conversation).toEqual([]); + expect(result.current.state.snapshot).toBeNull(); + }); + }); +}); diff --git a/frontend/src/hooks/usePairWritingState.ts b/frontend/src/hooks/usePairWritingState.ts new file mode 100644 index 00000000..e29a4acb --- /dev/null +++ b/frontend/src/hooks/usePairWritingState.ts @@ -0,0 +1,337 @@ +/** + * Pair Writing Mode State Hook + * + * Manages session-scoped state for Pair Writing Mode including: + * - Editor content and unsaved changes tracking + * - Manual snapshot for comparison + * - Conversation history (session-scoped per REQ-F-27) + * - Current text selection + * + * @see .sdd/plans/memory-loop/2026-01-20-pair-writing-mode-plan.md TD-5 + */ + +import { useReducer, useCallback } from "react"; + +// ---------------------------------------------------------------------------- +// Types +// ---------------------------------------------------------------------------- + +/** + * Text selection within the editor. + */ +export interface TextSelection { + /** Selected text content */ + text: string; + /** Start position (character offset) */ + start: number; + /** End position (character offset) */ + end: number; + /** 1-indexed start line number */ + startLine: number; + /** 1-indexed end line number */ + endLine: number; +} + +/** + * Message in the pair writing conversation. + * Simpler than SessionContext's ConversationMessage since advisory actions + * don't need tool invocations. + */ +export interface PairWritingMessage { + /** Unique message ID */ + id: string; + /** Role: user or assistant */ + role: "user" | "assistant"; + /** Message content */ + content: string; + /** Timestamp */ + timestamp: Date; + /** Whether this message is still streaming */ + isStreaming?: boolean; +} + +/** + * Pair Writing Mode state. + * Session-scoped: cleared when exiting Pair Writing Mode (REQ-F-27). + */ +export interface PairWritingState { + /** Whether Pair Writing Mode is active */ + isActive: boolean; + /** Current editor content */ + content: string; + /** Manual snapshot for comparison (REQ-F-23, REQ-F-24) */ + snapshot: string | null; + /** Conversation history (session-scoped per REQ-F-22) */ + conversation: PairWritingMessage[]; + /** Current text selection */ + selection: TextSelection | null; + /** Whether there are unsaved manual edits (REQ-F-30) */ + hasUnsavedChanges: boolean; +} + +/** + * Actions returned by the hook for state management. + */ +export interface PairWritingActions { + /** Activate Pair Writing Mode with initial content */ + activate: (content: string) => void; + /** Deactivate Pair Writing Mode (clears all state per REQ-F-27) */ + deactivate: () => void; + /** Update editor content */ + setContent: (content: string) => void; + /** Take a snapshot of current content (REQ-F-23) */ + takeSnapshot: () => void; + /** Clear the current snapshot */ + clearSnapshot: () => void; + /** Add a message to conversation */ + addMessage: (message: Omit) => void; + /** Update the last message (for streaming) */ + updateLastMessage: (content: string, isStreaming?: boolean) => void; + /** Set the current text selection */ + setSelection: (selection: TextSelection | null) => void; + /** Clear all state (alias for deactivate, used on exit) */ + clearAll: () => void; + /** Mark changes as saved (resets hasUnsavedChanges) */ + markSaved: () => void; + /** Reload content from disk (updates content, clears unsaved flag) */ + reloadContent: (content: string) => void; +} + +// ---------------------------------------------------------------------------- +// Reducer +// ---------------------------------------------------------------------------- + +type PairWritingAction = + | { type: "ACTIVATE"; content: string } + | { type: "DEACTIVATE" } + | { type: "SET_CONTENT"; content: string } + | { type: "TAKE_SNAPSHOT" } + | { type: "CLEAR_SNAPSHOT" } + | { type: "ADD_MESSAGE"; message: PairWritingMessage } + | { type: "UPDATE_LAST_MESSAGE"; content: string; isStreaming?: boolean } + | { type: "SET_SELECTION"; selection: TextSelection | null } + | { type: "MARK_SAVED" } + | { type: "RELOAD_CONTENT"; content: string }; + +/** + * Generate a unique message ID. + */ +function generateMessageId(): string { + return `pw-msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; +} + +/** + * Initial state for Pair Writing Mode. + */ +function createInitialState(): PairWritingState { + return { + isActive: false, + content: "", + snapshot: null, + conversation: [], + selection: null, + hasUnsavedChanges: false, + }; +} + +/** + * Pair Writing state reducer. + */ +function pairWritingReducer( + state: PairWritingState, + action: PairWritingAction +): PairWritingState { + switch (action.type) { + case "ACTIVATE": + return { + ...createInitialState(), + isActive: true, + content: action.content, + }; + + case "DEACTIVATE": + // Clear all state per REQ-F-27 (session-scoped) + return createInitialState(); + + case "SET_CONTENT": + // Track unsaved changes when content differs from what was loaded + return { + ...state, + content: action.content, + hasUnsavedChanges: true, + }; + + case "TAKE_SNAPSHOT": + // REQ-F-24: Only one snapshot at a time; new snapshot replaces previous + return { + ...state, + snapshot: state.content, + }; + + case "CLEAR_SNAPSHOT": + return { + ...state, + snapshot: null, + }; + + case "ADD_MESSAGE": + return { + ...state, + conversation: [...state.conversation, action.message], + }; + + case "UPDATE_LAST_MESSAGE": { + if (state.conversation.length === 0) return state; + + const messages = [...state.conversation]; + const lastMessage = messages[messages.length - 1]; + + if (lastMessage.role !== "assistant") { + console.warn( + "[usePairWritingState] UPDATE_LAST_MESSAGE ignored: last message is not an assistant message" + ); + return state; + } + + messages[messages.length - 1] = { + ...lastMessage, + content: lastMessage.content + action.content, + isStreaming: action.isStreaming ?? lastMessage.isStreaming, + }; + + return { ...state, conversation: messages }; + } + + case "SET_SELECTION": + return { + ...state, + selection: action.selection, + }; + + case "MARK_SAVED": + return { + ...state, + hasUnsavedChanges: false, + }; + + case "RELOAD_CONTENT": + // Used after Quick Actions complete (file was written by Claude) + // Updates content without marking as unsaved + return { + ...state, + content: action.content, + hasUnsavedChanges: false, + }; + + default: + return state; + } +} + +// ---------------------------------------------------------------------------- +// Hook +// ---------------------------------------------------------------------------- + +/** + * Hook for managing Pair Writing Mode state. + * + * State is session-scoped: conversation and snapshot are cleared when + * deactivating Pair Writing Mode (REQ-F-27). + * + * @example + * ```tsx + * const { state, actions } = usePairWritingState(); + * + * // Enter Pair Writing Mode + * actions.activate(fileContent); + * + * // Take a snapshot before editing + * actions.takeSnapshot(); + * + * // Update content as user types + * actions.setContent(newContent); + * + * // Exit (clears conversation and snapshot) + * actions.clearAll(); + * ``` + */ +export function usePairWritingState(): { + state: PairWritingState; + actions: PairWritingActions; +} { + const [state, dispatch] = useReducer(pairWritingReducer, undefined, createInitialState); + + const activate = useCallback((content: string) => { + dispatch({ type: "ACTIVATE", content }); + }, []); + + const deactivate = useCallback(() => { + dispatch({ type: "DEACTIVATE" }); + }, []); + + const setContent = useCallback((content: string) => { + dispatch({ type: "SET_CONTENT", content }); + }, []); + + const takeSnapshot = useCallback(() => { + dispatch({ type: "TAKE_SNAPSHOT" }); + }, []); + + const clearSnapshot = useCallback(() => { + dispatch({ type: "CLEAR_SNAPSHOT" }); + }, []); + + const addMessage = useCallback( + (message: Omit) => { + dispatch({ + type: "ADD_MESSAGE", + message: { + ...message, + id: generateMessageId(), + timestamp: new Date(), + }, + }); + }, + [] + ); + + const updateLastMessage = useCallback( + (content: string, isStreaming?: boolean) => { + dispatch({ type: "UPDATE_LAST_MESSAGE", content, isStreaming }); + }, + [] + ); + + const setSelection = useCallback((selection: TextSelection | null) => { + dispatch({ type: "SET_SELECTION", selection }); + }, []); + + const clearAll = useCallback(() => { + dispatch({ type: "DEACTIVATE" }); + }, []); + + const markSaved = useCallback(() => { + dispatch({ type: "MARK_SAVED" }); + }, []); + + const reloadContent = useCallback((content: string) => { + dispatch({ type: "RELOAD_CONTENT", content }); + }, []); + + return { + state, + actions: { + activate, + deactivate, + setContent, + takeSnapshot, + clearSnapshot, + addMessage, + updateLastMessage, + setSelection, + clearAll, + markSaved, + reloadContent, + }, + }; +} diff --git a/frontend/src/hooks/useTextSelection.ts b/frontend/src/hooks/useTextSelection.ts new file mode 100644 index 00000000..fb75a21f --- /dev/null +++ b/frontend/src/hooks/useTextSelection.ts @@ -0,0 +1,311 @@ +/** + * useTextSelection Hook + * + * Tracks the current text selection within an element and provides + * context information for AI-assisted text revision. + * + * Features: + * - Listens for selection changes via Selection API + * - Calculates line numbers (1-indexed) + * - Extracts paragraph context (delimited by \n\n) + * - Returns null when no text is selected + * + * Spec Requirements: + * - REQ-F-4: Selection + surrounding context sent to Claude + * - TD-4: Paragraph context extraction (one paragraph before/after) + */ + +import { useState, useEffect, useCallback, useRef } from "react"; + +/** + * Context information about the current text selection. + * Used for Quick Actions and Advisory Actions in Pair Writing Mode. + */ +export interface SelectionContext { + /** The selected text */ + text: string; + /** 1-indexed line number where selection starts */ + startLine: number; + /** 1-indexed line number where selection ends */ + endLine: number; + /** Total lines in the document */ + totalLines: number; + /** Paragraph before the selection (delimited by \n\n) */ + contextBefore: string; + /** Paragraph after the selection (delimited by \n\n) */ + contextAfter: string; +} + +/** + * Return type for the useTextSelection hook. + */ +export interface UseTextSelectionResult { + /** Current selection context, or null if no selection */ + selection: SelectionContext | null; + /** Manually clear the selection state */ + clearSelection: () => void; +} + +/** + * Counts the number of newlines before a given position in text. + * Returns the 1-indexed line number. + */ +export function getLineNumber(text: string, position: number): number { + const clampedPosition = Math.min(position, text.length); + const textBefore = text.substring(0, clampedPosition); + const newlines = (textBefore.match(/\n/g) ?? []).length; + return newlines + 1; +} + +/** + * Counts total lines in text (number of newlines + 1). + */ +export function countTotalLines(text: string): number { + if (text === "") return 1; + const newlines = (text.match(/\n/g) ?? []).length; + return newlines + 1; +} + +/** + * Extracts the paragraph before a given position. + * Paragraphs are delimited by blank lines (\n\n). + * Returns the full paragraph text (without trailing delimiter). + */ +export function extractParagraphBefore( + text: string, + selectionStart: number +): string { + if (selectionStart === 0) return ""; + + const textBefore = text.substring(0, selectionStart); + + // Find the last paragraph delimiter before selection + const lastDelimiter = textBefore.lastIndexOf("\n\n"); + + if (lastDelimiter === -1) { + // No delimiter found, everything before selection is the context + // But we want the paragraph, not partial text, so return empty + // Actually per spec, we want the preceding paragraph content + // If no delimiter, the "paragraph before" doesn't exist + return ""; + } + + // Find the start of that paragraph (look for another \n\n before it) + const paragraphStart = textBefore.lastIndexOf("\n\n", lastDelimiter - 1); + + if (paragraphStart === -1) { + // The paragraph starts at the beginning of the document + return textBefore.substring(0, lastDelimiter).trim(); + } + + // Extract the paragraph between the two delimiters + return textBefore.substring(paragraphStart + 2, lastDelimiter).trim(); +} + +/** + * Extracts the paragraph after a given position. + * Paragraphs are delimited by blank lines (\n\n). + * Returns the full paragraph text (without leading delimiter). + */ +export function extractParagraphAfter( + text: string, + selectionEnd: number +): string { + if (selectionEnd >= text.length) return ""; + + const textAfter = text.substring(selectionEnd); + + // Find the first paragraph delimiter after selection + const firstDelimiter = textAfter.indexOf("\n\n"); + + if (firstDelimiter === -1) { + // No delimiter found, no complete paragraph after + return ""; + } + + // Find the end of the next paragraph (another \n\n or end of text) + const nextDelimiter = textAfter.indexOf("\n\n", firstDelimiter + 2); + + if (nextDelimiter === -1) { + // Paragraph extends to end of document + return textAfter.substring(firstDelimiter + 2).trim(); + } + + // Extract the paragraph between the two delimiters + return textAfter.substring(firstDelimiter + 2, nextDelimiter).trim(); +} + +/** + * Extracts full selection context from document content. + * This is the core logic used by the hook, exposed for testing. + */ +export function extractSelectionContext( + content: string, + selectionStart: number, + selectionEnd: number +): SelectionContext | null { + // Validate inputs + if (selectionStart < 0 || selectionEnd < 0) return null; + if (selectionStart > content.length || selectionEnd > content.length) + return null; + if (selectionStart >= selectionEnd) return null; + + const selectedText = content.substring(selectionStart, selectionEnd); + + // Empty or whitespace-only selection is treated as no selection + if (selectedText.trim() === "") return null; + + return { + text: selectedText, + startLine: getLineNumber(content, selectionStart), + endLine: getLineNumber(content, selectionEnd), + totalLines: countTotalLines(content), + contextBefore: extractParagraphBefore(content, selectionStart), + contextAfter: extractParagraphAfter(content, selectionEnd), + }; +} + +/** + * Gets selection information from a textarea or contenteditable element. + * Returns start/end offsets or null if element doesn't support selection. + */ +function getElementSelection( + element: HTMLElement +): { start: number; end: number } | null { + if (element instanceof HTMLTextAreaElement) { + return { + start: element.selectionStart, + end: element.selectionEnd, + }; + } + + // For contenteditable elements, use Selection API + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0) return null; + + const range = selection.getRangeAt(0); + + // Check if selection is within our element + if (!element.contains(range.commonAncestorContainer)) return null; + + // Calculate offsets relative to the element's text content + const preCaretRange = range.cloneRange(); + preCaretRange.selectNodeContents(element); + preCaretRange.setEnd(range.startContainer, range.startOffset); + const start = preCaretRange.toString().length; + + preCaretRange.setEnd(range.endContainer, range.endOffset); + const end = preCaretRange.toString().length; + + return { start, end }; +} + +/** + * React hook for tracking text selection within an element. + * + * @param elementRef - Ref to the element to track selection in (textarea or contenteditable) + * @param content - The current text content of the element + * @returns Selection context or null if no selection + * + * @example + * ```tsx + * const textareaRef = useRef(null); + * const [content, setContent] = useState(""); + * const { selection } = useTextSelection(textareaRef, content); + * + * if (selection) { + * console.log(`Selected "${selection.text}" on lines ${selection.startLine}-${selection.endLine}`); + * } + * ``` + */ +export function useTextSelection( + elementRef: React.RefObject, + content: string +): UseTextSelectionResult { + const [selection, setSelection] = useState(null); + + // Keep content ref for use in event handlers + const contentRef = useRef(content); + contentRef.current = content; + + const clearSelection = useCallback(() => { + setSelection(null); + }, []); + + // Update selection on selection change events + const updateSelection = useCallback(() => { + const element = elementRef.current; + if (!element) { + setSelection(null); + return; + } + + const elementSelection = getElementSelection(element); + if (!elementSelection) { + setSelection(null); + return; + } + + const context = extractSelectionContext( + contentRef.current, + elementSelection.start, + elementSelection.end + ); + + setSelection(context); + }, [elementRef]); + + useEffect(() => { + const element = elementRef.current; + if (!element) return; + + // For textarea, listen to select event + if (element instanceof HTMLTextAreaElement) { + const handleSelect = () => updateSelection(); + const handleMouseUp = () => updateSelection(); + const handleKeyUp = (e: KeyboardEvent) => { + // Update on shift+arrow keys (selection change) + if (e.shiftKey || e.key === "Escape") { + updateSelection(); + } + }; + + element.addEventListener("select", handleSelect); + element.addEventListener("mouseup", handleMouseUp); + element.addEventListener("keyup", handleKeyUp); + + return () => { + element.removeEventListener("select", handleSelect); + element.removeEventListener("mouseup", handleMouseUp); + element.removeEventListener("keyup", handleKeyUp); + }; + } + + // For contenteditable, listen to document selectionchange + const handleSelectionChange = () => { + updateSelection(); + }; + + document.addEventListener("selectionchange", handleSelectionChange); + + return () => { + document.removeEventListener("selectionchange", handleSelectionChange); + }; + }, [elementRef, updateSelection]); + + // Clear selection when content changes + // We don't have the original character positions, so we can't revalidate. + // User will re-select if needed. + useEffect(() => { + if (selection) { + setSelection(null); + } + // Intentionally only depend on content, not selection. + // We want to clear when content changes, not re-run when selection changes. + }, [content]); + + return { + selection, + clearSelection, + }; +} diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index 9c924272..f4b17233 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -686,6 +686,101 @@ export const CreateVaultMessageSchema = z.object({ title: z.string().min(1, "Vault title is required"), }); +// ============================================================================= +// Pair Writing Mode Client Messages +// ============================================================================= + +/** + * Action types for Quick Actions (transformative, all platforms) + * - tighten: Make more concise without losing meaning + * - embellish: Add detail, nuance, or context + * - correct: Fix typos and grammar only + * - polish: Correct + improve prose + */ +export const QuickActionTypeSchema = z.enum(["tighten", "embellish", "correct", "polish"]); + +/** + * Action types for Advisory Actions (Pair Writing Mode, desktop only) + * - validate: Fact-check the claim + * - critique: Analyze clarity, voice, structure + * - compare: Compare current text to snapshot + */ +export const AdvisoryActionTypeSchema = z.enum(["validate", "critique", "compare"]); + +/** + * Client requests a Quick Action on selected text (all platforms) + * Claude uses Read/Edit tools to modify the file directly + */ +export const QuickActionRequestMessageSchema = z.object({ + type: z.literal("quick_action_request"), + /** The action to perform */ + action: QuickActionTypeSchema, + /** The selected text to transform */ + selection: z.string().min(1, "Selection is required"), + /** Paragraph before the selection (for context) */ + contextBefore: z.string(), + /** Paragraph after the selection (for context) */ + contextAfter: z.string(), + /** Path to the file being edited (relative to content root) */ + filePath: z.string().min(1, "File path is required"), + /** 1-indexed line number where selection starts */ + selectionStartLine: z.number().int().min(1, "Selection start line must be at least 1"), + /** 1-indexed line number where selection ends */ + selectionEndLine: z.number().int().min(1, "Selection end line must be at least 1"), + /** Total lines in the document (for position hint calculation) */ + totalLines: z.number().int().min(1, "Total lines must be at least 1"), +}); + +/** + * Client requests an Advisory Action on selected text (Pair Writing Mode, desktop) + * Response appears in conversation pane; user manually applies changes + */ +export const AdvisoryActionRequestMessageSchema = z.object({ + type: z.literal("advisory_action_request"), + /** The advisory action to perform */ + action: AdvisoryActionTypeSchema, + /** The selected text to analyze */ + selection: z.string().min(1, "Selection is required"), + /** Paragraph before the selection (for context) */ + contextBefore: z.string(), + /** Paragraph after the selection (for context) */ + contextAfter: z.string(), + /** Path to the file being edited (relative to content root) */ + filePath: z.string().min(1, "File path is required"), + /** 1-indexed line number where selection starts */ + selectionStartLine: z.number().int().min(1, "Selection start line must be at least 1"), + /** 1-indexed line number where selection ends */ + selectionEndLine: z.number().int().min(1, "Selection end line must be at least 1"), + /** Total lines in the document (for position hint calculation) */ + totalLines: z.number().int().min(1, "Total lines must be at least 1"), + /** For compare action: the corresponding text from the snapshot */ + snapshotSelection: z.string().optional(), +}); + +/** + * Client sends freeform chat in Pair Writing Mode (desktop only) + * Selection context is optional (user may chat without a selection) + */ +export const PairChatRequestMessageSchema = z.object({ + type: z.literal("pair_chat_request"), + /** The user's message text */ + text: z.string().min(1, "Message text is required"), + /** Path to the file being edited (relative to content root) */ + filePath: z.string().min(1, "File path is required"), + /** The selected text (optional) */ + selection: z.string().optional(), + /** Paragraph before the selection (for context) */ + contextBefore: z.string().optional(), + /** Paragraph after the selection (for context) */ + contextAfter: z.string().optional(), + /** 1-indexed line number where selection starts */ + selectionStartLine: z.number().int().min(1).optional(), + /** 1-indexed line number where selection ends */ + selectionEndLine: z.number().int().min(1).optional(), + /** Total lines in the document */ + totalLines: z.number().int().min(1).optional(), +}); + // ============================================================================= // Memory Extraction Client Messages // ============================================================================= @@ -787,6 +882,10 @@ export const ClientMessageSchema = z.discriminatedUnion("type", [ SetPinnedAssetsMessageSchema, UpdateVaultConfigMessageSchema, CreateVaultMessageSchema, + // Pair Writing Mode + QuickActionRequestMessageSchema, + AdvisoryActionRequestMessageSchema, + PairChatRequestMessageSchema, // Memory Extraction GetMemoryMessageSchema, SaveMemoryMessageSchema, @@ -1558,6 +1657,12 @@ export type GetPinnedAssetsMessage = z.infer; export type UpdateVaultConfigMessage = z.infer; export type CreateVaultMessage = z.infer; +// Pair Writing Mode types +export type QuickActionType = z.infer; +export type AdvisoryActionType = z.infer; +export type QuickActionRequestMessage = z.infer; +export type AdvisoryActionRequestMessage = z.infer; +export type PairChatRequestMessage = z.infer; export type GetMemoryMessage = z.infer; export type SaveMemoryMessage = z.infer; export type GetExtractionPromptMessage = z.infer; From 4abe1cde0527a5e052a1fba94eef84c0fd2ddf31 Mon Sep 17 00:00:00 2001 From: Ronald Roy Date: Tue, 20 Jan 2026 10:53:53 -0800 Subject: [PATCH 06/19] feat: Implement Phase 2 context menu for Pair Writing Mode (#369) Adds the context menu infrastructure for Quick Actions: Components (frontend): - EditorContextMenu: Portal-rendered menu with keyboard navigation - Right-click trigger for desktop - Long-press trigger for mobile (uses useLongPress hook) - Shows 4 Quick Actions: Tighten, Embellish, Correct, Polish - Accessible with role="menu" and keyboard navigation Integration (frontend): - MemoryEditor wired with context menu and selection tracking - Uses useTextSelection for selection context extraction - Only shows menu when text is selected - Prevents browser context menu when our menu opens Test coverage: 49 new tests for EditorContextMenu and integration. Co-Authored-By: Claude Opus 4.5 --- .../2026-01-20-pair-writing-mode-progress.md | 12 +- frontend/src/components/EditorContextMenu.css | 117 +++++ frontend/src/components/EditorContextMenu.tsx | 414 ++++++++++++++++++ frontend/src/components/MemoryEditor.tsx | 93 ++++ .../__tests__/EditorContextMenu.test.tsx | 382 ++++++++++++++++ .../__tests__/MemoryEditor.test.tsx | 95 +++- 6 files changed, 1107 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/EditorContextMenu.css create mode 100644 frontend/src/components/EditorContextMenu.tsx create mode 100644 frontend/src/components/__tests__/EditorContextMenu.test.tsx diff --git a/.sdd/progress/memory-loop/2026-01-20-pair-writing-mode-progress.md b/.sdd/progress/memory-loop/2026-01-20-pair-writing-mode-progress.md index 2e17cbaa..fb5ae39b 100644 --- a/.sdd/progress/memory-loop/2026-01-20-pair-writing-mode-progress.md +++ b/.sdd/progress/memory-loop/2026-01-20-pair-writing-mode-progress.md @@ -12,7 +12,7 @@ authored_by: # Pair Writing Mode - Implementation Progress -**Last Updated**: 2026-01-20 | **Status**: 43% complete (6 of 14 tasks) +**Last Updated**: 2026-01-20 | **Status**: 57% complete (8 of 14 tasks) ## Current Session **Date**: 2026-01-20 | **Working On**: Phase 1 Foundation Tasks | **Blockers**: None @@ -24,6 +24,8 @@ authored_by: - TASK-006: Quick Action Prompts Configuration (60 tests passing) - TASK-009: Pair Writing State Management (27 tests passing) - TASK-010: Conversation Pane Extraction (17 tests passing) +- TASK-004: Editor Context Menu Component (28 tests passing) +- TASK-005: Integrate Context Menu into MemoryEditor (21 tests passing) ## Discovered Issues - None @@ -44,9 +46,9 @@ authored_by: ### Phase 2: Context Menu -**Upcoming** ⏳ -- [ ] TASK-004: Editor Context Menu Component -- [ ] TASK-005: Integrate Context Menu into MemoryEditor +**Completed** ✅ +- [x] TASK-004: Editor Context Menu Component - *Completed 2026-01-20* +- [x] TASK-005: Integrate Context Menu into MemoryEditor - *Completed 2026-01-20* ### Phase 3: Quick Actions @@ -86,7 +88,7 @@ authored_by: | Quick Action prompts | ✅ Complete (60 tests) | | usePairWritingState hook | ✅ Complete (27 tests) | | ConversationPane | ✅ Complete (17 tests) | -| EditorContextMenu | ⏳ Pending | +| EditorContextMenu | ✅ Complete (28 tests) | | pair-writing-handlers | ⏳ Pending | | PairWritingMode | ⏳ Pending | diff --git a/frontend/src/components/EditorContextMenu.css b/frontend/src/components/EditorContextMenu.css new file mode 100644 index 00000000..a3c81afe --- /dev/null +++ b/frontend/src/components/EditorContextMenu.css @@ -0,0 +1,117 @@ +/** + * EditorContextMenu Component Styles + * + * Context menu for Quick Actions on text selections. + * Uses glass morphism styling consistent with FileTree context menu. + */ + +.editor-context-menu { + position: fixed; + z-index: 1000; + min-width: 180px; + max-width: 240px; + padding: var(--spacing-xs); + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + box-shadow: 0 8px 32px var(--color-black-a40); + animation: editor-context-menu-appear 0.1s ease-out; +} + +@keyframes editor-context-menu-appear { + from { + opacity: 0; + transform: scale(0.95); + } + to { + opacity: 1; + transform: scale(1); + } +} + +/* Menu item */ +.editor-context-menu__item { + display: flex; + align-items: flex-start; + gap: var(--spacing-sm); + width: 100%; + padding: var(--spacing-sm) var(--spacing-md); + background: transparent; + border: none; + border-radius: var(--radius-sm); + color: var(--color-text); + font-size: var(--text-sm); + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; +} + +.editor-context-menu__item:hover, +.editor-context-menu__item--focused { + background: var(--color-accent-primary-a15); +} + +.editor-context-menu__item:focus { + outline: none; +} + +.editor-context-menu__item:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: -2px; +} + +/* Icon container */ +.editor-context-menu__icon { + display: flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + flex-shrink: 0; + margin-top: 2px; +} + +.editor-context-menu__icon-svg { + width: 16px; + height: 16px; + color: var(--color-text-accent-secondary); +} + +/* Content area (label + description) */ +.editor-context-menu__content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.editor-context-menu__label { + font-weight: 500; + color: var(--color-text); +} + +.editor-context-menu__description { + font-size: var(--text-xs); + color: var(--color-text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* Touch device adjustments */ +@media (hover: none) { + .editor-context-menu__item { + min-height: 48px; + padding: var(--spacing-md); + } + + .editor-context-menu__item:hover { + background: transparent; + } + + .editor-context-menu__item:active { + background: var(--color-accent-primary-a15); + } +} diff --git a/frontend/src/components/EditorContextMenu.tsx b/frontend/src/components/EditorContextMenu.tsx new file mode 100644 index 00000000..17d270a1 --- /dev/null +++ b/frontend/src/components/EditorContextMenu.tsx @@ -0,0 +1,414 @@ +/** + * EditorContextMenu Component + * + * Context menu for Quick Actions on text selections in the markdown editor. + * Supports right-click (desktop) and long-press (mobile) triggers. + * Renders via portal at the selection position. + * + * Implements: TD-1, TD-12 from the Pair Writing Mode plan. + * Addresses: REQ-F-2, REQ-F-3, REQ-NF-3, REQ-NF-5 from spec. + */ + +import { useEffect, useRef, useCallback, useState } from "react"; +import { createPortal } from "react-dom"; +import "./EditorContextMenu.css"; + +/** + * Quick Action types available in the context menu. + * These are transformative actions that directly modify the selected text. + */ +export type QuickActionType = "tighten" | "embellish" | "correct" | "polish"; + +/** + * Position for rendering the context menu. + */ +export interface MenuPosition { + x: number; + y: number; +} + +/** + * Props for EditorContextMenu component. + */ +export interface EditorContextMenuProps { + /** Whether the menu is currently open */ + isOpen: boolean; + /** Position to render the menu at (viewport coordinates) */ + position: MenuPosition | null; + /** Callback when a Quick Action is selected */ + onAction: (action: QuickActionType) => void; + /** Callback when the menu should be dismissed */ + onDismiss: () => void; +} + +/** + * Menu item configuration for Quick Actions. + */ +interface MenuItem { + action: QuickActionType; + label: string; + description: string; +} + +/** + * Quick Actions menu items configuration. + */ +const QUICK_ACTIONS: MenuItem[] = [ + { + action: "tighten", + label: "Tighten", + description: "Make more concise", + }, + { + action: "embellish", + label: "Embellish", + description: "Add detail and nuance", + }, + { + action: "correct", + label: "Correct", + description: "Fix typos and grammar", + }, + { + action: "polish", + label: "Polish", + description: "Correct and improve prose", + }, +]; + +/** + * EditorContextMenu displays Quick Actions for text selections. + * + * Features: + * - Renders via React portal at specified position + * - Keyboard navigation (Arrow keys, Enter, Escape) + * - Click outside dismissal + * - Accessible via role="menu" and role="menuitem" + * + * Usage: + * ```tsx + * handleQuickAction(action)} + * onDismiss={() => setMenuOpen(false)} + * /> + * ``` + */ +export function EditorContextMenu({ + isOpen, + position, + onAction, + onDismiss, +}: EditorContextMenuProps): React.ReactNode { + const menuRef = useRef(null); + const [focusedIndex, setFocusedIndex] = useState(0); + + // Reset focus index when menu opens + useEffect(() => { + if (isOpen) { + setFocusedIndex(0); + } + }, [isOpen]); + + // Focus the menu when it opens + useEffect(() => { + if (isOpen && menuRef.current) { + // Focus the first menu item + const firstItem = menuRef.current.querySelector( + '[role="menuitem"]' + ); + firstItem?.focus(); + } + }, [isOpen]); + + // Click outside handler + useEffect(() => { + if (!isOpen) return; + + function handleClickOutside(event: MouseEvent) { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onDismiss(); + } + } + + // Use mousedown to catch click before it propagates + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [isOpen, onDismiss]); + + // Escape key handler + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") { + event.preventDefault(); + onDismiss(); + } + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onDismiss]); + + // Keyboard navigation within the menu + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setFocusedIndex((prev) => (prev + 1) % QUICK_ACTIONS.length); + break; + case "ArrowUp": + event.preventDefault(); + setFocusedIndex( + (prev) => (prev - 1 + QUICK_ACTIONS.length) % QUICK_ACTIONS.length + ); + break; + case "Enter": + case " ": + event.preventDefault(); + onAction(QUICK_ACTIONS[focusedIndex].action); + break; + case "Home": + event.preventDefault(); + setFocusedIndex(0); + break; + case "End": + event.preventDefault(); + setFocusedIndex(QUICK_ACTIONS.length - 1); + break; + } + }, + [focusedIndex, onAction] + ); + + // Focus the correct item when focusedIndex changes + useEffect(() => { + if (!isOpen || !menuRef.current) return; + + const items = menuRef.current.querySelectorAll( + '[role="menuitem"]' + ); + items[focusedIndex]?.focus(); + }, [focusedIndex, isOpen]); + + // Handle item click + const handleItemClick = useCallback( + (action: QuickActionType) => { + onAction(action); + }, + [onAction] + ); + + if (!isOpen || !position) { + return null; + } + + // Calculate menu position to keep it in viewport + const menuStyle = calculateMenuPosition(position); + + const menuContent = ( +
+ {QUICK_ACTIONS.map((item, index) => ( + + ))} +
+ ); + + // Render via portal to document.body + return createPortal(menuContent, document.body); +} + +/** + * Calculate menu position to keep it within viewport bounds. + */ +function calculateMenuPosition( + position: MenuPosition +): React.CSSProperties { + // Menu dimensions (approximate, CSS will handle actual sizing) + const menuWidth = 200; + const menuHeight = 200; + const padding = 8; + + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + + let x = position.x; + let y = position.y; + + // Keep menu within horizontal bounds + if (x + menuWidth > viewportWidth - padding) { + x = viewportWidth - menuWidth - padding; + } + if (x < padding) { + x = padding; + } + + // Keep menu within vertical bounds + if (y + menuHeight > viewportHeight - padding) { + // Position above if not enough space below + y = position.y - menuHeight; + if (y < padding) { + y = padding; + } + } + + return { + left: x, + top: y, + }; +} + +/** + * Icon component for each Quick Action type. + */ +function ActionIcon({ action }: { action: QuickActionType }): React.ReactNode { + switch (action) { + case "tighten": + return ; + case "embellish": + return ; + case "correct": + return ; + case "polish": + return ; + } +} + +/** + * Tighten icon (compress/minimize). + */ +function TightenIcon(): React.ReactNode { + return ( + + {/* Arrows pointing inward */} + + + + + + ); +} + +/** + * Embellish icon (expand/add). + */ +function EmbellishIcon(): React.ReactNode { + return ( + + {/* Arrows pointing outward */} + + + + + + ); +} + +/** + * Correct icon (checkmark). + */ +function CorrectIcon(): React.ReactNode { + return ( + + + + ); +} + +/** + * Polish icon (sparkles). + */ +function PolishIcon(): React.ReactNode { + return ( + + + + + + ); +} + +/** + * Exported for use by parent components that need to extract position + * from contextmenu or long-press events. + */ +export function getMenuPositionFromEvent( + event: React.MouseEvent | React.TouchEvent +): MenuPosition { + if ("touches" in event) { + const touch = event.touches[0] || event.changedTouches[0]; + return { + x: touch?.clientX ?? 0, + y: touch?.clientY ?? 0, + }; + } + return { + x: event.clientX, + y: event.clientY, + }; +} diff --git a/frontend/src/components/MemoryEditor.tsx b/frontend/src/components/MemoryEditor.tsx index 1afc3a22..c0f225ad 100644 --- a/frontend/src/components/MemoryEditor.tsx +++ b/frontend/src/components/MemoryEditor.tsx @@ -16,6 +16,14 @@ import { useState, useCallback, useEffect, useRef } from "react"; import type { ClientMessage, ServerMessage } from "@memory-loop/shared"; +import { + EditorContextMenu, + getMenuPositionFromEvent, + type MenuPosition, + type QuickActionType, +} from "./EditorContextMenu"; +import { useLongPress } from "../hooks/useLongPress"; +import { useTextSelection, type SelectionContext } from "../hooks/useTextSelection"; import "./MemoryEditor.css"; /** @@ -57,9 +65,23 @@ export function MemoryEditor({ const [, setSizeBytes] = useState(0); const [fileExists, setFileExists] = useState(false); + // Context menu state + const [menuOpen, setMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState(null); + + // Ref to the textarea for selection tracking + const textareaRef = useRef(null); + // Track if we've requested the content const hasRequestedRef = useRef(false); + // Track text selection + const { selection } = useTextSelection(textareaRef, content); + + // Store selection context for use in action handler + const selectionRef = useRef(null); + selectionRef.current = selection; + // Calculate current content size const currentSize = new TextEncoder().encode(content).length; const sizePercentage = Math.min((currentSize / MAX_MEMORY_SIZE) * 100, 100); @@ -133,6 +155,66 @@ export function MemoryEditor({ setError(null); }, [originalContent]); + // Open context menu at position (only if there's a selection) + const openContextMenu = useCallback((position: MenuPosition) => { + // Only show menu if there's selected text + if (!selectionRef.current) return; + setMenuPosition(position); + setMenuOpen(true); + }, []); + + // Close context menu + const closeContextMenu = useCallback(() => { + setMenuOpen(false); + setMenuPosition(null); + }, []); + + // Handle right-click (desktop context menu) + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + // Only intercept if there's a selection + if (!selectionRef.current) return; + + e.preventDefault(); + openContextMenu(getMenuPositionFromEvent(e)); + }, + [openContextMenu] + ); + + // Handle long-press (mobile context menu) + const handleLongPress = useCallback( + (e: React.TouchEvent) => { + // Only show menu if there's a selection + if (!selectionRef.current) return; + + openContextMenu(getMenuPositionFromEvent(e)); + }, + [openContextMenu] + ); + + // Long press handlers for mobile + const longPressHandlers = useLongPress(handleLongPress, { duration: 500 }); + + // Handle Quick Action selection + const handleAction = useCallback( + (action: QuickActionType) => { + const currentSelection = selectionRef.current; + if (!currentSelection) { + closeContextMenu(); + return; + } + + // Log the action with selection context (WebSocket dispatch comes in TASK-008) + console.log("Quick Action triggered:", { + action, + selection: currentSelection, + }); + + closeContextMenu(); + }, + [closeContextMenu] + ); + // Format bytes for display const formatBytes = (bytes: number): string => { if (bytes < 1024) return `${bytes} B`; @@ -186,9 +268,12 @@ export function MemoryEditor({
) : (