From cd62f6c02cb1aee5cec10ebb726ad3acb67ca298 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:10:11 +0000 Subject: [PATCH 01/17] Implement replay --- Cargo.lock | 24 +- .../plans/2026-03-15-replay-widget.md | 237 ++++++++++++ hub-client/src/components/Editor.css | 12 + hub-client/src/components/Editor.tsx | 46 ++- hub-client/src/components/ReplayDrawer.css | 154 ++++++++ .../src/components/ReplayDrawer.test.tsx | 150 ++++++++ hub-client/src/components/ReplayDrawer.tsx | 140 +++++++ hub-client/src/hooks/useReplayMode.test.ts | 341 ++++++++++++++++++ hub-client/src/hooks/useReplayMode.ts | 229 ++++++++++++ hub-client/src/services/automergeSync.test.ts | 47 +++ hub-client/src/services/automergeSync.ts | 15 + hub-client/src/test-utils/mockSyncClient.ts | 10 + ts-packages/quarto-sync-client/src/client.ts | 17 + 13 files changed, 1408 insertions(+), 14 deletions(-) create mode 100644 claude-notes/plans/2026-03-15-replay-widget.md create mode 100644 hub-client/src/components/ReplayDrawer.css create mode 100644 hub-client/src/components/ReplayDrawer.test.tsx create mode 100644 hub-client/src/components/ReplayDrawer.tsx create mode 100644 hub-client/src/hooks/useReplayMode.test.ts create mode 100644 hub-client/src/hooks/useReplayMode.ts diff --git a/Cargo.lock b/Cargo.lock index f447f1cc..0a4f6c46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -61,9 +61,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" @@ -537,9 +537,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" dependencies = [ "find-msvc-tools", "jobserver", @@ -652,9 +652,9 @@ checksum = "b9e769b5c8c8283982a987c6e948e540254f1058d5a74b8794914d4ef5fc2a24" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "colored" @@ -4296,9 +4296,9 @@ dependencies = [ [[package]] name = "sdd" -version = "4.7.4" +version = "4.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29768e2b8c67de4b243612538a62e021aab09a461ef11f9c6c07a05887a4c80a" +checksum = "e6ca0e33fc1ae39e36b2d1fdfc8ee470b26397b642ff87572a59a36ff4f2340b" [[package]] name = "sec1" @@ -4827,9 +4827,9 @@ dependencies = [ [[package]] name = "sys_traits" -version = "0.1.24" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5410f31d223892c1ce7a098da845c99d023b4c7f18632bc8f09e60dfae3cbb75" +checksum = "24954f769b36305ab4dc502d095da5cfd9c697c73a8f2c0636a7fca76ff1f24b" dependencies = [ "sys_traits_macros", ] @@ -5013,9 +5013,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] diff --git a/claude-notes/plans/2026-03-15-replay-widget.md b/claude-notes/plans/2026-03-15-replay-widget.md new file mode 100644 index 00000000..274a48b4 --- /dev/null +++ b/claude-notes/plans/2026-03-15-replay-widget.md @@ -0,0 +1,237 @@ +# Replay Widget Implementation Plan + +## Overview + +Add a document history replay feature to hub-client. The replay widget lives in a bottom drawer that expands to reveal a media-player-style timeline. When activated, the user enters **replay mode**: a read-only state where they can scrub through the entire Automerge change history. An **Apply** button exits replay mode and applies the viewed historical state as a new Automerge change. Collapsing the drawer exits replay mode without changes. + +## Architecture + +### Key Automerge APIs (verified against installed `@automerge/automerge-repo@2.5.1`) + +| API | Source | Purpose | +|-----|--------|---------| +| `handle.history()` | `DocHandle.d.ts:103` | Returns `UrlHeads[] | undefined` — topologically sorted array of every change's heads (undefined if doc not ready) | +| `handle.view(heads)` | `DocHandle.d.ts:117` | Returns a read-only `DocHandle` at the given heads | +| `handle.doc()` | `DocHandle.d.ts:79` | Returns the current `Doc` | +| `handle.metadata(change?)` | `DocHandle.d.ts:143` | Returns `DecodedChange \| undefined` with timestamp, message, actor (**@hidden API — may be unstable**). Takes a single change hash `string`, NOT `UrlHeads`. | +| `handle.change(fn)` | `DocHandle.d.ts:184` | Apply a new change to the document | +| `handle.diff(first, second?)` | `DocHandle.d.ts:131` | Returns `Patch[]` between two heads | +| `updateText(doc, ['text'], content)` | `@automerge/automerge-repo` | Incremental text update | + +### Document Schema + +File documents use the `TextDocumentContent` interface from `@quarto/quarto-automerge-schema`: +```typescript +interface TextDocumentContent { + text: string; // Automerge Text type serializes to string +} +``` + +To extract text content from a viewed handle: +```typescript +const viewedHandle = handle.view(history[index]); +const doc = viewedHandle.doc(); +if (doc && isTextDocument(doc)) { + const text = doc.text || ''; +} +``` + +The `isTextDocument()` type guard (from `@quarto/quarto-automerge-schema`) checks for the `text` field. This is the same pattern used throughout `client.ts` (lines 157, 186, 211, 364). + +### Sync Strategy: UI-Level Guard (NOT Network Pause) + +> **IMPORTANT LESSON LEARNED**: The original plan proposed using `repo.networkSubsystem.disconnect()` / `reconnect()` to pause sync during replay. This was **wrong**. Investigation revealed that `NetworkSubsystem.disconnect()` calls `adapter.disconnect()` on each network adapter, which in `BrowserWebSocketClientAdapter` **closes the WebSocket** (`socket.close()`) and emits `peer-disconnected` events. This is destructive — it kills the connection to the server, can trigger the app's "Connection lost" error handler in `App.tsx`, and makes the app unresponsive. +> +> The correct approach is to **leave the network alone** and guard at the UI level instead: +> - The `useReplayMode` hook does NOT call `pauseSync()` or `resumeSync()` +> - The Automerge content sync effect in `Editor.tsx` is guarded with `if (replayState.isActive) return;` to skip updates during replay +> - Sync continues in the background; incoming changes are absorbed by Automerge but not pushed to Monaco +> - On exit, the guard lifts and the sync effect naturally re-syncs Monaco with the current Automerge state +> +> The `pauseSync()` / `resumeSync()` functions were still added to `client.ts` and `automergeSync.ts` (they are valid lower-level operations), but they are **not used by the replay feature**. + +The existing `getFileHandle(path)` already returns `DocHandle | null`, which (when non-null) has `history()` and `view()` methods directly on it. The replay hook uses this existing path. **The `null` return must be guarded** — `enter()` is a no-op if the handle is not available. + +### State Management + +Follow existing pattern: a `useReplayMode` hook (like `usePresence`, `usePreference`) that encapsulates all replay logic. No new context provider needed — the hook returns everything the UI components need. + +### UI Location + +A **bottom drawer** that slides up from the bottom of `.editor-container`. This is separate from the sidebar (which is for navigation/panels) and is architecturally more appropriate for a transient media-player control. The drawer has two states: +- **Collapsed**: A thin bar at the bottom with a clock/history icon and "History" label. Clicking expands. +- **Expanded**: A ~80px tall panel with timeline scrubber, play/pause, step buttons, timestamp display, and Apply button. + +## Design Decisions + +1. **Per-file replay**: History is per Automerge document (per file), not project-wide. The replay widget operates on the currently selected file's `DocHandle`. This matches Automerge's data model where each file is a separate document. + +2. **No animation/playback timer library**: Use `setInterval` for play mode (auto-advance through history). The native `` serves as the timeline scrubber — no need for a slider library. + +3. **Apply = overwrite current text**: The Apply button reads the text content at the viewed historical heads and calls `updateFileContent(path, historicText)`, which uses `updateText` under the hood. This creates a new Automerge change that makes the document match the historical state, preserving the full change graph (no history rewriting). + +4. **Monaco read-only**: Set `readOnly: true` and `domReadOnly: true` on the editor options when replay mode is active. Since Monaco is keyed by `currentFile?.path`, and we don't want to force a remount, we'll use `editorRef.current.updateOptions()` to toggle read-only dynamically. + +5. **Preview continues working**: The `content` state that drives PreviewRouter will be updated when scrubbing, so the preview pane shows the historical state in real-time. + +6. **File switching disabled**: While in replay mode, file selection in the sidebar is blocked to avoid complexity with multi-file history state. + +7. **No network pause**: Replay mode does NOT touch the network connection. The Automerge content sync effect in `Editor.tsx` is guarded at the UI level to be a no-op during replay, keeping the underlying sync healthy. + +8. **Defensive error handling**: `enter()` is wrapped in try-catch so that failures in `history()` or `view()` are logged and the hook stays inactive rather than crashing the app. + +## Work Items + +### Phase 1: Sync service layer + +- [x] Add `pauseSync()` and `resumeSync()` to `ts-packages/quarto-sync-client/src/client.ts` inside `createSyncClient()` (general-purpose API, not used by replay) +- [x] Add `pauseSync()` and `resumeSync()` wrapper exports to `hub-client/src/services/automergeSync.ts` +- [x] Write vitest tests for `pauseSync()`/`resumeSync()` in `hub-client/src/services/automergeSync.test.ts` +- [x] Verify `getFileHandle(path)` returns a handle with `history()` and `view()` methods — no new function needed +- [x] Run tests, verify they pass + +### Phase 2: `useReplayMode` hook + +- [x] Write vitest tests for `useReplayMode` in `hub-client/src/hooks/useReplayMode.test.ts` (20 tests): + - Test `enter()` loads history and activates replay + - Test `enter()` is a no-op when `getFileHandle()` returns `null` + - Test `enter()` is a no-op when `handle.history()` returns `undefined` + - Test `seekTo(index)` updates `currentContent` with correct text + - Test `seekTo()` with out-of-bounds index clamps to valid range + - Test `play()`/`pause()` starts/stops auto-advance interval + - Test `exit()` resets state + - Test `apply()` calls `updateFileContent(path, content)` and resets + - Test `stepForward()`/`stepBackward()` at boundaries (first/last change) +- [x] Create `hub-client/src/hooks/useReplayMode.ts` +- [x] Hook interface: + ```typescript + interface ReplayState { + isActive: boolean; + historyLength: number; // total number of changes + currentIndex: number; // 0-based index into history + isPlaying: boolean; // auto-advancing + currentContent: string; // text at current index + timestamp: number | null; // unix timestamp of current change + } + + interface ReplayControls { + enter: () => void; // enter replay mode + exit: () => void; // exit without applying + apply: () => void; // exit and apply historical state + seekTo: (index: number) => void; + play: () => void; + pause: () => void; + stepForward: () => void; + stepBackward: () => void; + } + + function useReplayMode( + filePath: string | null, + ): { state: ReplayState; controls: ReplayControls } + ``` +- [x] On `enter()`: guard `getFileHandle(path)` for `null` return (no-op if unavailable), load `handle.history()` (guard against `undefined` return), set index to last (current state). Wrapped in try-catch. +- [x] On `seekTo(index)`: extract text via `handle.view(history[index])` then `viewedHandle.doc()`, read `doc.text ?? ''` +- [x] On `play()`: start `setInterval` that increments index (default ~200ms per step) +- [x] On `exit()`: clear replay state (no network operations) +- [x] On `apply()`: read content at current index, call `updateFileContent(path, content)`, reset state +- [x] `metadata()` receives `history[index][0]` (single change hash string), NOT the full `UrlHeads` array +- [x] Memoize history array (only computed on enter, not on every render) +- [x] Handle edge cases: file with no history, `history()` returning `undefined`, `getFileHandle()` returning `null`, binary files +- [x] Run tests, verify they pass + +### Phase 3: ReplayDrawer component + +- [x] Write vitest tests for `ReplayDrawer` in `hub-client/src/components/ReplayDrawer.test.tsx` (13 tests): + - Test collapsed state renders clock icon + "History" label + - Test clicking collapsed bar calls `controls.enter()` and expands + - Test expanded state renders scrubber, transport controls, Apply/Close buttons + - Test Apply button calls `controls.apply()` + - Test Close button calls `controls.exit()` + - Test keyboard shortcuts (Space, Left, Right, Escape) + - Test scrubber `onChange` calls `controls.seekTo()` +- [x] Create `hub-client/src/components/ReplayDrawer.tsx` +- [x] Create `hub-client/src/components/ReplayDrawer.css` +- [x] Collapsed state: thin bar (32px) with clock icon + "History" label +- [x] Expanded state (~80px): + - Timeline scrubber: `` + - Transport controls: |◀ (step back), ▶/⏸ (play/pause), ▶| (step forward) + - Timestamp display: formatted date/time of current change + - Position indicator: "Change 42 of 100" + - **Apply** button (accent green, `#4ade80` to match existing palette) + - **Close** button (exits replay, equivalent to collapsing) +- [x] Expanding the drawer calls `controls.enter()` (enters replay mode) +- [x] Collapsing the drawer calls `controls.exit()` (exits replay mode) +- [x] Keyboard shortcuts: Space = play/pause, Left/Right = step, Escape = exit +- [x] Style: dark theme matching existing UI (`#1a1a2e` background, `#1f3460` borders) +- [x] Run tests, verify they pass + +### Phase 4: Editor integration + +- [x] Add `useReplayMode` hook to `Editor.tsx` +- [x] **Guard Automerge content sync effect**: Add `if (replayState.isActive) return;` at the top of the effect that syncs `fileContents` to Monaco, so incoming Automerge changes don't overwrite replay content +- [x] Pass replay state to Monaco: when `isActive`, call `editorRef.current.updateOptions({ readOnly: true, domReadOnly: true })`; when inactive, restore +- [x] Override `content` state: when replay is active, use `replayState.currentContent` for both Monaco and preview +- [x] Suppress `handleEditorChange`: early-return when replay is active +- [x] Block file switching: when replay is active, `handleSelectFile` is a no-op +- [x] Presence broadcasting: N/A — editor is read-only during replay, cursor won't change +- [x] Add visual indicator: "REPLAY MODE" banner in header area +- [x] Render `` at the bottom of `.editor-container`, below `
` +- [x] Run all tests (345 tests, 17 files), verify they pass + +### Phase 5: Apply logic + +- [x] `apply()` in the hook: capture `currentContent`, call `updateFileContent(path, content)`, reset state +- [x] This creates a new Automerge change that sets the document text to the historical value +- [x] Verified via test: `updateFileContent` is called with the correct content +- [x] On reset, the Automerge sync effect resumes naturally and syncs Monaco + +### Phase 6: Polish and edge cases + +- [x] Handle the case where the document is modified by a peer while in replay mode — sync continues in background, CRDT merge handles reconciliation when replay exits +- [ ] Performance: for very long histories (10,000+ changes), consider lazy loading or sampling the timeline +- [x] Throttle `seekTo` calls during rapid scrubbing — not needed, `handle.view()` is in-memory, React batches renders +- [x] Add transition animation for drawer expand/collapse (CSS `transition: height 0.2s ease`) +- [x] Binary files: `enter()` is a no-op when handle returns null or doc has no text field +- [ ] Test replay mode exit on disconnect/navigation + +## File Changes Summary + +| File | Change | +|------|--------| +| `ts-packages/quarto-sync-client/src/client.ts` | Add `pauseSync()`, `resumeSync()` to internal API and return object (general-purpose, not used by replay) | +| `hub-client/src/services/automergeSync.ts` | Add `pauseSync()`, `resumeSync()` wrapper exports | +| `hub-client/src/hooks/useReplayMode.ts` | **New** — core replay logic hook (no network operations) | +| `hub-client/src/hooks/useReplayMode.test.ts` | **New** — 20 tests for hook | +| `hub-client/src/components/ReplayDrawer.tsx` | **New** — drawer UI component | +| `hub-client/src/components/ReplayDrawer.css` | **New** — drawer styles | +| `hub-client/src/components/ReplayDrawer.test.tsx` | **New** — 13 tests for component | +| `hub-client/src/components/Editor.tsx` | Integrate replay mode, read-only toggle, content override, **guard Automerge sync effect** | +| `hub-client/src/components/Editor.css` | Replay mode banner style | +| `hub-client/src/test-utils/mockSyncClient.ts` | Add `pauseSync()`, `resumeSync()` to mock | +| `hub-client/src/services/automergeSync.test.ts` | Add pause/resume tests | + +## Key Lesson: NetworkSubsystem.disconnect() Is Destructive + +The original plan assumed `repo.networkSubsystem.disconnect()` was a lightweight "pause sync" operation. In reality, it: + +1. Calls `adapter.disconnect()` on each network adapter +2. `BrowserWebSocketClientAdapter.disconnect()` **closes the WebSocket** (`socket.close()`) +3. Emits `peer-disconnected` events +4. The closed WebSocket can trigger the app's `onConnectionChange(false)` → `setConnectionError('Connection lost')` path in `App.tsx` + +The fix: replay mode operates purely at the UI level. The Automerge sync effect in `Editor.tsx` is guarded with `if (replayState.isActive) return;`, and the hook never touches the network. This keeps the connection healthy and avoids all the cascading side effects. + +## Testing Framework + +All tests use **vitest** (v4.0.17) with `@testing-library/react` for hook/component tests. Follow the existing patterns: +- `@vitest-environment jsdom` directive at top of test files +- `vi.mock()` for service dependencies +- `renderHook()` + `act()` for hook tests (see `hub-client/src/hooks/useAuth.test.ts` for reference) +- Test files co-located with source: `useReplayMode.test.ts` next to `useReplayMode.ts` + +## Non-Goals + +- **Project-wide history**: We don't replay all files simultaneously. This would require coordinating across multiple Automerge documents and is significantly more complex. +- **Diffing UI**: No side-by-side diff view or highlighted changes. The user simply sees the document as it was at that point in time. +- **Branching/forking**: Apply overwrites the current state; it doesn't create a branch. +- **Persistent replay sessions**: Replay state is ephemeral — closing the drawer or refreshing the page exits replay mode. diff --git a/hub-client/src/components/Editor.css b/hub-client/src/components/Editor.css index 813d1107..6266791d 100644 --- a/hub-client/src/components/Editor.css +++ b/hub-client/src/components/Editor.css @@ -343,6 +343,18 @@ z-index: 0; } +/* Replay mode banner */ +.replay-mode-banner { + background: #0a4f0a; + color: #4ade80; + text-align: center; + padding: 4px 0; + font-size: 11px; + font-weight: 700; + letter-spacing: 2px; + border-bottom: 1px solid #166616; +} + /* Monaco editor overrides */ .monaco-editor { padding-top: 0 !important; diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 57eeb42d..0dda2f26 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -20,6 +20,7 @@ import { usePreference } from '../hooks/usePreference'; import { useIntelligence } from '../hooks/useIntelligence'; import { useSlideThumbnails } from '../hooks/useSlideThumbnails'; import { useCursorToSlide } from '../hooks/useCursorToSlide'; +import { useReplayMode } from '../hooks/useReplayMode'; import { diffToMonacoEdits } from '../utils/diffToMonacoEdits'; import { diagnosticsToMarkers } from '../utils/diagnosticToMonaco'; import FileSidebar from './FileSidebar'; @@ -35,6 +36,7 @@ import AboutTab from './tabs/AboutTab'; import ViewToggleControl from './ViewToggleControl'; import { useViewMode } from './ViewModeContext'; import MarkdownSummary from './MarkdownSummary'; +import ReplayDrawer from './ReplayDrawer'; import './Editor.css'; import PreviewRouter from './PreviewRouter'; @@ -120,6 +122,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC enableSymbols: true, }); + // Replay mode for document history + const { state: replayState, controls: replayControls } = useReplayMode(currentFile?.path ?? null); + // Get content from fileContents map, or use default for new files const getContent = useCallback((file: FileEntry | null): string => { if (!file) return ''; @@ -268,6 +273,27 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC } }, [currentFile, project.description]); + // Toggle Monaco read-only mode during replay + useEffect(() => { + if (!editorRef.current) return; + const readOnly = replayState.isActive; + editorRef.current.updateOptions({ readOnly, domReadOnly: readOnly }); + }, [replayState.isActive]); + + // When replay content changes, update Monaco and local state + useEffect(() => { + if (!replayState.isActive) return; + setContent(replayState.currentContent); + + // Also update Monaco directly for display + const model = editorRef.current?.getModel(); + if (model && editorRef.current) { + applyingRemoteRef.current = true; + model.setValue(replayState.currentContent); + applyingRemoteRef.current = false; + } + }, [replayState.isActive, replayState.currentContent]); + // Refresh intelligence (outline) when content changes // VFS is updated via Automerge callbacks, so we trigger refresh after content changes useEffect(() => { @@ -307,6 +333,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC useEffect(() => { if (!currentFile) return; + // During replay mode, the replay hook controls content — skip Automerge sync + if (replayState.isActive) return; + const automergeContent = fileContents.get(currentFile.path); if (automergeContent === undefined) return; @@ -335,7 +364,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // Always update React state to keep preview in sync. // Since Monaco is uncontrolled, this won't affect editor content or cursor. setContent(automergeContent); - }, [currentFile, fileContents]); + }, [currentFile, fileContents, replayState.isActive]); // Update currentFile when files list changes (e.g., on initial load) // Note: setState in effect is intentional - syncing with external file list @@ -373,6 +402,8 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC }, [route, files, fileContents, currentFile]); const handleEditorChange = (value: string | undefined) => { + // Skip changes during replay mode (editor is read-only, but belt-and-suspenders) + if (replayState.isActive) return; // Skip echo when applying remote changes if (applyingRemoteRef.current) return; @@ -437,6 +468,8 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC // Handle file selection from sidebar (uses replaceState - no history entry) const handleSelectFile = useCallback((file: FileEntry) => { + // Block file switching during replay mode + if (replayState.isActive) return; // Don't switch to binary files in the editor if (isBinaryExtension(file.path)) { // For now, just ignore binary file selection @@ -452,7 +485,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC setUnlocatedErrors([]); // Update URL without adding history entry (sidebar navigation) onNavigateToFile(file.path, { replace: true }); - }, [fileContents, onNavigateToFile]); + }, [fileContents, onNavigateToFile, replayState.isActive]); // Handle opening a file in a new browser tab const handleOpenInNewTab = useCallback((file: FileEntry) => { @@ -726,6 +759,10 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC /> )} + {!isFullscreenPreview && replayState.isActive && ( +
REPLAY MODE
+ )} + {!isFullscreenPreview && unlocatedErrors.length > 0 && (
{unlocatedErrors.map((diag, i) => ( @@ -896,6 +933,11 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
+ {/* Replay mode drawer */} + {!isFullscreenPreview && ( + + )} + {/* New file dialog */} = {}): ReplayState { + return { + isActive: false, + historyLength: 0, + currentIndex: 0, + isPlaying: false, + currentContent: '', + timestamp: null, + ...overrides, + }; +} + +function makeControls(overrides: Partial = {}): ReplayControls { + return { + enter: vi.fn(), + exit: vi.fn(), + apply: vi.fn(), + seekTo: vi.fn(), + play: vi.fn(), + pause: vi.fn(), + stepForward: vi.fn(), + stepBackward: vi.fn(), + ...overrides, + }; +} + +describe('ReplayDrawer', () => { + let controls: ReplayControls; + + beforeEach(() => { + controls = makeControls(); + }); + + afterEach(() => { + cleanup(); + }); + + describe('collapsed state', () => { + it('renders clock icon and "History" label', () => { + render(); + expect(screen.getByText('History')).toBeDefined(); + }); + + it('clicking the bar calls controls.enter()', () => { + render(); + fireEvent.click(screen.getByText('History')); + expect(controls.enter).toHaveBeenCalled(); + }); + }); + + describe('expanded state', () => { + const activeState = makeState({ + isActive: true, + historyLength: 100, + currentIndex: 42, + currentContent: 'hello', + timestamp: 1710000000000, + }); + + it('renders transport controls when active', () => { + render(); + expect(screen.getByLabelText('Step backward')).toBeDefined(); + expect(screen.getByLabelText('Play')).toBeDefined(); + expect(screen.getByLabelText('Step forward')).toBeDefined(); + }); + + it('renders Apply and Close buttons', () => { + render(); + expect(screen.getByText('Apply')).toBeDefined(); + expect(screen.getByText('Close')).toBeDefined(); + }); + + it('renders position indicator', () => { + render(); + expect(screen.getByText(/43 of 100/)).toBeDefined(); + }); + + it('Apply button calls controls.apply()', () => { + render(); + fireEvent.click(screen.getByText('Apply')); + expect(controls.apply).toHaveBeenCalled(); + }); + + it('Close button calls controls.exit()', () => { + render(); + fireEvent.click(screen.getByText('Close')); + expect(controls.exit).toHaveBeenCalled(); + }); + + it('shows Pause button when playing', () => { + const playingState = makeState({ + ...activeState, + isPlaying: true, + }); + render(); + expect(screen.getByLabelText('Pause')).toBeDefined(); + }); + + it('scrubber onChange calls controls.seekTo()', () => { + render(); + const scrubber = screen.getByRole('slider'); + fireEvent.change(scrubber, { target: { value: '10' } }); + expect(controls.seekTo).toHaveBeenCalledWith(10); + }); + }); + + describe('keyboard shortcuts', () => { + const activeState = makeState({ + isActive: true, + historyLength: 100, + currentIndex: 50, + currentContent: 'test', + }); + + it('Space toggles play/pause', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: ' ' }); + expect(controls.play).toHaveBeenCalled(); + }); + + it('ArrowLeft calls stepBackward', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'ArrowLeft' }); + expect(controls.stepBackward).toHaveBeenCalled(); + }); + + it('ArrowRight calls stepForward', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'ArrowRight' }); + expect(controls.stepForward).toHaveBeenCalled(); + }); + + it('Escape calls exit', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'Escape' }); + expect(controls.exit).toHaveBeenCalled(); + }); + }); +}); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx new file mode 100644 index 00000000..aa1c0b3c --- /dev/null +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -0,0 +1,140 @@ +import { useCallback } from 'react'; +import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; +import './ReplayDrawer.css'; + +interface Props { + state: ReplayState; + controls: ReplayControls; +} + +function formatTimestamp(ts: number | null): string { + if (ts === null) return ''; + const date = new Date(ts); + return date.toLocaleString(); +} + +export default function ReplayDrawer({ state, controls }: Props) { + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!state.isActive) return; + + switch (e.key) { + case ' ': + e.preventDefault(); + if (state.isPlaying) { + controls.pause(); + } else { + controls.play(); + } + break; + case 'ArrowLeft': + e.preventDefault(); + controls.stepBackward(); + break; + case 'ArrowRight': + e.preventDefault(); + controls.stepForward(); + break; + case 'Escape': + e.preventDefault(); + controls.exit(); + break; + } + }, [state.isActive, state.isPlaying, controls]); + + const handleScrubberChange = useCallback((e: React.ChangeEvent) => { + controls.seekTo(parseInt(e.target.value, 10)); + }, [controls]); + + if (!state.isActive) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + {state.isPlaying ? ( + + ) : ( + + )} + +
+ +
+ +
+ +
+ + {state.currentIndex + 1} of {state.historyLength} + + {state.timestamp && ( + + {formatTimestamp(state.timestamp)} + + )} +
+ +
+ + +
+
+
+ ); +} diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts new file mode 100644 index 00000000..659fc0f8 --- /dev/null +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -0,0 +1,341 @@ +/** + * Tests for useReplayMode hook + * + * @vitest-environment jsdom + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; + +vi.mock('../services/automergeSync', () => ({ + getFileHandle: vi.fn(), + updateFileContent: vi.fn(), +})); + +import { useReplayMode } from './useReplayMode'; +import { + getFileHandle, + updateFileContent, +} from '../services/automergeSync'; + +const mockGetFileHandle = vi.mocked(getFileHandle); +const mockUpdateFileContent = vi.mocked(updateFileContent); + +// Helper to create a mock handle with history and view support. +// history() returns UrlHeads[] where each UrlHeads is string[]. +// metadata() receives a single change hash string (first element of UrlHeads). +function createMockHandle(texts: string[], timestamps?: number[]) { + const historyHeads = texts.map((_, i) => [`head-${i}`]); + + const handle = { + history: vi.fn(() => historyHeads), + view: vi.fn((heads: string[]) => { + const index = historyHeads.findIndex(h => h[0] === heads[0]); + return { + doc: () => ({ text: texts[index] ?? '' }), + }; + }), + metadata: vi.fn((changeHash?: string) => { + if (!changeHash) return undefined; + const index = historyHeads.findIndex(h => h[0] === changeHash); + if (index < 0) return undefined; + const ts = timestamps?.[index] ?? 1000000 + index * 1000; + return { time: ts }; + }), + doc: vi.fn(() => ({ text: texts[texts.length - 1] })), + }; + + return handle; +} + +describe('useReplayMode', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('starts in inactive state', () => { + const { result } = renderHook(() => useReplayMode('index.qmd')); + expect(result.current.state.isActive).toBe(false); + expect(result.current.state.historyLength).toBe(0); + expect(result.current.state.currentIndex).toBe(0); + expect(result.current.state.isPlaying).toBe(false); + expect(result.current.state.currentContent).toBe(''); + }); + + describe('enter()', () => { + it('loads history and activates replay', () => { + const handle = createMockHandle(['a', 'ab', 'abc']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(handle.history).toHaveBeenCalled(); + expect(result.current.state.isActive).toBe(true); + expect(result.current.state.historyLength).toBe(3); + // Starts at last index (current state) + expect(result.current.state.currentIndex).toBe(2); + expect(result.current.state.currentContent).toBe('abc'); + }); + + it('is a no-op when getFileHandle returns null', () => { + mockGetFileHandle.mockReturnValue(null as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.isActive).toBe(false); + }); + + it('is a no-op when handle.history() returns undefined', () => { + const handle = { + history: vi.fn(() => undefined), + view: vi.fn(), + metadata: vi.fn(), + doc: vi.fn(), + }; + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.isActive).toBe(false); + }); + + it('is a no-op when history is empty', () => { + const handle = createMockHandle([]); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.isActive).toBe(false); + }); + + it('is a no-op when filePath is null', () => { + const { result } = renderHook(() => useReplayMode(null)); + act(() => { result.current.controls.enter(); }); + + expect(mockGetFileHandle).not.toHaveBeenCalled(); + expect(result.current.state.isActive).toBe(false); + }); + }); + + describe('seekTo()', () => { + it('updates currentContent with correct text', () => { + const handle = createMockHandle(['first', 'second', 'third']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + + expect(result.current.state.currentIndex).toBe(0); + expect(result.current.state.currentContent).toBe('first'); + }); + + it('clamps out-of-bounds index to valid range (too high)', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(100); }); + + expect(result.current.state.currentIndex).toBe(2); + }); + + it('clamps out-of-bounds index to valid range (negative)', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(-5); }); + + expect(result.current.state.currentIndex).toBe(0); + }); + }); + + describe('play() / pause()', () => { + it('starts auto-advance interval on play', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + + expect(result.current.state.isPlaying).toBe(false); + + act(() => { result.current.controls.play(); }); + expect(result.current.state.isPlaying).toBe(true); + + // Advance one tick + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(1); + + // Advance another tick + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(2); + }); + + it('stops auto-advance on pause', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(1); + + act(() => { result.current.controls.pause(); }); + expect(result.current.state.isPlaying).toBe(false); + + act(() => { vi.advanceTimersByTime(200); }); + // Should not advance further + expect(result.current.state.currentIndex).toBe(1); + }); + + it('stops playing when reaching the end of history', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + + // Advance past all items + act(() => { vi.advanceTimersByTime(200); }); // index 1 + act(() => { vi.advanceTimersByTime(200); }); // index 2 + act(() => { vi.advanceTimersByTime(200); }); // at end, should stop + + expect(result.current.state.currentIndex).toBe(2); + expect(result.current.state.isPlaying).toBe(false); + }); + }); + + describe('stepForward() / stepBackward()', () => { + it('steps forward by one', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.stepForward(); }); + + expect(result.current.state.currentIndex).toBe(1); + }); + + it('does not step forward past the end', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + // Already at index 2 (last) + act(() => { result.current.controls.stepForward(); }); + + expect(result.current.state.currentIndex).toBe(2); + }); + + it('steps backward by one', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.stepBackward(); }); + + expect(result.current.state.currentIndex).toBe(1); + }); + + it('does not step backward past the beginning', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.stepBackward(); }); + + expect(result.current.state.currentIndex).toBe(0); + }); + }); + + describe('exit()', () => { + it('resets state', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + expect(result.current.state.isActive).toBe(true); + + act(() => { result.current.controls.exit(); }); + + expect(result.current.state.isActive).toBe(false); + expect(result.current.state.historyLength).toBe(0); + expect(result.current.state.currentContent).toBe(''); + }); + + it('stops playback on exit', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + expect(result.current.state.isPlaying).toBe(true); + + act(() => { result.current.controls.exit(); }); + expect(result.current.state.isPlaying).toBe(false); + + // Ensure interval is cleared + act(() => { vi.advanceTimersByTime(1000); }); + expect(result.current.state.isActive).toBe(false); + }); + }); + + describe('apply()', () => { + it('calls updateFileContent with historical content and resets', () => { + const handle = createMockHandle(['first', 'second', 'third']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(1); }); + + expect(result.current.state.currentContent).toBe('second'); + + act(() => { result.current.controls.apply(); }); + + expect(mockUpdateFileContent).toHaveBeenCalledWith('index.qmd', 'second'); + expect(result.current.state.isActive).toBe(false); + }); + }); + + describe('timestamp', () => { + it('provides timestamp for current change', () => { + const timestamps = [1000000, 1001000, 1002000]; + const handle = createMockHandle(['a', 'b', 'c'], timestamps); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(1); }); + + expect(result.current.state.timestamp).toBe(1001000); + }); + }); +}); diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts new file mode 100644 index 00000000..59753f90 --- /dev/null +++ b/hub-client/src/hooks/useReplayMode.ts @@ -0,0 +1,229 @@ +import { useState, useCallback, useRef } from 'react'; +import { + getFileHandle, + updateFileContent, +} from '../services/automergeSync'; + +export interface ReplayState { + isActive: boolean; + historyLength: number; + currentIndex: number; + isPlaying: boolean; + currentContent: string; + timestamp: number | null; +} + +export interface ReplayControls { + enter: () => void; + exit: () => void; + apply: () => void; + seekTo: (index: number) => void; + play: () => void; + pause: () => void; + stepForward: () => void; + stepBackward: () => void; +} + +const INITIAL_STATE: ReplayState = { + isActive: false, + historyLength: 0, + currentIndex: 0, + isPlaying: false, + currentContent: '', + timestamp: null, +}; + +const PLAY_INTERVAL_MS = 200; + +// Type helpers for DocHandle methods we use (avoids importing Automerge types) +interface ViewableHandle { + history(): unknown[] | undefined; + view(heads: unknown): { doc(): { text?: string } | undefined | null }; + metadata(change?: string): { time?: number } | undefined; +} + +function asViewable(handle: unknown): ViewableHandle { + return handle as ViewableHandle; +} + +export function useReplayMode( + filePath: string | null, +): { state: ReplayState; controls: ReplayControls } { + const [state, setState] = useState(INITIAL_STATE); + + // Store history array and handle in refs (stable across renders, not reactive) + const historyRef = useRef([]); + const handleRef = useRef(null); + const intervalRef = useRef | null>(null); + // Keep current index in a ref for the interval callback + const indexRef = useRef(0); + + const clearPlayInterval = useCallback(() => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + const getContentAtIndex = useCallback((index: number): string => { + const handle = handleRef.current; + const history = historyRef.current; + if (!handle || index < 0 || index >= history.length) return ''; + try { + const viewedHandle = asViewable(handle).view(history[index]); + const doc = viewedHandle.doc(); + return doc?.text ?? ''; + } catch (e) { + console.warn('[useReplayMode] Failed to get content at index', index, e); + return ''; + } + }, []); + + const getTimestampAtIndex = useCallback((index: number): number | null => { + const handle = handleRef.current; + const history = historyRef.current; + if (!handle || index < 0 || index >= history.length) return null; + try { + // metadata() expects a single change hash string. + // history entries are UrlHeads (string[]), so extract the first element. + const heads = history[index]; + const changeHash = Array.isArray(heads) ? heads[0] : heads; + if (typeof changeHash !== 'string') return null; + const meta = asViewable(handle).metadata(changeHash); + return meta?.time ?? null; + } catch { + return null; + } + }, []); + + const enter = useCallback(() => { + if (!filePath) return; + + try { + const handle = getFileHandle(filePath); + if (!handle) return; + + const history = asViewable(handle).history(); + if (!history || history.length === 0) return; + + handleRef.current = handle; + historyRef.current = history; + + const lastIndex = history.length - 1; + indexRef.current = lastIndex; + const content = getContentAtIndex(lastIndex); + const timestamp = getTimestampAtIndex(lastIndex); + + setState({ + isActive: true, + historyLength: history.length, + currentIndex: lastIndex, + isPlaying: false, + currentContent: content, + timestamp, + }); + } catch (e) { + console.error('[useReplayMode] Failed to enter replay mode:', e); + } + }, [filePath, getContentAtIndex, getTimestampAtIndex]); + + const seekTo = useCallback((index: number) => { + const history = historyRef.current; + if (history.length === 0) return; + + const clamped = Math.max(0, Math.min(index, history.length - 1)); + indexRef.current = clamped; + const content = getContentAtIndex(clamped); + const timestamp = getTimestampAtIndex(clamped); + + setState(prev => ({ + ...prev, + currentIndex: clamped, + currentContent: content, + timestamp, + })); + }, [getContentAtIndex, getTimestampAtIndex]); + + const stopPlaying = useCallback(() => { + clearPlayInterval(); + setState(prev => ({ ...prev, isPlaying: false })); + }, [clearPlayInterval]); + + const play = useCallback(() => { + const history = historyRef.current; + if (history.length === 0) return; + + setState(prev => ({ ...prev, isPlaying: true })); + + intervalRef.current = setInterval(() => { + const nextIndex = indexRef.current + 1; + if (nextIndex >= history.length) { + clearPlayInterval(); + setState(prev => ({ ...prev, isPlaying: false })); + return; + } + indexRef.current = nextIndex; + const content = getContentAtIndex(nextIndex); + const timestamp = getTimestampAtIndex(nextIndex); + setState(prev => ({ + ...prev, + currentIndex: nextIndex, + currentContent: content, + timestamp, + })); + }, PLAY_INTERVAL_MS); + }, [clearPlayInterval, getContentAtIndex, getTimestampAtIndex]); + + const pause = useCallback(() => { + stopPlaying(); + }, [stopPlaying]); + + const stepForward = useCallback(() => { + const history = historyRef.current; + const next = indexRef.current + 1; + if (next < history.length) { + seekTo(next); + } + }, [seekTo]); + + const stepBackward = useCallback(() => { + const prev = indexRef.current - 1; + if (prev >= 0) { + seekTo(prev); + } + }, [seekTo]); + + const reset = useCallback(() => { + clearPlayInterval(); + handleRef.current = null; + historyRef.current = []; + indexRef.current = 0; + setState(INITIAL_STATE); + }, [clearPlayInterval]); + + const exit = useCallback(() => { + reset(); + }, [reset]); + + const apply = useCallback(() => { + const content = getContentAtIndex(indexRef.current); + if (filePath) { + updateFileContent(filePath, content); + } + reset(); + }, [filePath, getContentAtIndex, reset]); + + return { + state, + controls: { + enter, + exit, + apply, + seekTo, + play, + pause, + stepForward, + stepBackward, + }, + }; +} diff --git a/hub-client/src/services/automergeSync.test.ts b/hub-client/src/services/automergeSync.test.ts index 4b0c3a57..a6cef4de 100644 --- a/hub-client/src/services/automergeSync.test.ts +++ b/hub-client/src/services/automergeSync.test.ts @@ -13,6 +13,8 @@ import { isConnected, getFileContent, isFileBinary, + pauseSync, + resumeSync, _resetForTesting, _setClientForTesting, } from './automergeSync'; @@ -214,6 +216,51 @@ describe('automergeSync', () => { }); }); + describe('pauseSync / resumeSync', () => { + beforeEach(async () => { + mockClient = createMockSyncClient( + { + onFileAdded: vi.fn(), + onFileChanged: vi.fn(), + onBinaryChanged: vi.fn(), + onFileRemoved: vi.fn(), + onConnectionChange: vi.fn(), + }, + { + initialFiles: new Map([ + ['index.qmd', { type: 'text' as const, text: '# Hello' }], + ]), + }, + ); + _setClientForTesting(mockClient); + await mockClient.connect('ws://test', 'automerge:test'); + }); + + it('should call pauseSync without triggering onConnectionChange', () => { + const spy = vi.spyOn(mockClient, 'pauseSync'); + pauseSync(); + expect(spy).toHaveBeenCalled(); + // onConnectionChange should NOT have been called after the initial connect + expect(onConnectionChange).toHaveBeenCalledTimes(0); + }); + + it('should call resumeSync without triggering onConnectionChange', () => { + const spy = vi.spyOn(mockClient, 'resumeSync'); + pauseSync(); + resumeSync(); + expect(spy).toHaveBeenCalled(); + expect(onConnectionChange).toHaveBeenCalledTimes(0); + }); + + it('should preserve document state across pause/resume cycle', () => { + expect(getFileContent('index.qmd')).toBe('# Hello'); + pauseSync(); + expect(getFileContent('index.qmd')).toBe('# Hello'); + resumeSync(); + expect(getFileContent('index.qmd')).toBe('# Hello'); + }); + }); + describe('test isolation', () => { it('should have clean state after reset', () => { _resetForTesting(); diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index 09331f90..2be390ca 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -210,6 +210,21 @@ export function getFileHandle(path: string) { return ensureClient().getFileHandle(path); } +/** + * Pause all network sync without destroying the connection. + * Document handles and local state are preserved. + */ +export function pauseSync(): void { + ensureClient().pauseSync(); +} + +/** + * Resume network sync after a pause. + */ +export function resumeSync(): void { + ensureClient().resumeSync(); +} + /** * Get all current file paths that have handles. */ diff --git a/hub-client/src/test-utils/mockSyncClient.ts b/hub-client/src/test-utils/mockSyncClient.ts index b81f0237..423fd78a 100644 --- a/hub-client/src/test-utils/mockSyncClient.ts +++ b/hub-client/src/test-utils/mockSyncClient.ts @@ -53,6 +53,8 @@ export interface MockSyncClient { renameFile(oldPath: string, newPath: string): void; getFileHandle(path: string): { documentId: string } | null; getFilePaths(): string[]; + pauseSync(): void; + resumeSync(): void; createNewProject(options: CreateProjectOptions): Promise; // Test helpers @@ -219,6 +221,14 @@ export function createMockSyncClient( return Array.from(files.keys()); }, + pauseSync(): void { + // No-op in mock — sync is simulated + }, + + resumeSync(): void { + // No-op in mock — sync is simulated + }, + async createNewProject(options: CreateProjectOptions): Promise { // Clear existing state files.clear(); diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index 64c05c58..22a3e0bb 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -562,6 +562,21 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS return Array.from(state.fileHandles.keys()); } + /** + * Pause all network sync without destroying the connection. + * Document handles and local state are preserved. + */ + function pauseSync(): void { + state.repo?.networkSubsystem.disconnect(); + } + + /** + * Resume network sync after a pause. + */ + function resumeSync(): void { + state.repo?.networkSubsystem.reconnect(); + } + /** * Create a new project with the given files. */ @@ -697,6 +712,8 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS isConnected, getFileHandle, getFilePaths, + pauseSync, + resumeSync, createNewProject, }; } From cd7008034c5d1b5e4e0aa8e7de96b6ff95713251 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 12:24:44 +0000 Subject: [PATCH 02/17] Fix preview refresh on exiting replay mode --- hub-client/src/components/Editor.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 0dda2f26..f349f5f6 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -280,12 +280,16 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC editorRef.current.updateOptions({ readOnly, domReadOnly: readOnly }); }, [replayState.isActive]); - // When replay content changes, update Monaco and local state + // When replay content changes, update Monaco directly for display. + // We intentionally do NOT call setContent() here — keeping `content` state + // at its pre-replay value ensures that when replay exits, the Automerge sync + // effect's setContent(automergeContent) produces a state change, triggering + // a preview re-render. Without this, apply() would write the same historical + // text that `content` already holds, causing React to skip the re-render and + // leaving the preview stale. useEffect(() => { if (!replayState.isActive) return; - setContent(replayState.currentContent); - // Also update Monaco directly for display const model = editorRef.current?.getModel(); if (model && editorRef.current) { applyingRemoteRef.current = true; From 51f38408a9c6f0363654653ca83ae8f1ae444bee Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:08:41 +0000 Subject: [PATCH 03/17] Add seek to start / end buttons --- .../src/components/ReplayDrawer.test.tsx | 28 +++++++++++++++++ hub-client/src/components/ReplayDrawer.tsx | 24 ++++++++++++++- hub-client/src/hooks/useReplayMode.test.ts | 30 +++++++++++++++++++ hub-client/src/hooks/useReplayMode.ts | 17 +++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 061bfa9c..3e0718ac 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -27,6 +27,8 @@ function makeControls(overrides: Partial = {}): ReplayControls { exit: vi.fn(), apply: vi.fn(), seekTo: vi.fn(), + seekToStart: vi.fn(), + seekToEnd: vi.fn(), play: vi.fn(), pause: vi.fn(), stepForward: vi.fn(), @@ -70,9 +72,23 @@ describe('ReplayDrawer', () => { it('renders transport controls when active', () => { render(); + expect(screen.getByLabelText('Skip to start')).toBeDefined(); expect(screen.getByLabelText('Step backward')).toBeDefined(); expect(screen.getByLabelText('Play')).toBeDefined(); expect(screen.getByLabelText('Step forward')).toBeDefined(); + expect(screen.getByLabelText('Skip to end')).toBeDefined(); + }); + + it('Skip to start button calls controls.seekToStart()', () => { + render(); + fireEvent.click(screen.getByLabelText('Skip to start')); + expect(controls.seekToStart).toHaveBeenCalled(); + }); + + it('Skip to end button calls controls.seekToEnd()', () => { + render(); + fireEvent.click(screen.getByLabelText('Skip to end')); + expect(controls.seekToEnd).toHaveBeenCalled(); }); it('renders Apply and Close buttons', () => { @@ -141,6 +157,18 @@ describe('ReplayDrawer', () => { expect(controls.stepForward).toHaveBeenCalled(); }); + it('Home calls seekToStart', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'Home' }); + expect(controls.seekToStart).toHaveBeenCalled(); + }); + + it('End calls seekToEnd', () => { + const { container } = render(); + fireEvent.keyDown(container.firstChild!, { key: 'End' }); + expect(controls.seekToEnd).toHaveBeenCalled(); + }); + it('Escape calls exit', () => { const { container } = render(); fireEvent.keyDown(container.firstChild!, { key: 'Escape' }); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index aa1c0b3c..e7fcf0da 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -34,6 +34,14 @@ export default function ReplayDrawer({ state, controls }: Props) { e.preventDefault(); controls.stepForward(); break; + case 'Home': + e.preventDefault(); + controls.seekToStart(); + break; + case 'End': + e.preventDefault(); + controls.seekToEnd(); + break; case 'Escape': e.preventDefault(); controls.exit(); @@ -64,12 +72,19 @@ export default function ReplayDrawer({ state, controls }: Props) { >
+ {state.isPlaying ? ( + diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts index 659fc0f8..508ac390 100644 --- a/hub-client/src/hooks/useReplayMode.test.ts +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -272,6 +272,36 @@ describe('useReplayMode', () => { }); }); + describe('seekToStart() / seekToEnd()', () => { + it('seekToStart jumps to index 0', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + // Starts at index 2 (last) + expect(result.current.state.currentIndex).toBe(2); + + act(() => { result.current.controls.seekToStart(); }); + expect(result.current.state.currentIndex).toBe(0); + expect(result.current.state.currentContent).toBe('a'); + }); + + it('seekToEnd jumps to last index', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + expect(result.current.state.currentIndex).toBe(0); + + act(() => { result.current.controls.seekToEnd(); }); + expect(result.current.state.currentIndex).toBe(2); + expect(result.current.state.currentContent).toBe('c'); + }); + }); + describe('exit()', () => { it('resets state', () => { const handle = createMockHandle(['a', 'b', 'c']); diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index 59753f90..3da01d18 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -18,6 +18,8 @@ export interface ReplayControls { exit: () => void; apply: () => void; seekTo: (index: number) => void; + seekToStart: () => void; + seekToEnd: () => void; play: () => void; pause: () => void; stepForward: () => void; @@ -193,6 +195,19 @@ export function useReplayMode( } }, [seekTo]); + const seekToStart = useCallback(() => { + if (historyRef.current.length > 0) { + seekTo(0); + } + }, [seekTo]); + + const seekToEnd = useCallback(() => { + const history = historyRef.current; + if (history.length > 0) { + seekTo(history.length - 1); + } + }, [seekTo]); + const reset = useCallback(() => { clearPlayInterval(); handleRef.current = null; @@ -220,6 +235,8 @@ export function useReplayMode( exit, apply, seekTo, + seekToStart, + seekToEnd, play, pause, stepForward, From 58ef57c4067fc4b5e3aa8116897c31aee8973e24 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:43:58 +0000 Subject: [PATCH 04/17] Improve UI elements --- hub-client/src/components/ReplayDrawer.css | 184 ++++++++++++------ .../src/components/ReplayDrawer.test.tsx | 18 +- hub-client/src/components/ReplayDrawer.tsx | 59 +++--- hub-client/src/hooks/useReplayMode.ts | 2 +- 4 files changed, 161 insertions(+), 102 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 7434e8de..5542a2ab 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -4,52 +4,101 @@ background: #1a1a2e; border-top: 1px solid #1f3460; flex-shrink: 0; + transition: height 0.2s ease; } .replay-drawer--collapsed { - height: 32px; + height: 28px; + padding-bottom: env(safe-area-inset-bottom, 0px); } .replay-drawer--expanded { - height: 80px; + height: 72px; + padding-bottom: env(safe-area-inset-bottom, 0px); outline: none; + display: flex; + flex-direction: column; } +/* Toggle button — matches .section-header style from SidebarTabs */ .replay-drawer__toggle { display: flex; align-items: center; gap: 6px; - width: 100%; - height: 100%; - padding: 0 12px; + padding: 0; background: none; border: none; - color: #888; - font-size: 13px; + color: #ccc; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.5px; + text-transform: uppercase; cursor: pointer; - transition: color 0.2s; + text-align: left; + transition: color 0.15s; + user-select: none; } .replay-drawer__toggle:hover { - color: #ccc; + color: #fff; } -.replay-drawer__icon { - font-size: 14px; +/* Collapsed: toggle fills the bar */ +.replay-drawer--collapsed .replay-drawer__toggle { + width: 100%; + height: 100%; + padding: 0 10px; } +.replay-drawer__chevron { + font-size: 8px; + color: #888; + width: 12px; + flex-shrink: 0; +} + +/* Header row (expanded): chevron+History | info | Apply */ +.replay-drawer__header { + display: flex; + align-items: center; + gap: 10px; + height: 28px; + padding: 4px 10px 0; + flex-shrink: 0; +} + +.replay-drawer__info { + display: flex; + align-items: baseline; + gap: 8px; + margin-left: auto; +} + +.replay-drawer__position { + font-size: 12px; + color: #aaa; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; +} + +.replay-drawer__timestamp { + font-size: 11px; + color: #666; +} + +/* Controls row: transport + scrubber */ .replay-drawer__controls { display: flex; align-items: center; - gap: 12px; - height: 100%; - padding: 0 16px; + gap: 8px; + flex: 1; + min-height: 0; + padding: 0 10px 10px; } .replay-drawer__transport { display: flex; align-items: center; - gap: 4px; + gap: 2px; flex-shrink: 0; } @@ -57,16 +106,16 @@ display: flex; align-items: center; justify-content: center; - width: 32px; - height: 32px; + width: 26px; + height: 26px; padding: 0; background: #0f3460; border: 1px solid #1f4460; - border-radius: 6px; + border-radius: 4px; color: #ccc; - font-size: 14px; + font-size: 12px; cursor: pointer; - transition: all 0.2s; + transition: all 0.15s; } .replay-drawer__btn:hover { @@ -75,19 +124,21 @@ } .replay-drawer__btn--play { - width: 36px; - height: 36px; - font-size: 16px; + width: 30px; + height: 30px; + font-size: 14px; } .replay-drawer__btn--apply { width: auto; - padding: 0 12px; + height: 22px; + padding: 0 10px; background: #0a4f0a; border-color: #166616; color: #4ade80; - font-size: 13px; + font-size: 12px; font-weight: 600; + border-radius: 3px; } .replay-drawer__btn--apply:hover { @@ -95,60 +146,65 @@ color: #6ee7a0; } -.replay-drawer__btn--close { - width: auto; - padding: 0 12px; - background: none; - border-color: #333; - color: #888; - font-size: 13px; -} - -.replay-drawer__btn--close:hover { - border-color: #666; - color: #ccc; -} - .replay-drawer__scrubber { flex: 1; - min-width: 100px; + min-width: 80px; + display: flex; + align-items: center; } .replay-drawer__slider { + -webkit-appearance: none; + appearance: none; width: 100%; - height: 4px; - accent-color: #4ade80; + height: 3px; + margin: 0; + padding: 0; + background: #1f3460; + border-radius: 2px; + outline: none; cursor: pointer; } -.replay-drawer__info { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 2px; - flex-shrink: 0; - min-width: 100px; +.replay-drawer__slider::-webkit-slider-runnable-track { + height: 3px; + background: #1f3460; + border-radius: 2px; } -.replay-drawer__position { - font-size: 13px; - color: #ccc; - font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; +.replay-drawer__slider::-webkit-slider-thumb { + -webkit-appearance: none; + width: 14px; + height: 14px; + background: #8899aa; + border: none; + border-radius: 50%; + margin-top: -5.5px; + transition: background 0.15s, transform 0.15s; } -.replay-drawer__timestamp { - font-size: 11px; - color: #888; +.replay-drawer__slider:hover::-webkit-slider-thumb { + background: #ccc; + transform: scale(1.2); } -.replay-drawer__actions { - display: flex; - align-items: center; - gap: 8px; - flex-shrink: 0; +.replay-drawer__slider::-moz-range-track { + height: 3px; + background: #1f3460; + border-radius: 2px; + border: none; } -/* Transition for expand/collapse */ -.replay-drawer { - transition: height 0.2s ease; +.replay-drawer__slider::-moz-range-thumb { + width: 14px; + height: 14px; + background: #8899aa; + border: none; + border-radius: 50%; + transition: background 0.15s, transform 0.15s; +} + +.replay-drawer__slider:hover::-moz-range-thumb { + background: #ccc; + transform: scale(1.2); } diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 3e0718ac..82965e09 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -49,14 +49,14 @@ describe('ReplayDrawer', () => { }); describe('collapsed state', () => { - it('renders clock icon and "History" label', () => { + it('renders chevron and "History" label', () => { render(); - expect(screen.getByText('History')).toBeDefined(); + expect(screen.getByText('Replay')).toBeDefined(); }); it('clicking the bar calls controls.enter()', () => { render(); - fireEvent.click(screen.getByText('History')); + fireEvent.click(screen.getByText('Replay')); expect(controls.enter).toHaveBeenCalled(); }); }); @@ -91,10 +91,10 @@ describe('ReplayDrawer', () => { expect(controls.seekToEnd).toHaveBeenCalled(); }); - it('renders Apply and Close buttons', () => { + it('renders Apply button and collapse toggle in header', () => { render(); - expect(screen.getByText('Apply')).toBeDefined(); - expect(screen.getByText('Close')).toBeDefined(); + expect(screen.getByText('Restore')).toBeDefined(); + expect(screen.getByLabelText('Collapse history')).toBeDefined(); }); it('renders position indicator', () => { @@ -104,13 +104,13 @@ describe('ReplayDrawer', () => { it('Apply button calls controls.apply()', () => { render(); - fireEvent.click(screen.getByText('Apply')); + fireEvent.click(screen.getByText('Restore')); expect(controls.apply).toHaveBeenCalled(); }); - it('Close button calls controls.exit()', () => { + it('header toggle calls controls.exit()', () => { render(); - fireEvent.click(screen.getByText('Close')); + fireEvent.click(screen.getByLabelText('Collapse history')); expect(controls.exit).toHaveBeenCalled(); }); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index e7fcf0da..abbde8ee 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -57,8 +57,8 @@ export default function ReplayDrawer({ state, controls }: Props) { return (
); @@ -70,6 +70,35 @@ export default function ReplayDrawer({ state, controls }: Props) { onKeyDown={handleKeyDown} tabIndex={0} > +
+ + +
+ + {state.currentIndex + 1} of {state.historyLength} + + {state.timestamp && ( + + {formatTimestamp(state.timestamp)} + + )} +
+ + +
+
- -
- - {state.currentIndex + 1} of {state.historyLength} - - {state.timestamp && ( - - {formatTimestamp(state.timestamp)} - - )} -
- -
- - -
); diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index 3da01d18..9ade3f6e 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -35,7 +35,7 @@ const INITIAL_STATE: ReplayState = { timestamp: null, }; -const PLAY_INTERVAL_MS = 200; +const PLAY_INTERVAL_MS = 80; // Type helpers for DocHandle methods we use (avoids importing Automerge types) interface ViewableHandle { From 087b4c05076acda10dfcd9c1b15598e0bda25a57 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 13:56:53 +0000 Subject: [PATCH 05/17] Fix timestamp display --- hub-client/src/components/ReplayDrawer.test.tsx | 2 +- hub-client/src/components/ReplayDrawer.tsx | 2 +- hub-client/src/hooks/useReplayMode.test.ts | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 82965e09..167d0f7f 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -67,7 +67,7 @@ describe('ReplayDrawer', () => { historyLength: 100, currentIndex: 42, currentContent: 'hello', - timestamp: 1710000000000, + timestamp: 1710000000, }); it('renders transport controls when active', () => { diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index abbde8ee..fe110b8f 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -9,7 +9,7 @@ interface Props { function formatTimestamp(ts: number | null): string { if (ts === null) return ''; - const date = new Date(ts); + const date = new Date(ts * 1000); return date.toLocaleString(); } diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts index 508ac390..5979f9b3 100644 --- a/hub-client/src/hooks/useReplayMode.test.ts +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -176,12 +176,12 @@ describe('useReplayMode', () => { act(() => { result.current.controls.play(); }); expect(result.current.state.isPlaying).toBe(true); - // Advance one tick - act(() => { vi.advanceTimersByTime(200); }); + // Advance one tick (PLAY_INTERVAL_MS = 80ms) + act(() => { vi.advanceTimersByTime(80); }); expect(result.current.state.currentIndex).toBe(1); // Advance another tick - act(() => { vi.advanceTimersByTime(200); }); + act(() => { vi.advanceTimersByTime(80); }); expect(result.current.state.currentIndex).toBe(2); }); @@ -193,13 +193,13 @@ describe('useReplayMode', () => { act(() => { result.current.controls.enter(); }); act(() => { result.current.controls.seekTo(0); }); act(() => { result.current.controls.play(); }); - act(() => { vi.advanceTimersByTime(200); }); + act(() => { vi.advanceTimersByTime(80); }); expect(result.current.state.currentIndex).toBe(1); act(() => { result.current.controls.pause(); }); expect(result.current.state.isPlaying).toBe(false); - act(() => { vi.advanceTimersByTime(200); }); + act(() => { vi.advanceTimersByTime(80); }); // Should not advance further expect(result.current.state.currentIndex).toBe(1); }); From c6f15d1baa293cd37b3fdcfc44c1b649ce0a107a Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:01:38 +0000 Subject: [PATCH 06/17] Make drawer easier to collapse --- hub-client/src/components/ReplayDrawer.css | 7 ++++++- hub-client/src/components/ReplayDrawer.tsx | 12 ++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 5542a2ab..5399130c 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -57,7 +57,7 @@ flex-shrink: 0; } -/* Header row (expanded): chevron+History | info | Apply */ +/* Header row (expanded): clickable full-width to collapse */ .replay-drawer__header { display: flex; align-items: center; @@ -65,6 +65,11 @@ height: 28px; padding: 4px 10px 0; flex-shrink: 0; + cursor: pointer; +} + +.replay-drawer__header:hover .replay-drawer__toggle { + color: #fff; } .replay-drawer__info { diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index fe110b8f..9abbac03 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -70,15 +70,11 @@ export default function ReplayDrawer({ state, controls }: Props) { onKeyDown={handleKeyDown} tabIndex={0} > -
- +
@@ -93,7 +89,7 @@ export default function ReplayDrawer({ state, controls }: Props) { From 4c6263d24f4c97cba511e01a315f96a946c2b250 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:09:46 +0000 Subject: [PATCH 07/17] Improve slider UI --- hub-client/src/components/ReplayDrawer.css | 30 +++++++++++----------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 5399130c..78b8a8c3 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -162,47 +162,47 @@ -webkit-appearance: none; appearance: none; width: 100%; - height: 3px; + height: 6px; margin: 0; - padding: 0; - background: #1f3460; - border-radius: 2px; + padding: 8px 0; + background: transparent; + box-sizing: content-box; outline: none; cursor: pointer; } .replay-drawer__slider::-webkit-slider-runnable-track { - height: 3px; + height: 6px; background: #1f3460; - border-radius: 2px; + border-radius: 3px; } .replay-drawer__slider::-webkit-slider-thumb { -webkit-appearance: none; - width: 14px; - height: 14px; + width: 16px; + height: 16px; background: #8899aa; border: none; border-radius: 50%; - margin-top: -5.5px; + margin-top: -5px; transition: background 0.15s, transform 0.15s; } .replay-drawer__slider:hover::-webkit-slider-thumb { background: #ccc; - transform: scale(1.2); + transform: scale(1.15); } .replay-drawer__slider::-moz-range-track { - height: 3px; + height: 6px; background: #1f3460; - border-radius: 2px; + border-radius: 3px; border: none; } .replay-drawer__slider::-moz-range-thumb { - width: 14px; - height: 14px; + width: 16px; + height: 16px; background: #8899aa; border: none; border-radius: 50%; @@ -211,5 +211,5 @@ .replay-drawer__slider:hover::-moz-range-thumb { background: #ccc; - transform: scale(1.2); + transform: scale(1.15); } From 1adaf22f7eeb950626b935c19a8b26d7066a1eb9 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:32:58 +0000 Subject: [PATCH 08/17] Refine UI elements further --- hub-client/src/components/Editor.tsx | 4 +- hub-client/src/components/ReplayDrawer.css | 27 ++++++++-- hub-client/src/components/ReplayDrawer.tsx | 58 ++++++++++++++++++++-- hub-client/src/components/SidebarTabs.css | 6 +++ hub-client/src/components/SidebarTabs.tsx | 5 +- hub-client/src/hooks/useReplayMode.test.ts | 10 ++-- hub-client/src/hooks/useReplayMode.ts | 21 ++++++-- 7 files changed, 112 insertions(+), 19 deletions(-) diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index f349f5f6..90246e4f 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -781,7 +781,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC
{!isFullscreenPreview && ( - + {(activeTab) => { switch (activeTab) { case 'files': @@ -939,7 +939,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC {/* Replay mode drawer */} {!isFullscreenPreview && ( - + )} {/* New file dialog */} diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 78b8a8c3..0607460c 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -39,10 +39,15 @@ user-select: none; } -.replay-drawer__toggle:hover { +.replay-drawer__toggle:hover:not(:disabled) { color: #fff; } +.replay-drawer__toggle:disabled { + opacity: 0.35; + cursor: default; +} + /* Collapsed: toggle fills the bar */ .replay-drawer--collapsed .replay-drawer__toggle { width: 100%; @@ -156,6 +161,22 @@ min-width: 80px; display: flex; align-items: center; + position: relative; +} + +.replay-drawer__tooltip { + position: absolute; + bottom: 100%; + transform: translateX(-50%); + margin-bottom: 6px; + padding: 3px 8px; + background: #0f0f1e; + border: 1px solid #1f3460; + border-radius: 4px; + color: #ccc; + font-size: 11px; + white-space: nowrap; + pointer-events: none; } .replay-drawer__slider { @@ -173,7 +194,7 @@ .replay-drawer__slider::-webkit-slider-runnable-track { height: 6px; - background: #1f3460; + background: var(--slider-track, #1f3460); border-radius: 3px; } @@ -195,7 +216,7 @@ .replay-drawer__slider::-moz-range-track { height: 6px; - background: #1f3460; + background: var(--slider-track, #1f3460); border-radius: 3px; border: none; } diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 9abbac03..ab573cfb 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -1,10 +1,11 @@ -import { useCallback } from 'react'; +import { useState, useCallback, useRef, useEffect } from 'react'; import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; import './ReplayDrawer.css'; interface Props { state: ReplayState; controls: ReplayControls; + disabled?: boolean; } function formatTimestamp(ts: number | null): string { @@ -13,7 +14,16 @@ function formatTimestamp(ts: number | null): string { return date.toLocaleString(); } -export default function ReplayDrawer({ state, controls }: Props) { +export default function ReplayDrawer({ state, controls, disabled }: Props) { + const drawerRef = useRef(null); + + // Auto-focus the drawer when replay mode activates so keyboard shortcuts work immediately + useEffect(() => { + if (state.isActive) { + drawerRef.current?.focus(); + } + }, [state.isActive]); + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { if (!state.isActive) return; @@ -53,10 +63,34 @@ export default function ReplayDrawer({ state, controls }: Props) { controls.seekTo(parseInt(e.target.value, 10)); }, [controls]); + // Tooltip state for scrubber hover + const [scrubberTooltip, setScrubberTooltip] = useState<{ left: number; text: string } | null>(null); + const scrubberRef = useRef(null); + + const handleScrubberMouseMove = useCallback((e: React.MouseEvent) => { + const rect = e.currentTarget.getBoundingClientRect(); + const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); + const index = Math.round(fraction * (state.historyLength - 1)); + const ts = controls.getTimestampAtIndex(index); + const text = ts !== null ? formatTimestamp(ts) : `Change ${index + 1}`; + // Position relative to the scrubber container + const left = e.clientX - (scrubberRef.current?.getBoundingClientRect().left ?? rect.left); + setScrubberTooltip({ left, text }); + }, [state.historyLength, controls]); + + const handleScrubberMouseLeave = useCallback(() => { + setScrubberTooltip(null); + }, []); + if (!state.isActive) { return (
- @@ -64,8 +98,13 @@ export default function ReplayDrawer({ state, controls }: Props) { ); } + const progressPercent = state.historyLength > 1 + ? (state.currentIndex / (state.historyLength - 1)) * 100 + : 0; + return (
-
+
+ {scrubberTooltip && ( +
+ {scrubberTooltip.text} +
+ )}
diff --git a/hub-client/src/components/SidebarTabs.css b/hub-client/src/components/SidebarTabs.css index be22a69a..947e6c4e 100644 --- a/hub-client/src/components/SidebarTabs.css +++ b/hub-client/src/components/SidebarTabs.css @@ -61,6 +61,12 @@ background: #16213e; } +/* Dimmed state during replay mode */ +.sidebar-sections--disabled { + opacity: 0.4; + pointer-events: none; +} + /* When FILES section is expanded, let it take available space */ .sidebar-section.expanded:first-child .section-content { flex: 1; diff --git a/hub-client/src/components/SidebarTabs.tsx b/hub-client/src/components/SidebarTabs.tsx index 01686f46..50f27de7 100644 --- a/hub-client/src/components/SidebarTabs.tsx +++ b/hub-client/src/components/SidebarTabs.tsx @@ -27,9 +27,10 @@ const SECTIONS: Section[] = [ interface SidebarTabsProps { children: (sectionId: SectionId) => ReactNode; + disabled?: boolean; } -export default function SidebarTabs({ children }: SidebarTabsProps) { +export default function SidebarTabs({ children, disabled }: SidebarTabsProps) { const [expandedSections, setExpandedSections] = useState>(() => { const initial = new Set(); for (const section of SECTIONS) { @@ -53,7 +54,7 @@ export default function SidebarTabs({ children }: SidebarTabsProps) { }; return ( -
+
{SECTIONS.map((section) => { const isExpanded = expandedSections.has(section.id); return ( diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts index 5979f9b3..c03a1eac 100644 --- a/hub-client/src/hooks/useReplayMode.test.ts +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -176,12 +176,12 @@ describe('useReplayMode', () => { act(() => { result.current.controls.play(); }); expect(result.current.state.isPlaying).toBe(true); - // Advance one tick (PLAY_INTERVAL_MS = 80ms) - act(() => { vi.advanceTimersByTime(80); }); + // Advance one tick (adaptive interval: 15000/4 = 3750, clamped to 200ms) + act(() => { vi.advanceTimersByTime(200); }); expect(result.current.state.currentIndex).toBe(1); // Advance another tick - act(() => { vi.advanceTimersByTime(80); }); + act(() => { vi.advanceTimersByTime(200); }); expect(result.current.state.currentIndex).toBe(2); }); @@ -193,13 +193,13 @@ describe('useReplayMode', () => { act(() => { result.current.controls.enter(); }); act(() => { result.current.controls.seekTo(0); }); act(() => { result.current.controls.play(); }); - act(() => { vi.advanceTimersByTime(80); }); + act(() => { vi.advanceTimersByTime(200); }); expect(result.current.state.currentIndex).toBe(1); act(() => { result.current.controls.pause(); }); expect(result.current.state.isPlaying).toBe(false); - act(() => { vi.advanceTimersByTime(80); }); + act(() => { vi.advanceTimersByTime(200); }); // Should not advance further expect(result.current.state.currentIndex).toBe(1); }); diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index 9ade3f6e..258ecaeb 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -24,6 +24,7 @@ export interface ReplayControls { pause: () => void; stepForward: () => void; stepBackward: () => void; + getTimestampAtIndex: (index: number) => number | null; } const INITIAL_STATE: ReplayState = { @@ -35,7 +36,10 @@ const INITIAL_STATE: ReplayState = { timestamp: null, }; -const PLAY_INTERVAL_MS = 80; +// Target ~15 seconds for a full playback, clamped to [16ms, 200ms] per step +const PLAY_TARGET_DURATION_MS = 15000; +const PLAY_MIN_INTERVAL_MS = 16; +const PLAY_MAX_INTERVAL_MS = 200; // Type helpers for DocHandle methods we use (avoids importing Automerge types) interface ViewableHandle { @@ -155,8 +159,18 @@ export function useReplayMode( const history = historyRef.current; if (history.length === 0) return; + // If at the end, restart from the beginning + if (indexRef.current >= history.length - 1) { + seekTo(0); + } + setState(prev => ({ ...prev, isPlaying: true })); + const interval = Math.max( + PLAY_MIN_INTERVAL_MS, + Math.min(PLAY_MAX_INTERVAL_MS, Math.round(PLAY_TARGET_DURATION_MS / history.length)), + ); + intervalRef.current = setInterval(() => { const nextIndex = indexRef.current + 1; if (nextIndex >= history.length) { @@ -173,8 +187,8 @@ export function useReplayMode( currentContent: content, timestamp, })); - }, PLAY_INTERVAL_MS); - }, [clearPlayInterval, getContentAtIndex, getTimestampAtIndex]); + }, interval); + }, [clearPlayInterval, getContentAtIndex, getTimestampAtIndex, seekTo]); const pause = useCallback(() => { stopPlaying(); @@ -241,6 +255,7 @@ export function useReplayMode( pause, stepForward, stepBackward, + getTimestampAtIndex, }, }; } From 43c95648d79abb9a55e476d76d3c3820eb722ec4 Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:41:23 +0000 Subject: [PATCH 09/17] Preview when replaying --- hub-client/src/components/Editor.tsx | 42 +++++++++++++++++++++------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 90246e4f..5b3ba0dd 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -12,6 +12,7 @@ import { renameFile, exportProjectAsZip, } from '../services/automergeSync'; +import { vfsAddFile, isWasmReady } from '../services/wasmRenderer'; import type { Diagnostic } from '../types/diagnostic'; import { registerIntelligenceProviders, disposeIntelligenceProviders } from '../services/monacoProviders'; import { processFileForUpload } from '../services/resourceService'; @@ -280,13 +281,14 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC editorRef.current.updateOptions({ readOnly, domReadOnly: readOnly }); }, [replayState.isActive]); - // When replay content changes, update Monaco directly for display. - // We intentionally do NOT call setContent() here — keeping `content` state - // at its pre-replay value ensures that when replay exits, the Automerge sync - // effect's setContent(automergeContent) produces a state change, triggering - // a preview re-render. Without this, apply() would write the same historical - // text that `content` already holds, causing React to skip the re-render and - // leaving the preview stale. + // Content for preview and MarkdownSummary: show replay content when active, + // otherwise the normal Automerge-synced content. We keep `content` state + // untouched during replay so that when replay exits, the Automerge sync + // effect's setContent(automergeContent) always produces a state change. + const displayContent = replayState.isActive ? replayState.currentContent : content; + + // When replay content changes, update Monaco and VFS for display. + // Writing to VFS ensures the preview renderer sees historical content. useEffect(() => { if (!replayState.isActive) return; @@ -296,7 +298,27 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC model.setValue(replayState.currentContent); applyingRemoteRef.current = false; } - }, [replayState.isActive, replayState.currentContent]); + + // Update VFS so the WASM renderer sees the historical content + if (currentFile && isWasmReady()) { + vfsAddFile(currentFile.path, replayState.currentContent); + } + }, [replayState.isActive, replayState.currentContent, currentFile]); + + // Restore VFS when replay exits — the Automerge sync effect restores Monaco + // and React state, but doesn't re-write VFS unless fileContents changed. + const prevReplayActiveRef = useRef(false); + useEffect(() => { + const wasActive = prevReplayActiveRef.current; + prevReplayActiveRef.current = replayState.isActive; + + if (wasActive && !replayState.isActive && currentFile && isWasmReady()) { + const liveContent = fileContents.get(currentFile.path); + if (liveContent !== undefined) { + vfsAddFile(currentFile.path, liveContent); + } + } + }, [replayState.isActive, currentFile, fileContents]); // Refresh intelligence (outline) when content changes // VFS is updated via Automerge callbacks, so we trigger refresh after content changes @@ -846,7 +868,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC {viewMode === 'preview' && (
{ if (previewScrollToLineRef.current) { previewScrollToLineRef.current(lineNumber); @@ -917,7 +939,7 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC )} Date: Sun, 15 Mar 2026 14:52:31 +0000 Subject: [PATCH 10/17] Add relative times --- hub-client/src/components/ReplayDrawer.css | 7 +++++++ hub-client/src/components/ReplayDrawer.tsx | 24 +++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 0607460c..d7635cfe 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -93,6 +93,13 @@ .replay-drawer__timestamp { font-size: 11px; color: #666; + display: flex; + align-items: baseline; + gap: 6px; +} + +.replay-drawer__relative { + color: #888; } /* Controls row: transport + scrubber */ diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index ab573cfb..2b8546c1 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -8,7 +8,28 @@ interface Props { disabled?: boolean; } +function formatRelativeTime(ts: number): string { + const now = Date.now(); + const diffMs = now - ts * 1000; + const diffSec = Math.floor(diffMs / 1000); + if (diffSec < 60) return 'just now'; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHr = Math.floor(diffMin / 60); + if (diffHr < 24) return `${diffHr}h ago`; + const diffDays = Math.floor(diffHr / 24); + if (diffDays < 30) return `${diffDays}d ago`; + // Beyond 30 days, show short date + const date = new Date(ts * 1000); + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +} + function formatTimestamp(ts: number | null): string { + if (ts === null) return ''; + return formatRelativeTime(ts); +} + +function formatFullTimestamp(ts: number | null): string { if (ts === null) return ''; const date = new Date(ts * 1000); return date.toLocaleString(); @@ -121,7 +142,8 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) { {state.timestamp && ( - {formatTimestamp(state.timestamp)} + {formatFullTimestamp(state.timestamp)} + {formatTimestamp(state.timestamp)} )}
From 4f66d79af7fd8d6cda8b5485d96b8400f414703d Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:56:11 +0000 Subject: [PATCH 11/17] Tighten dimensions --- hub-client/src/components/ReplayDrawer.css | 48 +++++++++++----------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index d7635cfe..7e12e872 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -106,16 +106,16 @@ .replay-drawer__controls { display: flex; align-items: center; - gap: 8px; + gap: 10px; flex: 1; min-height: 0; - padding: 0 10px 10px; + padding: 0 10px 6px; } .replay-drawer__transport { display: flex; align-items: center; - gap: 2px; + gap: 3px; flex-shrink: 0; } @@ -123,14 +123,14 @@ display: flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 24px; + height: 24px; padding: 0; background: #0f3460; border: 1px solid #1f4460; border-radius: 4px; color: #ccc; - font-size: 12px; + font-size: 11px; cursor: pointer; transition: all 0.15s; } @@ -141,21 +141,21 @@ } .replay-drawer__btn--play { - width: 30px; - height: 30px; - font-size: 14px; + width: 28px; + height: 28px; + font-size: 13px; } .replay-drawer__btn--apply { width: auto; - height: 22px; + height: 24px; padding: 0 10px; background: #0a4f0a; border-color: #166616; color: #4ade80; - font-size: 12px; + font-size: 11px; font-weight: 600; - border-radius: 3px; + border-radius: 4px; } .replay-drawer__btn--apply:hover { @@ -190,7 +190,7 @@ -webkit-appearance: none; appearance: none; width: 100%; - height: 6px; + height: 4px; margin: 0; padding: 8px 0; background: transparent; @@ -200,37 +200,37 @@ } .replay-drawer__slider::-webkit-slider-runnable-track { - height: 6px; + height: 4px; background: var(--slider-track, #1f3460); - border-radius: 3px; + border-radius: 2px; } .replay-drawer__slider::-webkit-slider-thumb { -webkit-appearance: none; - width: 16px; - height: 16px; + width: 12px; + height: 12px; background: #8899aa; border: none; border-radius: 50%; - margin-top: -5px; + margin-top: -4px; transition: background 0.15s, transform 0.15s; } .replay-drawer__slider:hover::-webkit-slider-thumb { background: #ccc; - transform: scale(1.15); + transform: scale(1.2); } .replay-drawer__slider::-moz-range-track { - height: 6px; + height: 4px; background: var(--slider-track, #1f3460); - border-radius: 3px; + border-radius: 2px; border: none; } .replay-drawer__slider::-moz-range-thumb { - width: 16px; - height: 16px; + width: 12px; + height: 12px; background: #8899aa; border: none; border-radius: 50%; @@ -239,5 +239,5 @@ .replay-drawer__slider:hover::-moz-range-thumb { background: #ccc; - transform: scale(1.15); + transform: scale(1.2); } From 085786b7f550e51ce0367def4372876d4a50acdd Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 15:45:11 +0000 Subject: [PATCH 12/17] Add playback speed control --- hub-client/src/components/ReplayDrawer.css | 8 ++ .../src/components/ReplayDrawer.test.tsx | 16 ++++ hub-client/src/components/ReplayDrawer.tsx | 9 ++- hub-client/src/hooks/useReplayMode.test.ts | 76 ++++++++++++++++++- hub-client/src/hooks/useReplayMode.ts | 61 ++++++++++----- 5 files changed, 148 insertions(+), 22 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 7e12e872..32b28b6a 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -146,6 +146,14 @@ font-size: 13px; } +.replay-drawer__btn--speed { + width: auto; + padding: 0 6px; + font-size: 10px; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; + margin-left: 3px; +} + .replay-drawer__btn--apply { width: auto; height: 24px; diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 167d0f7f..165254f4 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -15,6 +15,7 @@ function makeState(overrides: Partial = {}): ReplayState { historyLength: 0, currentIndex: 0, isPlaying: false, + playbackSpeed: 1, currentContent: '', timestamp: null, ...overrides, @@ -33,6 +34,7 @@ function makeControls(overrides: Partial = {}): ReplayControls { pause: vi.fn(), stepForward: vi.fn(), stepBackward: vi.fn(), + cycleSpeed: vi.fn(), ...overrides, }; } @@ -129,6 +131,20 @@ describe('ReplayDrawer', () => { fireEvent.change(scrubber, { target: { value: '10' } }); expect(controls.seekTo).toHaveBeenCalledWith(10); }); + + it('speed button shows current speed and calls cycleSpeed', () => { + render(); + const speedBtn = screen.getByLabelText('Playback speed'); + expect(speedBtn.textContent).toBe('1x'); + fireEvent.click(speedBtn); + expect(controls.cycleSpeed).toHaveBeenCalled(); + }); + + it('speed button reflects 4x speed', () => { + const fastState = makeState({ ...activeState, playbackSpeed: 4 }); + render(); + expect(screen.getByLabelText('Playback speed').textContent).toBe('4x'); + }); }); describe('keyboard shortcuts', () => { diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 2b8546c1..2ef0431b 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -93,7 +93,7 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) { const fraction = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)); const index = Math.round(fraction * (state.historyLength - 1)); const ts = controls.getTimestampAtIndex(index); - const text = ts !== null ? formatTimestamp(ts) : `Change ${index + 1}`; + const text = ts !== null ? formatFullTimestamp(ts) : `Change ${index + 1}`; // Position relative to the scrubber container const left = e.clientX - (scrubberRef.current?.getBoundingClientRect().left ?? rect.left); setScrubberTooltip({ left, text }); @@ -203,6 +203,13 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) { > ⏭ +
diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts index c03a1eac..73535ed6 100644 --- a/hub-client/src/hooks/useReplayMode.test.ts +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -176,7 +176,7 @@ describe('useReplayMode', () => { act(() => { result.current.controls.play(); }); expect(result.current.state.isPlaying).toBe(true); - // Advance one tick (adaptive interval: 15000/4 = 3750, clamped to 200ms) + // Advance one tick (base interval 200ms at 1x speed) act(() => { vi.advanceTimersByTime(200); }); expect(result.current.state.currentIndex).toBe(1); @@ -223,6 +223,80 @@ describe('useReplayMode', () => { }); }); + describe('cycleSpeed()', () => { + it('cycles through 1x, 2x, 4x speeds', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + + expect(result.current.state.playbackSpeed).toBe(1); + + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(2); + + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(4); + + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(1); + }); + + it('advances faster at 2x speed', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd', 'e']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.cycleSpeed(); }); // 2x + act(() => { result.current.controls.play(); }); + + // At 2x, interval is 100ms + act(() => { vi.advanceTimersByTime(100); }); + expect(result.current.state.currentIndex).toBe(1); + + act(() => { vi.advanceTimersByTime(100); }); + expect(result.current.state.currentIndex).toBe(2); + }); + + it('restarts interval at new speed when changed during playback', () => { + const handle = createMockHandle(['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(0); }); + act(() => { result.current.controls.play(); }); + + // Advance one step at 1x (200ms) + act(() => { vi.advanceTimersByTime(200); }); + expect(result.current.state.currentIndex).toBe(1); + + // Switch to 4x while playing + act(() => { result.current.controls.cycleSpeed(); }); // 2x + act(() => { result.current.controls.cycleSpeed(); }); // 4x + + // At 4x, interval is 50ms + act(() => { vi.advanceTimersByTime(50); }); + expect(result.current.state.currentIndex).toBe(2); + }); + + it('resets speed on exit', () => { + const handle = createMockHandle(['a', 'b', 'c']); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.cycleSpeed(); }); + expect(result.current.state.playbackSpeed).toBe(2); + + act(() => { result.current.controls.exit(); }); + expect(result.current.state.playbackSpeed).toBe(1); + }); + }); + describe('stepForward() / stepBackward()', () => { it('steps forward by one', () => { const handle = createMockHandle(['a', 'b', 'c']); diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index 258ecaeb..1d46018f 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -4,11 +4,15 @@ import { updateFileContent, } from '../services/automergeSync'; +export const PLAYBACK_SPEEDS = [1, 2, 4] as const; +export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number]; + export interface ReplayState { isActive: boolean; historyLength: number; currentIndex: number; isPlaying: boolean; + playbackSpeed: PlaybackSpeed; currentContent: string; timestamp: number | null; } @@ -24,6 +28,7 @@ export interface ReplayControls { pause: () => void; stepForward: () => void; stepBackward: () => void; + cycleSpeed: () => void; getTimestampAtIndex: (index: number) => number | null; } @@ -32,14 +37,13 @@ const INITIAL_STATE: ReplayState = { historyLength: 0, currentIndex: 0, isPlaying: false, + playbackSpeed: 1, currentContent: '', timestamp: null, }; -// Target ~15 seconds for a full playback, clamped to [16ms, 200ms] per step -const PLAY_TARGET_DURATION_MS = 15000; -const PLAY_MIN_INTERVAL_MS = 16; -const PLAY_MAX_INTERVAL_MS = 200; +// Base interval at 1x speed; divided by playback speed multiplier +const PLAY_BASE_INTERVAL_MS = 200; // Type helpers for DocHandle methods we use (avoids importing Automerge types) interface ViewableHandle { @@ -61,8 +65,9 @@ export function useReplayMode( const historyRef = useRef([]); const handleRef = useRef(null); const intervalRef = useRef | null>(null); - // Keep current index in a ref for the interval callback + // Keep current index and speed in refs for the interval callback const indexRef = useRef(0); + const speedRef = useRef(1); const clearPlayInterval = useCallback(() => { if (intervalRef.current !== null) { @@ -125,6 +130,7 @@ export function useReplayMode( historyLength: history.length, currentIndex: lastIndex, isPlaying: false, + playbackSpeed: 1, currentContent: content, timestamp, }); @@ -155,21 +161,10 @@ export function useReplayMode( setState(prev => ({ ...prev, isPlaying: false })); }, [clearPlayInterval]); - const play = useCallback(() => { + const startPlayInterval = useCallback(() => { + clearPlayInterval(); const history = historyRef.current; - if (history.length === 0) return; - - // If at the end, restart from the beginning - if (indexRef.current >= history.length - 1) { - seekTo(0); - } - - setState(prev => ({ ...prev, isPlaying: true })); - - const interval = Math.max( - PLAY_MIN_INTERVAL_MS, - Math.min(PLAY_MAX_INTERVAL_MS, Math.round(PLAY_TARGET_DURATION_MS / history.length)), - ); + const interval = Math.round(PLAY_BASE_INTERVAL_MS / speedRef.current); intervalRef.current = setInterval(() => { const nextIndex = indexRef.current + 1; @@ -188,7 +183,20 @@ export function useReplayMode( timestamp, })); }, interval); - }, [clearPlayInterval, getContentAtIndex, getTimestampAtIndex, seekTo]); + }, [clearPlayInterval, getContentAtIndex, getTimestampAtIndex]); + + const play = useCallback(() => { + const history = historyRef.current; + if (history.length === 0) return; + + // If at the end, restart from the beginning + if (indexRef.current >= history.length - 1) { + seekTo(0); + } + + setState(prev => ({ ...prev, isPlaying: true })); + startPlayInterval(); + }, [seekTo, startPlayInterval]); const pause = useCallback(() => { stopPlaying(); @@ -209,6 +217,17 @@ export function useReplayMode( } }, [seekTo]); + const cycleSpeed = useCallback(() => { + const currentIdx = PLAYBACK_SPEEDS.indexOf(speedRef.current); + const nextSpeed = PLAYBACK_SPEEDS[(currentIdx + 1) % PLAYBACK_SPEEDS.length]; + speedRef.current = nextSpeed; + setState(prev => ({ ...prev, playbackSpeed: nextSpeed })); + // If currently playing, restart the interval at the new speed + if (intervalRef.current !== null) { + startPlayInterval(); + } + }, [startPlayInterval]); + const seekToStart = useCallback(() => { if (historyRef.current.length > 0) { seekTo(0); @@ -227,6 +246,7 @@ export function useReplayMode( handleRef.current = null; historyRef.current = []; indexRef.current = 0; + speedRef.current = 1; setState(INITIAL_STATE); }, [clearPlayInterval]); @@ -255,6 +275,7 @@ export function useReplayMode( pause, stepForward, stepBackward, + cycleSpeed, getTimestampAtIndex, }, }; From 905b3df9ee13e0f0229db0346bff298c3db0796d Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:48:27 +0000 Subject: [PATCH 13/17] Fix document borrow --- hub-client/src/components/Editor.tsx | 11 ++- hub-client/src/hooks/useReplayMode.test.ts | 27 ++++-- hub-client/src/hooks/useReplayMode.ts | 103 ++++++++++++++++----- hub-client/src/services/automergeSync.ts | 59 ++++++++++++ 4 files changed, 167 insertions(+), 33 deletions(-) diff --git a/hub-client/src/components/Editor.tsx b/hub-client/src/components/Editor.tsx index 5b3ba0dd..9f78eaa6 100644 --- a/hub-client/src/components/Editor.tsx +++ b/hub-client/src/components/Editor.tsx @@ -123,8 +123,10 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC enableSymbols: true, }); - // Replay mode for document history - const { state: replayState, controls: replayControls } = useReplayMode(currentFile?.path ?? null); + // Replay mode for document history. + // isActiveRef is updated synchronously in enter()/exit() — before React + // re-renders — so it can guard handleEditorChange against stale closures. + const { state: replayState, controls: replayControls, isActiveRef: replayActiveRef } = useReplayMode(currentFile?.path ?? null); // Get content from fileContents map, or use default for new files const getContent = useCallback((file: FileEntry | null): string => { @@ -428,8 +430,9 @@ export default function Editor({ project, files, fileContents, onDisconnect, onC }, [route, files, fileContents, currentFile]); const handleEditorChange = (value: string | undefined) => { - // Skip changes during replay mode (editor is read-only, but belt-and-suspenders) - if (replayState.isActive) return; + // Skip changes during replay mode. Use the ref (always current) rather than + // the closure value (can be stale between setState and re-render). + if (replayActiveRef.current) return; // Skip echo when applying remote changes if (applyingRemoteRef.current) return; diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts index 73535ed6..c69eac68 100644 --- a/hub-client/src/hooks/useReplayMode.test.ts +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -10,31 +10,33 @@ import { renderHook, act } from '@testing-library/react'; vi.mock('../services/automergeSync', () => ({ getFileHandle: vi.fn(), updateFileContent: vi.fn(), + freeDoc: vi.fn(), + cloneHandleDoc: vi.fn(), + viewText: vi.fn(), })); import { useReplayMode } from './useReplayMode'; import { getFileHandle, updateFileContent, + cloneHandleDoc, + viewText, } from '../services/automergeSync'; const mockGetFileHandle = vi.mocked(getFileHandle); const mockUpdateFileContent = vi.mocked(updateFileContent); +const mockCloneHandleDoc = vi.mocked(cloneHandleDoc); +const mockViewText = vi.mocked(viewText); -// Helper to create a mock handle with history and view support. +// Helper to create a mock handle with history support. // history() returns UrlHeads[] where each UrlHeads is string[]. // metadata() receives a single change hash string (first element of UrlHeads). +// Also configures mockCloneHandleDoc and mockViewText for the given texts. function createMockHandle(texts: string[], timestamps?: number[]) { const historyHeads = texts.map((_, i) => [`head-${i}`]); const handle = { history: vi.fn(() => historyHeads), - view: vi.fn((heads: string[]) => { - const index = historyHeads.findIndex(h => h[0] === heads[0]); - return { - doc: () => ({ text: texts[index] ?? '' }), - }; - }), metadata: vi.fn((changeHash?: string) => { if (!changeHash) return undefined; const index = historyHeads.findIndex(h => h[0] === changeHash); @@ -45,6 +47,17 @@ function createMockHandle(texts: string[], timestamps?: number[]) { doc: vi.fn(() => ({ text: texts[texts.length - 1] })), }; + // cloneHandleDoc returns a sentinel object representing the clone + const cloneObj = { __clone: true }; + mockCloneHandleDoc.mockReturnValue(cloneObj); + + // viewText extracts text from the clone given heads + mockViewText.mockImplementation((_clone: unknown, heads: unknown) => { + const headArr = heads as string[]; + const index = historyHeads.findIndex(h => h[0] === headArr[0]); + return texts[index] ?? ''; + }); + return handle; } diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index 1d46018f..f72348d6 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -2,6 +2,9 @@ import { useState, useCallback, useRef } from 'react'; import { getFileHandle, updateFileContent, + freeDoc, + cloneHandleDoc, + viewText, } from '../services/automergeSync'; export const PLAYBACK_SPEEDS = [1, 2, 4] as const; @@ -48,7 +51,6 @@ const PLAY_BASE_INTERVAL_MS = 200; // Type helpers for DocHandle methods we use (avoids importing Automerge types) interface ViewableHandle { history(): unknown[] | undefined; - view(heads: unknown): { doc(): { text?: string } | undefined | null }; metadata(change?: string): { time?: number } | undefined; } @@ -58,16 +60,27 @@ function asViewable(handle: unknown): ViewableHandle { export function useReplayMode( filePath: string | null, -): { state: ReplayState; controls: ReplayControls } { +): { state: ReplayState; controls: ReplayControls; isActiveRef: React.RefObject } { const [state, setState] = useState(INITIAL_STATE); // Store history array and handle in refs (stable across renders, not reactive) const historyRef = useRef([]); const handleRef = useRef(null); + // Independent clone of the doc used for all view() operations during replay. + // Views of this clone borrow from the clone's WASM state — not the original + // handle's — so handle.history() on re-entry is never blocked. + const cloneRef = useRef(null); + // Cache of extracted text content keyed by history index. + // Avoids repeated WASM view() calls for the same index. + const textCacheRef = useRef>(new Map()); const intervalRef = useRef | null>(null); // Keep current index and speed in refs for the interval callback const indexRef = useRef(0); const speedRef = useRef(1); + // Synchronous replay-active flag: updated immediately in enter()/reset(), + // before React re-renders. Consumers can read this ref to guard against + // stale closures that still see isActive === false. + const isActiveRef = useRef(false); const clearPlayInterval = useCallback(() => { if (intervalRef.current !== null) { @@ -77,13 +90,18 @@ export function useReplayMode( }, []); const getContentAtIndex = useCallback((index: number): string => { - const handle = handleRef.current; + const clone = cloneRef.current; const history = historyRef.current; - if (!handle || index < 0 || index >= history.length) return ''; + if (!clone || index < 0 || index >= history.length) return ''; + + // Return cached text if available + const cached = textCacheRef.current.get(index); + if (cached !== undefined) return cached; + try { - const viewedHandle = asViewable(handle).view(history[index]); - const doc = viewedHandle.doc(); - return doc?.text ?? ''; + const text = viewText(clone, history[index]); + textCacheRef.current.set(index, text); + return text; } catch (e) { console.warn('[useReplayMode] Failed to get content at index', index, e); return ''; @@ -108,17 +126,35 @@ export function useReplayMode( }, []); const enter = useCallback(() => { - if (!filePath) return; + if (!filePath) { + console.warn('[useReplayMode] enter(): no filePath'); + return; + } try { const handle = getFileHandle(filePath); - if (!handle) return; + if (!handle) { + console.warn('[useReplayMode] enter(): no handle for', filePath); + return; + } const history = asViewable(handle).history(); - if (!history || history.length === 0) return; + if (!history || history.length === 0) { + console.warn('[useReplayMode] enter(): no history (got', history, ')'); + return; + } + + const clone = cloneHandleDoc(handle); + if (!clone) { + console.warn('[useReplayMode] enter(): failed to clone doc for', filePath); + return; + } handleRef.current = handle; historyRef.current = history; + cloneRef.current = clone; + textCacheRef.current = new Map(); + isActiveRef.current = true; const lastIndex = history.length - 1; indexRef.current = lastIndex; @@ -135,6 +171,15 @@ export function useReplayMode( timestamp, }); } catch (e) { + // Roll back the synchronous ref so the guard stays consistent + isActiveRef.current = false; + if (cloneRef.current) { + freeDoc(cloneRef.current); + cloneRef.current = null; + } + handleRef.current = null; + historyRef.current = []; + textCacheRef.current = new Map(); console.error('[useReplayMode] Failed to enter replay mode:', e); } }, [filePath, getContentAtIndex, getTimestampAtIndex]); @@ -167,21 +212,27 @@ export function useReplayMode( const interval = Math.round(PLAY_BASE_INTERVAL_MS / speedRef.current); intervalRef.current = setInterval(() => { - const nextIndex = indexRef.current + 1; - if (nextIndex >= history.length) { + try { + const nextIndex = indexRef.current + 1; + if (nextIndex >= history.length) { + clearPlayInterval(); + setState(prev => ({ ...prev, isPlaying: false })); + return; + } + indexRef.current = nextIndex; + const content = getContentAtIndex(nextIndex); + const timestamp = getTimestampAtIndex(nextIndex); + setState(prev => ({ + ...prev, + currentIndex: nextIndex, + currentContent: content, + timestamp, + })); + } catch (e) { + console.error('[useReplayMode] Playback error, stopping:', e); clearPlayInterval(); setState(prev => ({ ...prev, isPlaying: false })); - return; } - indexRef.current = nextIndex; - const content = getContentAtIndex(nextIndex); - const timestamp = getTimestampAtIndex(nextIndex); - setState(prev => ({ - ...prev, - currentIndex: nextIndex, - currentContent: content, - timestamp, - })); }, interval); }, [clearPlayInterval, getContentAtIndex, getTimestampAtIndex]); @@ -243,8 +294,15 @@ export function useReplayMode( const reset = useCallback(() => { clearPlayInterval(); + isActiveRef.current = false; + // Free the clone's WASM state immediately so borrows don't linger. + if (cloneRef.current) { + freeDoc(cloneRef.current); + cloneRef.current = null; + } handleRef.current = null; historyRef.current = []; + textCacheRef.current = new Map(); indexRef.current = 0; speedRef.current = 1; setState(INITIAL_STATE); @@ -278,5 +336,6 @@ export function useReplayMode( cycleSpeed, getTimestampAtIndex, }, + isActiveRef, }; } diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index 2be390ca..5d6137aa 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -18,6 +18,13 @@ import { type FilePayload, } from '@quarto/quarto-sync-client'; +import { decodeHeads } from '@automerge/automerge-repo'; + +import { + free as automergeFreeFn, + clone as automergeCloneFn, + view as automergeViewFn, +} from '@automerge/automerge'; import { vfsAddFile, vfsAddBinaryFile, vfsRemoveFile, vfsClear, initWasm } from './wasmRenderer'; // Re-export types for use in other components @@ -210,6 +217,58 @@ export function getFileHandle(path: string) { return ensureClient().getFileHandle(path); } +/** + * Free an Automerge Doc's WASM resources immediately, rather than waiting + * for JS garbage collection. Use this to release WASM borrows created by + * DocHandle.view()/doc() so that subsequent handle.history() calls don't + * hit Rust's aliasing guard. + */ +export function freeDoc(doc: unknown): void { + try { + automergeFreeFn(doc as Parameters[0]); + } catch { + // Silently ignore — doc may already be freed or not a WASM-backed object + } +} + +/** + * Clone the current document from a DocHandle. + * Returns an independent Automerge doc with its own WASM state, so views + * created from this clone do NOT hold borrows on the original handle's doc. + */ +export function cloneHandleDoc(handle: unknown): unknown | null { + try { + const doc = (handle as { doc(): unknown }).doc(); + if (!doc) return null; + return automergeCloneFn(doc as Parameters[0]); + } catch { + return null; + } +} + +/** + * Create a read-only view of a cloned doc at given heads and extract the + * text field. The view shares the clone's WASM handle (not the original + * handle's), so it will not block future handle.history() calls. + * + * @param clonedDoc - An Automerge doc (typically from cloneHandleDoc) + * @param heads - UrlHeads (base58check-encoded string[]) from handle.history() + */ +export function viewText(clonedDoc: unknown, heads: unknown): string { + try { + // history() returns UrlHeads (base58check-encoded). Automerge.view() + // expects decoded hex-string heads. + const decoded = decodeHeads(heads as Parameters[0]); + const viewed = automergeViewFn( + clonedDoc as Parameters[0], + decoded as unknown as Parameters[1], + ); + return (viewed as { text?: string })?.text ?? ''; + } catch { + return ''; + } +} + /** * Pause all network sync without destroying the connection. * Document handles and local state are preserved. From 17f30a6a7dd3cd82ff218f2b03a8c76afd85be2c Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:56:22 +0000 Subject: [PATCH 14/17] Clean up post-fix --- hub-client/src/hooks/useReplayMode.test.ts | 1 - hub-client/src/hooks/useReplayMode.ts | 91 ++++++++-------------- hub-client/src/services/automergeSync.ts | 42 ++++------ 3 files changed, 46 insertions(+), 88 deletions(-) diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts index c69eac68..b8168dca 100644 --- a/hub-client/src/hooks/useReplayMode.test.ts +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -108,7 +108,6 @@ describe('useReplayMode', () => { it('is a no-op when handle.history() returns undefined', () => { const handle = { history: vi.fn(() => undefined), - view: vi.fn(), metadata: vi.fn(), doc: vi.fn(), }; diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index f72348d6..8bbccc97 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -94,18 +94,12 @@ export function useReplayMode( const history = historyRef.current; if (!clone || index < 0 || index >= history.length) return ''; - // Return cached text if available const cached = textCacheRef.current.get(index); if (cached !== undefined) return cached; - try { - const text = viewText(clone, history[index]); - textCacheRef.current.set(index, text); - return text; - } catch (e) { - console.warn('[useReplayMode] Failed to get content at index', index, e); - return ''; - } + const text = viewText(clone, history[index]); + textCacheRef.current.set(index, text); + return text; }, []); const getTimestampAtIndex = useCallback((index: number): number | null => { @@ -126,62 +120,43 @@ export function useReplayMode( }, []); const enter = useCallback(() => { - if (!filePath) { - console.warn('[useReplayMode] enter(): no filePath'); - return; - } + if (!filePath) return; + // These calls can throw (WASM errors). If any fail, no refs have been + // set yet so there is nothing to roll back. + let handle, history: unknown[], clone; try { - const handle = getFileHandle(filePath); - if (!handle) { - console.warn('[useReplayMode] enter(): no handle for', filePath); - return; - } + handle = getFileHandle(filePath); + if (!handle) return; - const history = asViewable(handle).history(); - if (!history || history.length === 0) { - console.warn('[useReplayMode] enter(): no history (got', history, ')'); - return; - } + history = asViewable(handle).history() ?? []; + if (history.length === 0) return; - const clone = cloneHandleDoc(handle); - if (!clone) { - console.warn('[useReplayMode] enter(): failed to clone doc for', filePath); - return; - } - - handleRef.current = handle; - historyRef.current = history; - cloneRef.current = clone; - textCacheRef.current = new Map(); - isActiveRef.current = true; - - const lastIndex = history.length - 1; - indexRef.current = lastIndex; - const content = getContentAtIndex(lastIndex); - const timestamp = getTimestampAtIndex(lastIndex); - - setState({ - isActive: true, - historyLength: history.length, - currentIndex: lastIndex, - isPlaying: false, - playbackSpeed: 1, - currentContent: content, - timestamp, - }); + clone = cloneHandleDoc(handle); } catch (e) { - // Roll back the synchronous ref so the guard stays consistent - isActiveRef.current = false; - if (cloneRef.current) { - freeDoc(cloneRef.current); - cloneRef.current = null; - } - handleRef.current = null; - historyRef.current = []; - textCacheRef.current = new Map(); console.error('[useReplayMode] Failed to enter replay mode:', e); + return; } + + // Past this point everything is safe (assignments + cached accessors). + handleRef.current = handle; + historyRef.current = history; + cloneRef.current = clone; + textCacheRef.current = new Map(); + isActiveRef.current = true; + + const lastIndex = history.length - 1; + indexRef.current = lastIndex; + + setState({ + isActive: true, + historyLength: history.length, + currentIndex: lastIndex, + isPlaying: false, + playbackSpeed: 1, + currentContent: getContentAtIndex(lastIndex), + timestamp: getTimestampAtIndex(lastIndex), + }); }, [filePath, getContentAtIndex, getTimestampAtIndex]); const seekTo = useCallback((index: number) => { diff --git a/hub-client/src/services/automergeSync.ts b/hub-client/src/services/automergeSync.ts index 5d6137aa..e30b91f9 100644 --- a/hub-client/src/services/automergeSync.ts +++ b/hub-client/src/services/automergeSync.ts @@ -219,15 +219,13 @@ export function getFileHandle(path: string) { /** * Free an Automerge Doc's WASM resources immediately, rather than waiting - * for JS garbage collection. Use this to release WASM borrows created by - * DocHandle.view()/doc() so that subsequent handle.history() calls don't - * hit Rust's aliasing guard. + * for JS garbage collection. */ export function freeDoc(doc: unknown): void { try { automergeFreeFn(doc as Parameters[0]); } catch { - // Silently ignore — doc may already be freed or not a WASM-backed object + // doc may already be freed or not a WASM-backed object } } @@ -236,37 +234,23 @@ export function freeDoc(doc: unknown): void { * Returns an independent Automerge doc with its own WASM state, so views * created from this clone do NOT hold borrows on the original handle's doc. */ -export function cloneHandleDoc(handle: unknown): unknown | null { - try { - const doc = (handle as { doc(): unknown }).doc(); - if (!doc) return null; - return automergeCloneFn(doc as Parameters[0]); - } catch { - return null; - } +export function cloneHandleDoc(handle: unknown): unknown { + const doc = (handle as { doc(): unknown }).doc(); + return automergeCloneFn(doc as Parameters[0]); } /** * Create a read-only view of a cloned doc at given heads and extract the - * text field. The view shares the clone's WASM handle (not the original - * handle's), so it will not block future handle.history() calls. - * - * @param clonedDoc - An Automerge doc (typically from cloneHandleDoc) - * @param heads - UrlHeads (base58check-encoded string[]) from handle.history() + * text field. Heads are UrlHeads (base58check-encoded string[]) as returned + * by handle.history(). */ export function viewText(clonedDoc: unknown, heads: unknown): string { - try { - // history() returns UrlHeads (base58check-encoded). Automerge.view() - // expects decoded hex-string heads. - const decoded = decodeHeads(heads as Parameters[0]); - const viewed = automergeViewFn( - clonedDoc as Parameters[0], - decoded as unknown as Parameters[1], - ); - return (viewed as { text?: string })?.text ?? ''; - } catch { - return ''; - } + const decoded = decodeHeads(heads as Parameters[0]); + const viewed = automergeViewFn( + clonedDoc as Parameters[0], + decoded as unknown as Parameters[1], + ); + return (viewed as { text?: string })?.text ?? ''; } /** From 48f3fd18a3a8d530a6b9ed08b569f03d0e6ef0de Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Sun, 15 Mar 2026 20:56:29 +0000 Subject: [PATCH 15/17] Use waveform scrubber to show author numbers --- hub-client/src/components/ReplayDrawer.css | 90 +++++++++---------- .../src/components/ReplayDrawer.test.tsx | 4 + hub-client/src/components/ReplayDrawer.tsx | 44 ++++++++- hub-client/src/hooks/useReplayMode.ts | 34 ++++++- 4 files changed, 114 insertions(+), 58 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 32b28b6a..5631a1a7 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -171,81 +171,71 @@ color: #6ee7a0; } -.replay-drawer__scrubber { +/* Waveform scrubber container */ +.replay-waveform-container { flex: 1; min-width: 80px; - display: flex; - align-items: center; position: relative; + height: 24px; } -.replay-drawer__tooltip { - position: absolute; - bottom: 100%; - transform: translateX(-50%); - margin-bottom: 6px; - padding: 3px 8px; - background: #0f0f1e; - border: 1px solid #1f3460; - border-radius: 4px; - color: #ccc; - font-size: 11px; - white-space: nowrap; - pointer-events: none; +.replay-waveform { + display: block; + width: 100%; + height: 100%; + border-radius: 3px; } -.replay-drawer__slider { +/* Transparent range input overlaid on waveform for interaction */ +.replay-waveform__input { -webkit-appearance: none; appearance: none; + position: absolute; + top: 0; + left: 0; width: 100%; - height: 4px; + height: 100%; margin: 0; - padding: 8px 0; + padding: 0; background: transparent; - box-sizing: content-box; outline: none; cursor: pointer; } -.replay-drawer__slider::-webkit-slider-runnable-track { - height: 4px; - background: var(--slider-track, #1f3460); - border-radius: 2px; +.replay-waveform__input::-webkit-slider-runnable-track { + background: transparent; + height: 100%; } -.replay-drawer__slider::-webkit-slider-thumb { +.replay-waveform__input::-webkit-slider-thumb { -webkit-appearance: none; - width: 12px; - height: 12px; - background: #8899aa; - border: none; - border-radius: 50%; - margin-top: -4px; - transition: background 0.15s, transform 0.15s; + width: 0; + height: 0; } -.replay-drawer__slider:hover::-webkit-slider-thumb { - background: #ccc; - transform: scale(1.2); -} - -.replay-drawer__slider::-moz-range-track { - height: 4px; - background: var(--slider-track, #1f3460); - border-radius: 2px; +.replay-waveform__input::-moz-range-track { + background: transparent; + height: 100%; border: none; } -.replay-drawer__slider::-moz-range-thumb { - width: 12px; - height: 12px; - background: #8899aa; +.replay-waveform__input::-moz-range-thumb { + width: 0; + height: 0; border: none; - border-radius: 50%; - transition: background 0.15s, transform 0.15s; } -.replay-drawer__slider:hover::-moz-range-thumb { - background: #ccc; - transform: scale(1.2); +.replay-drawer__tooltip { + position: absolute; + bottom: 100%; + transform: translateX(-50%); + margin-bottom: 6px; + padding: 3px 8px; + background: #0f0f1e; + border: 1px solid #1f3460; + border-radius: 4px; + color: #ccc; + font-size: 11px; + white-space: nowrap; + pointer-events: none; } diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 165254f4..8923d023 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -18,6 +18,7 @@ function makeState(overrides: Partial = {}): ReplayState { playbackSpeed: 1, currentContent: '', timestamp: null, + chunkAuthors: [], ...overrides, }; } @@ -35,6 +36,7 @@ function makeControls(overrides: Partial = {}): ReplayControls { stepForward: vi.fn(), stepBackward: vi.fn(), cycleSpeed: vi.fn(), + getTimestampAtIndex: vi.fn().mockReturnValue(null), ...overrides, }; } @@ -70,6 +72,7 @@ describe('ReplayDrawer', () => { currentIndex: 42, currentContent: 'hello', timestamp: 1710000000, + chunkAuthors: Array.from({ length: 10 }, () => 1), }); it('renders transport controls when active', () => { @@ -153,6 +156,7 @@ describe('ReplayDrawer', () => { historyLength: 100, currentIndex: 50, currentContent: 'test', + chunkAuthors: Array.from({ length: 10 }, () => 1), }); it('Space toggles play/pause', () => { diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 2ef0431b..f79c5f87 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; import './ReplayDrawer.css'; @@ -103,6 +103,22 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) { setScrubberTooltip(null); }, []); + // Two stops per chunk (start + end at same color) to prevent SVG interpolation. + // Lerps from blue (1 author) to orange (5+ authors). + const gradientStops = useMemo(() => { + const chunks = state.chunkAuthors; + const n = chunks.length || 1; + const lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t); + const stops: { color: string; offset: number }[] = []; + for (let i = 0; i < chunks.length; i++) { + const t = Math.min(1, Math.max(0, (chunks[i] - 1) / 4)); + const color = `rgb(${lerp(74, 204, t)},${lerp(128, 122, t)},${lerp(204, 74, t)})`; + stops.push({ color, offset: (i / n) * 100 }); + stops.push({ color, offset: ((i + 1) / n) * 100 }); + } + return stops; + }, [state.chunkAuthors]); + if (!state.isActive) { return (
@@ -212,7 +228,28 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) {
-
+
+ + + + {gradientStops.map((s, i) => ( + + ))} + + + + + + {scrubberTooltip && (
{ if (!filePath) return; - // These calls can throw (WASM errors). If any fail, no refs have been - // set yet so there is nothing to roll back. let handle, history: unknown[], clone; try { handle = getFileHandle(filePath); @@ -138,7 +138,6 @@ export function useReplayMode( return; } - // Past this point everything is safe (assignments + cached accessors). handleRef.current = handle; historyRef.current = history; cloneRef.current = clone; @@ -148,6 +147,32 @@ export function useReplayMode( const lastIndex = history.length - 1; indexRef.current = lastIndex; + // Split history into ≤100 equal chunks for the author-color gradient. + const MAX_CHUNKS = 100; + const chunkCount = Math.min(history.length, MAX_CHUNKS); + const chunkSize = history.length / chunkCount; + + // Compute author counts synchronously — ≤500 metadata() calls (100 chunks × 5 samples). + const SAMPLES_PER_CHUNK = 5; + const viewable = asViewable(handle); + const chunkAuthors = new Array(chunkCount); + for (let i = 0; i < chunkCount; i++) { + const startIdx = Math.round(i * chunkSize); + const endIdx = Math.min(Math.round((i + 1) * chunkSize), history.length); + const span = endIdx - startIdx; + const step = Math.max(1, Math.floor(span / SAMPLES_PER_CHUNK)); + const actors = new Set(); + for (let j = startIdx; j < endIdx; j += step) { + const heads = history[j]; + const changeHash = Array.isArray(heads) ? heads[0] : heads; + if (typeof changeHash === 'string') { + const meta = viewable.metadata(changeHash); + if (meta?.actor) actors.add(meta.actor); + } + } + chunkAuthors[i] = actors.size; + } + setState({ isActive: true, historyLength: history.length, @@ -156,6 +181,7 @@ export function useReplayMode( playbackSpeed: 1, currentContent: getContentAtIndex(lastIndex), timestamp: getTimestampAtIndex(lastIndex), + chunkAuthors, }); }, [filePath, getContentAtIndex, getTimestampAtIndex]); From 17db73c010df4fd4df704a66a9faef3a8262c22f Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:14:01 +0000 Subject: [PATCH 16/17] Add actor awareness --- hub-client/src/components/ReplayDrawer.css | 6 ++++ .../src/components/ReplayDrawer.test.tsx | 7 ++++ hub-client/src/components/ReplayDrawer.tsx | 5 +++ hub-client/src/hooks/useReplayMode.test.ts | 19 ++++++++-- hub-client/src/hooks/useReplayMode.ts | 36 ++++++++++++------- 5 files changed, 57 insertions(+), 16 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.css b/hub-client/src/components/ReplayDrawer.css index 5631a1a7..40c76661 100644 --- a/hub-client/src/components/ReplayDrawer.css +++ b/hub-client/src/components/ReplayDrawer.css @@ -90,6 +90,12 @@ font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; } +.replay-drawer__actor { + font-size: 11px; + color: #888; + font-family: 'SF Mono', Monaco, 'Cascadia Code', monospace; +} + .replay-drawer__timestamp { font-size: 11px; color: #666; diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 8923d023..335ee7e7 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -18,6 +18,7 @@ function makeState(overrides: Partial = {}): ReplayState { playbackSpeed: 1, currentContent: '', timestamp: null, + actor: null, chunkAuthors: [], ...overrides, }; @@ -72,6 +73,7 @@ describe('ReplayDrawer', () => { currentIndex: 42, currentContent: 'hello', timestamp: 1710000000, + actor: 'abcdef0123456789abcdef0123456789', chunkAuthors: Array.from({ length: 10 }, () => 1), }); @@ -107,6 +109,11 @@ describe('ReplayDrawer', () => { expect(screen.getByText(/43 of 100/)).toBeDefined(); }); + it('renders actor short hash', () => { + render(); + expect(screen.getByText('abcdef01')).toBeDefined(); + }); + it('Apply button calls controls.apply()', () => { render(); fireEvent.click(screen.getByText('Restore')); diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index f79c5f87..27a55de4 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -156,6 +156,11 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) { {state.currentIndex + 1} of {state.historyLength} + {state.actor && ( + + {state.actor.slice(0, 8)} + + )} {state.timestamp && ( {formatFullTimestamp(state.timestamp)} diff --git a/hub-client/src/hooks/useReplayMode.test.ts b/hub-client/src/hooks/useReplayMode.test.ts index b8168dca..44e842b2 100644 --- a/hub-client/src/hooks/useReplayMode.test.ts +++ b/hub-client/src/hooks/useReplayMode.test.ts @@ -32,7 +32,7 @@ const mockViewText = vi.mocked(viewText); // history() returns UrlHeads[] where each UrlHeads is string[]. // metadata() receives a single change hash string (first element of UrlHeads). // Also configures mockCloneHandleDoc and mockViewText for the given texts. -function createMockHandle(texts: string[], timestamps?: number[]) { +function createMockHandle(texts: string[], timestamps?: number[], actors?: string[]) { const historyHeads = texts.map((_, i) => [`head-${i}`]); const handle = { @@ -42,7 +42,8 @@ function createMockHandle(texts: string[], timestamps?: number[]) { const index = historyHeads.findIndex(h => h[0] === changeHash); if (index < 0) return undefined; const ts = timestamps?.[index] ?? 1000000 + index * 1000; - return { time: ts }; + const actor = actors?.[index] ?? `actor${index}abcdef0123456789`; + return { time: ts, actor }; }), doc: vi.fn(() => ({ text: texts[texts.length - 1] })), }; @@ -441,7 +442,7 @@ describe('useReplayMode', () => { }); }); - describe('timestamp', () => { + describe('timestamp and actor', () => { it('provides timestamp for current change', () => { const timestamps = [1000000, 1001000, 1002000]; const handle = createMockHandle(['a', 'b', 'c'], timestamps); @@ -453,5 +454,17 @@ describe('useReplayMode', () => { expect(result.current.state.timestamp).toBe(1001000); }); + + it('provides actor hash for current change', () => { + const actors = ['aaa111', 'bbb222', 'ccc333']; + const handle = createMockHandle(['a', 'b', 'c'], undefined, actors); + mockGetFileHandle.mockReturnValue(handle as never); + + const { result } = renderHook(() => useReplayMode('index.qmd')); + act(() => { result.current.controls.enter(); }); + act(() => { result.current.controls.seekTo(1); }); + + expect(result.current.state.actor).toBe('bbb222'); + }); }); }); diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index 191057a1..c421b706 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -18,6 +18,7 @@ export interface ReplayState { playbackSpeed: PlaybackSpeed; currentContent: string; timestamp: number | null; + actor: string | null; // short hash of the actor who made the change chunkAuthors: number[]; // distinct author count per equal-width chunk (0 = pending) } @@ -44,6 +45,7 @@ const INITIAL_STATE: ReplayState = { playbackSpeed: 1, currentContent: '', timestamp: null, + actor: null, chunkAuthors: [], }; @@ -104,23 +106,27 @@ export function useReplayMode( return text; }, []); - const getTimestampAtIndex = useCallback((index: number): number | null => { + const getMetadataAtIndex = useCallback((index: number): { timestamp: number | null; actor: string | null } => { const handle = handleRef.current; const history = historyRef.current; - if (!handle || index < 0 || index >= history.length) return null; + if (!handle || index < 0 || index >= history.length) return { timestamp: null, actor: null }; try { // metadata() expects a single change hash string. // history entries are UrlHeads (string[]), so extract the first element. const heads = history[index]; const changeHash = Array.isArray(heads) ? heads[0] : heads; - if (typeof changeHash !== 'string') return null; + if (typeof changeHash !== 'string') return { timestamp: null, actor: null }; const meta = asViewable(handle).metadata(changeHash); - return meta?.time ?? null; + return { timestamp: meta?.time ?? null, actor: meta?.actor ?? null }; } catch { - return null; + return { timestamp: null, actor: null }; } }, []); + const getTimestampAtIndex = useCallback((index: number): number | null => { + return getMetadataAtIndex(index).timestamp; + }, [getMetadataAtIndex]); + const enter = useCallback(() => { if (!filePath) return; @@ -173,6 +179,7 @@ export function useReplayMode( chunkAuthors[i] = actors.size; } + const lastMeta = getMetadataAtIndex(lastIndex); setState({ isActive: true, historyLength: history.length, @@ -180,10 +187,11 @@ export function useReplayMode( isPlaying: false, playbackSpeed: 1, currentContent: getContentAtIndex(lastIndex), - timestamp: getTimestampAtIndex(lastIndex), + timestamp: lastMeta.timestamp, + actor: lastMeta.actor, chunkAuthors, }); - }, [filePath, getContentAtIndex, getTimestampAtIndex]); + }, [filePath, getContentAtIndex, getMetadataAtIndex]); const seekTo = useCallback((index: number) => { const history = historyRef.current; @@ -192,15 +200,16 @@ export function useReplayMode( const clamped = Math.max(0, Math.min(index, history.length - 1)); indexRef.current = clamped; const content = getContentAtIndex(clamped); - const timestamp = getTimestampAtIndex(clamped); + const meta = getMetadataAtIndex(clamped); setState(prev => ({ ...prev, currentIndex: clamped, currentContent: content, - timestamp, + timestamp: meta.timestamp, + actor: meta.actor, })); - }, [getContentAtIndex, getTimestampAtIndex]); + }, [getContentAtIndex, getMetadataAtIndex]); const stopPlaying = useCallback(() => { clearPlayInterval(); @@ -222,12 +231,13 @@ export function useReplayMode( } indexRef.current = nextIndex; const content = getContentAtIndex(nextIndex); - const timestamp = getTimestampAtIndex(nextIndex); + const meta = getMetadataAtIndex(nextIndex); setState(prev => ({ ...prev, currentIndex: nextIndex, currentContent: content, - timestamp, + timestamp: meta.timestamp, + actor: meta.actor, })); } catch (e) { console.error('[useReplayMode] Playback error, stopping:', e); @@ -235,7 +245,7 @@ export function useReplayMode( setState(prev => ({ ...prev, isPlaying: false })); } }, interval); - }, [clearPlayInterval, getContentAtIndex, getTimestampAtIndex]); + }, [clearPlayInterval, getContentAtIndex, getMetadataAtIndex]); const play = useCallback(() => { const history = historyRef.current; From 442db1e939c46d769038e05c730eb07b7b5d40eb Mon Sep 17 00:00:00 2001 From: shikokuchuo <53399081+shikokuchuo@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:40:10 +0000 Subject: [PATCH 17/17] Add actors to scrub widget --- .../src/components/ReplayDrawer.test.tsx | 6 +-- hub-client/src/components/ReplayDrawer.tsx | 42 ++++++++++--------- hub-client/src/hooks/useReplayMode.test.ts | 2 + hub-client/src/hooks/useReplayMode.ts | 41 ++++++++++++++---- 4 files changed, 59 insertions(+), 32 deletions(-) diff --git a/hub-client/src/components/ReplayDrawer.test.tsx b/hub-client/src/components/ReplayDrawer.test.tsx index 335ee7e7..507acd4c 100644 --- a/hub-client/src/components/ReplayDrawer.test.tsx +++ b/hub-client/src/components/ReplayDrawer.test.tsx @@ -19,7 +19,7 @@ function makeState(overrides: Partial = {}): ReplayState { currentContent: '', timestamp: null, actor: null, - chunkAuthors: [], + chunkActors: [], ...overrides, }; } @@ -74,7 +74,7 @@ describe('ReplayDrawer', () => { currentContent: 'hello', timestamp: 1710000000, actor: 'abcdef0123456789abcdef0123456789', - chunkAuthors: Array.from({ length: 10 }, () => 1), + chunkActors: Array.from({ length: 10 }, () => [{ actor: 'abcdef0123456789abcdef0123456789', fraction: 1 }]), }); it('renders transport controls when active', () => { @@ -163,7 +163,7 @@ describe('ReplayDrawer', () => { historyLength: 100, currentIndex: 50, currentContent: 'test', - chunkAuthors: Array.from({ length: 10 }, () => 1), + chunkActors: Array.from({ length: 10 }, () => [{ actor: 'actor1', fraction: 1 }]), }); it('Space toggles play/pause', () => { diff --git a/hub-client/src/components/ReplayDrawer.tsx b/hub-client/src/components/ReplayDrawer.tsx index 27a55de4..3d2c52b2 100644 --- a/hub-client/src/components/ReplayDrawer.tsx +++ b/hub-client/src/components/ReplayDrawer.tsx @@ -1,5 +1,6 @@ import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import type { ReplayState, ReplayControls } from '../hooks/useReplayMode'; +import { actorColor } from '../hooks/useReplayMode'; import './ReplayDrawer.css'; interface Props { @@ -103,21 +104,22 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) { setScrubberTooltip(null); }, []); - // Two stops per chunk (start + end at same color) to prevent SVG interpolation. - // Lerps from blue (1 author) to orange (5+ authors). - const gradientStops = useMemo(() => { - const chunks = state.chunkAuthors; + // Build per-chunk stacked rects: each chunk is a vertical column, split by actor fractions. + const chunkRects = useMemo(() => { + const chunks = state.chunkActors; const n = chunks.length || 1; - const lerp = (a: number, b: number, t: number) => Math.round(a + (b - a) * t); - const stops: { color: string; offset: number }[] = []; + const chunkWidth = 100 / n; + const rects: { x: number; y: number; width: number; height: number; color: string }[] = []; for (let i = 0; i < chunks.length; i++) { - const t = Math.min(1, Math.max(0, (chunks[i] - 1) / 4)); - const color = `rgb(${lerp(74, 204, t)},${lerp(128, 122, t)},${lerp(204, 74, t)})`; - stops.push({ color, offset: (i / n) * 100 }); - stops.push({ color, offset: ((i + 1) / n) * 100 }); + const x = i * chunkWidth; + let y = 0; + for (const { actor, fraction } of chunks[i]) { + rects.push({ x, y, width: chunkWidth, height: fraction, color: actorColor(actor) }); + y += fraction; + } } - return stops; - }, [state.chunkAuthors]); + return rects; + }, [state.chunkActors]); if (!state.isActive) { return ( @@ -239,15 +241,15 @@ export default function ReplayDrawer({ state, controls, disabled }: Props) { viewBox="0 0 100 1" preserveAspectRatio="none" > - - - {gradientStops.map((s, i) => ( - - ))} - - + {/* Background */} - + {/* Actor-colored chunk rects */} + {chunkRects.map((r, i) => ( + + ))} + {/* Dim the portion past the playhead */} + + {/* Playhead */} { // Starts at last index (current state) expect(result.current.state.currentIndex).toBe(2); expect(result.current.state.currentContent).toBe('abc'); + // chunkActors should have entries with fractions summing to 1 + expect(result.current.state.chunkActors.length).toBeGreaterThan(0); }); it('is a no-op when getFileHandle returns null', () => { diff --git a/hub-client/src/hooks/useReplayMode.ts b/hub-client/src/hooks/useReplayMode.ts index c421b706..70663fa9 100644 --- a/hub-client/src/hooks/useReplayMode.ts +++ b/hub-client/src/hooks/useReplayMode.ts @@ -10,6 +10,11 @@ import { export const PLAYBACK_SPEEDS = [1, 2, 4] as const; export type PlaybackSpeed = (typeof PLAYBACK_SPEEDS)[number]; +export interface ChunkActorShare { + actor: string; + fraction: number; +} + export interface ReplayState { isActive: boolean; historyLength: number; @@ -19,7 +24,13 @@ export interface ReplayState { currentContent: string; timestamp: number | null; actor: string | null; // short hash of the actor who made the change - chunkAuthors: number[]; // distinct author count per equal-width chunk (0 = pending) + chunkActors: ChunkActorShare[][]; // per-chunk actor fractions for the waveform +} + +/** Deterministic color from an actor hash string. */ +export function actorColor(actor: string): string { + const hue = parseInt(actor.slice(0, 6), 16) % 360; + return `hsl(${hue}, 60%, 55%)`; } export interface ReplayControls { @@ -46,7 +57,7 @@ const INITIAL_STATE: ReplayState = { currentContent: '', timestamp: null, actor: null, - chunkAuthors: [], + chunkActors: [], }; // Base interval at 1x speed; divided by playback speed multiplier @@ -153,30 +164,42 @@ export function useReplayMode( const lastIndex = history.length - 1; indexRef.current = lastIndex; - // Split history into ≤100 equal chunks for the author-color gradient. + // Split history into ≤100 equal chunks for the actor-colored waveform. const MAX_CHUNKS = 100; const chunkCount = Math.min(history.length, MAX_CHUNKS); const chunkSize = history.length / chunkCount; - // Compute author counts synchronously — ≤500 metadata() calls (100 chunks × 5 samples). + // Collect actor frequencies per chunk — ≤500 metadata() calls (100 chunks × 5 samples). const SAMPLES_PER_CHUNK = 5; const viewable = asViewable(handle); - const chunkAuthors = new Array(chunkCount); + const chunkActors: ChunkActorShare[][] = new Array(chunkCount); + for (let i = 0; i < chunkCount; i++) { const startIdx = Math.round(i * chunkSize); const endIdx = Math.min(Math.round((i + 1) * chunkSize), history.length); const span = endIdx - startIdx; const step = Math.max(1, Math.floor(span / SAMPLES_PER_CHUNK)); - const actors = new Set(); + const counts = new Map(); + let totalSamples = 0; for (let j = startIdx; j < endIdx; j += step) { const heads = history[j]; const changeHash = Array.isArray(heads) ? heads[0] : heads; if (typeof changeHash === 'string') { const meta = viewable.metadata(changeHash); - if (meta?.actor) actors.add(meta.actor); + if (meta?.actor) { + counts.set(meta.actor, (counts.get(meta.actor) ?? 0) + 1); + totalSamples++; + } } } - chunkAuthors[i] = actors.size; + if (totalSamples === 0) { + chunkActors[i] = []; + } else { + chunkActors[i] = Array.from(counts.entries()).map(([actor, count]) => ({ + actor, + fraction: count / totalSamples, + })); + } } const lastMeta = getMetadataAtIndex(lastIndex); @@ -189,7 +212,7 @@ export function useReplayMode( currentContent: getContentAtIndex(lastIndex), timestamp: lastMeta.timestamp, actor: lastMeta.actor, - chunkAuthors, + chunkActors, }); }, [filePath, getContentAtIndex, getMetadataAtIndex]);