Flow-in-Speech Flow Annotations
Lets a viewer drop a timestamped note while watching a streamed or recorded
round in the debate-videos player, tie it to a specific flow argument, and
jump straight back to it later.
- Route:
/annotations - Nav: the Tools page's Prep & Practice group; the Reason Editor's
Workspace menu (
t annotationsin Ctrl/Cmd-Shift-Space's command palette) - Package:
debate-round
What it shows
A form to drop a new FlowAnnotation:
- Flow ID, Speech (e.g.
1AC), and Box path (a comma-separated path, e.g.0, 1) address exactly which flowed argument the annotation is attached to, the same wayboxFromPathalready addresses a box elsewhere in the flow spreadsheet. - Timestamp defaults to "Current position" — the persistent video
player's live playback position, read from
debate-videos'suseVideoPlayerStore— or a manualm:ss/h:mm:ssentry when nothing is playing. - An optional note.
Below the form, every persisted annotation renders newest-first with its formatted timestamp, flow/speech/box address, and note. Each has:
- Jump to — seeks that annotation's timestamp, via the shared
jumpToAnnotationhelper: if its recording is already loaded in the player, it seeks in place viasendYouTubeCommand("seekTo", ...); otherwise it first switches the persistent player to that recording viauseVideoPlayerStore.setActiveVideo(videoId, videoId, undefined, startTimeSeconds), opening it already positioned at the annotation's timestamp (using the video's&start=URL param rather than aseekTopostMessage, since there's no "player ready" signal to gate on for a video that isn't loaded yet). Disabled only when the annotation has novideoIdat all. - Clear — deletes the annotation.
Data flow
flow/flow-annotations.ts — createFlowAnnotation, formatAnnotationTimestamp,
parseAnnotationTimestamp, parseBoxPathInput
state/flowAnnotations.ts (localStorage: flowAnnotations)
→ buildFlowAnnotationsPanelView() — every persisted FlowAnnotation, newest first
→ panels/FlowAnnotationsPanel.tsx — renders the drop-annotation form + annotation list;
reads debate-videos's useVideoPlayerStore for the
live playback position and video id
→ apps/debate-ai.com/app/annotations/page.tsx — mounts the panel as a route
Dropping an annotation at the live position:
panels/FlowAnnotationsPanel.tsx
→ createFlowAnnotation({ ..., videoId: activeVideoId, videoTitle: activeVideoTitle })
— flow/flow-annotations.ts
→ saveFlowAnnotation(...) — state/flowAnnotations.ts
→ panel re-reads buildFlowAnnotationsPanelView() to refresh
Jumping back to one:
panels/FlowAnnotationsPanel.tsx
→ jumpToAnnotation(annotation, deps) — flow/flow-annotations.ts
same video already loaded:
→ sendYouTubeCommand("seekTo", [timestampMs / 1000, true]) — debate-videos
→ sendYouTubeCommand("playVideo")
different (or no) video loaded:
→ setActiveVideo(videoId, videoTitle ?? videoId, undefined, timestampMs / 1000)
— debate-videosEvery annotation data model and persistence rule already existed and was
Vitest-covered; this feature closes follow-up (a), "a video-player UI
(debate-videos) that lets a viewer drop an annotation at the current
playback position, persisted through flowAnnotations.ts, and jump back to
one," named under idea #15 ("Flow-in-Speech Flow Annotations") in
TODO.md. It adds:
flow/flow-annotations.ts: an optionalFlowAnnotation.videoId(additive — existing annotations without one are still valid), plusgetAnnotationsForVideo,formatAnnotationTimestamp,parseAnnotationTimestamp, andparseBoxPathInput.state/flowAnnotations.ts:listFlowAnnotationsForVideoandbuildFlowAnnotationsPanelView.debate-videos'ssendYouTubeCommandgains a"seekTo"command (the YouTube IFrame API'sseekTo(seconds, allowSeekAhead)), alongside the already-existingplayVideo/pauseVideo/setPlaybackRate.
Vitest-covered in packages/debate-round/test/flow-annotations.test.ts and
packages/debate-round/test/flowAnnotations.test.ts.
FlowSpreadsheet affordance (removed — see note)
⚠️ Known regression, discovered 2026-09-05: PR #498 ("Remove flow spreadsheet grid, show round flows in round editor", merged 2026-09-03) deleted the AG Grid-based
FlowSpreadsheetview entirely — along withflow/AnnotationBadge.tsx,flow/AnnotationCellRenderer.tsx,flow/annotation-cells.ts, and their tests — in favor of the new "ebb flow" split speech-editor view (debate-flow'sEbbFlowEmbed.tsx/HotGrid.tsx). Round flows are now shown in the round editor's flow tabs instead. The section below describes that deleted grid affordance as still live; it is not — the new flow editor has no equivalent annotation-badge affordance today. This is a real capability gap, not just stale prose: a viewer editing a flow in the current editor has no in-grid indicator that a box already has an annotation, unlike the standaloneFlowAnnotationsPanel(/annotations) documented above, which is unaffected (it never depended on the grid). Left as a "Known gap" below rather than rebuilt in this pass — restoring it means designing a Handsontable-native equivalent (a custom renderer/cell metadata approach), not porting the deleted AG Grid code, which is its own slice of work. The rest of this section is kept for history only.
Every cell in the live FlowSpreadsheet grid (/debate) whose box already
has one or more persisted FlowAnnotations shows a small clock badge next
to its text. Hovering the badge lists each annotation's formatted timestamp
and note; clicking it jumps to the earliest one via the exact same
jumpToAnnotation helper as FlowAnnotationsPanel's own Jump to
button, switching videos first when needed.
This closes follow-up (b), "a flow-grid affordance (FlowSpreadsheet) that
surfaces annotations on their box via listFlowAnnotationsForBox and links
back to the timestamp," named under idea #15 in TODO.md. It adds:
flow/annotation-cells.ts: pure helpers —boxPathForCell(reconstructs a grid cell'sboxPathfrom its row'soriginalIndexand column index, matching howdataTransform.ts#buildRowDataflattens a box chain viachildren[0]),columnIndexFromField(parses an AG Gridcol_Nfield name), andpickJumpAnnotation(the earliest annotation by timestamp).flow/AnnotationBadge.tsx: the clock badge, shared by both cell renderers below.flow/AnnotationCellRenderer.tsx: the cell renderer used on every column after the first.flow/FirstColumnCellRenderer.tsx: unchanged heading/indent behavior, now also rendering the same badge.flow/useFlowGridConfig.ts/flow/FlowSpreadsheet.tsx: wire ahandleJumpToAnnotationcallback (uses the samejumpToAnnotationhelper asFlowAnnotationsPanel.handleJump) into both renderers'cellRendererParams.
Vitest-covered in packages/debate-round/test/annotation-cells.test.ts
(box-path derivation, field parsing, earliest-annotation selection) and
packages/debate-round/test/AnnotationBadge.test.tsx (empty vs. populated
render, singular/plural wording, tooltip content).
Cross-recording "Jump to"
Closes the "Newly discovered small gaps" item logged by a previous run's doc/tracker drift audit: "Jump to" used to disable itself instead of switching videos when an annotation's recording wasn't the one loaded.
flow/flow-annotations.ts:jumpToAnnotation(annotation, deps), a pure, dependency-injected helper — same-video jumps still seek in place viasendYouTubeCommand("seekTo", ...); a different (or no) video loaded instead callsdeps.setActiveVideo(videoId, videoId, undefined, timestampMs / 1000). Returnsfalse(no-op) only when the annotation has novideoId.debate-videos'suseVideoPlayerStore.setActiveVideogains an optional 4thstartTimeSecondsparam that overrides the video's own saved-resume lookup, feeding the existing&start=YouTube-embed URL param — chosen over aseekTopostMessage immediately after switching because the iframe's new document isn't guaranteed to have loaded yet and this codebase has no "player ready" signal to gate on.panels/FlowAnnotationsPanel.tsx/flow/FlowSpreadsheet.tsx: bothhandleJump/handleJumpToAnnotationnow call the shared helper; the panel's button is disabled only when the annotation has novideoId.
Vitest-covered (4 new cases in
packages/debate-round/test/flow-annotations.test.ts's jumpToAnnotation
suite: same-video seek, cross-video switch, switch when nothing is loaded,
and the no-videoId no-op).
Video title on a cross-recording jump
Closes the "falls back to the bare videoId as the player's displayed
title" Known gap below: FlowAnnotation now optionally carries the
recording's own display title, captured at the moment the annotation was
dropped (from useVideoPlayerStore's activeVideoTitle, the same title
VideoCardThumbnail.tsx passes to setActiveVideo when a video is
started), so a later cross-recording "Jump to" can show it instead of the
bare id.
flow/flow-annotations.ts: an optionalFlowAnnotation.videoTitle(additive — existing annotations without one are unaffected), trimmed and omitted when blank, mirroring the existingvideoIdhandling increateFlowAnnotation.jumpToAnnotationnow callsdeps.setActiveVideo(videoId, annotation.videoTitle ?? annotation.videoId, ...), falling back to the bare id only when no title was captured.panels/FlowAnnotationsPanel.tsx:handleAddpasses the liveactiveVideoTitlethrough alongsideactiveVideoIdwhen dropping an annotation at the current playback position.
This only threads through a title an annotation already had in scope at
creation time — there is still no standalone videoId → title catalog/
lookup service, so an annotation dropped before this change, or dropped
without the live player active, still falls back to the bare id.
Vitest-covered (2 new cases in
packages/debate-round/test/flow-annotations.test.ts: createFlowAnnotation
trims/omits videoTitle the same way it does videoId, and
jumpToAnnotation uses a recorded videoTitle over the bare videoId when
switching recordings).
Search/filter by speech, speaker, or tag
Closes the "Search/filter annotations by speech, speaker, or tag" follow-up
named under idea #15 in TODO.md.
flow/flow-annotations.ts: two new optionalFlowAnnotationfields,speakerandtag(additive — existing annotations without either are still valid), trimmed and omitted when blank increateFlowAnnotation, mirroring the existingvideoId/videoTitlehandling. A newAnnotationFiltertype (speechId/speaker/tag, every field optional and AND-combined — an unset field matches anything) plusfilterFlowAnnotations(annotations, filter), following the same "sequential guard clause per field" shape asflow/argument-tree.ts'sArgumentTreeFilter/filterArgumentTree(idea #10's own filter). It preserves the input array's order rather than re-sorting, since the panel always hands it an already-ordered (newest-first) list.panels/FlowAnnotationsPanel.tsx: the drop-annotation form gains optional Speaker and Tag text inputs. Above the annotation list, threeSelectdropdowns — Speech, Speaker, Tag — each populated with the distinct values actually present across the current annotations (plus an "Any …" option), matchingArgumentTreePanel.tsx's own dropdown-filter pattern. The filter lives in local component state (not persisted across visits, unlike idea #10's per-round saved filter selections) and is applied to the rendered list only — it never affects what's stored. A "Clear filters" button appears once any filter is set. Each rendered annotation now also shows itsspeaker/tagas small badges when set.
Vitest-covered with 9 new cases in
packages/debate-round/test/flow-annotations.test.ts (createFlowAnnotation
trims/omits speaker and tag the same way as videoId/videoTitle; and
filterFlowAnnotations's empty-filter passthrough, single-field filtering
for each of speechId/speaker/tag, AND-combining multiple fields,
excluding annotations missing a required field, order preservation, and the
no-matches case).
Bulk export
Closes the "Bulk-export a round's annotations into a Speech Document" follow-up
named under idea #15 in TODO.md. A .docx Speech Document export isn't
attempted, for the same reason idea #6's own "send to Speech Document"
follow-up and idea #10's outline export stayed plain-text: the only Speech
Document type in this repo lives in the reason-editor package, which
debate-round doesn't depend on. This closes the "bulk-export" half with a
downloadable plain-text snapshot of every annotation on one flow instead — a
debate round, in this data model, is scoped by FlowAnnotation.flowId, the
only round-identifying field an annotation carries.
flow/flow-annotations.ts:AnnotationFiltergains an optionalflowIdfield (an unsetflowIdstill matches everything, so every existing filter call is unaffected), applied the same AND-combined way asspeechId/speaker/tag.flow/flow-annotations-export.ts:buildFlowAnnotationsExportText(annotations, flowId)— a pure builder that narrows to oneflowId, sorts by timestamp, and renders one line per annotation (- [m:ss] <speech> box [<path>], with speaker/tag appended in parentheses and a note on its own indented line when set);flowAnnotationsExportFilename(flowId), mirroringround/pre-round-briefing.ts#preRoundBriefingFilename's exact sanitization rule.panels/FlowAnnotationsPanel.tsx: a new Flow filter dropdown (alongside the existing Speech/Speaker/Tag ones, populated from the distinctflowIds actually present) drives a "Download annotations" button that appears once a specific flow is selected, using the same anchor+Blob download pattern asPreRoundBriefingsPanel.tsx#handleDownload.
Vitest-covered: 2 new cases in packages/debate-round/test/flow-annotations.test.ts's
filterFlowAnnotations suite (flowId filtering alone, and flowId: 0
treated as a real filter value rather than an unset one — a plain
truthiness check would have skipped it), and a new
packages/debate-round/test/flow-annotations-export.test.ts covering
buildFlowAnnotationsExportText (header, empty-flow message, cross-flow
exclusion, timestamp/speech/box-path rendering, speaker+tag suffix, note
line, timestamp ordering) and flowAnnotationsExportFilename.
Known gaps
Switching videos for a cross-recording jump falls back to the bareClosed: see "Video title on a cross-recording jump" above. An annotation dropped without the live player active (or before this change) still has no title to fall back on.videoIdas the player's displayed title (e.g. "Now playing:dQw4w9WgXcQ") since no stored catalog maps avideoIdto a title —FlowAnnotationitself doesn't carry one, only whatever created the annotation (e.g.VideoCard.tsx) ever knew it.TheClosed:FlowSpreadsheetbadge reads annotations fromlocalStorageat cell render time; it does not live-update if another tab drops a new annotation while the grid is open.FlowSpreadsheetnow listens for the browser'sstorageevent (which fires only in other same-origin tabs, never the tab that wrote the change) viaflow/live-update.ts#isFlowLiveUpdateStorageEventand force-refreshes every grid cell when it fires for theflowAnnotations/flowEdits/prepNoteskeys, so an annotation logged in one tab now shows up in this badge (and theEditBadge/PrepNoteBadge, seeshared-flow-sync.md) in every other open tab on the nextstorageevent, not just after a manual reload.The standalone Flow Annotations panel (Closed: the panel now listens for the browser'spanels/FlowAnnotationsPanel.tsx— the video-player annotation list itself, distinct from theFlowSpreadsheetgrid badge above) readslocalStorageon mount and after its own add/clear actions only; it did not live-update if another tab dropped or cleared an annotation while the panel was open.storageevent too, via a newflow/live-update.ts#isFlowAnnotationsPanelLiveUpdateStorageEvent(scoped to theflowAnnotationskey alone, since this panel doesn't render theflowEdits/prepNotesbadges the grid's predicate also covers), and re-reads the annotation list when it fires. Vitest-covered inpackages/debate-round/test/live-update.test.ts.annotations are localClosed for a signed-in user: annotations sync to the account throughlocalStorageonlysaved_tool_records— see Tool Data Sync. There is still no collaborative live sync: they follow one user across devices, they are not a shared stream two people annotate at once.- The current flow editor (the "ebb flow" split speech-editor view that
replaced
FlowSpreadsheetin PR #498) has no in-grid indicator that a box already has an annotation — see the "FlowSpreadsheet affordance (removed — see note)" section above.
