diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 9e76d05e2..92709a70a 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -98,6 +98,7 @@ Edit-and-rerun is deliberately latest-turn-only. `core/messages/utils.ts::getLat - **Run stream options** are sanitized by `core/api/stream-mode.ts`: the Gateway-supported set is `values`, `messages-tuple`, `updates`, `debug`, `tasks`, `checkpoints`, and `custom`; any request containing an unsupported mode throws before HTTP instead of being partially forwarded or silently defaulting to `values`. `streamResumable` is retained by thread hooks only for SDK-side reconnect bookkeeping but stripped before the HTTP request because the Gateway does not accept that request option; actual replay uses the SSE `Last-Event-ID` cursor. Keep this boundary aligned with the backend request schema; `messages` and `events` are not supported and must not be forwarded. - **SSE replay gaps** are handled in `core/api/api-client.ts`, which wraps both initial and joined run streams because the upstream SDK ignores unknown event names. An id-less backend `gap` control frame clears stale reconnect metadata, emits an internal `stream_replay_gap` custom event, reloads durable thread values, and rejoins after the server-provided retained tail, with up to five recovery rejoins after the original stream (six total stream calls on an all-gap exhaustion path). The wrapper remains a lazy async iterable because the SDK consumes it with `for await`. `core/threads/hooks.ts` clears optimistic/transient/subtask state, invalidates durable history caches, and shows the localized recovery warning; never let a gap fall through as a normal stream finish or cancel the still-running backend run. - **Streaming Markdown rendering** is owned by `core/streamdown`: Streamdown's `animated` / `isAnimating` API handles incremental word animation, while the shared `streamdownRenderingPlugins` config registers the named code-highlighting and Mermaid plugins required by Streamdown 2.5. Keep wrappers and derived configs wired to that shared object; do not reintroduce a rehype plugin that wraps every word, because reparsing a growing block remounts old words and replays their animation. +- Citation links in message and artifact Markdown must derive their `citation:` label from the full `ReactNode` children tree, since Streamdown may provide element or array children during streaming rather than a plain string. - **Environment validation** uses `@t3-oss/env-nextjs` with Zod schemas (`src/env.js`). Skip with `SKIP_ENV_VALIDATION=1` - **Subtask step history and runtime metadata** (`core/tasks/`) — the subtask card shows a subagent's full step timeline (#3779): its assistant reasoning turns interleaved with the tools it ran. `Subtask.steps[]` is accumulated live from `task_running` events (appended via `mergeSteps`, not overwritten) and backfilled on expand for historical runs by `fetchSubtaskSteps`, which pages the events endpoint scoped to one task (GET `/runs/{runId}/events?event_types=subagent.step&task_id=…&after_seq=…`) until a short page, so the run-wide limit can't truncate the timeline. `task_started` carries the effective `model_name`; `task_running` carries a cumulative usage snapshot after each completed LLM call. `core/tasks/lifecycle.ts` normalizes these additive events, and `computeNextSubtask` keeps the largest cumulative total so replayed or late SSE frames cannot double-count or roll the folded card backward. Terminal ToolMessage metadata (`subagent_model_name` / `subagent_token_usage`) restores the same values from normal history after reload; no per-card event fetch is needed. `core/tasks/steps.ts` is the pure step model: `messageToStep` (live), `eventsToSteps` (reload), `mergeSteps` (dedup by `message_index`), and `stepsForDisplay` (what the card renders — keeps tool steps + AI steps with text, drops the trailing final-answer AI step when completed since it's shown as `result`). `core/tasks/context.tsx`'s `useUpdateSubtask` applies updates against a `tasksRef` mirroring the latest state (not a closure snapshot), so a late-resolving `fetchSubtaskSteps` backfill merges into current state instead of clobbering SSE steps or sibling subtasks that arrived meanwhile. The owning `run_id` is carried onto history content messages in `buildVisibleHistoryMessages` so the card can resolve the events endpoint. diff --git a/frontend/src/components/workspace/citations/artifact-link.tsx b/frontend/src/components/workspace/citations/artifact-link.tsx index a78957918..119093753 100644 --- a/frontend/src/components/workspace/citations/artifact-link.tsx +++ b/frontend/src/components/workspace/citations/artifact-link.tsx @@ -4,7 +4,7 @@ import { cn } from "@/lib/utils"; import { isSafeHref } from "../messages/markdown-link"; -import { CitationLink } from "./citation-link"; +import { CitationLink, extractReactNodeText } from "./citation-link"; function isExternalUrl(href: string | undefined): boolean { return !!href && /^https?:\/\//.test(href); @@ -33,8 +33,9 @@ export function ArtifactLink(props: AnchorHTMLAttributes) { ); } - if (typeof props.children === "string") { - const match = /^citation:(.+)$/.exec(props.children); + const childrenText = extractReactNodeText(props.children); + if (childrenText !== null) { + const match = /^citation:(.+)$/.exec(childrenText); if (match) { const [, text] = match; return {text}; diff --git a/frontend/src/components/workspace/citations/citation-link.tsx b/frontend/src/components/workspace/citations/citation-link.tsx index a80e71bc4..40c3c3064 100644 --- a/frontend/src/components/workspace/citations/citation-link.tsx +++ b/frontend/src/components/workspace/citations/citation-link.tsx @@ -1,5 +1,5 @@ import { ExternalLinkIcon } from "lucide-react"; -import type { ComponentProps } from "react"; +import { isValidElement, type ComponentProps, type ReactNode } from "react"; import { Badge } from "@/components/ui/badge"; import { @@ -9,6 +9,25 @@ import { } from "@/components/ui/hover-card"; import { cn } from "@/lib/utils"; +/** Extract visible text from renderer-provided ReactNode children. */ +export function extractReactNodeText(node: ReactNode): string | null { + if (typeof node === "string" || typeof node === "number") { + return String(node); + } + if (Array.isArray(node)) { + const text = node + .map(extractReactNodeText) + .filter((value): value is string => value !== null) + .join(""); + return text || null; + } + if (isValidElement(node)) { + const children = (node.props as { children?: ReactNode }).children; + return children === undefined ? null : extractReactNodeText(children); + } + return null; +} + export function CitationLink({ href, children, @@ -18,9 +37,7 @@ export function CitationLink({ // Priority: children > domain const childrenText = - typeof children === "string" - ? children.replace(/^citation:\s*/i, "") - : null; + extractReactNodeText(children)?.replace(/^citation:\s*/i, "") ?? null; const isGenericText = childrenText === "Source" || childrenText === "来源"; const displayText = (!isGenericText && childrenText) ?? domain; diff --git a/frontend/src/components/workspace/messages/markdown-link.tsx b/frontend/src/components/workspace/messages/markdown-link.tsx index 3a37fac38..38625ef7b 100644 --- a/frontend/src/components/workspace/messages/markdown-link.tsx +++ b/frontend/src/components/workspace/messages/markdown-link.tsx @@ -3,7 +3,7 @@ import type { AnchorHTMLAttributes } from "react"; import { resolveMarkdownArtifactURL } from "@/core/artifacts/utils"; import { cn } from "@/lib/utils"; -import { CitationLink } from "../citations/citation-link"; +import { CitationLink, extractReactNodeText } from "../citations/citation-link"; /** * Schemes we are willing to render as a navigable ````. @@ -91,8 +91,9 @@ export function createMarkdownLinkComponent(threadId?: string) { ); } // Safe-href check passed — citation links now route through CitationLink. - if (typeof props.children === "string") { - const match = /^citation:(.+)$/.exec(props.children); + const childrenText = extractReactNodeText(props.children); + if (childrenText !== null) { + const match = /^citation:(.+)$/.exec(childrenText); if (match) { const [, text] = match; return ( diff --git a/frontend/tests/unit/components/workspace/citations/artifact-link.test.ts b/frontend/tests/unit/components/workspace/citations/artifact-link.test.ts index 8bda35c76..96af92e08 100644 --- a/frontend/tests/unit/components/workspace/citations/artifact-link.test.ts +++ b/frontend/tests/unit/components/workspace/citations/artifact-link.test.ts @@ -28,6 +28,18 @@ describe("ArtifactLink rendering", () => { expect(html).toContain('rel="noopener noreferrer"'); }); + it("renders citation labels when children are React elements", () => { + const html = renderToStaticMarkup( + createElement( + ArtifactLink, + { href: "https://example.com/report" }, + createElement("span", null, "citation:Test Source"), + ), + ); + expect(html).not.toContain("citation:"); + expect(html).toContain("Test Source"); + }); + it("renders scheme-less relative hrefs as navigable anchors", () => { const tests = ["report.md", "./report.md", "../assets/chart.png"]; for (const href of tests) { diff --git a/frontend/tests/unit/components/workspace/citations/citation-link.test.ts b/frontend/tests/unit/components/workspace/citations/citation-link.test.ts new file mode 100644 index 000000000..9fad1d9f3 --- /dev/null +++ b/frontend/tests/unit/components/workspace/citations/citation-link.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "@rstest/core"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { CitationLink } from "@/components/workspace/citations/citation-link"; + +describe("CitationLink rendering", () => { + it("strips the citation prefix from React element arrays", () => { + const html = renderToStaticMarkup( + createElement( + CitationLink, + { href: "https://example.com/report" }, + createElement("span", { key: "prefix" }, "citation:"), + createElement("span", { key: "title" }, "Test Source"), + ), + ); + + expect(html).not.toContain("citation:"); + expect(html).toContain("Test Source"); + }); +}); diff --git a/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts b/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts index 699967005..b90941a9e 100644 --- a/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts +++ b/frontend/tests/unit/components/workspace/messages/markdown-link.test.ts @@ -78,6 +78,19 @@ describe("MarkdownLink rendering", () => { expect(html).toContain('rel="noopener noreferrer"'); }); + it("renders citation labels when children are React element arrays", () => { + const html = renderToStaticMarkup( + createElement( + MarkdownLink, + { href: "https://example.com/report" }, + createElement("span", { key: "prefix" }, "citation:"), + createElement("span", { key: "title" }, "Test Source"), + ), + ); + expect(html).not.toContain("citation:"); + expect(html).toContain("Test Source"); + }); + it("renders a scheme-less relative href as a navigable anchor", () => { const tests = ["report.md", "./report.md", "../assets/chart.png"]; for (const href of tests) {