Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions echo/agent/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>;chunk_id:<chunk_id>]` when a chunk id is available, otherwise `[^1]: [conversation_id:<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.

Expand Down
3 changes: 3 additions & 0 deletions echo/agent/tests/test_agent_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -485,6 +485,9 @@ def test_system_prompt_contains_conversational_and_research_directives():
assert "[conversation_id:<id>;chunk_id:<chunk_id>]" in SYSTEM_PROMPT
assert "[conversation_id:<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
Expand Down
25 changes: 24 additions & 1 deletion echo/frontend/src/components/chat/AgenticChatPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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:");
});
});
2 changes: 1 addition & 1 deletion echo/frontend/src/components/chat/AgenticChatPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ const FocusedOnLine = ({
);
};

const enrichAgenticContent = ({
export const enrichAgenticContent = ({
content,
conversationNames,
language,
Expand Down
55 changes: 55 additions & 0 deletions echo/frontend/src/components/chat/ChatHistoryMessage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (
<a
href={href}
className={className}
{...props}
onClick={(event) => {
event.preventDefault();
const target = document.getElementById(
decodeURIComponent(href.slice(1)),
);
if (target) flashFootnoteTarget(target);
}}
>
{children}
</a>
);
}

if (isDocsHref(href)) {
return (
<AgenticDocsLink href={href ?? ""}>{children}</AgenticDocsLink>
Expand Down Expand Up @@ -333,6 +386,8 @@ export const ChatHistoryMessage = ({
className="prose-sm"
content={message.content}
components={markdownComponents}
footnoteLabel={t`Sources`}
footnoteIdPrefix={`msg-${message.id}-`}
/>
{portalStartLink ? (
<Box
Expand Down
25 changes: 25 additions & 0 deletions echo/frontend/src/components/common/Markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,20 @@ export const Markdown = ({
content,
className,
components: customComponents,
footnoteLabel,
footnoteIdPrefix,
}: {
content: string;
className?: string;
components?: Components;
/** Visible, localised heading for the GFM footnote list. Without it the
* renderer's default English "Footnotes" heading is kept but visually
* hidden (screen readers still announce it). */
footnoteLabel?: string;
/** Prefix for footnote ids and hrefs. Every message rendered on a page
* otherwise mints the same `#user-content-fn-1` ids, so the browser
* resolves a superscript to the first message that defined it. */
footnoteIdPrefix?: string;
}) => {
// FIXME: workaround to load Tally embeds
useEffect(() => {
Expand Down Expand Up @@ -57,13 +67,28 @@ export const Markdown = ({
[customComponents],
);

const remarkRehypeOptions = useMemo(
() => ({
...(footnoteIdPrefix ? { clobberPrefix: footnoteIdPrefix } : {}),
// A caller who names the label wants it visible; without one, the
// renderer's default English "Footnotes" heading stays, hidden with
// our own class. The library's default is `sr-only`, which our
// Tailwind build never generates (no source file uses it).
...(footnoteLabel
? { footnoteLabel, footnoteLabelProperties: {} }
: { footnoteLabelProperties: { className: "dembrane-sr-only" } }),
}),
[footnoteLabel, footnoteIdPrefix],
);

return (
<ReactMarkdown
className={cn(
"prose prose-table:block prose-table:w-full prose-table:overflow-x-scroll",
className,
)}
remarkPlugins={[remarkGfm]}
remarkRehypeOptions={remarkRehypeOptions}
components={components}
>
{processedContent}
Expand Down
16 changes: 16 additions & 0 deletions echo/frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,22 @@ body {
font-feature-settings: var(--app-font-feature-settings) !important;
}

/* Visually hidden, kept for screen readers. Our own name on purpose: Tailwind
owns `sr-only` and only generates it when a source file uses it, so markup
injected at runtime (the markdown renderer's footnote heading) needs a class
we define unconditionally. */
.dembrane-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}

[aria-label="editable markdown"] {
font-family: var(--app-font-family) !important;
font-feature-settings: var(--app-font-feature-settings) !important;
Expand Down
40 changes: 22 additions & 18 deletions echo/frontend/src/locales/cs-CZ.po
Original file line number Diff line number Diff line change
Expand Up @@ -1884,7 +1884,7 @@ msgstr "Billing period"
msgid "Book a call"
msgstr "Book a call"

#: src/components/common/FeedbackPortalModal.tsx:98
#: src/components/common/FeedbackPortalModal.tsx:115
msgid "Book a call with us"
msgstr "Book a call with us"

Expand Down Expand Up @@ -1986,7 +1986,7 @@ msgstr "can read"
#: src/components/conversation/ConversationAccordion.tsx:336
#: src/components/common/InputModal.tsx:77
#: src/components/common/ImageCropModal.tsx:145
#: src/components/common/FeedbackPortalModal.tsx:124
#: src/components/common/FeedbackPortalModal.tsx:141
#: src/components/common/ConfirmModal.tsx:44
#: src/components/chat/ProjectUpdateSuggestionCard.tsx:722
#: src/components/chat/AgenticChatPanel.tsx:734
Expand Down Expand Up @@ -2528,7 +2528,7 @@ msgstr "Content"
msgid "Context"
msgstr "Context"

#: src/components/chat/ChatHistoryMessage.tsx:394
#: src/components/chat/ChatHistoryMessage.tsx:449
msgid "Context added:"
msgstr "Context added:"

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

#: src/features/sidebar/views/HelpView.tsx:36
#: src/features/sidebar/blocks/HelpBlock.tsx:46
#: src/components/chat/ChatHistoryMessage.tsx:172
#: src/components/chat/ChatHistoryMessage.tsx:192
msgid "Documentation"
msgstr "Documentation"

Expand Down Expand Up @@ -4229,7 +4229,7 @@ msgstr "Feature available soon"
msgid "Feedback"
msgstr "Feedback"

#: src/components/common/FeedbackPortalModal.tsx:44
#: src/components/common/FeedbackPortalModal.tsx:61
msgid "Feedback portal"
msgstr "Feedback portal"

Expand Down Expand Up @@ -4763,7 +4763,7 @@ msgstr "Hours"
msgid "Hours from now"
msgstr ""

#: src/components/chat/ChatHistoryMessage.tsx:185
#: src/components/chat/ChatHistoryMessage.tsx:205
msgid "How Ask works and what it can do."
msgstr ""

Expand Down Expand Up @@ -5133,7 +5133,7 @@ msgstr ""
msgid "Join for support (24h)"
msgstr ""

#: src/components/common/FeedbackPortalModal.tsx:115
#: src/components/common/FeedbackPortalModal.tsx:132
msgid "Join our Slack community"
msgstr ""

Expand Down Expand Up @@ -5187,7 +5187,7 @@ msgstr ""
msgid "Just started"
msgstr ""

#: src/components/common/FeedbackPortalModal.tsx:64
#: src/components/common/FeedbackPortalModal.tsx:81
msgid "Just talk or type naturally. Your input goes directly to our product team and genuinely helps us make dembrane better. We read everything."
msgstr "Just talk or type naturally. Your input goes directly to our product team and genuinely helps us make dembrane better. We read everything."

Expand Down Expand Up @@ -6742,7 +6742,7 @@ msgstr ""
msgid "Open all"
msgstr "Open all"

#: src/components/chat/ChatHistoryMessage.tsx:184
#: src/components/chat/ChatHistoryMessage.tsx:204
msgid "Open chat documentation"
msgstr ""

Expand All @@ -6752,7 +6752,7 @@ msgstr ""
msgid "Open conversation"
msgstr "Open conversation"

#: src/components/chat/ChatHistoryMessage.tsx:178
#: src/components/chat/ChatHistoryMessage.tsx:198
msgid "Open documentation"
msgstr ""

Expand Down Expand Up @@ -6798,8 +6798,8 @@ msgstr ""
msgid "Open to the workspace"
msgstr "Open to the workspace"

#: src/components/chat/ChatHistoryMessage.tsx:227
#: src/components/chat/ChatHistoryMessage.tsx:231
#: src/components/chat/ChatHistoryMessage.tsx:280
#: src/components/chat/ChatHistoryMessage.tsx:284
msgid "Open transcript"
msgstr ""

Expand Down Expand Up @@ -6863,7 +6863,7 @@ msgstr "or"
msgid "Or choose a time"
msgstr ""

#: src/components/common/FeedbackPortalModal.tsx:91
#: src/components/common/FeedbackPortalModal.tsx:108
msgid "Or prefer to chat directly?"
msgstr "Or prefer to chat directly?"

Expand Down Expand Up @@ -8515,7 +8515,7 @@ msgstr "Save"
msgid "Save access"
msgstr ""

#: src/components/chat/ChatHistoryMessage.tsx:296
#: src/components/chat/ChatHistoryMessage.tsx:349
msgid "Save as template"
msgstr "Save as template"

Expand Down Expand Up @@ -8558,7 +8558,7 @@ msgstr ""
msgid "Saving..."
msgstr "Saving..."

#: src/components/common/FeedbackPortalModal.tsx:87
#: src/components/common/FeedbackPortalModal.tsx:104
msgid "Scan or click the QR code to open the feedback portal"
msgstr ""

Expand Down Expand Up @@ -9372,6 +9372,10 @@ msgstr "Sort"
msgid "Source {0}"
msgstr "Source {0}"

#: src/components/chat/ChatHistoryMessage.tsx:389
msgid "Sources"
msgstr ""

#: src/components/project/ProjectPortalEditor.tsx:644
msgid "Spanish"
msgstr "Spanish"
Expand Down Expand Up @@ -9866,7 +9870,7 @@ msgstr ""
msgid "The organisation this invite was for has been deleted. There's nothing to join."
msgstr "The organisation this invite was for has been deleted. There's nothing to join."

#: src/components/chat/ChatHistoryMessage.tsx:179
#: src/components/chat/ChatHistoryMessage.tsx:199
msgid "The page this answer refers to."
msgstr ""

Expand Down Expand Up @@ -10375,7 +10379,7 @@ msgstr ""
msgid "To assign a new tag, please create it first in the portal settings."
msgstr ""

#: src/components/common/FeedbackPortalModal.tsx:57
#: src/components/common/FeedbackPortalModal.tsx:74
msgid "To help us act on it, try to include where it happened and what you were trying to do. For bugs, tell us what went wrong. For ideas, tell us what need it would solve for you."
msgstr "To help us act on it, try to include where it happened and what you were trying to do. For bugs, tell us what went wrong. For ideas, tell us what need it would solve for you."

Expand Down Expand Up @@ -11301,7 +11305,7 @@ msgstr ""
msgid "We will only send you a message if your host generates a report, we never share your details with anyone. You can opt out at any time."
msgstr "We will only send you a message if your host generates a report, we never share your details with anyone. You can opt out at any time."

#: src/components/common/FeedbackPortalModal.tsx:50
#: src/components/common/FeedbackPortalModal.tsx:67
msgid "We'd love to hear from you. Whether you have an idea for something new, you've hit a bug, spotted a translation that feels off, or just want to share how things have been going."
msgstr "We'd love to hear from you. Whether you have an idea for something new, you've hit a bug, spotted a translation that feels off, or just want to share how things have been going."

Expand Down
2 changes: 1 addition & 1 deletion echo/frontend/src/locales/cs-CZ.ts

Large diffs are not rendered by default.

Loading
Loading