From 6e779e61ffd3f06da2cace9f3b2289c475a4d73e Mon Sep 17 00:00:00 2001 From: Daniel Date: Sun, 13 Sep 2026 14:42:51 +0200 Subject: [PATCH] fix: citation chips that sit together read in ascending order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [1] early in an answer and then "[2][1]" later showed "2 1". A run of adjacent chips is now sorted by what it displays — a core rule after inline parsing — so it reads "1 2". Text between two clusters keeps them apart, every chip still points at its own source, and the numbering itself is unchanged: only the order within a run moves. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/js/assistant/citations.js | 35 +++++++++++++++++++++++++++++--- test/assistant-citations.test.js | 13 ++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/public/js/assistant/citations.js b/public/js/assistant/citations.js index ac7f4690..cdfa1389 100644 --- a/public/js/assistant/citations.js +++ b/public/js/assistant/citations.js @@ -78,13 +78,41 @@ function displayNumber(env, n) { return i + 1; } +// What a chip shows. A list ordered by citation already says what the reader +// should see, and the panel shows the same number; otherwise, first +// appearance in this text. +function displayFor(env, meta) { + var source = sourceByNumber((env && env.assistantSources) || [], meta.source); + return source && source.sourceNumber != null ? source.number : meta.display; +} + +// Chips that sit together read in order. "[2][1]" written by the model after +// [1] appeared earlier showed "2 1"; the run is sorted by what it displays, so +// it reads "1 2". Only runs of adjacent chips are touched — text between two +// clusters keeps them apart — and every chip still points at its own source, +// so nothing about accuracy changes. +function sortCitationRuns(state) { + state.tokens.forEach(function (block) { + if (block.type !== 'inline' || !block.children) return; + var kids = block.children, i = 0; + while (i < kids.length) { + if (kids[i].type !== 'assistant_cite') { i++; continue; } + var j = i; + while (j < kids.length && kids[j].type === 'assistant_cite') j++; + if (j - i > 1) { + var run = kids.slice(i, j).sort(function (a, b) { return displayFor(state.env, a.meta) - displayFor(state.env, b.meta); }); + for (var k = 0; k < run.length; k++) kids[i + k] = run[k]; + } + i = j; + } + }); +} + function renderCitation(tokens, idx, o, env) { var meta = tokens[idx].meta; var opts = (env && env.assistantOpts) || {}; var source = sourceByNumber((env && env.assistantSources) || [], meta.source); - // A list ordered by citation already says what the reader should see, and - // the panel shows the same number. Otherwise, first appearance in this text. - var display = source && source.sourceNumber != null ? source.number : meta.display; + var display = displayFor(env, meta); var title = source ? source.title || source.resource || 'Source' : 'Source'; var page = source && (source.page || source.page_number || source.pageNumber); var label = 'Source ' + display + ': ' + title + (page ? ', page ' + page : ''); @@ -112,6 +140,7 @@ export function installCitations(md) { md.__assistantCitations = true; // Before 'escape', or \[1\] is consumed as an escaped bracket first. md.inline.ruler.before('escape', 'assistant_cite', citationRule); + md.core.ruler.push('assistant_cite_order', sortCitationRuns); md.renderer.rules.assistant_cite = renderCitation; var image = md.renderer.rules.image || function (t, i, o, e, self) { return self.renderToken(t, i, o); }; md.renderer.rules.image = function (tokens, idx, o, env, self) { diff --git a/test/assistant-citations.test.js b/test/assistant-citations.test.js index ec91cb01..d6452563 100644 --- a/test/assistant-citations.test.js +++ b/test/assistant-citations.test.js @@ -371,3 +371,16 @@ test('a markdown link or footnote whose text is a bare number stays a link, not assert.equal((html.match(/assistant-cite/g) || []).length, 1); assert.match(html, /data-source-number="2"/); }); + +test('chips that sit together read in ascending order, and still point at their own sources', async () => { + const { renderAssistantMarkdown } = await loadCitationModule(); + const four = [1, 2, 3, 4].map(n => ({ number: n, title: 'S' + n })); + // [1] appears first; the model then writes [2][1] — which showed "2 1". + const html = renderAssistantMarkdown('First claim [1]. Second claim [2][1]. Third [3, 2].', four); + const chips = [...html.matchAll(/data-source-number="(\d+)"[^>]*data-display-number="(\d+)"/g)].map(m => m[1] + '→' + m[2]); + assert.deepEqual(chips, ['1→1', '1→1', '2→2', '2→2', '3→3'], 'each run ascends by display; identity is untouched'); + // Clusters separated by text are not merged into one run. + const apart = renderAssistantMarkdown('A [2] then [1].', four); + const order = [...apart.matchAll(/data-display-number="(\d+)"/g)].map(m => m[1]); + assert.deepEqual(order, ['1', '2'], 'first appearance still numbers them; nothing is reordered across text'); +});