fix(frontend): avoid render-time subtask context mutation

This commit is contained in:
copilot-swe-agent[bot]
2026-06-07 13:35:28 +00:00
committed by GitHub
parent 9593214065
commit 150d03f2e7
5 changed files with 135 additions and 90 deletions
@@ -16,7 +16,6 @@ import {
import { import {
extractContentFromMessage, extractContentFromMessage,
extractPresentFilesFromMessage, extractPresentFilesFromMessage,
extractTextFromMessage,
getAssistantTurnCopyData, getAssistantTurnCopyData,
getAssistantTurnUsageMessages, getAssistantTurnUsageMessages,
getMessageGroups, getMessageGroups,
@@ -27,9 +26,7 @@ import {
isAssistantMessageGroupStreaming, isAssistantMessageGroupStreaming,
} from "@/core/messages/utils"; } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { Subtask } from "@/core/tasks"; import { buildSubtaskMapFromMessages } from "@/core/tasks/derive";
import { useUpdateSubtask } from "@/core/tasks/context";
import { parseSubtaskResult } from "@/core/tasks/subtask-result";
import type { AgentThreadState } from "@/core/threads"; import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -177,8 +174,8 @@ export function MessageList({
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages; const messages = thread.messages;
const tasks = useMemo(() => buildSubtaskMapFromMessages(messages), [messages]);
const groupedMessages = getMessageGroups(messages); const groupedMessages = getMessageGroups(messages);
const turnUsageMessagesByGroupIndex = const turnUsageMessagesByGroupIndex =
getAssistantTurnUsageMessages(groupedMessages); getAssistantTurnUsageMessages(groupedMessages);
@@ -354,42 +351,29 @@ export function MessageList({
</div> </div>
); );
} else if (group.type === "assistant:subagent") { } else if (group.type === "assistant:subagent") {
const tasks = new Set<Subtask>();
for (const message of group.messages) {
if (message.type === "ai") {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name === "task") {
const task: Subtask = {
id: toolCall.id!,
subagent_type: toolCall.args.subagent_type,
description: toolCall.args.description,
prompt: toolCall.args.prompt,
status: "in_progress",
};
updateSubtask(task);
tasks.add(task);
}
}
} else if (message.type === "tool") {
const taskId = message.tool_call_id;
if (taskId) {
const parsed = parseSubtaskResult(
extractTextFromMessage(message),
);
updateSubtask({ id: taskId, ...parsed });
}
}
}
const results: React.ReactNode[] = []; const results: React.ReactNode[] = [];
const subagentDebugMessageIds: string[] = []; const subagentDebugMessageIds: string[] = [];
if (tasks.size > 0) { const groupTaskIds = Array.from(
new Set(
group.messages.flatMap((message) =>
message.type === "ai"
? (message.tool_calls ?? [])
.map((toolCall) =>
toolCall.name === "task" ? toolCall.id : null,
)
.filter((taskId): taskId is string => Boolean(taskId))
: [],
),
),
);
if (groupTaskIds.length > 0) {
results.push( results.push(
<div <div
key="subtask-count" key="subtask-count"
className="text-muted-foreground pt-2 text-sm font-normal" className="text-muted-foreground pt-2 text-sm font-normal"
> >
{t.subtasks.executing(tasks.size)} {t.subtasks.executing(groupTaskIds.length)}
</div>, </div>,
); );
} }
@@ -417,10 +401,14 @@ export function MessageList({
?.filter((toolCall) => toolCall.name === "task") ?.filter((toolCall) => toolCall.name === "task")
.map((toolCall) => toolCall.id); .map((toolCall) => toolCall.id);
for (const taskId of taskIds ?? []) { for (const taskId of taskIds ?? []) {
const task = taskId ? tasks[taskId] : undefined;
if (!taskId || !task) {
continue;
}
results.push( results.push(
<SubtaskCard <SubtaskCard
key={"task-group-" + taskId} key={"task-group-" + taskId}
taskId={taskId!} task={task}
isLoading={thread.isLoading} isLoading={thread.isLoading}
/>, />,
); );
@@ -20,7 +20,8 @@ import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils"; import { hasToolCalls } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown"; import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context"; import type { Subtask } from "@/core/tasks";
import { useLatestSubtaskMessage } from "@/core/tasks/context";
import { explainLastToolCall } from "@/core/tools/utils"; import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -31,26 +32,30 @@ import { MarkdownContent } from "./markdown-content";
export function SubtaskCard({ export function SubtaskCard({
className, className,
taskId, task,
isLoading, isLoading,
}: { }: {
className?: string; className?: string;
taskId: string; task: Subtask;
isLoading: boolean; isLoading: boolean;
}) { }) {
const { t } = useI18n(); const { t } = useI18n();
const [collapsed, setCollapsed] = useState(true); const [collapsed, setCollapsed] = useState(true);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const task = useSubtask(taskId)!; const latestMessage = useLatestSubtaskMessage(task.id);
const mergedTask = useMemo(
() => (latestMessage ? { ...task, latestMessage } : task),
[latestMessage, task],
);
const icon = useMemo(() => { const icon = useMemo(() => {
if (task.status === "completed") { if (mergedTask.status === "completed") {
return <CheckCircleIcon className="size-3" />; return <CheckCircleIcon className="size-3" />;
} else if (task.status === "failed") { } else if (mergedTask.status === "failed") {
return <XCircleIcon className="size-3 text-red-500" />; return <XCircleIcon className="size-3 text-red-500" />;
} else if (task.status === "in_progress") { } else if (mergedTask.status === "in_progress") {
return <Loader2Icon className="size-3 animate-spin" />; return <Loader2Icon className="size-3 animate-spin" />;
} }
}, [task.status]); }, [mergedTask.status]);
return ( return (
<ChainOfThought <ChainOfThought
className={cn("relative w-full gap-2 rounded-lg border py-0", className)} className={cn("relative w-full gap-2 rounded-lg border py-0", className)}
@@ -59,10 +64,10 @@ export function SubtaskCard({
<div <div
className={cn( className={cn(
"ambilight z-[-1]", "ambilight z-[-1]",
task.status === "in_progress" ? "enabled" : "", mergedTask.status === "in_progress" ? "enabled" : "",
)} )}
></div> ></div>
{task.status === "in_progress" && ( {mergedTask.status === "in_progress" && (
<> <>
<ShineBorder <ShineBorder
borderWidth={1.5} borderWidth={1.5}
@@ -81,12 +86,12 @@ export function SubtaskCard({
<ChainOfThoughtStep <ChainOfThoughtStep
className="font-normal" className="font-normal"
label={ label={
task.status === "in_progress" ? ( mergedTask.status === "in_progress" ? (
<Shimmer duration={3} spread={3}> <Shimmer duration={3} spread={3}>
{task.description} {mergedTask.description}
</Shimmer> </Shimmer>
) : ( ) : (
task.description mergedTask.description
) )
} }
icon={<ClipboardListIcon />} icon={<ClipboardListIcon />}
@@ -96,19 +101,21 @@ export function SubtaskCard({
<div <div
className={cn( className={cn(
"text-muted-foreground flex items-center gap-1 text-xs font-normal", "text-muted-foreground flex items-center gap-1 text-xs font-normal",
task.status === "failed" ? "text-red-500 opacity-67" : "", mergedTask.status === "failed"
? "text-red-500 opacity-67"
: "",
)} )}
> >
{icon} {icon}
<FlipDisplay <FlipDisplay
className="max-w-[420px] truncate pb-1" className="max-w-[420px] truncate pb-1"
uniqueKey={task.latestMessage?.id ?? ""} uniqueKey={mergedTask.latestMessage?.id ?? ""}
> >
{task.status === "in_progress" && {mergedTask.status === "in_progress" &&
task.latestMessage && mergedTask.latestMessage &&
hasToolCalls(task.latestMessage) hasToolCalls(mergedTask.latestMessage)
? explainLastToolCall(task.latestMessage, t) ? explainLastToolCall(mergedTask.latestMessage, t)
: t.subtasks[task.status]} : t.subtasks[mergedTask.status]}
</FlipDisplay> </FlipDisplay>
</div> </div>
)} )}
@@ -123,29 +130,29 @@ export function SubtaskCard({
</Button> </Button>
</div> </div>
<ChainOfThoughtContent className="px-4 pb-4"> <ChainOfThoughtContent className="px-4 pb-4">
{task.prompt && ( {mergedTask.prompt && (
<ChainOfThoughtStep <ChainOfThoughtStep
label={ label={
<Streamdown <Streamdown
{...streamdownPluginsWithWordAnimation} {...streamdownPluginsWithWordAnimation}
components={{ a: CitationLink }} components={{ a: CitationLink }}
> >
{task.prompt} {mergedTask.prompt}
</Streamdown> </Streamdown>
} }
></ChainOfThoughtStep> ></ChainOfThoughtStep>
)} )}
{task.status === "in_progress" && {mergedTask.status === "in_progress" &&
task.latestMessage && mergedTask.latestMessage &&
hasToolCalls(task.latestMessage) && ( hasToolCalls(mergedTask.latestMessage) && (
<ChainOfThoughtStep <ChainOfThoughtStep
label={t.subtasks.in_progress} label={t.subtasks.in_progress}
icon={<Loader2Icon className="size-4 animate-spin" />} icon={<Loader2Icon className="size-4 animate-spin" />}
> >
{explainLastToolCall(task.latestMessage, t)} {explainLastToolCall(mergedTask.latestMessage, t)}
</ChainOfThoughtStep> </ChainOfThoughtStep>
)} )}
{task.status === "completed" && ( {mergedTask.status === "completed" && (
<> <>
<ChainOfThoughtStep <ChainOfThoughtStep
label={t.subtasks.completed} label={t.subtasks.completed}
@@ -153,9 +160,9 @@ export function SubtaskCard({
></ChainOfThoughtStep> ></ChainOfThoughtStep>
<ChainOfThoughtStep <ChainOfThoughtStep
label={ label={
task.result ? ( mergedTask.result ? (
<MarkdownContent <MarkdownContent
content={task.result} content={mergedTask.result}
isLoading={false} isLoading={false}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
/> />
@@ -164,9 +171,9 @@ export function SubtaskCard({
></ChainOfThoughtStep> ></ChainOfThoughtStep>
</> </>
)} )}
{task.status === "failed" && ( {mergedTask.status === "failed" && (
<ChainOfThoughtStep <ChainOfThoughtStep
label={<div className="text-red-500">{task.error}</div>} label={<div className="text-red-500">{mergedTask.error}</div>}
icon={<XCircleIcon className="size-4 text-red-500" />} icon={<XCircleIcon className="size-4 text-red-500" />}
></ChainOfThoughtStep> ></ChainOfThoughtStep>
)} )}
+24 -21
View File
@@ -1,23 +1,26 @@
import type { AIMessage } from "@langchain/langgraph-sdk";
import { createContext, useCallback, useContext, useState } from "react"; import { createContext, useCallback, useContext, useState } from "react";
import type { Subtask } from "./types";
export interface SubtaskContextValue { export interface SubtaskContextValue {
tasks: Record<string, Subtask>; latestMessages: Record<string, AIMessage>;
setTasks: (tasks: Record<string, Subtask>) => void; setLatestMessages: React.Dispatch<
React.SetStateAction<Record<string, AIMessage>>
>;
} }
export const SubtaskContext = createContext<SubtaskContextValue>({ export const SubtaskContext = createContext<SubtaskContextValue>({
tasks: {}, latestMessages: {},
setTasks: () => { setLatestMessages: () => {
/* noop */ /* noop */
}, },
}); });
export function SubtasksProvider({ children }: { children: React.ReactNode }) { export function SubtasksProvider({ children }: { children: React.ReactNode }) {
const [tasks, setTasks] = useState<Record<string, Subtask>>({}); const [latestMessages, setLatestMessages] = useState<Record<string, AIMessage>>(
{},
);
return ( return (
<SubtaskContext.Provider value={{ tasks, setTasks }}> <SubtaskContext.Provider value={{ latestMessages, setLatestMessages }}>
{children} {children}
</SubtaskContext.Provider> </SubtaskContext.Provider>
); );
@@ -33,21 +36,21 @@ export function useSubtaskContext() {
return context; return context;
} }
export function useSubtask(id: string) { export function useLatestSubtaskMessage(id: string) {
const { tasks } = useSubtaskContext(); const { latestMessages } = useSubtaskContext();
return tasks[id]; return latestMessages[id];
} }
export function useUpdateSubtask() { export function useUpdateLatestMessage() {
const { tasks, setTasks } = useSubtaskContext(); const { setLatestMessages } = useSubtaskContext();
const updateSubtask = useCallback( const updateLatestMessage = useCallback(
(task: Partial<Subtask> & { id: string }) => { (taskId: string, message: AIMessage) => {
tasks[task.id] = { ...tasks[task.id], ...task } as Subtask; setLatestMessages((current) => ({
if (task.latestMessage) { ...current,
setTasks({ ...tasks }); [taskId]: message,
} }));
}, },
[tasks, setTasks], [setLatestMessages],
); );
return updateSubtask; return updateLatestMessage;
} }
+47
View File
@@ -0,0 +1,47 @@
import type { Message } from "@langchain/langgraph-sdk";
import { extractTextFromMessage } from "@/core/messages/utils";
import { parseSubtaskResult } from "./subtask-result";
import type { Subtask } from "./types";
export function buildSubtaskMapFromMessages(
messages: Message[],
): Record<string, Subtask> {
const tasks: Record<string, Subtask> = {};
for (const message of messages) {
if (message.type === "ai") {
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name !== "task" || !toolCall.id) {
continue;
}
tasks[toolCall.id] = {
id: toolCall.id,
status: "in_progress",
subagent_type: String(toolCall.args?.subagent_type ?? ""),
description: String(toolCall.args?.description ?? ""),
prompt: String(toolCall.args?.prompt ?? ""),
};
}
continue;
}
if (message.type !== "tool" || !message.tool_call_id) {
continue;
}
const task = tasks[message.tool_call_id];
if (!task) {
continue;
}
tasks[message.tool_call_id] = {
...task,
...parseSubtaskResult(extractTextFromMessage(message)),
};
}
return tasks;
}
+3 -3
View File
@@ -19,7 +19,7 @@ import { useI18n } from "../i18n/hooks";
import { isHiddenFromUIMessage } from "../messages/utils"; import { isHiddenFromUIMessage } from "../messages/utils";
import type { FileInMessage } from "../messages/utils"; import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings"; import type { LocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context"; import { useUpdateLatestMessage } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads"; import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads"; import { promptInputFilePartToFile, uploadFiles } from "../uploads";
@@ -393,7 +393,7 @@ export function useThreadStream({
}, []); }, []);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask(); const updateLatestMessage = useUpdateLatestMessage();
const thread = useStream<AgentThreadState>({ const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock), client: getAPIClient(isMock),
@@ -503,7 +503,7 @@ export function useThreadStream({
task_id: string; task_id: string;
message: AIMessage; message: AIMessage;
}; };
updateSubtask({ id: e.task_id, latestMessage: e.message }); updateLatestMessage(e.task_id, e.message);
return; return;
} }