Skip to content
Contents
handovers/2026-08-18-session-a5-citations-notes.md

Rendered from the repository — the file stays the source of truth.

Session A5 — Citation navigation + notes (2026-08-18)

Goal

Close the loop that defines the product (CF-07 interactions 1–4, CF-10 MVP): clicking a citation opens the source viewer at the exact cited passage, highlighted; notes exist — manual ones and assistant answers saved as notes with their citations preserved and still navigable.

What was done

  • Citation → passage resolution (CF-07): new owner-scoped repository query sourceRepository.findChunkLocation(chunkId, ownerId) (chunks → sources → notebooks join), surfaced as resolveCitation in source-service and resolveCitationAction in the sources actions. Resolution happens server-side on every chip click — chips carry only data-chunk-id; offsets never come from the client. null covers every degraded case alike (unknown id, cascaded chunk, foreign owner) so a dangling chip degrades instead of erroring.
  • Highlight: pure splitAtPassage(content, charStart, charEnd) in src/lib/passage.ts — UTF-16 code-unit offsets, exactly the convention ingestion used, so the A3 invariant carries through 1:1; out-of-range clamps, empty/inverted ranges yield null (no highlight). The viewer wraps the passage in a <mark data-testid="cited-passage">, scrolls it into view (block: "center") once content arrives, and shows a “Cited passage — Page N · section” badge in the header. Viewer stays dialog-state only — A3’s URL-addressability point remains open, deliberately.
  • Cross-panel wiring: NotebookBridge React context (provided by NotebookWorkspace) carries openCitation, the removedChunkIds set, and a notesVersion counter across the server-rendered Studio boundary — the notes section lives in page.tsx’s children, so props can’t reach it. Chip clicks resolve → the workspace hands SourcesPanel a token-keyed ViewerOpenRequest (adopted during render, not in an effect — the React compiler lint forbids sync setState in effects).
  • Dangling citations degrade gracefully: resolution failure adds the chunk id to removedChunkIds; those chips re-render inert (muted, aria-disabled, tooltip “Source removed”) — no error, no dead click. In notes, all [n] markers without resolvable citation data render as the same inert chip (unresolvedMarkers="removed"), which covers both the deleted-source cascade and the cleared-chat set-null path. In chat, markers without data stay literal text (mid-stream markers and model-invented markers are indistinguishable from dangling ones there).
  • Save response as note (CF-10): the DB message id now reaches the client as a data-persisted stream part — the chat route persists the assistant message before writing finish and emits the part; toUIMessages adds the identical part on rehydration, so live and reloaded messages behave the same. The assistant action row (ui-research §2.2) shows “Save to note” (pin icon) once that part exists. saveMessageAsNote titles the note from the nearest preceding user question (first line, ≤80 chars + ellipsis, fallback “Saved from chat”), sets sourceMessageId, and guards idempotence cheaply: saving the same message again returns the existing note instead of duplicating.
  • Notes UI (Studio column, own subtree src/components/notes/): list (title, date, pin icon for saved-from-chat), “Add note” pinned at the column bottom (ui-research §2.3; creates a “New note” and opens the editor), note dialog with view mode (rendered through the same AssistantMarkdown, chips clickable via the bridge) and edit mode (title input + plain textarea — no rich text), delete with confirmation. page.tsx changed only to mount <NotesSection> in the Studio section; D2’s artifact area mounts above it.
  • Server layer: note-service (validation caps: title 200 chars, content 100k chars, NoteInputError for user input) + notes/actions.ts following the established pattern (requireUser, ownerId into every repository call, errors as messages, UUID guards before queries). Repository additions only where a query shape was genuinely missing: findChunkLocation (source repo), findMessageById + listCitationsForMessage (conversation repo). No schema changes, no migrations.

Verified locally

  • bun test: 107 pass, 0 fail (80 pre-existing + 27 new: passage slicing incl. unicode + real-fixture chunk round-trip, chunk-location resolution authz/dangling, message/citation lookups, note-service CRUD validation + save-as-note title derivation/idempotence/authz + citation rehydration + orphaning, persisted-id contract).
  • bunx eslint src: clean. bun run build (varlock, worktree root): passes; next.config.ts and Dockerfile untouched.
  • E2E against supabase start + real Scaleway (fresh user a5-tester@example.com, notebook fd87ccd3…, 10-paragraph pasted text incl. German umlauts and CJK):
    • Ask → answer cites [1] → chip click opens the viewer with the passage <mark>-highlighted and scrolled into view; header shows the “Cited passage” badge. SQL: the mark text (1542 chars, matching first/last 60) equals the cited chunk’s text, and substring(content, char_start+1, char_end-char_start) = text is true — highlight ≡ chunk ≡ offsets, through the unicode paragraphs.
    • “Save to note” → note appears live in the Studio list (bridge refresh), titled from the question, pinned “Saved from chat”; SQL shows source_message_id → the assistant message. Clicking the chip inside the note opens the same highlighted viewer. Saving again: still exactly 1 note (idempotence guard).
    • Reload: history + chips rehydrate; the reloaded chip navigates; the reloaded message still offers “Save to note” (data-persisted from toUIMessages).
    • Delete the cited source: citations cascade to 0 rows (SQL), the note survives; clicking the chat chip opens nothing and the chip re-renders inert (“Citation 1: source removed”, muted, aria-disabled); the note’s marker renders the same inert chip.
    • Clear chat: source_message_id set NULL (SQL), note survives with content intact, “Saved from chat” badge gone, marker inert.
    • Manual note: Add note → editor → save → renders (bold works) → edit round-trip → delete with confirmation → gone.
    • Console clean (one devtools a11y hint: note dialog fields lack id/name — same pattern as the existing chat input).
    • Screenshots: handovers/assets/2026-08-18-a5-*.jpeg (workspace with chip + note, viewer highlight, orphaned note with inert chip).

Gotchas / known behavior

  1. Tooltip-chip first-click automation artifact: chrome-devtools MCP coordinate clicks on a citation chip sometimes land on the tooltip that opens mid-click and do nothing; the second click always works, and programmatic chip.click() always works. Real users hover before clicking (tooltip already open, chip stays hit-testable — elementFromPoint verified), so this is an automation quirk, not an app bug. Same family as A3’s fill-doesn’t-fire-onChange note.
  2. Stopped streams and Save to note: the data-persisted part is written after generation finishes; a client that pressed Stop never receives it, so the truncated message shows no “Save to note” until reload (where the full persisted answer returns — A4’s stop-abort caveat). Accepted for MVP.
  3. Notes render every unresolved [n] marker as an inert “source removed” chip — required for the cleared-chat case (set-null leaves no way to distinguish orphaned citations from hand-typed [n] in a manual note). A hand-typed [7] in a manual note therefore renders as an inert chip, not literal text. Cosmetic tradeoff, documented choice.
  4. In chat (unlike notes), markers without citation data stay literal text — during streaming the data part lags the text, and model-invented markers are indistinguishable from dangling ones.
  5. PNG screenshots time out on this Wayland box (Page.captureScreenshot protocol timeout) — JPEG works fine; a5 evidence is JPEG.

Hot files touched

  • None: bun.lock, root package.json, .env.schema, AGENTS.md all untouched (no new dependencies, no new env vars, no new shadcn components). Expected merge collisions with D2 are limited to page.tsx (Studio section) — trivial, Lane A wins per the roadmap.

Open questions / next sessions

  • URL-addressable viewer still open (A3): the viewer (now with highlight) remains dialog state; a ?source=…&chunk=… param would make citations shareable/deep-linkable. Nothing blocks retrofitting.
  • A6 (demo polish) may want: an explicit empty-viewer state when a citation resolves but the source is still processing; surfacing the save error inline is minimal (plain text next to the button).
  • The NotebookBridge context is the natural place for D2’s Studio artifacts to hook refresh/notification patterns if needed.