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 asresolveCitationinsource-serviceandresolveCitationActionin the sources actions. Resolution happens server-side on every chip click — chips carry onlydata-chunk-id; offsets never come from the client.nullcovers every degraded case alike (unknown id, cascaded chunk, foreign owner) so a dangling chip degrades instead of erroring. - Highlight: pure
splitAtPassage(content, charStart, charEnd)insrc/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:
NotebookBridgeReact context (provided byNotebookWorkspace) carriesopenCitation, theremovedChunkIdsset, and anotesVersioncounter across the server-rendered Studio boundary — the notes section lives inpage.tsx’s children, so props can’t reach it. Chip clicks resolve → the workspace handsSourcesPanela token-keyedViewerOpenRequest(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-persistedstream part — the chat route persists the assistant message before writingfinishand emits the part;toUIMessagesadds 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.saveMessageAsNotetitles the note from the nearest preceding user question (first line, ≤80 chars + ellipsis, fallback “Saved from chat”), setssourceMessageId, 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 sameAssistantMarkdown, chips clickable via the bridge) and edit mode (title input + plain textarea — no rich text), delete with confirmation.page.tsxchanged 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,NoteInputErrorfor user input) +notes/actions.tsfollowing 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.tsandDockerfileuntouched.- E2E against
supabase start+ real Scaleway (fresh usera5-tester@example.com, notebookfd87ccd3…, 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’stext, andsubstring(content, char_start+1, char_end-char_start) = textis 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_idset 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).
- Ask → answer cites [1] → chip click opens the viewer with the
passage
Gotchas / known behavior
- 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 —elementFromPointverified), so this is an automation quirk, not an app bug. Same family as A3’sfill-doesn’t-fire-onChange note. - Stopped streams and Save to note: the
data-persistedpart 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. - 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. - 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.
- PNG screenshots time out on this Wayland box (
Page.captureScreenshotprotocol timeout) — JPEG works fine; a5 evidence is JPEG.
Hot files touched
- None:
bun.lock, rootpackage.json,.env.schema,AGENTS.mdall untouched (no new dependencies, no new env vars, no new shadcn components). Expected merge collisions with D2 are limited topage.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
NotebookBridgecontext is the natural place for D2’s Studio artifacts to hook refresh/notification patterns if needed.