diff --git a/src/components/chat/AgentTurn.tsx b/src/components/chat/AgentTurn.tsx index b9bd534..0597475 100644 --- a/src/components/chat/AgentTurn.tsx +++ b/src/components/chat/AgentTurn.tsx @@ -1220,45 +1220,142 @@ const QuestionRequestGroup = ({ const TodoPlanCard = ({ todoUpdate, }: { - todoUpdate: NonNullable[number]; + todoUpdate: NonNullable; }) => { const theme = useTheme(); const total = todoUpdate.todos.length; const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; - const isAborted = cancelled > 0 && !running; - const [expanded, setExpanded] = React.useState( - !isAborted && todoUpdate.todos.length <= 3, + const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length; + const progress = total > 0 ? Math.round((completed / total) * 100) : 0; + const isAborted = cancelled > 0 && completed + cancelled === total; + const canCollapse = total > 4; + const [expanded, setExpanded] = React.useState(!canCollapse && !isAborted); + const pinnedTodos = canCollapse ? todoUpdate.todos.slice(0, 4) : todoUpdate.todos; + const collapsibleTodos = canCollapse ? todoUpdate.todos.slice(4) : []; + const hiddenCount = expanded ? 0 : collapsibleTodos.length; + const latestUpdatedAt = Math.max( + todoUpdate.createdAt, + ...todoUpdate.todos + .map((todo) => todo.updatedAt ?? todo.createdAt ?? 0) + .filter((value) => value > 0), ); - React.useEffect(() => { - if (isAborted) { - setExpanded(false); - } - }, [isAborted]); - const visibleTodos = - isAborted && !expanded - ? [] - : expanded || total <= 3 - ? todoUpdate.todos - : [ - ...todoUpdate.todos.slice(0, 3), - ...(running && !todoUpdate.todos.slice(0, 3).some((todo) => todo.id === running.id) - ? [running] - : []), - ]; + const updatedAtLabel = + latestUpdatedAt > 0 + ? new Intl.DateTimeFormat("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }).format(new Date(latestUpdatedAt)) + : undefined; - const getTodoVisual = (status: NonNullable[number]["todos"][number]["status"]) => { + const getTodoVisual = (status: NonNullable["todos"][number]["status"]) => { if (status === "completed") { - return { icon: , color: theme.palette.success.main, label: "已完成" }; + return { icon: , color: theme.palette.success.main, label: "完成" }; } if (status === "in_progress") { - return { icon: , color: "#0288d1", label: "进行中" }; + return { icon: , color: "#0288d1", label: "进行中" }; } if (status === "cancelled") { - return { icon: , color: theme.palette.text.disabled, label: "已中止" }; + return { icon: , color: theme.palette.text.disabled, label: "中止" }; } - return { icon: , color: theme.palette.text.secondary, label: "待处理" }; + return { icon: , color: theme.palette.text.secondary, label: "待办" }; + }; + + const getPriorityLabel = (priority: NonNullable["todos"][number]["priority"]) => { + if (priority === "high") return { label: "高优先级", color: "#8a5a00" }; + if (priority === "medium") return { label: "中优先级", color: "#9a6a16" }; + if (priority === "low") return { label: "低优先级", color: "#8d7960" }; + return undefined; + }; + + const statusSummary = isAborted + ? `${completed} 完成 / ${cancelled} 中止` + : [ + completed ? `${completed} 完成` : null, + running ? "1 进行中" : null, + pending ? `${pending} 待办` : null, + cancelled ? `${cancelled} 中止` : null, + ].filter(Boolean).join(" / ") || "等待任务"; + const renderTodoRow = ( + todo: NonNullable["todos"][number], + index: number, + ) => { + const visual = getTodoVisual(todo.status); + const priority = getPriorityLabel(todo.priority); + return ( + + + {visual.icon} + + + + {todo.content} + + + + {priority ? ( + + ) : null} + + + + ); }; if (total === 0) { @@ -1268,138 +1365,138 @@ const TodoPlanCard = ({ return ( setExpanded((value) => !value)} + onClick={() => { + if (canCollapse) { + setExpanded((value) => !value); + } + }} onKeyDown={(event) => { - if (event.key === "Enter" || event.key === " ") { + if (canCollapse && (event.key === "Enter" || event.key === " ")) { event.preventDefault(); setExpanded((value) => !value); } }} sx={{ - px: 1.5, + px: 1.4, py: 1.15, - cursor: "pointer", + cursor: canCollapse ? "pointer" : "default", transition: "background-color 0.2s ease", - "&:hover": { bgcolor: alpha("#000", 0.025) }, + "&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined, }} > + + + + + + + + 会话任务 + + + + + {statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""} + + + {canCollapse ? ( + + {expanded ? ( + + ) : ( + + )} + + ) : null} + - + - - - 任务规划 - - - {isAborted - ? `${completed}/${total} 已完成,${cancelled} 项已中止` - : `${completed}/${total} 已完成${running ? ",1 项进行中" : ""}`} - - - - {expanded ? ( - - ) : ( - - )} - - {visibleTodos.length ? ( - - {visibleTodos.map((todo, index) => { - const visual = getTodoVisual(todo.status); - return ( - - - {visual.icon} - - - {todo.content} - - - - ); - })} - - ) : null} + + {pinnedTodos.map((todo, index) => renderTodoRow(todo, index))} + {canCollapse ? ( + + + {collapsibleTodos.map((todo, index) => + renderTodoRow(todo, index + pinnedTodos.length), + )} + + + ) : null} + {hiddenCount > 0 ? ( + + 还有 {hiddenCount} 项,展开查看全部 + + ) : null} + ); }; @@ -1568,8 +1665,8 @@ export const AgentTurn = React.memo( /> ) : null} - {message.todos?.length ? ( - + {message.todos ? ( + ) : null} { } }); + it("shows shared todo state only on the latest assistant message in a session", async () => { + listChatSessions.mockResolvedValue([]); + jest.mocked(streamAgentChat) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "in_progress", + }, + ], + createdAt: 1000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }) + .mockImplementationOnce(async ({ onEvent }) => { + onEvent({ + type: "todo_update", + sessionId: "session-1", + todos: [ + { + id: "todo-1", + content: "创建任务列表", + status: "completed", + }, + { + id: "todo-2", + content: "更新任务状态", + status: "in_progress", + }, + ], + createdAt: 2000, + }); + onEvent({ + type: "done", + sessionId: "session-1", + }); + }); + + const { result } = renderHook(() => + useAgentChatSession({ + projectId: "project-1", + onToolCall: jest.fn(), + }), + ); + + await waitFor(() => expect(result.current.isHydrating).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("创建任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + await act(async () => { + await result.current.sendPrompt("更新任务"); + }); + await waitFor(() => expect(result.current.isStreaming).toBe(false)); + + const assistantMessages = result.current.messages.filter( + (message) => message.role === "assistant", + ); + + expect(assistantMessages).toHaveLength(2); + expect(assistantMessages[0].todos).toBeUndefined(); + expect(assistantMessages[1].todos).toEqual( + expect.objectContaining({ + sessionId: "session-1", + createdAt: 2000, + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "completed", + }), + expect.objectContaining({ + id: "todo-2", + status: "in_progress", + }), + ], + }), + ); + }); + it("hydrates a backend streaming session and resumes its stream", async () => { listChatSessions.mockResolvedValue([ { @@ -849,22 +937,20 @@ describe("useAgentChatSession", () => { endedAt: expect.any(Number), }), ], - todos: [ - expect.objectContaining({ - todos: [ - expect.objectContaining({ - id: "todo-1", - status: "cancelled", - updatedAt: expect.any(Number), - }), - expect.objectContaining({ - id: "todo-2", - status: "cancelled", - updatedAt: expect.any(Number), - }), - ], - }), - ], + todos: expect.objectContaining({ + todos: [ + expect.objectContaining({ + id: "todo-1", + status: "cancelled", + updatedAt: expect.any(Number), + }), + expect.objectContaining({ + id: "todo-2", + status: "cancelled", + updatedAt: expect.any(Number), + }), + ], + }), permissions: [ expect.objectContaining({ requestId: "perm-abort", diff --git a/src/components/chat/hooks/useAgentChatSession.ts b/src/components/chat/hooks/useAgentChatSession.ts index b49bcef..bb1ace1 100644 --- a/src/components/chat/hooks/useAgentChatSession.ts +++ b/src/components/chat/hooks/useAgentChatSession.ts @@ -139,19 +139,21 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) => }; }); -const cancelRunningTodos = (todos: AgentTodoUpdate[] | undefined) => - todos?.map((todoUpdate) => ({ - ...todoUpdate, - todos: todoUpdate.todos.map((todo) => - todo.status === "pending" || todo.status === "in_progress" - ? { - ...todo, - status: "cancelled" as const, - updatedAt: Date.now(), - } - : todo, - ), - })); +const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) => + todoUpdate + ? { + ...todoUpdate, + todos: todoUpdate.todos.map((todo) => + todo.status === "pending" || todo.status === "in_progress" + ? { + ...todo, + status: "cancelled" as const, + updatedAt: Date.now(), + } + : todo, + ), + } + : undefined; const upsertPermission = ( permissions: AgentPermissionRequest[] | undefined, @@ -354,17 +356,64 @@ const applyQuestionResponse = ( : question, ); -const upsertTodoUpdate = ( - todos: AgentTodoUpdate[] | undefined, +const createTodoUpdateFromEvent = ( event: StreamEvent & { type: "todo_update" }, -) => [ - { - sessionId: event.sessionId, - messageId: event.messageId, - todos: event.todos, - createdAt: event.createdAt, - }, -]; +): AgentTodoUpdate => ({ + sessionId: event.sessionId, + messageId: event.messageId, + todos: event.todos, + createdAt: event.createdAt, +}); + +const normalizeSessionTodos = ( + messages: Message[], + nextTodoUpdate?: AgentTodoUpdate, + targetAssistantMessageId?: string, +) => { + let latestTodoUpdate = nextTodoUpdate; + if (!latestTodoUpdate) { + for (const message of messages) { + if (message.todos) { + latestTodoUpdate = message.todos; + } + } + } + + if (!latestTodoUpdate) { + return messages; + } + + const targetMessageId = + targetAssistantMessageId ?? + [...messages].reverse().find((message) => message.role === "assistant")?.id; + if (!targetMessageId) { + return messages; + } + + let changed = false; + const nextMessages = messages.map((message) => { + if (message.id === targetMessageId) { + if (message.todos === latestTodoUpdate) { + return message; + } + changed = true; + return { + ...message, + todos: latestTodoUpdate, + }; + } + if (!message.todos) { + return message; + } + changed = true; + return { + ...message, + todos: undefined, + }; + }); + + return changed ? nextMessages : messages; +}; const rejectOpenPermissionsAfterAbort = ( permissions: AgentPermissionRequest[] | undefined, @@ -425,7 +474,7 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => { Boolean(rejectedPermissions?.length) || Boolean(rejectedQuestions?.length) || Boolean(completedProgress?.length) || - Boolean(cancelledTodos?.length); + Boolean(cancelledTodos); if (!hasVisibleOutput) { return message; @@ -544,7 +593,9 @@ export const useAgentChatSession = ({ hydrationNonceRef.current += 1; titleUpdateNonceRef.current += 1; - setMessages(dedupeQuestionsAcrossMessages(loadedState.messages)); + setMessages( + normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)), + ); setSessionTitle(loadedState.title); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setSessionId(loadedState.sessionId); @@ -654,8 +705,8 @@ export const useAgentChatSession = ({ } if (event.type === "state") { - const nextMessages = dedupeQuestionsAcrossMessages( - cloneMessages(event.messages as Message[]), + const nextMessages = normalizeSessionTodos( + dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])), ); messagesRef.current = nextMessages; setMessages(nextMessages); @@ -774,13 +825,10 @@ export const useAgentChatSession = ({ ); } else if (event.type === "todo_update") { setMessages((prev) => - prev.map((message) => - message.id === assistantMessageId - ? { - ...message, - todos: upsertTodoUpdate(message.todos, event), - } - : message, + normalizeSessionTodos( + prev, + createTodoUpdateFromEvent(event), + assistantMessageId, ), ); } else if (event.type === "done") { @@ -914,7 +962,7 @@ export const useAgentChatSession = ({ message.content.trim().length === 0 && !(message.artifacts?.length) && !(message.progress?.length) && - !(message.todos?.length) + !message.todos ), ), );