Skip to content

feat(citations): open a citation chip at its source page and block - #117

Merged
mrsibe merged 2 commits into
mainfrom
feat/citation-jump
Sep 25, 2026
Merged

mrsibe merged 2 commits into
mainfrom
feat/citation-jump

Conversation

@mrsibe

@mrsibe mrsibe commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Related issue

Fixes #72. Follows #69/#70 (citation snapshot + resolution) and #71 (reader contract).

What changed?

  • shared/utils/sourceAnchor.ts — citationToSourceAnchor, sourceAnchorToSearchParams, sourceAnchorFromSearchParams, withSourceAnchor, sourceAnchorsEqual. Pure: no DB, no DOM.
  • shared/types/source.ts — SourceAnchor { documentId, location }. It is the "open this source and go there" unit; location stays a ReaderAnchor so it can go straight to openAt.
  • chat/citationMarkers.ts — a remark plugin that rewrites [n] into a #citation-n link. It never decides presentation.
  • chat/CitationChip.tsx — renders [n] title · p.5, disables when the source is gone, falls back to plain text for an unresolved marker.
  • chat/MessageItem.tsx — maps markers to citations (parseCitations) and opens chips through the navigation hook.
  • hooks/useSourceAnchorNavigation.ts — the single openSourceAnchor / closeSourceAnchor entry point (store + URL query). This is what [Feat] Selection → excerpt to Note, anchored back to its source #73 will reuse.
  • SourcePanel.tsx — consumes the anchor, hydrates it from the URL on reload, passes it to the reader, clears a stale source query, and handles Esc.
  • reader/anchor.ts + both readers — resolveAnchor() implements the location priority and clears a stale highlight.

Design notes

  • One unit, two ends. ReaderAnchor answers "where inside the reader I already have"; SourceAnchor answers "which source, and where". [Feat] Citation click → open source at page/block with highlight #72 and [Feat] Selection → excerpt to Note, anchored back to its source #73 share the latter, not a bare ReaderAnchor.
  • Location priority: blockId → startOffset/endOffset → page → document top. blockId is a derived index, so it is a fast hint, not the only truth — after a reindex the canonical offsets recover the block.
  • Deep link is a query on /notebook/:id (?doc=&page=&start=&end=&block=), not location.state, so a reload restores the position. Parsing is defensive per field; closing the reader removes only those keys.
  • Parsing ≠ presentation. The markdown only yields a marker link; the chip owns the label, the page suffix and the disabled state, so Citation never carries a display string.

How was this tested?

  • npm run typecheck — passes.
  • npm test — 190 tests pass, 17 new:
    • test/sourceAnchor.test.ts — citation → anchor mapping, decode(encode(anchor)) === anchor, per-field defensive parsing (page=abc, page=-1, end < start, lone end), withSourceAnchor preserving unrelated query keys.
    • test/citationMarkers.test.ts — [n] becomes a link, several markers in one paragraph, inline/fenced code and reference links are untouched.
    • test/sourceReader.test.ts — resolveAnchor priority, including stale block id recovered through offsets.
  • npm run build — passes.
  • npm run check:design — no violations.

Manual QA (needs a real Electron window — not verified headlessly)

  • For the eval ground-truth question, the chip lands on the correct page and highlights the correct paragraph.
  • Several citations in one answer work repeatedly, including two citations on different pages of the same document.
  • A citation whose document was deleted is a disabled chip.
  • An old deep link to a deleted source returns to the list and drops the stale query.
  • Esc returns to the chat; the position restores after an app reload.
  • Highlight overlay stays aligned with the page at 50% / 100% / 150% zoom (this depends on [Feat] Source Reader architecture — PDF first (replace the chunk-concatenation <pre> viewer) #71's geometry).

Screenshots / recordings

Not captured. A real-window pass is still required before merge; this PR does not claim the GUI behaviour is verified.

Checklist

  • I have reviewed my own changes.
  • npm run typecheck passes.
  • npm run build passes.
  • I have tested the affected user workflow.
  • I have not included unrelated changes.
  • I have updated documentation when necessary.

Desktop / build changes

  • Not applicable
  • npm run build passes.

An answer's `[n]` was plain text. The provenance existed in metadata (#69) and
resolved markers (#70), and #71 defined how a reader is positioned, but nothing
connected the two — the visible payoff of the epic was missing.

- `SourceAnchor` (shared) is "open this source, then go there". It keeps
  `ReaderAnchor` unchanged so it can be handed straight to `openAt`, and it is
  now the unit #72 and #73 share instead of an anonymous `{ documentId, anchor }`.
  Citation → anchor, anchor → query, query → anchor are one pure module.
- `[n]` becomes a chip in the transcript. Parsing and presentation stay apart:
  a remark plugin only rewrites the marker into a `#citation-n` link, and
  `CitationChip` decides title / page / disabled — no display string is baked
  into the markdown.
- Location priority lives in the reader: blockId (a derived index that a reindex
  can invalidate) → canonical startOffset/endOffset → page → document top. A
  stale block id therefore still lands on the right paragraph after a reindex.
- Deep link: `/notebook/:id?doc=&page=&start=&end=&block=` survives a reload.
  Parsing is defensive — a malformed field is dropped on its own, never the whole
  link — and closing the reader removes only those keys.
- A deleted source degrades to a disabled chip; an old deep link to a deleted
  source returns to the list and clears the stale query.

Verification: typecheck, 190 tests (17 new: anchor round-trip and defensive
parsing, reader location priority, marker parsing), `npm run build`,
`npm run check:design`. The GUI round-trip (chip → correct page and highlight at
zoom, Esc) still needs a real Electron window and is listed as manual QA in the PR.
@github-actions github-actions Bot added the enhancement New feature or request label Sep 25, 2026
…k the page

Review follow-ups on this branch (#72):

- A loaded-empty document list means "every source is gone", not "nothing is
  loaded". `citationDocumentExists` (and therefore the chip) now keys off
  `documentsLoaded`; a previous `documents.length === 0` treated a notebook whose
  last source was deleted as if every citation still existed. A failed
  `loadDocuments()` also keeps `documentsLoaded: false` — a failed read is not
  evidence that a source was deleted.
- `resolveAnchor` returned `anchor.page ?? block.page`, so the page hint won over
  the locator that actually matched and could highlight page 4 while scrolling to
  page 5. The matched block/offset now owns the page; `anchor.page` is only used
  when the block carries no page of its own.
- URL → store hydration also syncs `null`, so a notebook switch or a history
  navigation that drops the query cannot leave a stale focused source behind.
- `TextSourceReader` scrolls back to the top when an anchor has no target,
  instead of clearing the highlight and staying at the old offset.

Verification: typecheck, 196 tests (6 new: loaded-empty and failed-load states,
stale page hint vs matched block), `npm run build`, `npm run check:design`.
GUI behaviour remains unverified and stays listed as manual QA.
@mrsibe
mrsibe merged commit a9fb4d3 into main Sep 25, 2026
3 checks passed
@mrsibe
mrsibe deleted the feat/citation-jump branch September 25, 2026 10:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat] Citation click → open source at page/block with highlight

1 participant