Repository navigation
feat(citations): open a citation chip at its source page and block - #117
Merged
Merged
Conversation
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.
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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;locationstays aReaderAnchorso it can go straight toopenAt.chat/citationMarkers.ts— a remark plugin that rewrites[n]into a#citation-nlink. 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 singleopenSourceAnchor/closeSourceAnchorentry 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 handlesEsc.reader/anchor.ts+ both readers —resolveAnchor()implements the location priority and clears a stale highlight.Design notes
ReaderAnchoranswers "where inside the reader I already have";SourceAnchoranswers "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 bareReaderAnchor.blockId→startOffset/endOffset→page→ document top.blockIdis a derived index, so it is a fast hint, not the only truth — after a reindex the canonical offsets recover the block./notebook/:id(?doc=&page=&start=&end=&block=), notlocation.state, so a reload restores the position. Parsing is defensive per field; closing the reader removes only those keys.Citationnever 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, loneend),withSourceAnchorpreserving 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—resolveAnchorpriority, 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)
Escreturns to the chat; the position restores after an app reload.Screenshots / recordings
Not captured. A real-window pass is still required before merge; this PR does not claim the GUI behaviour is verified.
Checklist
npm run typecheckpasses.npm run buildpasses.Desktop / build changes
npm run buildpasses.