diff --git a/echo/agent/agent.py b/echo/agent/agent.py index d3cbe7e6..2098210f 100644 --- a/echo/agent/agent.py +++ b/echo/agent/agent.py @@ -319,11 +319,11 @@ def _rename_tool_name(name: str) -> str: ## Citations - Ground every claim about the project in tool results. - 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. -- 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. +- 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. - Each footnote definition at the bottom must carry the exact citation tag in the format `[^1]: [conversation_id:;chunk_id:]` when a chunk id is available, otherwise `[^1]: [conversation_id:]`. - Quote with attribution inside your footnote definitions or inline text: "[Participant Name]: quoted text". - 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. -- If there are no claims to cite from the conversations, omit the footnotes and the "Sources" header entirely. +- If there are no claims to cite from the conversations, omit the footnotes entirely. - A few well-chosen quotes beat many. - Cite the doc path when you answer from documentation. diff --git a/echo/agent/tests/test_agent_tools.py b/echo/agent/tests/test_agent_tools.py index 8774462d..0f7ee81c 100644 --- a/echo/agent/tests/test_agent_tools.py +++ b/echo/agent/tests/test_agent_tools.py @@ -485,6 +485,9 @@ def test_system_prompt_contains_conversational_and_research_directives(): assert "[conversation_id:;chunk_id:]" in SYSTEM_PROMPT assert "[conversation_id:]" in SYSTEM_PROMPT assert "footnote" in prompt + # The frontend renders the footnote list under its own localized header; + # a model-written one duplicates it (ChatHistoryMessage.tsx) + assert "do not write any header above them" in prompt assert "worked from summaries only" in prompt assert "read the full transcript" in prompt assert "never fabricate quotes" in prompt diff --git a/echo/frontend/src/components/chat/AgenticChatPanel.test.tsx b/echo/frontend/src/components/chat/AgenticChatPanel.test.tsx index 88b5143f..04f531e6 100644 --- a/echo/frontend/src/components/chat/AgenticChatPanel.test.tsx +++ b/echo/frontend/src/components/chat/AgenticChatPanel.test.tsx @@ -197,7 +197,7 @@ vi.mock("./ChatHistoryMessage", () => ({ ), })); -import { AgenticChatPanel } from "./AgenticChatPanel"; +import { AgenticChatPanel, enrichAgenticContent } from "./AgenticChatPanel"; const at = (seq: number) => new Date(Date.UTC(2026, 7, 1, 10, seq)).toISOString(); @@ -650,3 +650,26 @@ describe("AgenticChatPanel, voice input", () => { expect(screen.getByTestId("chat-input-textarea")).toBeTruthy(); }); }); + +describe("enrichAgenticContent, footnote citations", () => { + const CONVERSATION_ID = "0aa78d5a-1111-2222-3333-444455556666"; + + const enrich = (content: string) => + enrichAgenticContent({ + content, + conversationNames: new Map([[CONVERSATION_ID, "Maria"]]), + language: "en-US", + projectId: "project-1", + workspaceId: "workspace-1", + }); + + it("turns footnote definition tags into rich transcript links", () => { + const enriched = enrich( + `Parking came up often[^1].\n\n[^1]: [conversation_id:${CONVERSATION_ID};chunk_id:chunk-9]`, + ); + + expect(enriched).toContain("[^1]: [Maria's transcript excerpt]("); + expect(enriched).toContain("#chunk-chunk-9"); + expect(enriched).not.toContain("conversation_id:"); + }); +}); diff --git a/echo/frontend/src/components/chat/AgenticChatPanel.tsx b/echo/frontend/src/components/chat/AgenticChatPanel.tsx index 69a2560e..62c89593 100644 --- a/echo/frontend/src/components/chat/AgenticChatPanel.tsx +++ b/echo/frontend/src/components/chat/AgenticChatPanel.tsx @@ -246,7 +246,7 @@ const FocusedOnLine = ({ ); }; -const enrichAgenticContent = ({ +export const enrichAgenticContent = ({ content, conversationNames, language, diff --git a/echo/frontend/src/components/chat/ChatHistoryMessage.tsx b/echo/frontend/src/components/chat/ChatHistoryMessage.tsx index 88f2de92..bb2107b3 100644 --- a/echo/frontend/src/components/chat/ChatHistoryMessage.tsx +++ b/echo/frontend/src/components/chat/ChatHistoryMessage.tsx @@ -76,6 +76,26 @@ const getLinkLabel = (children: React.ReactNode) => { const AGENTIC_LINK_CLASSES = "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)]"; +// The same flash the transcript page gives a deep-linked chunk +// (ConversationChunkAudioTranscript), so a footnote hop reads as the one +// highlight language the product has. Class names must match ones already in +// source, or the Tailwind build will not carry them. +const FOOTNOTE_HIGHLIGHT_CLASSES = [ + "!bg-cyan-50", + "ring-2", + "ring-cyan-300", + "rounded-sm", +]; +const FOOTNOTE_HIGHLIGHT_MS = 5000; + +const flashFootnoteTarget = (target: HTMLElement) => { + target.scrollIntoView({ behavior: "smooth", block: "center" }); + target.classList.add(...FOOTNOTE_HIGHLIGHT_CLASSES); + window.setTimeout(() => { + target.classList.remove(...FOOTNOTE_HIGHLIGHT_CLASSES); + }, FOOTNOTE_HIGHLIGHT_MS); +}; + const URL_PATTERN = /https?:\/\/[^\s<>)\]]+/g; function ownPortalStartLink(content: string, projectId?: string): string | null { @@ -216,6 +236,39 @@ export const ChatHistoryMessage = ({ return { a({ children, className, href, ...props }) { + // The ↩ back-references under each footnote add a second (or Nth) + // arrow icon per source line without earning it: the superscript + // that brought the reader down is still on screen after the + // highlight scroll. One icon per source, so these go. + if (className?.includes("data-footnote-backref")) { + return null; + } + + // Footnote hops (superscript -> definition) stay inside this + // message. Fragment navigation is the wrong tool for them in an + // SPA: it rewrites the URL, stacks history entries, a repeated + // click on the same fragment does not scroll again, and a target + // already on screen gives no feedback at all. So scroll and flash + // the target directly and leave the URL alone. + if (href?.startsWith("#")) { + return ( + { + event.preventDefault(); + const target = document.getElementById( + decodeURIComponent(href.slice(1)), + ); + if (target) flashFootnoteTarget(target); + }} + > + {children} + + ); + } + if (isDocsHref(href)) { return ( {children} @@ -333,6 +386,8 @@ export const ChatHistoryMessage = ({ className="prose-sm" content={message.content} components={markdownComponents} + footnoteLabel={t`Sources`} + footnoteIdPrefix={`msg-${message.id}-`} /> {portalStartLink ? (