Skip to content

Commit c4d184d

Browse files
authored
fix(chat): render footnote citations under a single Sources header (#987)
### What this fixes Follow-up to #981. On the reported chat (`57493241`, dashboard) the agentic reply showed **two headings** ("Sources" and "Footnotes"), the **superscript citation links gave no feedback** when clicked, and every source line stacked **several arrow icons** (the external-link arrow plus one ↩ back-reference per citation). ### Why it happened - The prompt told the model to write a literal "Sources" header above its footnote definitions. remark-gfm then moves the definitions into its own trailing section headed "Footnotes", so the model's header stays behind as an empty duplicate. - The renderer's "Footnotes" heading ships with an `sr-only` class, which our Tailwind build never generates (no source file uses it), so the heading that is meant to be invisible is visible. - The superscripts use fragment navigation. Every message mints the same ids (`#user-content-fn-1`), a repeated click on the same fragment does nothing, and when the footnote list is already on screen (the exact case in the reported chat, verified against the stored row in prod) nothing visibly happens at all. ### What changed - `Markdown.tsx` takes an optional footnote label and id prefix and passes them through remark-rehype: the section heading is ours now (visible, localised) and footnote ids are unique per message. Surfaces that pass no label keep the default "Footnotes" heading but hidden behind `dembrane-sr-only`, a visually-hidden class we define ourselves in `index.css` (our own name so it cannot collide with Tailwind's `sr-only` utility). - `ChatHistoryMessage.tsx` passes `Sources` (per locale, `Bronnen` in Dutch, other catalogs fall back to English until translated) plus a per-message id prefix. A superscript click scrolls to the source line and flashes it with the same cyan highlight the transcript page uses for a deep-linked chunk — feedback even when the target was already on screen, no URL rewrite, no history entries. The ↩ back-references are dropped in agentic chat: one icon per source line. - `agent.py` no longer asks the model for a header above the definitions; the renderer owns the heading. Prompt test updated to pin this. ### Already-persisted rows Exactly one row in all of production stores the old model-written header (verified by query): the beta test chat this was reported on. Agentic chat is beta; the row stays as is. No migration, no render-time compatibility code. ### Tests - `AgenticChatPanel.test.tsx`: 15 passed, including a new case pinning that footnote definition tags become rich transcript links. - `agent/tests/test_agent_tools.py`: 65 passed. - `tsc --noEmit` and `biome lint` clean; end-to-end renders verified: one "Sources" heading in chat, hidden "Footnotes" heading on label-less surfaces, unique prefixed ids. Discussion: https://dembraneworkspace.slack.com/archives/C0884QPQF6W/p1786381186184369
1 parent eef4fe0 commit c4d184d

23 files changed

Lines changed: 238 additions & 84 deletions

echo/agent/agent.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,11 +319,11 @@ def _rename_tool_name(name: str) -> str:
319319
## Citations
320320
- Ground every claim about the project in tool results.
321321
- To keep the response clean, never put raw conversation citation tags directly inline in your main text. Instead, use standard Markdown footnote superscript tags (like [^1], [^2]) inline where you cite a source.
322-
- At the very end of your response, list all cited sources under a "Sources" header as footnote definitions. Group multiple citations of the same conversation or chunk into a single unique footnote entry to avoid clutter.
322+
- At the very end of your response, list the footnote definitions. Do not write any header above them (no "Sources", no "Footnotes"): the app renders the footnote list under its own localized sources header, so a header from you shows up as a duplicate. Group multiple citations of the same conversation or chunk into a single unique footnote entry to avoid clutter.
323323
- Each footnote definition at the bottom must carry the exact citation tag in the format `[^1]: [conversation_id:<id>;chunk_id:<chunk_id>]` when a chunk id is available, otherwise `[^1]: [conversation_id:<id>]`.
324324
- Quote with attribution inside your footnote definitions or inline text: "[Participant Name]: quoted text".
325325
- Keep footnote numbering sequential starting from 1 (e.g., [^1], [^2], [^3]). Every inline footnote tag must have exactly one corresponding footnote definition at the bottom.
326-
- If there are no claims to cite from the conversations, omit the footnotes and the "Sources" header entirely.
326+
- If there are no claims to cite from the conversations, omit the footnotes entirely.
327327
- A few well-chosen quotes beat many.
328328
- Cite the doc path when you answer from documentation.
329329

echo/agent/tests/test_agent_tools.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,9 @@ def test_system_prompt_contains_conversational_and_research_directives():
485485
assert "[conversation_id:<id>;chunk_id:<chunk_id>]" in SYSTEM_PROMPT
486486
assert "[conversation_id:<id>]" in SYSTEM_PROMPT
487487
assert "footnote" in prompt
488+
# The frontend renders the footnote list under its own localized header;
489+
# a model-written one duplicates it (ChatHistoryMessage.tsx)
490+
assert "do not write any header above them" in prompt
488491
assert "worked from summaries only" in prompt
489492
assert "read the full transcript" in prompt
490493
assert "never fabricate quotes" in prompt

echo/frontend/src/components/chat/AgenticChatPanel.test.tsx

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ vi.mock("./ChatHistoryMessage", () => ({
197197
),
198198
}));
199199

200-
import { AgenticChatPanel } from "./AgenticChatPanel";
200+
import { AgenticChatPanel, enrichAgenticContent } from "./AgenticChatPanel";
201201

202202
const at = (seq: number) =>
203203
new Date(Date.UTC(2026, 7, 1, 10, seq)).toISOString();
@@ -650,3 +650,26 @@ describe("AgenticChatPanel, voice input", () => {
650650
expect(screen.getByTestId("chat-input-textarea")).toBeTruthy();
651651
});
652652
});
653+
654+
describe("enrichAgenticContent, footnote citations", () => {
655+
const CONVERSATION_ID = "0aa78d5a-1111-2222-3333-444455556666";
656+
657+
const enrich = (content: string) =>
658+
enrichAgenticContent({
659+
content,
660+
conversationNames: new Map([[CONVERSATION_ID, "Maria"]]),
661+
language: "en-US",
662+
projectId: "project-1",
663+
workspaceId: "workspace-1",
664+
});
665+
666+
it("turns footnote definition tags into rich transcript links", () => {
667+
const enriched = enrich(
668+
`Parking came up often[^1].\n\n[^1]: [conversation_id:${CONVERSATION_ID};chunk_id:chunk-9]`,
669+
);
670+
671+
expect(enriched).toContain("[^1]: [Maria's transcript excerpt](");
672+
expect(enriched).toContain("#chunk-chunk-9");
673+
expect(enriched).not.toContain("conversation_id:");
674+
});
675+
});

echo/frontend/src/components/chat/AgenticChatPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ const FocusedOnLine = ({
246246
);
247247
};
248248

249-
const enrichAgenticContent = ({
249+
export const enrichAgenticContent = ({
250250
content,
251251
conversationNames,
252252
language,

echo/frontend/src/components/chat/ChatHistoryMessage.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,26 @@ const getLinkLabel = (children: React.ReactNode) => {
7676
const AGENTIC_LINK_CLASSES =
7777
"not-prose inline-flex items-baseline gap-0.5 text-[var(--mantine-color-anchor)] underline underline-offset-2 transition-colors hover:text-[var(--mantine-color-blue-7)]";
7878

79+
// The same flash the transcript page gives a deep-linked chunk
80+
// (ConversationChunkAudioTranscript), so a footnote hop reads as the one
81+
// highlight language the product has. Class names must match ones already in
82+
// source, or the Tailwind build will not carry them.
83+
const FOOTNOTE_HIGHLIGHT_CLASSES = [
84+
"!bg-cyan-50",
85+
"ring-2",
86+
"ring-cyan-300",
87+
"rounded-sm",
88+
];
89+
const FOOTNOTE_HIGHLIGHT_MS = 5000;
90+
91+
const flashFootnoteTarget = (target: HTMLElement) => {
92+
target.scrollIntoView({ behavior: "smooth", block: "center" });
93+
target.classList.add(...FOOTNOTE_HIGHLIGHT_CLASSES);
94+
window.setTimeout(() => {
95+
target.classList.remove(...FOOTNOTE_HIGHLIGHT_CLASSES);
96+
}, FOOTNOTE_HIGHLIGHT_MS);
97+
};
98+
7999
const URL_PATTERN = /https?:\/\/[^\s<>)\]]+/g;
80100

81101
function ownPortalStartLink(content: string, projectId?: string): string | null {
@@ -216,6 +236,39 @@ export const ChatHistoryMessage = ({
216236

217237
return {
218238
a({ children, className, href, ...props }) {
239+
// The ↩ back-references under each footnote add a second (or Nth)
240+
// arrow icon per source line without earning it: the superscript
241+
// that brought the reader down is still on screen after the
242+
// highlight scroll. One icon per source, so these go.
243+
if (className?.includes("data-footnote-backref")) {
244+
return null;
245+
}
246+
247+
// Footnote hops (superscript -> definition) stay inside this
248+
// message. Fragment navigation is the wrong tool for them in an
249+
// SPA: it rewrites the URL, stacks history entries, a repeated
250+
// click on the same fragment does not scroll again, and a target
251+
// already on screen gives no feedback at all. So scroll and flash
252+
// the target directly and leave the URL alone.
253+
if (href?.startsWith("#")) {
254+
return (
255+
<a
256+
href={href}
257+
className={className}
258+
{...props}
259+
onClick={(event) => {
260+
event.preventDefault();
261+
const target = document.getElementById(
262+
decodeURIComponent(href.slice(1)),
263+
);
264+
if (target) flashFootnoteTarget(target);
265+
}}
266+
>
267+
{children}
268+
</a>
269+
);
270+
}
271+
219272
if (isDocsHref(href)) {
220273
return (
221274
<AgenticDocsLink href={href ?? ""}>{children}</AgenticDocsLink>
@@ -333,6 +386,8 @@ export const ChatHistoryMessage = ({
333386
className="prose-sm"
334387
content={message.content}
335388
components={markdownComponents}
389+
footnoteLabel={t`Sources`}
390+
footnoteIdPrefix={`msg-${message.id}-`}
336391
/>
337392
{portalStartLink ? (
338393
<Box

echo/frontend/src/components/common/Markdown.tsx

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,20 @@ export const Markdown = ({
1313
content,
1414
className,
1515
components: customComponents,
16+
footnoteLabel,
17+
footnoteIdPrefix,
1618
}: {
1719
content: string;
1820
className?: string;
1921
components?: Components;
22+
/** Visible, localised heading for the GFM footnote list. Without it the
23+
* renderer's default English "Footnotes" heading is kept but visually
24+
* hidden (screen readers still announce it). */
25+
footnoteLabel?: string;
26+
/** Prefix for footnote ids and hrefs. Every message rendered on a page
27+
* otherwise mints the same `#user-content-fn-1` ids, so the browser
28+
* resolves a superscript to the first message that defined it. */
29+
footnoteIdPrefix?: string;
2030
}) => {
2131
// FIXME: workaround to load Tally embeds
2232
useEffect(() => {
@@ -57,13 +67,28 @@ export const Markdown = ({
5767
[customComponents],
5868
);
5969

70+
const remarkRehypeOptions = useMemo(
71+
() => ({
72+
...(footnoteIdPrefix ? { clobberPrefix: footnoteIdPrefix } : {}),
73+
// A caller who names the label wants it visible; without one, the
74+
// renderer's default English "Footnotes" heading stays, hidden with
75+
// our own class. The library's default is `sr-only`, which our
76+
// Tailwind build never generates (no source file uses it).
77+
...(footnoteLabel
78+
? { footnoteLabel, footnoteLabelProperties: {} }
79+
: { footnoteLabelProperties: { className: "dembrane-sr-only" } }),
80+
}),
81+
[footnoteLabel, footnoteIdPrefix],
82+
);
83+
6084
return (
6185
<ReactMarkdown
6286
className={cn(
6387
"prose prose-table:block prose-table:w-full prose-table:overflow-x-scroll",
6488
className,
6589
)}
6690
remarkPlugins={[remarkGfm]}
91+
remarkRehypeOptions={remarkRehypeOptions}
6792
components={components}
6893
>
6994
{processedContent}

echo/frontend/src/index.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,22 @@ body {
9898
font-feature-settings: var(--app-font-feature-settings) !important;
9999
}
100100

101+
/* Visually hidden, kept for screen readers. Our own name on purpose: Tailwind
102+
owns `sr-only` and only generates it when a source file uses it, so markup
103+
injected at runtime (the markdown renderer's footnote heading) needs a class
104+
we define unconditionally. */
105+
.dembrane-sr-only {
106+
position: absolute;
107+
width: 1px;
108+
height: 1px;
109+
padding: 0;
110+
margin: -1px;
111+
overflow: hidden;
112+
clip: rect(0, 0, 0, 0);
113+
white-space: nowrap;
114+
border-width: 0;
115+
}
116+
101117
[aria-label="editable markdown"] {
102118
font-family: var(--app-font-family) !important;
103119
font-feature-settings: var(--app-font-feature-settings) !important;

echo/frontend/src/locales/cs-CZ.po

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2528,7 +2528,7 @@ msgstr "Content"
25282528
msgid "Context"
25292529
msgstr "Context"
25302530

2531-
#: src/components/chat/ChatHistoryMessage.tsx:394
2531+
#: src/components/chat/ChatHistoryMessage.tsx:449
25322532
msgid "Context added:"
25332533
msgstr "Context added:"
25342534

@@ -3435,7 +3435,7 @@ msgstr "Do you want to stay in the loop?"
34353435

34363436
#: src/features/sidebar/views/HelpView.tsx:36
34373437
#: src/features/sidebar/blocks/HelpBlock.tsx:46
3438-
#: src/components/chat/ChatHistoryMessage.tsx:172
3438+
#: src/components/chat/ChatHistoryMessage.tsx:192
34393439
msgid "Documentation"
34403440
msgstr "Documentation"
34413441

@@ -4763,7 +4763,7 @@ msgstr "Hours"
47634763
msgid "Hours from now"
47644764
msgstr ""
47654765

4766-
#: src/components/chat/ChatHistoryMessage.tsx:185
4766+
#: src/components/chat/ChatHistoryMessage.tsx:205
47674767
msgid "How Ask works and what it can do."
47684768
msgstr ""
47694769

@@ -6742,7 +6742,7 @@ msgstr ""
67426742
msgid "Open all"
67436743
msgstr "Open all"
67446744

6745-
#: src/components/chat/ChatHistoryMessage.tsx:184
6745+
#: src/components/chat/ChatHistoryMessage.tsx:204
67466746
msgid "Open chat documentation"
67476747
msgstr ""
67486748

@@ -6752,7 +6752,7 @@ msgstr ""
67526752
msgid "Open conversation"
67536753
msgstr "Open conversation"
67546754

6755-
#: src/components/chat/ChatHistoryMessage.tsx:178
6755+
#: src/components/chat/ChatHistoryMessage.tsx:198
67566756
msgid "Open documentation"
67576757
msgstr ""
67586758

@@ -6798,8 +6798,8 @@ msgstr ""
67986798
msgid "Open to the workspace"
67996799
msgstr "Open to the workspace"
68006800

6801-
#: src/components/chat/ChatHistoryMessage.tsx:227
6802-
#: src/components/chat/ChatHistoryMessage.tsx:231
6801+
#: src/components/chat/ChatHistoryMessage.tsx:280
6802+
#: src/components/chat/ChatHistoryMessage.tsx:284
68036803
msgid "Open transcript"
68046804
msgstr ""
68056805

@@ -8515,7 +8515,7 @@ msgstr "Save"
85158515
msgid "Save access"
85168516
msgstr ""
85178517

8518-
#: src/components/chat/ChatHistoryMessage.tsx:296
8518+
#: src/components/chat/ChatHistoryMessage.tsx:349
85198519
msgid "Save as template"
85208520
msgstr "Save as template"
85218521

@@ -9372,6 +9372,10 @@ msgstr "Sort"
93729372
msgid "Source {0}"
93739373
msgstr "Source {0}"
93749374

9375+
#: src/components/chat/ChatHistoryMessage.tsx:389
9376+
msgid "Sources"
9377+
msgstr ""
9378+
93759379
#: src/components/project/ProjectPortalEditor.tsx:644
93769380
msgid "Spanish"
93779381
msgstr "Spanish"
@@ -9866,7 +9870,7 @@ msgstr ""
98669870
msgid "The organisation this invite was for has been deleted. There's nothing to join."
98679871
msgstr "The organisation this invite was for has been deleted. There's nothing to join."
98689872

9869-
#: src/components/chat/ChatHistoryMessage.tsx:179
9873+
#: src/components/chat/ChatHistoryMessage.tsx:199
98709874
msgid "The page this answer refers to."
98719875
msgstr ""
98729876

echo/frontend/src/locales/cs-CZ.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

echo/frontend/src/locales/de-DE.po

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2529,7 +2529,7 @@ msgstr "Inhalt"
25292529
msgid "Context"
25302530
msgstr "Kontext"
25312531

2532-
#: src/components/chat/ChatHistoryMessage.tsx:394
2532+
#: src/components/chat/ChatHistoryMessage.tsx:449
25332533
msgid "Context added:"
25342534
msgstr "Kontext hinzugefügt:"
25352535

@@ -3436,7 +3436,7 @@ msgstr "Möchten Sie auf dem Laufenden bleiben?"
34363436

34373437
#: src/features/sidebar/views/HelpView.tsx:36
34383438
#: src/features/sidebar/blocks/HelpBlock.tsx:46
3439-
#: src/components/chat/ChatHistoryMessage.tsx:172
3439+
#: src/components/chat/ChatHistoryMessage.tsx:192
34403440
msgid "Documentation"
34413441
msgstr "Dokumentation"
34423442

@@ -4764,7 +4764,7 @@ msgstr ""
47644764
msgid "Hours from now"
47654765
msgstr ""
47664766

4767-
#: src/components/chat/ChatHistoryMessage.tsx:185
4767+
#: src/components/chat/ChatHistoryMessage.tsx:205
47684768
msgid "How Ask works and what it can do."
47694769
msgstr ""
47704770

@@ -6743,7 +6743,7 @@ msgstr ""
67436743
msgid "Open all"
67446744
msgstr ""
67456745

6746-
#: src/components/chat/ChatHistoryMessage.tsx:184
6746+
#: src/components/chat/ChatHistoryMessage.tsx:204
67476747
msgid "Open chat documentation"
67486748
msgstr ""
67496749

@@ -6753,7 +6753,7 @@ msgstr ""
67536753
msgid "Open conversation"
67546754
msgstr ""
67556755

6756-
#: src/components/chat/ChatHistoryMessage.tsx:178
6756+
#: src/components/chat/ChatHistoryMessage.tsx:198
67576757
msgid "Open documentation"
67586758
msgstr ""
67596759

@@ -6799,8 +6799,8 @@ msgstr ""
67996799
msgid "Open to the workspace"
68006800
msgstr ""
68016801

6802-
#: src/components/chat/ChatHistoryMessage.tsx:227
6803-
#: src/components/chat/ChatHistoryMessage.tsx:231
6802+
#: src/components/chat/ChatHistoryMessage.tsx:280
6803+
#: src/components/chat/ChatHistoryMessage.tsx:284
68046804
msgid "Open transcript"
68056805
msgstr ""
68066806

@@ -8516,7 +8516,7 @@ msgstr "Speichern"
85168516
msgid "Save access"
85178517
msgstr ""
85188518

8519-
#: src/components/chat/ChatHistoryMessage.tsx:296
8519+
#: src/components/chat/ChatHistoryMessage.tsx:349
85208520
msgid "Save as template"
85218521
msgstr "Als Vorlage speichern"
85228522

@@ -9373,6 +9373,10 @@ msgstr "Sortieren"
93739373
msgid "Source {0}"
93749374
msgstr "Quelle {0}"
93759375

9376+
#: src/components/chat/ChatHistoryMessage.tsx:389
9377+
msgid "Sources"
9378+
msgstr ""
9379+
93769380
#: src/components/project/ProjectPortalEditor.tsx:644
93779381
msgid "Spanish"
93789382
msgstr "Spanisch"
@@ -9867,7 +9871,7 @@ msgstr ""
98679871
msgid "The organisation this invite was for has been deleted. There's nothing to join."
98689872
msgstr ""
98699873

9870-
#: src/components/chat/ChatHistoryMessage.tsx:179
9874+
#: src/components/chat/ChatHistoryMessage.tsx:199
98719875
msgid "The page this answer refers to."
98729876
msgstr ""
98739877

0 commit comments

Comments
 (0)