fix(frontend): render one workspace-change card per run (#4559)

* fix(frontend): render one workspace-change card per run

The workspace-change card is resolved from (threadId, runId) alone, so
every AI message in a run fetches the identical summary. It was rendered
per AI message.

getMessageGroups() opens a separate terminal assistant group for every AI
message that has content and no tool calls, so a run ends in more than one
bubble whenever the model emits answer text mid-run that never gains a
tool call. Each bubble then painted a byte-identical "Edited N files"
card.

Fold the card onto a single position per run, matching how run duration
already anchors its own run-scoped display after the run's last visible
group. Anchoring keys on group index rather than message id because a
terminal assistant group holds exactly one message whose id may be absent.

Fixes #4555

* docs(frontend): explain the workspace-change anchor's group-type restriction

The helper narrows anchor candidates to terminal assistant groups while
getRunDurationDisplaysByGroupIndex accepts a run's last group of any type.
That asymmetry is load-bearing: run duration is emitted by MessageList
around every group, but the workspace-change card comes from
MessageListItem, which MessageList invokes only for human/assistant
groups. Anchoring a run that ends in an assistant:processing group would
pick a position that never renders and silently drop the card.

Record the rule in both places that could invite a future "unification":
the helper's docstring, and the frontend AGENTS.md paragraph that tells
maintainers where run-scoped displays belong.
This commit is contained in:
Aari 2026-07-29 14:01:28 +08:00 committed by GitHub
parent e56481d9e3
commit 43e5ef865d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 268 additions and 1 deletions

View File

@ -75,6 +75,8 @@ The frontend is a stateful chat application. Users create **threads** (conversat
Run duration is run-scoped UI metadata even though the compatibility field `additional_kwargs.turn_duration` is repeated on historical AI messages. `core/messages/run-duration.ts` folds those copies into one display anchored after the run's last visible message group. `MessageList` owns the temporary client-side duration for a just-completed live turn until authoritative history arrives. The duration is total run wall-clock time, not per-message reasoning time; reasoning disclosure and run activity/duration are rendered separately.
The workspace-change card follows the same rule: it is resolved from `(threadId, runId)` alone, so every AI message of a run would render an identical copy. A run ends in more than one terminal assistant bubble whenever the model emits answer text that never gains a tool call, so `core/messages/workspace-change-anchor.ts` picks the run's last assistant bubble and `MessageListItem` renders the badge only for that anchor (#4555). Any future run-scoped display belongs in the same place — do not hang one off every message. The two anchor helpers deliberately differ in which group types they accept as a run's last position, because an anchor is only useful where the display is actually rendered: run duration is emitted by `MessageList` around every group, so it accepts any type, while the workspace-change card comes from `MessageListItem` and so restricts to `assistant`. Keep a new helper's candidate set matched to its own render site rather than unifying them.
Composer drafts are tab-scoped browser state. `core/threads/composer-draft.ts` stores only text plus the selected slash-skill name in `sessionStorage`, keyed by user, agent, and logical conversation scope. New-chat pages pass the stable scope `"new"` because their runtime `threadId` is a fresh UUID on every reload; established conversations use their real thread ID. `InputBox` waits for enabled skills before restoring a skill chip, degrades a missing/disabled skill back to editable slash text, and clears the stored draft through `SendMessageOptions.onSent` only after the send passes the in-flight guard. Attachments, sidecar quotes, voice state, and polish undo state are not persisted.
Auth UI note: the login page's "keep me signed in" option submits only `remember_me` to the Gateway and may persist only the email address through `core/auth/remember-login.ts`. Passwords and tokens must never be stored in frontend storage; the `HttpOnly access_token` and readable `csrf_token` cookies remain Gateway-owned.

View File

@ -145,6 +145,7 @@ export function MessageListItem({
threadId,
artifactPaths = [],
showCopyButton = true,
showWorkspaceChanges = false,
canEdit = false,
isEditPending = false,
onEditAndRegenerate,
@ -157,6 +158,7 @@ export function MessageListItem({
feedback?: FeedbackData | null;
runId?: string;
showCopyButton?: boolean;
showWorkspaceChanges?: boolean;
canEdit?: boolean;
isEditPending?: boolean;
onEditAndRegenerate?: (replacementText: string) => void | Promise<boolean>;
@ -213,6 +215,7 @@ export function MessageListItem({
threadId={threadId}
artifactPaths={artifactPaths}
runId={runId}
showWorkspaceChanges={showWorkspaceChanges}
editState={
isHuman && isEditing
? {
@ -362,6 +365,7 @@ function MessageContent_({
threadId,
artifactPaths,
runId,
showWorkspaceChanges = false,
editState,
}: {
className?: string;
@ -370,6 +374,7 @@ function MessageContent_({
threadId: string;
artifactPaths: readonly string[];
runId?: string;
showWorkspaceChanges?: boolean;
editState?: {
draft: string;
disabled: boolean;
@ -568,7 +573,7 @@ function MessageContent_({
components={components}
/>
<CitationSourcesPanel sources={citationSources} />
{message.type === "ai" && (
{message.type === "ai" && showWorkspaceChanges && (
<WorkspaceChangeBadge
threadId={threadId}
runId={runId}

View File

@ -60,6 +60,7 @@ import {
isHiddenFromUIMessage,
type MessageGroup as ThreadMessageGroup,
} from "@/core/messages/utils";
import { getWorkspaceChangeAnchorGroupIndices } from "@/core/messages/workspace-change-anchor";
import {
buildMessageSidecarContext,
type SidecarContext,
@ -495,6 +496,10 @@ export function MessageList({
() => getRunDurationDisplaysByGroupIndex(groupedMessages),
[groupedMessages],
);
const workspaceChangeAnchorGroupIndices = useMemo(
() => getWorkspaceChangeAnchorGroupIndices(groupedMessages),
[groupedMessages],
);
useEffect(() => {
setClientDurationsByGroupId((current) => {
let next: Map<string, number> | undefined;
@ -1065,6 +1070,9 @@ export function MessageList({
: undefined
}
showCopyButton={group.type !== "assistant"}
showWorkspaceChanges={workspaceChangeAnchorGroupIndices.has(
groupIndex,
)}
canEdit={
group.type === "human" &&
Boolean(msg.id) &&

View File

@ -0,0 +1,45 @@
import { getMessageRunId } from "./run-duration";
import type { MessageGroup } from "./utils";
/**
* Locate the single UI position that owns each run's workspace-change summary.
* The card is resolved from `(threadId, runId)` alone, so every AI message of a
* run renders an identical copy and a run ends in more than one terminal
* assistant bubble whenever the model emits intermediate answer text that never
* gains a tool call. Anchor the card on the run's last assistant bubble instead
* of repeating it under each one (#4555).
*
* Returns group indices rather than message ids because terminal assistant
* groups hold exactly one message and that message's id may be absent.
*
* Candidates are restricted to `assistant` groups, unlike
* `getRunDurationDisplaysByGroupIndex`, which accepts a run's last group of any
* type. The asymmetry is load-bearing rather than an oversight: run duration is
* rendered by `MessageList` around every group, while this card is rendered by
* `MessageListItem`, which `MessageList` invokes only for `human`/`assistant`
* groups. Anchoring a run that ends in an `assistant:processing` group would
* pick a position that never renders and silently drop the card, so do not
* unify the two helpers.
*/
export function getWorkspaceChangeAnchorGroupIndices(
groups: MessageGroup[],
): Set<number> {
const anchorByRunId = new Map<string, number>();
groups.forEach((group, groupIndex) => {
if (group.type !== "assistant") {
return;
}
for (const message of group.messages) {
if (message.type !== "ai") {
continue;
}
const runId = getMessageRunId(message);
if (runId) {
anchorByRunId.set(runId, groupIndex);
}
}
});
return new Set(anchorByRunId.values());
}

View File

@ -121,4 +121,101 @@ test.describe("Workspace changes", () => {
await expect(page.getByText("+Ready")).toBeVisible();
await expect(page.getByText("-Draft")).toBeVisible();
});
test("renders one badge for a run that ends in two assistant bubbles", async ({
page,
}) => {
// Answer text the model emitted mid-run that never gained a tool call
// settles into its own terminal assistant bubble, so this run owns two.
// The card is resolved from (threadId, runId) alone, so rendering it per
// message painted the identical summary twice (#4555).
mockLangGraphAPI(page, {
threads: [
{
thread_id: THREAD_ID,
title: "Workspace changes",
updated_at: "2026-07-04T10:00:00Z",
messages: [
{
type: "human",
id: "msg-human-multi-bubble",
content: [{ type: "text", text: "Create a report" }],
run_id: RUN_ID,
},
{
type: "ai",
id: "msg-ai-multi-bubble-first",
content: "Let me check the workspace first.",
run_id: RUN_ID,
},
{
type: "ai",
id: "msg-ai-multi-bubble-final",
content: "I updated the workspace report.",
run_id: RUN_ID,
},
],
},
],
});
await page.route(
`**/api/threads/${THREAD_ID}/runs/${RUN_ID}/workspace-changes?*`,
async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
available: true,
version: 1,
summary: {
created: 0,
modified: 1,
deleted: 0,
symlink_created: 0,
additions: 8,
deletions: 2,
truncated: false,
},
files: [
{
path: "/mnt/user-data/outputs/report.md",
root: "outputs",
status: "modified",
binary: false,
sensitive: false,
size_before: 12,
size_after: 20,
sha256_before: "before",
sha256_after: "after",
diff: null,
diff_truncated: false,
diff_unavailable_reason: null,
additions: 8,
deletions: 2,
},
],
limits: {},
}),
});
},
);
await page.goto(`/workspace/chats/${THREAD_ID}`);
await expect(page.getByText("Edited 1 file")).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("Edited 1 file")).toHaveCount(1);
// The surviving card belongs to the run's last bubble, matching how run
// duration anchors its own run-scoped display.
const assistantTurns = page.locator("[data-assistant-turn]");
await expect(assistantTurns).toHaveCount(2);
await expect(assistantTurns.nth(0).getByText("Edited 1 file")).toHaveCount(
0,
);
await expect(assistantTurns.nth(1).getByText("Edited 1 file")).toHaveCount(
1,
);
});
});

View File

@ -0,0 +1,110 @@
import type { Message } from "@langchain/langgraph-sdk";
import { describe, expect, test } from "@rstest/core";
import { getMessageGroups } from "@/core/messages/utils";
import { getWorkspaceChangeAnchorGroupIndices } from "@/core/messages/workspace-change-anchor";
function message(
id: string,
type: Message["type"],
content: string,
runId?: string,
): Message {
return {
id,
type,
content,
...(runId ? { run_id: runId } : {}),
} as Message;
}
function toolCall(id: string, runId: string, callId: string): Message {
return {
...message(id, "ai", "", runId),
tool_calls: [{ id: callId, name: "write_file", args: {} }],
} as Message;
}
function toolResult(id: string, runId: string, callId: string): Message {
return {
...message(id, "tool", "ok", runId),
tool_call_id: callId,
} as Message;
}
describe("workspace change card placement", () => {
test("anchors one card on the run's last assistant bubble", () => {
// A settled run whose model emitted answer text mid-run that never gained a
// tool call: two terminal assistant bubbles share one run_id (#4555).
const groups = getMessageGroups([
message("human-1", "human", "Update the config"),
message("ai-middle", "ai", "Let me check the repo first.", "run-1"),
toolCall("ai-tool-1", "run-1", "call-1"),
toolResult("tool-1", "run-1", "call-1"),
message("ai-final", "ai", "Done. I updated two files.", "run-1"),
]);
expect(groups.map((group) => group.type)).toEqual([
"human",
"assistant",
"assistant:processing",
"assistant",
]);
expect([...getWorkspaceChangeAnchorGroupIndices(groups)]).toEqual([3]);
});
test("anchors one card per run across consecutive turns", () => {
const groups = getMessageGroups([
message("human-1", "human", "First task"),
message("ai-1-middle", "ai", "Working on it.", "run-1"),
message("ai-1-final", "ai", "First answer", "run-1"),
message("human-2", "human", "Second task"),
message("ai-2-final", "ai", "Second answer", "run-2"),
]);
expect([...getWorkspaceChangeAnchorGroupIndices(groups)]).toEqual([2, 4]);
});
test("ignores groups that never render the card", () => {
// Processing groups hold the tool steps and carry the same run_id, but the
// card is rendered only by terminal assistant bubbles.
const groups = getMessageGroups([
message("human-1", "human", "Just run a tool"),
toolCall("ai-tool-1", "run-1", "call-1"),
toolResult("tool-1", "run-1", "call-1"),
]);
expect(getWorkspaceChangeAnchorGroupIndices(groups).size).toBe(0);
});
test("skips assistant bubbles without a run id", () => {
// The card resolves from (threadId, runId); a bubble with no run_id cannot
// fetch one and must not claim the anchor.
const groups = getMessageGroups([
message("human-1", "human", "Hello"),
message("ai-final", "ai", "Hi there"),
]);
expect(getWorkspaceChangeAnchorGroupIndices(groups).size).toBe(0);
});
test("keeps the streaming bubble anchored while the turn is still loading", () => {
// Mid-stream a content-only message stays in the processing group, so the
// run has no terminal bubble yet and no card position.
const messages = [
message("human-1", "human", "Update the config"),
message("ai-middle", "ai", "Let me check the repo first.", "run-1"),
];
const streaming = getMessageGroups(messages, {
isCurrentTurnLoading: true,
});
expect(getWorkspaceChangeAnchorGroupIndices(streaming).size).toBe(0);
const settled = getMessageGroups([
...messages,
message("ai-final", "ai", "Done.", "run-1"),
]);
expect([...getWorkspaceChangeAnchorGroupIndices(settled)]).toEqual([2]);
});
});