Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions frontend/src/components/PairWritingEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ export interface PairWritingEditorProps {
action: QuickActionType,
selection: SelectionContext
) => void;
/** Called when text selection changes (null when no selection) */
onSelectionChange?: (selection: SelectionContext | null) => void;
hasSnapshot?: boolean;
snapshotContent?: string;
/** Dependency injection for testing */
Expand All @@ -58,6 +60,7 @@ export function PairWritingEditor({
onQuickActionComplete,
onAdvisoryAction,
onQuickAction,
onSelectionChange,
hasSnapshot = false,
ContextMenuComponent = EditorContextMenu,
}: PairWritingEditorProps): React.ReactNode {
Expand All @@ -71,6 +74,11 @@ export function PairWritingEditor({

const { selection } = useTextSelection(textareaRef, content);

// Notify parent when selection changes
useEffect(() => {
onSelectionChange?.(selection);
}, [selection, onSelectionChange]);

// Refs for stable callback access to current values
const selectionRef = useRef<SelectionContext | null>(null);
selectionRef.current = selection;
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/PairWritingMode.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
backdrop-filter: blur(var(--glass-blur));
min-height: 48px;
gap: var(--spacing-md);
position: relative;
z-index: 11; /* Above content panes and (+) for snapshot preview popover. */
}

@supports not (backdrop-filter: blur(10px)) {
Expand Down Expand Up @@ -124,6 +126,44 @@
background: var(--color-accent-primary-a15);
}

/* Snapshot button wrapper for hover preview positioning */
.pair-writing-toolbar__snapshot-wrapper {
position: relative;
}

/* Snapshot preview popover */
.pair-writing-toolbar__snapshot-preview {
position: absolute;
top: 100%;
right: 0;
margin-top: var(--spacing-xs);
min-width: 300px;
max-width: 500px;
max-height: 300px;
overflow-y: auto;
background: var(--color-surface);
border: 1px solid var(--glass-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
z-index: 100;
padding: var(--spacing-sm);
}

.pair-writing-toolbar__snapshot-preview-content {
margin: 0;
font-family: var(--font-mono);
font-size: var(--text-xs);
line-height: 1.5;
color: var(--color-text);
white-space: pre-wrap;
word-break: break-word;
}

.pair-writing-toolbar__snapshot-preview-ellipsis {
color: var(--color-text-secondary);
font-style: italic;
}

/* Save button */
.pair-writing-toolbar__btn--save {
background: var(--gradient-primary);
Expand Down
18 changes: 15 additions & 3 deletions frontend/src/components/PairWritingMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { PairWritingEditor } from "./PairWritingEditor";
import { Discussion } from "./Discussion";
import { ConfirmDialog } from "./ConfirmDialog";
import type { AdvisoryActionType, QuickActionType } from "./EditorContextMenu";
import type { SelectionContext } from "../hooks/useTextSelection";
import { type SelectionContext } from "../hooks/useTextSelection";
import type { ConnectionStatus } from "../hooks/useWebSocket";
import "./PairWritingMode.css";

Expand Down Expand Up @@ -89,6 +89,7 @@ export function PairWritingMode({
const { addMessage } = useSession();
const [showExitConfirm, setShowExitConfirm] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [currentSelection, setCurrentSelection] = useState<SelectionContext | null>(null);

// Track previous initialContent to detect external changes (not local edits)
const prevInitialContent = useRef(initialContent);
Expand All @@ -110,10 +111,18 @@ export function PairWritingMode({
}
}, [initialContent, state.isActive, actions]);

// Handle selection changes from editor
const handleSelectionChange = useCallback((selection: SelectionContext | null) => {
setCurrentSelection(selection);
}, []);

// Handle snapshot button (REQ-F-23)
// Captures the currently selected text, not the entire file
const handleSnapshot = useCallback(() => {
actions.takeSnapshot();
}, [actions]);
if (currentSelection?.text) {
actions.takeSnapshot(currentSelection.text);
}
}, [actions, currentSelection]);

// Handle save button (REQ-F-29)
const handleSave = useCallback(() => {
Expand Down Expand Up @@ -222,11 +231,13 @@ export function PairWritingMode({
<PairWritingToolbar
hasUnsavedChanges={state.hasUnsavedChanges}
hasSnapshot={state.snapshot !== null}
hasSelection={currentSelection !== null}
isSaving={isSaving}
onSnapshot={handleSnapshot}
onSave={handleSave}
onExit={handleExitClick}
filePath={filePath}
snapshotContent={state.snapshot ?? undefined}
/>

{/* Split-screen content (REQ-F-11, TD-6) */}
Expand All @@ -242,6 +253,7 @@ export function PairWritingMode({
onQuickActionComplete={handleQuickActionComplete}
onQuickAction={handleQuickAction}
onAdvisoryAction={handleAdvisoryAction}
onSelectionChange={handleSelectionChange}
hasSnapshot={state.snapshot !== null}
snapshotContent={state.snapshot ?? undefined}
/>
Expand Down
102 changes: 80 additions & 22 deletions frontend/src/components/PairWritingToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,16 @@
* @see .sdd/specs/memory-loop/2026-01-20-pair-writing-mode.md REQ-F-14, REQ-F-23, REQ-F-29, REQ-F-30
*/

import React from "react";
import React, { useState } from "react";
import "./PairWritingMode.css";

export interface PairWritingToolbarProps {
/** Whether there are unsaved manual edits (triggers exit warning per REQ-F-30) */
hasUnsavedChanges: boolean;
/** Whether a snapshot currently exists (REQ-F-24) */
hasSnapshot: boolean;
/** Whether text is currently selected in the editor */
hasSelection?: boolean;
/** Whether save is in progress */
isSaving?: boolean;
/** Called when user clicks Snapshot button (REQ-F-23) */
Expand All @@ -25,6 +27,8 @@ export interface PairWritingToolbarProps {
onExit: () => void;
/** Current file path being edited (displayed in toolbar) */
filePath?: string;
/** The actual snapshot text content (for hover preview) */
snapshotContent?: string;
}

/**
Expand All @@ -37,15 +41,44 @@ export interface PairWritingToolbarProps {
*
* Note: Exit confirmation dialog is handled by the parent PairWritingMode component.
*/
/**
* Truncates snapshot content for preview display.
* Limits to ~500 characters or ~15 lines, whichever is shorter.
*/
function truncateForPreview(content: string): { text: string; isTruncated: boolean } {
const MAX_CHARS = 500;
const MAX_LINES = 15;

const lines = content.split("\n");
let result = "";
let lineCount = 0;

for (const line of lines) {
if (lineCount >= MAX_LINES || result.length + line.length > MAX_CHARS) {
return { text: result.trimEnd(), isTruncated: true };
}
result += (lineCount > 0 ? "\n" : "") + line;
lineCount++;
}

return { text: result, isTruncated: false };
}

export function PairWritingToolbar({
hasUnsavedChanges,
hasSnapshot,
hasSelection = false,
isSaving = false,
onSnapshot,
onSave,
onExit,
filePath,
snapshotContent,
}: PairWritingToolbarProps): React.ReactNode {
const [isHoveringSnapshot, setIsHoveringSnapshot] = useState(false);

// Snapshot requires a selection (captures selected text, not entire file)
const canSnapshot = hasSelection;
return (
<div className="pair-writing-toolbar" role="toolbar" aria-label="Pair Writing toolbar">
{/* Left section: file info */}
Expand All @@ -64,29 +97,54 @@ export function PairWritingToolbar({

{/* Right section: action buttons */}
<div className="pair-writing-toolbar__actions">
{/* Snapshot button (REQ-F-23) */}
<button
type="button"
className={`pair-writing-toolbar__btn pair-writing-toolbar__btn--snapshot${hasSnapshot ? " pair-writing-toolbar__btn--has-snapshot" : ""}`}
onClick={onSnapshot}
aria-pressed={hasSnapshot}
title={hasSnapshot ? "Update snapshot (replaces previous)" : "Take snapshot for comparison"}
{/* Snapshot button with hover preview (REQ-F-23) */}
<div
className="pair-writing-toolbar__snapshot-wrapper"
onMouseEnter={() => setIsHoveringSnapshot(true)}
onMouseLeave={() => setIsHoveringSnapshot(false)}
>
<svg
className="pair-writing-toolbar__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
<button
type="button"
className={`pair-writing-toolbar__btn pair-writing-toolbar__btn--snapshot${hasSnapshot ? " pair-writing-toolbar__btn--has-snapshot" : ""}`}
onClick={onSnapshot}
disabled={!canSnapshot}
aria-pressed={hasSnapshot}
title={
!canSnapshot
? "Select text to snapshot"
: hasSnapshot
? "Update snapshot (replaces previous)"
: "Take snapshot of selection"
}
>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span className="pair-writing-toolbar__label">Snapshot</span>
</button>
<svg
className="pair-writing-toolbar__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
<circle cx="12" cy="13" r="4" />
</svg>
<span className="pair-writing-toolbar__label">Snapshot</span>
</button>
{/* Snapshot preview popover */}
{hasSnapshot && isHoveringSnapshot && snapshotContent && (() => {
const { text, isTruncated } = truncateForPreview(snapshotContent);
return (
<div className="pair-writing-toolbar__snapshot-preview" role="tooltip">
<pre className="pair-writing-toolbar__snapshot-preview-content">
{text}
{isTruncated && <span className="pair-writing-toolbar__snapshot-preview-ellipsis">...</span>}
</pre>
</div>
);
})()}
</div>

{/* Save button (REQ-F-29) */}
<button
Expand Down
30 changes: 20 additions & 10 deletions frontend/src/components/__tests__/PairWritingMode.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,28 @@

import { describe, it, expect, afterEach, mock } from "bun:test";
import { render, screen, fireEvent, cleanup, within, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { useEffect, type ReactNode } from "react";
import { PairWritingMode } from "../PairWritingMode";
import { SessionProvider, useSession } from "../../contexts/SessionContext";
import type { PairWritingEditorProps } from "../PairWritingEditor";
import type { SelectionContext } from "../../hooks/useTextSelection";

// Mock selection used across mock editors
const mockSelection: SelectionContext = {
text: "test selection",
contextBefore: "before ",
contextAfter: " after",
startLine: 5,
endLine: 5,
totalLines: 10,
};

// Mock components injected via props (no mock.module pollution)
function MockEditor() {
function MockEditor(props: PairWritingEditorProps) {
// Simulate having a selection so the Snapshot button is enabled
useEffect(() => {
props.onSelectionChange?.(mockSelection);
}, []);
return <div data-testid="pair-writing-editor">PairWritingEditor</div>;
}

Expand All @@ -33,14 +47,10 @@ function MockDiscussion() {
* Mock editor that exposes callbacks via buttons for testing
*/
function MockEditorWithCallbacks(props: PairWritingEditorProps) {
const mockSelection: SelectionContext = {
text: "test selection",
contextBefore: "before ",
contextAfter: " after",
startLine: 5,
endLine: 5,
totalLines: 10,
};
// Simulate having a selection so the Snapshot button is enabled
useEffect(() => {
props.onSelectionChange?.(mockSelection);
}, []);

return (
<div data-testid="pair-writing-editor">
Expand Down
Loading