Skip to content
Open
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
3 changes: 3 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,15 @@
"@eslint/eslintrc": "^3.3.1",
"@playwright/test": "^1.59.1",
"@tailwindcss/postcss": "^4.0.15",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/gsap": "^3.0.0",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.23.0",
"eslint-config-next": "^15.2.3",
"jsdom": "^29.1.1",
"postcss": "^8.5.3",
"prettier": "^3.5.3",
"prettier-plugin-tailwindcss": "^0.6.11",
Expand Down
419 changes: 417 additions & 2 deletions frontend/pnpm-lock.yaml

Large diffs are not rendered by default.

54 changes: 28 additions & 26 deletions frontend/src/components/workspace/messages/message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ import {
extractContentFromMessage,
extractPresentFilesFromMessage,
extractReasoningContentFromMessage,
extractTextFromMessage,
getAssistantTurnUsageMessages,
getMessageGroups,
hasContent,
Expand All @@ -26,8 +25,7 @@ import {
} from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import type { Subtask } from "@/core/tasks";
import { useUpdateSubtask } from "@/core/tasks/context";
import { parseSubtaskResult } from "@/core/tasks/subtask-result";
import { buildSubtaskMapFromMessages } from "@/core/tasks/derive";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -175,7 +173,6 @@ export function MessageList({
}) {
const { t } = useI18n();
const rehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const updateSubtask = useUpdateSubtask();
const messages = thread.messages;
const groupedMessages = getMessageGroups(messages);
const turnUsageMessagesByGroupIndex =
Expand All @@ -185,6 +182,18 @@ export function MessageList({
[messages, t],
);

// Derive the subtask card map from the messages and pass each entry
// straight into `SubtaskCard` as a prop. The previous implementation
// called `updateSubtask` *during render* from the per-message render
// loop below — that mutated context object in place without triggering
// a re-render and is the React-19 Strict-Mode-warning pattern #3147
// removes. Render-driven base fields no longer round-trip through
// shared state; only the SSE-driven `latestMessage` does.
const derivedSubtasks = useMemo(
() => buildSubtaskMapFromMessages(messages),
[messages],
);

const renderAssistantCopyButton = useCallback((messages: Message[]) => {
const clipboardData = [...messages]
.reverse()
Expand Down Expand Up @@ -341,29 +350,19 @@ export function MessageList({
</div>
);
} else if (group.type === "assistant:subagent") {
// The subtask context is fed by `derivedSubtasks` / the effect
// above — render only consumes it. Collect the per-group task
// *references* (used downstream to build subtask cards and
// count rendered subtasks) directly from the AI tool_calls so
// we do not mutate any shared state here.
Comment on lines +353 to +357
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 });
if (message.type !== "ai") continue;
for (const toolCall of message.tool_calls ?? []) {
if (toolCall.name !== "task" || !toolCall.id) continue;
const task = derivedSubtasks[toolCall.id];
if (task) {
tasks.add(task);
}
}
}
Expand Down Expand Up @@ -404,10 +403,13 @@ export function MessageList({
?.filter((toolCall) => toolCall.name === "task")
.map((toolCall) => toolCall.id);
for (const taskId of taskIds ?? []) {
if (!taskId) continue;
const taskForCard = derivedSubtasks[taskId];
if (!taskForCard) continue;
results.push(
<SubtaskCard
key={"task-group-" + taskId}
taskId={taskId!}
task={taskForCard}
isLoading={thread.isLoading}
/>,
);
Expand Down
17 changes: 13 additions & 4 deletions frontend/src/components/workspace/messages/subtask-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ import { useI18n } from "@/core/i18n/hooks";
import { hasToolCalls } from "@/core/messages/utils";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
import { useSubtask } from "@/core/tasks/context";
import { useLatestSubtaskMessage } from "@/core/tasks/context";
import type { Subtask } from "@/core/tasks/types";
import { explainLastToolCall } from "@/core/tools/utils";
import { cn } from "@/lib/utils";

Expand All @@ -31,17 +32,25 @@ import { MarkdownContent } from "./markdown-content";

export function SubtaskCard({
className,
taskId,
task: baseTask,
isLoading,
}: {
className?: string;
taskId: string;
task: Subtask;
isLoading: boolean;
}) {
const { t } = useI18n();
const [collapsed, setCollapsed] = useState(true);
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
const task = useSubtask(taskId)!;
// Bytedance/deer-flow#3147: base fields come in as a prop (derived
// from the message list inside MessageList), only the streaming
// `latestMessage` is pulled from the shared context — it lands via
// `task_running` SSE events outside the render phase.
const latestMessage = useLatestSubtaskMessage(baseTask.id);
const task = useMemo<Subtask>(
() => (latestMessage ? { ...baseTask, latestMessage } : baseTask),
[baseTask, latestMessage],
);
const icon = useMemo(() => {
if (task.status === "completed") {
return <CheckCircleIcon className="size-3" />;
Expand Down
102 changes: 69 additions & 33 deletions frontend/src/core/tasks/context.tsx
Original file line number Diff line number Diff line change
@@ -1,53 +1,89 @@
import { createContext, useCallback, useContext, useState } from "react";
import type { AIMessage } from "@langchain/langgraph-sdk";
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
} from "react";

import type { Subtask } from "./types";

export interface SubtaskContextValue {
tasks: Record<string, Subtask>;
setTasks: (tasks: Record<string, Subtask>) => void;
/**
* Bytedance/deer-flow issue #3147: the `task` subtask state used to live
* entirely on a mutable React context — `MessageList` wrote into it
* during render and `SubtaskCard` read it back through `useSubtask`.
* Render-time mutation broke React 19 Strict Mode and hid card lifecycle
* regressions because only the SSE-driven `latestMessage` path actually
* called `setState`.
*
* The new shape splits responsibilities by data origin:
*
* 1. **Base fields** (`status` / `result` / `error` / `description` / ...)
* are derived from the thread's message list with
* `buildSubtaskMapFromMessages(messages)` and passed to `SubtaskCard`
* directly as a `task` prop. They no longer round-trip through the
* context, so the first render after a new message arrives is already
* correct — no `useEffect` lag, no `!`-asserted undefined.
*
* 2. **Latest streaming AIMessage** comes from the `task_running` custom
* SSE event handled in `useThreadStream`. That handler fires outside
* React render, so writing to context state there is safe. The
* Provider in this file owns that map and `SubtaskCard` reads it via
* `useLatestSubtaskMessage(taskId)`.
*/
interface LatestMessageContextValue {
latestMessages: Record<string, AIMessage>;
setLatestMessage: (taskId: string, message: AIMessage) => void;
}

export const SubtaskContext = createContext<SubtaskContextValue>({
tasks: {},
setTasks: () => {
/* noop */
},
});
const LatestMessageContext = createContext<
LatestMessageContextValue | undefined
>(undefined);

export function SubtasksProvider({ children }: { children: React.ReactNode }) {
const [tasks, setTasks] = useState<Record<string, Subtask>>({});
const [latestMessages, setLatestMessages] = useState<
Record<string, AIMessage>
>({});

const setLatestMessage = useCallback((taskId: string, message: AIMessage) => {
setLatestMessages((prev) =>
prev[taskId] === message ? prev : { ...prev, [taskId]: message },
);
}, []);

const value = useMemo<LatestMessageContextValue>(
() => ({ latestMessages, setLatestMessage }),
[latestMessages, setLatestMessage],
);

return (
<SubtaskContext.Provider value={{ tasks, setTasks }}>
<LatestMessageContext.Provider value={value}>
{children}
</SubtaskContext.Provider>
</LatestMessageContext.Provider>
);
}

export function useSubtaskContext() {
const context = useContext(SubtaskContext);
function useLatestMessageContext(): LatestMessageContextValue {
const context = useContext(LatestMessageContext);
if (context === undefined) {
throw new Error(
"useSubtaskContext must be used within a SubtaskContext.Provider",
"useLatestMessageContext must be used within a SubtasksProvider",
);
}
return context;
}

export function useSubtask(id: string) {
const { tasks } = useSubtaskContext();
return tasks[id];
/** Read the latest `task_running` AIMessage emitted for *taskId*, or `undefined`. */
export function useLatestSubtaskMessage(taskId: string): AIMessage | undefined {
return useLatestMessageContext().latestMessages[taskId];
}

export function useUpdateSubtask() {
const { tasks, setTasks } = useSubtaskContext();
const updateSubtask = useCallback(
(task: Partial<Subtask> & { id: string }) => {
tasks[task.id] = { ...tasks[task.id], ...task } as Subtask;
if (task.latestMessage) {
setTasks({ ...tasks });
}
},
[tasks, setTasks],
);
return updateSubtask;
/**
* Register the latest streaming AIMessage for a task. Call this from a
* stream event handler (e.g. `onCustomEvent`), not from render.
*/
export function useUpdateLatestMessage(): (
taskId: string,
message: AIMessage,
) => void {
return useLatestMessageContext().setLatestMessage;
}
57 changes: 57 additions & 0 deletions frontend/src/core/tasks/derive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { Message } from "@langchain/langgraph-sdk";

import { extractTextFromMessage } from "../messages/utils";

import { parseSubtaskResult } from "./subtask-result";
import type { Subtask } from "./types";

/**
* Derive the subtask card map from the current thread message list.
*
* Bytedance/deer-flow issue #3147: the old data flow built this map by
* calling `updateSubtask` *during render* from `MessageList`, which silently
* mutated the SubtaskContext object without triggering a re-render (only
* the SSE `latestMessage` path called `setState`). That worked by accident
* but is exactly the render-time mutation React Strict Mode warns about
* and the kind of pattern that masks card-stuck regressions like
* `#3107` BUG-007.
*
* Replace it with a pure function over the message list. The result is
* passed into `SubtasksProvider` via `setBaseTasksFromMessages`, batched
* inside an effect, so render stays read-only. The SSE-driven
* `latestMessage` path stays separate (see `useUpdateLatestMessage`).
Comment on lines +9 to +22
*/
export function buildSubtaskMapFromMessages(
messages: readonly 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;
// Seed the card in `in_progress` the moment the AI emits the
// tool_call. The matching ToolMessage flips it to terminal below.
tasks[toolCall.id] = {
id: toolCall.id,
subagent_type: String(toolCall.args?.subagent_type ?? ""),
description: String(toolCall.args?.description ?? ""),
prompt: String(toolCall.args?.prompt ?? ""),
status: "in_progress",
};
}
} else if (message.type === "tool") {
const taskId = message.tool_call_id;
if (!taskId || !(taskId in tasks)) continue;
// NOTE: `parseSubtaskResult` will gain a second argument for
// ``additional_kwargs.subagent_status`` once #3146 lands. This call
// site is forward-compatible: when the signature widens, we just
// pass `message.additional_kwargs` here and the structured field
// will take precedence over text parsing automatically.
const parsed = parseSubtaskResult(extractTextFromMessage(message));
tasks[taskId] = { ...tasks[taskId], ...parsed } as Subtask;
}
}

return tasks;
}
6 changes: 3 additions & 3 deletions frontend/src/core/threads/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { getBackendBaseURL } from "../config";
import { useI18n } from "../i18n/hooks";
import type { FileInMessage } from "../messages/utils";
import type { LocalSettings } from "../settings";
import { useUpdateSubtask } from "../tasks/context";
import { useUpdateLatestMessage } from "../tasks/context";
import type { UploadedFileInfo } from "../uploads";
import { promptInputFilePartToFile, uploadFiles } from "../uploads";

Expand Down Expand Up @@ -217,7 +217,7 @@ export function useThreadStream({
}, []);

const queryClient = useQueryClient();
const updateSubtask = useUpdateSubtask();
const updateLatestMessage = useUpdateLatestMessage();

const thread = useStream<AgentThreadState>({
client: getAPIClient(isMock),
Expand Down Expand Up @@ -312,7 +312,7 @@ export function useThreadStream({
task_id: string;
message: AIMessage;
};
updateSubtask({ id: e.task_id, latestMessage: e.message });
updateLatestMessage(e.task_id, e.message);
return;
}

Expand Down
Loading
Loading