feat(chat): refine shared todo card

This commit is contained in:
2026-06-08 19:14:30 +08:00
parent 3a36c693cd
commit 865e425748
4 changed files with 417 additions and 186 deletions
@@ -259,6 +259,94 @@ describe("useAgentChatSession", () => {
}
});
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",
@@ -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
),
),
);