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..09a45a5a --- /dev/null +++ b/.sdd/plans/memory-loop/2026-01-20-pair-writing-mode-plan.md @@ -0,0 +1,658 @@ +--- +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.2.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) + +*Addresses*: REQ-F-8, REQ-F-28, REQ-F-29, REQ-F-30 + +### 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" + />
void; + /** Callback when the menu should be dismissed */ + onDismiss: () => void; + /** + * Editor mode determines which actions are shown. + * - "browse": Only Quick Actions (Tighten, Embellish, Correct, Polish) + * - "pair-writing": Quick Actions + Advisory Actions (Validate, Critique) + Compare + * Defaults to "browse" for backward compatibility. + */ + mode?: EditorMode; + /** + * Whether a snapshot exists. When true and mode is "pair-writing", + * the "Compare to snapshot" action is shown. + */ + hasSnapshot?: boolean; + /** + * Callback when an Advisory Action is selected (Validate, Critique, Compare). + * Advisory actions dispatch to conversation pane, not inline replacement. + * Only relevant in "pair-writing" mode. + */ + onAdvisoryAction?: (action: AdvisoryActionType) => void; +} + +/** + * Menu item configuration for Quick Actions. + */ +interface QuickMenuItem { + action: QuickActionType; + label: string; + description: string; +} + +/** + * Menu item configuration for Advisory Actions. + */ +interface AdvisoryMenuItem { + action: AdvisoryActionType; + label: string; + description: string; +} + +/** + * Quick Actions menu items configuration. + * These are transformative actions (REQ-F-1). + */ +const QUICK_ACTIONS: QuickMenuItem[] = [ + { + 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", + }, +]; + +/** + * Advisory Actions menu items configuration. + * These are non-transformative actions shown only in Pair Writing Mode (REQ-F-15). + */ +const ADVISORY_ACTIONS: AdvisoryMenuItem[] = [ + { + action: "validate", + label: "Validate", + description: "Fact-check the claim", + }, + { + action: "critique", + label: "Critique", + description: "Analyze clarity, voice, structure", + }, +]; + +/** + * Compare action shown when snapshot exists (REQ-F-25). + */ +const COMPARE_ACTION: AdvisoryMenuItem = { + action: "compare", + label: "Compare to snapshot", + description: "Show what changed", +}; + +/** + * 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" + * - In Pair Writing Mode: shows Advisory Actions (Validate, Critique) + * - Shows Compare action when snapshot exists + * + * Usage: + * ```tsx + * // Browse mode (default) + * handleQuickAction(action)} + * onDismiss={() => setMenuOpen(false)} + * /> + * + * // Pair Writing Mode with snapshot + * handleQuickAction(action)} + * onAdvisoryAction={(action) => handleAdvisoryAction(action)} + * onDismiss={() => setMenuOpen(false)} + * mode="pair-writing" + * hasSnapshot={true} + * /> + * ``` + */ +export function EditorContextMenu({ + isOpen, + position, + onAction, + onDismiss, + mode = "browse", + hasSnapshot = false, + onAdvisoryAction, +}: EditorContextMenuProps): React.ReactNode { + const menuRef = useRef(null); + const [focusedIndex, setFocusedIndex] = useState(0); + + // Build the list of all menu items based on mode + const allItems = buildMenuItems(mode, hasSnapshot); + const itemCount = allItems.length; + + // 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]); + + // Handle action based on item type + const handleItemAction = useCallback( + (item: MenuItem) => { + if (item.type === "quick") { + onAction(item.action as QuickActionType); + } else if (onAdvisoryAction) { + onAdvisoryAction(item.action as AdvisoryActionType); + } + }, + [onAction, onAdvisoryAction] + ); + + // Keyboard navigation within the menu + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setFocusedIndex((prev) => (prev + 1) % itemCount); + break; + case "ArrowUp": + event.preventDefault(); + setFocusedIndex((prev) => (prev - 1 + itemCount) % itemCount); + break; + case "Enter": + case " ": + event.preventDefault(); + handleItemAction(allItems[focusedIndex]); + break; + case "Home": + event.preventDefault(); + setFocusedIndex(0); + break; + case "End": + event.preventDefault(); + setFocusedIndex(itemCount - 1); + break; + } + }, + [focusedIndex, itemCount, allItems, handleItemAction] + ); + + // 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]); + + if (!isOpen || !position) { + return null; + } + + // Calculate menu position to keep it in viewport (consider more items in pair-writing mode) + const estimatedHeight = itemCount * 56 + 16; // 56px per item + padding + const menuStyle = calculateMenuPosition(position, estimatedHeight); + + // Determine aria-label based on mode + const ariaLabel = mode === "pair-writing" ? "Writing Actions" : "Quick Actions"; + + const menuContent = ( +
+ {allItems.map((item, index) => ( + + ))} +
+ ); + + // Render via portal to document.body + return createPortal(menuContent, document.body); +} + +// ---------------------------------------------------------------------------- +// Helper Types and Functions +// ---------------------------------------------------------------------------- + +/** + * Unified menu item type that can represent both quick and advisory actions. + */ +interface MenuItem { + action: QuickActionType | AdvisoryActionType; + label: string; + description: string; + type: "quick" | "advisory"; +} + +/** + * Build the list of menu items based on mode and snapshot state. + */ +function buildMenuItems(mode: EditorMode, hasSnapshot: boolean): MenuItem[] { + // Always include Quick Actions + const items: MenuItem[] = QUICK_ACTIONS.map((item) => ({ + ...item, + type: "quick" as const, + })); + + // In Pair Writing Mode, add Advisory Actions (REQ-F-15) + if (mode === "pair-writing") { + items.push( + ...ADVISORY_ACTIONS.map((item) => ({ + ...item, + type: "advisory" as const, + })) + ); + + // Add Compare action if snapshot exists (REQ-F-25) + if (hasSnapshot) { + items.push({ + ...COMPARE_ACTION, + type: "advisory" as const, + }); + } + } + + return items; +} + +/** + * Calculate menu position to keep it within viewport bounds. + */ +function calculateMenuPosition( + position: MenuPosition, + estimatedHeight = 200 +): React.CSSProperties { + // Menu dimensions (approximate, CSS will handle actual sizing) + const menuWidth = 200; + const menuHeight = estimatedHeight; + 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 all menu item types (quick and advisory actions). + */ +function MenuItemIcon({ action }: { action: QuickActionType | AdvisoryActionType }): React.ReactNode { + switch (action) { + case "tighten": + return ; + case "embellish": + return ; + case "correct": + return ; + case "polish": + return ; + case "validate": + return ; + case "critique": + return ; + case "compare": + 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 ( + + + + + + ); +} + +/** + * Validate icon (shield with checkmark). + */ +function ValidateIcon(): React.ReactNode { + return ( + + + + + ); +} + +/** + * Critique icon (magnifying glass with lines). + */ +function CritiqueIcon(): React.ReactNode { + return ( + + + + + + + ); +} + +/** + * Compare icon (two documents with diff). + */ +function CompareIcon(): 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/MarkdownViewer.css b/frontend/src/components/MarkdownViewer.css index 0717855a..764a1095 100644 --- a/frontend/src/components/MarkdownViewer.css +++ b/frontend/src/components/MarkdownViewer.css @@ -509,6 +509,57 @@ } } +/* Toolbar actions container - groups action buttons */ +.markdown-viewer__toolbar-actions { + display: flex; + align-items: center; + gap: var(--spacing-sm); +} + +/* Pair Writing button - desktop only (REQ-F-9, REQ-F-10) */ +.markdown-viewer__pair-writing-btn { + display: flex; + align-items: center; + justify-content: center; + padding: var(--spacing-xs) var(--spacing-md); + min-height: 32px; + background: transparent; + border: 1px solid var(--glass-border); + border-radius: var(--radius-md); + color: var(--color-text-accent); + cursor: pointer; + font-size: var(--text-sm); + font-weight: var(--font-medium); + transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease, box-shadow 0.3s ease; +} + +.markdown-viewer__pair-writing-btn:hover { + background: var(--color-accent-primary-a10); + border-color: var(--color-accent); + color: var(--color-text); + box-shadow: 0 0 12px var(--color-accent-primary-a15); +} + +.markdown-viewer__pair-writing-btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* Hide Pair Writing button on touch/mobile devices (REQ-F-10) */ +@media (hover: none), (pointer: coarse) { + .markdown-viewer__pair-writing-btn { + display: none; + } +} + +@media (hover: none) { + .markdown-viewer__pair-writing-btn:hover { + background: transparent; + border-color: var(--glass-border); + box-shadow: none; + } +} + /* Error message display (REQ-F-14) */ .markdown-viewer__adjust-error { padding: var(--spacing-sm) var(--spacing-md); diff --git a/frontend/src/components/MarkdownViewer.tsx b/frontend/src/components/MarkdownViewer.tsx index dbc8aa85..8b14c4c2 100644 --- a/frontend/src/components/MarkdownViewer.tsx +++ b/frontend/src/components/MarkdownViewer.tsx @@ -34,6 +34,8 @@ export interface MarkdownViewerProps { onSave?: (content: string) => void; /** Callback to open mobile file browser (only shown on mobile) */ onMobileMenuClick?: () => void; + /** Callback to enter Pair Writing Mode (desktop only, REQ-F-9) */ + onEnterPairWriting?: () => void; } /** @@ -354,6 +356,7 @@ export function MarkdownViewer({ assetBaseUrl = "/vault/assets", onSave, onMobileMenuClick, + onEnterPairWriting, }: MarkdownViewerProps): ReactNode { const { browser, @@ -609,14 +612,27 @@ export function MarkdownViewer({ )} - +
+ + {/* Pair Writing button - desktop only (REQ-F-9, REQ-F-10) */} + {onEnterPairWriting && ( + + )} +
{currentFileTruncated && ( diff --git a/frontend/src/components/PairWritingEditor.css b/frontend/src/components/PairWritingEditor.css new file mode 100644 index 00000000..9869b4ee --- /dev/null +++ b/frontend/src/components/PairWritingEditor.css @@ -0,0 +1,80 @@ +/** + * PairWritingEditor styles + * + * Editor component for Pair Writing Mode with Quick Actions support. + */ + +.pair-writing-editor { + display: flex; + flex-direction: column; + height: 100%; + position: relative; +} + +.pair-writing-editor__textarea { + flex: 1; + width: 100%; + padding: 1rem; + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.875rem; + line-height: 1.6; + color: var(--color-text); + background-color: var(--color-surface); + border: none; + resize: none; + outline: none; +} + +.pair-writing-editor__textarea:focus { + outline: none; +} + +.pair-writing-editor__textarea::placeholder { + color: var(--color-text-muted); +} + +/* Processing state */ +.pair-writing-editor--processing { + pointer-events: none; +} + +.pair-writing-editor__textarea--processing { + opacity: 0.6; +} + +/* Processing overlay */ +.pair-writing-editor__processing-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + padding: 1.5rem 2rem; + background-color: var(--color-surface-elevated, var(--color-surface)); + border-radius: 0.5rem; + box-shadow: var(--shadow-lg, 0 4px 12px rgba(0, 0, 0, 0.15)); + z-index: 10; +} + +.pair-writing-editor__processing-spinner { + width: 24px; + height: 24px; + border: 2px solid var(--color-border); + border-top-color: var(--color-primary); + border-radius: 50%; + animation: pair-writing-editor-spin 0.8s linear infinite; +} + +@keyframes pair-writing-editor-spin { + to { + transform: rotate(360deg); + } +} + +.pair-writing-editor__processing-text { + font-size: 0.875rem; + color: var(--color-text-muted); +} diff --git a/frontend/src/components/PairWritingEditor.tsx b/frontend/src/components/PairWritingEditor.tsx new file mode 100644 index 00000000..90f2baec --- /dev/null +++ b/frontend/src/components/PairWritingEditor.tsx @@ -0,0 +1,328 @@ +/** + * PairWritingEditor Component + * + * Editor for Pair Writing Mode that handles arbitrary markdown files. + * Supports Quick Actions via context menu with text selection. + * + * Key difference from MemoryEditor: This component receives content via props + * rather than fetching a specific file. It's designed for editing any vault file. + * + * Features: + * - Textarea for editing content + * - Context menu with Quick Actions (Tighten, Embellish, Correct, Polish) + * - Advisory Actions in pair-writing mode (Validate, Critique, Compare) + * - WebSocket integration for streaming Quick Action responses + * - Toast notifications for Claude's commentary + * + * Spec Requirements: + * - REQ-F-4: Selection + context sent to Claude for Quick Actions + * - REQ-F-6: Toast for Claude commentary + * - REQ-F-7: Loading indicator on selection during processing + * - REQ-F-8: Quick Actions persist immediately via Claude Edit tool + * - REQ-F-12: Editor pane in split view + * - REQ-F-15: Advisory Actions (Validate, Critique) + */ + +import { useState, useCallback, useEffect, useRef } from "react"; +import type { + ClientMessage, + ServerMessage, + QuickActionType as QuickActionTypeProtocol, +} from "@memory-loop/shared"; +import { + EditorContextMenu, + getMenuPositionFromEvent, + type MenuPosition, + type QuickActionType, + type AdvisoryActionType, +} from "./EditorContextMenu"; +import { useLongPress } from "../hooks/useLongPress"; +import { + useTextSelection, + type SelectionContext, +} from "../hooks/useTextSelection"; +import { Toast, type ToastVariant } from "./Toast"; +import "./PairWritingEditor.css"; + +/** + * Props for the PairWritingEditor component. + */ +export interface PairWritingEditorProps { + /** Initial file content to display */ + initialContent: string; + /** File path (relative to vault content root) for Quick Actions */ + filePath: string; + /** Function to send WebSocket messages */ + sendMessage: (message: ClientMessage) => void; + /** Last received server message (for handling responses) */ + lastMessage: ServerMessage | null; + /** Called when content changes (for tracking unsaved changes) */ + onContentChange?: (content: string) => void; + /** Called when Quick Action completes and file should be reloaded */ + onQuickActionComplete?: (path: string) => void; + /** Called when an Advisory Action is triggered (for conversation pane) */ + onAdvisoryAction?: ( + action: AdvisoryActionType, + selection: SelectionContext + ) => void; + /** Whether a snapshot exists (shows Compare action) */ + hasSnapshot?: boolean; + /** Current snapshot content (for Compare action) */ + snapshotContent?: string; + // Dependency injection for testing (avoids mock.module pollution) + /** Context menu component (defaults to EditorContextMenu) */ + ContextMenuComponent?: typeof EditorContextMenu; + /** Toast component (defaults to Toast) */ + ToastComponent?: typeof Toast; +} + +/** + * PairWritingEditor Component + * + * A textarea-based editor for Pair Writing Mode that supports: + * - Quick Actions via context menu (right-click/long-press) + * - Advisory Actions for pair-writing mode + * - Real-time streaming of Claude's responses + * - Toast notifications for commentary + */ +export function PairWritingEditor({ + initialContent, + filePath, + sendMessage, + lastMessage, + onContentChange, + onQuickActionComplete, + onAdvisoryAction, + hasSnapshot = false, + ContextMenuComponent = EditorContextMenu, + ToastComponent = Toast, +}: PairWritingEditorProps): React.ReactNode { + // Content state - initialized from props, updated on edit + const [content, setContent] = useState(initialContent); + + // Context menu state + const [menuOpen, setMenuOpen] = useState(false); + const [menuPosition, setMenuPosition] = useState(null); + + // Quick Action state (REQ-F-7: loading indicator during processing) + const [isProcessingQuickAction, setIsProcessingQuickAction] = useState(false); + const [quickActionMessageId, setQuickActionMessageId] = useState< + string | null + >(null); + // Use ref for confirmation to avoid infinite loop in useEffect + // (state in deps would re-trigger effect on every chunk append) + const quickActionConfirmationRef = useRef(""); + + // Toast state (REQ-F-6: commentary displayed as toast) + const [toastVisible, setToastVisible] = useState(false); + const [toastMessage, setToastMessage] = useState(""); + const [toastVariant, setToastVariant] = useState("success"); + + // Ref to the textarea for selection tracking + const textareaRef = useRef(null); + + // Track text selection + const { selection } = useTextSelection(textareaRef, content); + + // Store selection context for use in action handlers + const selectionRef = useRef(null); + selectionRef.current = selection; + + // Track filePath in ref for use in callbacks + const filePathRef = useRef(filePath); + filePathRef.current = filePath; + + // Update content when initialContent changes (e.g., after Quick Action reload) + useEffect(() => { + setContent(initialContent); + }, [initialContent]); + + // Handle server messages for Quick Actions + useEffect(() => { + if (!lastMessage || !isProcessingQuickAction) return; + + if (lastMessage.type === "response_start") { + // Store message ID for tracking + setQuickActionMessageId(lastMessage.messageId); + } else if (lastMessage.type === "response_chunk") { + // Accumulate confirmation message text from Claude + if (lastMessage.messageId === quickActionMessageId) { + quickActionConfirmationRef.current += lastMessage.content; + } + } else if (lastMessage.type === "response_end") { + // Quick Action complete - clear processing state + setIsProcessingQuickAction(false); + setQuickActionMessageId(null); + + // Show toast with confirmation message (REQ-F-6) + const confirmation = quickActionConfirmationRef.current.trim(); + if (confirmation) { + setToastMessage(confirmation); + setToastVariant("success"); + setToastVisible(true); + } + quickActionConfirmationRef.current = ""; + + // Trigger file reload (REQ-F-8: file is already updated by Claude's Edit tool) + onQuickActionComplete?.(filePathRef.current); + } else if (lastMessage.type === "error") { + // Clear Quick Action processing state on error + setIsProcessingQuickAction(false); + setQuickActionMessageId(null); + quickActionConfirmationRef.current = ""; + // Show error toast + setToastMessage(lastMessage.message); + setToastVariant("error"); + setToastVisible(true); + } + }, [lastMessage, isProcessingQuickAction, quickActionMessageId, onQuickActionComplete]); + + // Handle content change + const handleChange = useCallback( + (e: React.ChangeEvent) => { + const newContent = e.target.value; + setContent(newContent); + onContentChange?.(newContent); + }, + [onContentChange] + ); + + // 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 handleQuickAction = useCallback( + (action: QuickActionType) => { + const currentSelection = selectionRef.current; + if (!currentSelection) { + closeContextMenu(); + return; + } + + // Close menu immediately + closeContextMenu(); + + // Set processing state for loading indicator (REQ-F-7) + setIsProcessingQuickAction(true); + quickActionConfirmationRef.current = ""; + setQuickActionMessageId(null); + + // Send quick_action_request message (REQ-F-4) + sendMessage({ + type: "quick_action_request", + action: action as QuickActionTypeProtocol, + selection: currentSelection.text, + contextBefore: currentSelection.contextBefore, + contextAfter: currentSelection.contextAfter, + filePath: filePathRef.current, + selectionStartLine: currentSelection.startLine, + selectionEndLine: currentSelection.endLine, + totalLines: currentSelection.totalLines, + }); + }, + [closeContextMenu, sendMessage] + ); + + // Handle Advisory Action selection (REQ-F-15) + const handleAdvisoryAction = useCallback( + (action: AdvisoryActionType) => { + const currentSelection = selectionRef.current; + if (!currentSelection) { + closeContextMenu(); + return; + } + + // Close menu immediately + closeContextMenu(); + + // Delegate to parent for conversation pane handling + onAdvisoryAction?.(action, currentSelection); + }, + [closeContextMenu, onAdvisoryAction] + ); + + return ( +
+ {/* Textarea for editing */} +