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
+195 -98
View File
@@ -1220,45 +1220,142 @@ const QuestionRequestGroup = ({
const TodoPlanCard = ({ const TodoPlanCard = ({
todoUpdate, todoUpdate,
}: { }: {
todoUpdate: NonNullable<Message["todos"]>[number]; todoUpdate: NonNullable<Message["todos"]>;
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
const total = todoUpdate.todos.length; const total = todoUpdate.todos.length;
const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length; const completed = todoUpdate.todos.filter((todo) => todo.status === "completed").length;
const running = todoUpdate.todos.find((todo) => todo.status === "in_progress"); const running = todoUpdate.todos.find((todo) => todo.status === "in_progress");
const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length; const cancelled = todoUpdate.todos.filter((todo) => todo.status === "cancelled").length;
const isAborted = cancelled > 0 && !running; const pending = todoUpdate.todos.filter((todo) => todo.status === "pending").length;
const [expanded, setExpanded] = React.useState( const progress = total > 0 ? Math.round((completed / total) * 100) : 0;
!isAborted && todoUpdate.todos.length <= 3, 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(() => { const updatedAtLabel =
if (isAborted) { latestUpdatedAt > 0
setExpanded(false); ? new Intl.DateTimeFormat("zh-CN", {
} hour: "2-digit",
}, [isAborted]); minute: "2-digit",
const visibleTodos = }).format(new Date(latestUpdatedAt))
isAborted && !expanded : undefined;
? []
: expanded || total <= 3
? todoUpdate.todos
: [
...todoUpdate.todos.slice(0, 3),
...(running && !todoUpdate.todos.slice(0, 3).some((todo) => todo.id === running.id)
? [running]
: []),
];
const getTodoVisual = (status: NonNullable<Message["todos"]>[number]["todos"][number]["status"]) => { const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => {
if (status === "completed") { if (status === "completed") {
return { icon: <CheckCircleRounded sx={{ fontSize: 18 }} />, color: theme.palette.success.main, label: "完成" }; return { icon: <CheckCircleRounded sx={{ fontSize: 17 }} />, color: theme.palette.success.main, label: "完成" };
} }
if (status === "in_progress") { if (status === "in_progress") {
return { icon: <CircularProgress size={16} />, color: "#0288d1", label: "进行中" }; return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" };
} }
if (status === "cancelled") { if (status === "cancelled") {
return { icon: <BlockRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.disabled, label: "中止" }; return { icon: <BlockRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.disabled, label: "中止" };
} }
return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 18 }} />, color: theme.palette.text.secondary, label: "待处理" }; return { icon: <RadioButtonUncheckedRounded sx={{ fontSize: 17 }} />, color: theme.palette.text.secondary, label: "待" };
};
const getPriorityLabel = (priority: NonNullable<Message["todos"]>["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<Message["todos"]>["todos"][number],
index: number,
) => {
const visual = getTodoVisual(todo.status);
const priority = getPriorityLabel(todo.priority);
return (
<Stack
key={`${todo.id}-${index}`}
direction="row"
alignItems="flex-start"
spacing={1}
sx={{
py: 0.8,
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: 1.25,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
mt: 0.1,
}}
>
{visual.icon}
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography
variant="body2"
sx={{
minWidth: 0,
wordBreak: "break-word",
lineHeight: 1.45,
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</Typography>
</Box>
<Stack direction="row" spacing={0.5} sx={{ flex: "0 0 auto" }}>
{priority ? (
<Chip
size="small"
label={priority.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.66rem",
fontWeight: 800,
color: priority.color,
bgcolor: alpha(priority.color, 0.045),
border: `1px solid ${alpha(priority.color, 0.16)}`,
"& .MuiChip-label": { px: 0.75 },
}}
/>
) : null}
<Chip
size="small"
label={visual.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.66rem",
fontWeight: 800,
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
"& .MuiChip-label": { px: 0.75 },
}}
/>
</Stack>
</Stack>
);
}; };
if (total === 0) { if (total === 0) {
@@ -1268,63 +1365,78 @@ const TodoPlanCard = ({
return ( return (
<Box <Box
sx={{ sx={{
borderRadius: 3, borderRadius: 2,
overflow: "hidden", overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`, border: `1px solid ${alpha("#00838f", 0.16)}`,
bgcolor: alpha("#fff", 0.48), bgcolor: alpha("#f8fbfc", 0.82),
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
backdropFilter: "blur(20px)",
}} }}
> >
<Stack <Stack
direction="row"
alignItems="center"
spacing={1} spacing={1}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={() => setExpanded((value) => !value)} onClick={() => {
if (canCollapse) {
setExpanded((value) => !value);
}
}}
onKeyDown={(event) => { onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") { if (canCollapse && (event.key === "Enter" || event.key === " ")) {
event.preventDefault(); event.preventDefault();
setExpanded((value) => !value); setExpanded((value) => !value);
} }
}} }}
sx={{ sx={{
px: 1.5, px: 1.4,
py: 1.15, py: 1.15,
cursor: "pointer", cursor: canCollapse ? "pointer" : "default",
transition: "background-color 0.2s ease", transition: "background-color 0.2s ease",
"&:hover": { bgcolor: alpha("#000", 0.025) }, "&:hover": canCollapse ? { bgcolor: alpha("#00838f", 0.035) } : undefined,
}} }}
> >
<Stack direction="row" alignItems="center" spacing={1}>
<Box <Box
sx={{ sx={{
width: 30, width: 28,
height: 30, height: 28,
borderRadius: "50%", borderRadius: 1.5,
display: "grid", display: "grid",
placeItems: "center", placeItems: "center",
flex: "0 0 auto", flex: "0 0 auto",
color: "#00838f", color: "#00838f",
bgcolor: alpha("#00838f", 0.1), bgcolor: alpha("#00838f", 0.1),
border: `1px solid ${alpha("#00838f", 0.15)}`, border: `1px solid ${alpha("#00838f", 0.14)}`,
}} }}
> >
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} /> <AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
</Box> </Box>
<Box sx={{ minWidth: 0, flex: 1 }}> <Box sx={{ minWidth: 0, flex: 1 }}>
<Stack direction="row" alignItems="center" spacing={0.75}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}> <Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography> </Typography>
<Chip
size="small"
label={running ? "执行中" : isAborted ? "已中止" : completed === total ? "已完成" : "已同步"}
sx={{
height: 20,
borderRadius: "10px",
fontSize: "0.66rem",
fontWeight: 800,
color: running ? "#0277bd" : isAborted ? "text.secondary" : "#00838f",
bgcolor: alpha(running ? "#0288d1" : isAborted ? "#64748b" : "#00838f", 0.08),
"& .MuiChip-label": { px: 0.75 },
}}
/>
</Stack>
<Typography variant="caption" color="text.secondary"> <Typography variant="caption" color="text.secondary">
{isAborted {statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""}
? `${completed}/${total} 已完成,${cancelled} 项已中止`
: `${completed}/${total} 已完成${running ? "1 项进行中" : ""}`}
</Typography> </Typography>
</Box> </Box>
{canCollapse ? (
<IconButton <IconButton
size="small" size="small"
aria-label={expanded ? "收起任务规划" : "展开任务规划"} aria-label={expanded ? "收起会话任务" : "展开会话任务"}
sx={{ sx={{
width: 28, width: 28,
height: 28, height: 28,
@@ -1339,67 +1451,52 @@ const TodoPlanCard = ({
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} /> <KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)} )}
</IconButton> </IconButton>
) : null}
</Stack> </Stack>
<Box
{visibleTodos.length ? (
<Stack spacing={0} sx={{ px: 1.5, pb: 1.2 }}>
{visibleTodos.map((todo, index) => {
const visual = getTodoVisual(todo.status);
return (
<Stack
key={`${todo.id}-${index}`}
direction="row"
alignItems="center"
spacing={1}
sx={{ sx={{
py: 0.75, height: 6,
borderTop: index === 0 ? `1px solid ${alpha("#000", 0.05)}` : "none", borderRadius: 999,
color: todo.status === "cancelled" ? "text.disabled" : "text.primary", overflow: "hidden",
bgcolor: alpha("#00838f", 0.1),
}} }}
> >
<Box <Box
sx={{ sx={{
width: 24, width: `${progress}%`,
height: 24, height: "100%",
borderRadius: "50%", borderRadius: 999,
display: "grid", bgcolor: isAborted ? theme.palette.text.disabled : "#00838f",
placeItems: "center", transition: "width 0.25s ease",
flex: "0 0 auto",
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
}}
>
{visual.icon}
</Box>
<Typography
variant="body2"
sx={{
minWidth: 0,
flex: 1,
wordBreak: "break-word",
textDecoration: todo.status === "cancelled" ? "line-through" : undefined,
}}
>
{todo.content}
</Typography>
<Chip
size="small"
label={visual.label}
sx={{
height: 22,
borderRadius: "11px",
fontSize: "0.68rem",
fontWeight: 800,
color: visual.color,
bgcolor: alpha(visual.color, 0.08),
"& .MuiChip-label": { px: 0.85 },
}} }}
/> />
</Box>
</Stack> </Stack>
);
})} <Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}>
{pinnedTodos.map((todo, index) => renderTodoRow(todo, index))}
{canCollapse ? (
<Collapse in={expanded} timeout={220} unmountOnExit={false}>
<Stack spacing={0}>
{collapsibleTodos.map((todo, index) =>
renderTodoRow(todo, index + pinnedTodos.length),
)}
</Stack> </Stack>
</Collapse>
) : null} ) : null}
{hiddenCount > 0 ? (
<Typography
variant="caption"
color="text.secondary"
sx={{
pt: 0.8,
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
}}
>
{hiddenCount}
</Typography>
) : null}
</Stack>
</Box> </Box>
); );
}; };
@@ -1568,8 +1665,8 @@ export const AgentTurn = React.memo(
/> />
) : null} ) : null}
{message.todos?.length ? ( {message.todos ? (
<TodoPlanCard todoUpdate={message.todos[message.todos.length - 1]} /> <TodoPlanCard todoUpdate={message.todos} />
) : null} ) : null}
<Box <Box
+1 -1
View File
@@ -61,7 +61,7 @@ export type Message = {
artifacts?: AgentArtifact[]; artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[]; permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[]; questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate[]; todos?: AgentTodoUpdate;
}; };
export type Props = { export type Props = {
@@ -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 () => { it("hydrates a backend streaming session and resumes its stream", async () => {
listChatSessions.mockResolvedValue([ listChatSessions.mockResolvedValue([
{ {
@@ -849,8 +937,7 @@ describe("useAgentChatSession", () => {
endedAt: expect.any(Number), endedAt: expect.any(Number),
}), }),
], ],
todos: [ todos: expect.objectContaining({
expect.objectContaining({
todos: [ todos: [
expect.objectContaining({ expect.objectContaining({
id: "todo-1", id: "todo-1",
@@ -864,7 +951,6 @@ describe("useAgentChatSession", () => {
}), }),
], ],
}), }),
],
permissions: [ permissions: [
expect.objectContaining({ expect.objectContaining({
requestId: "perm-abort", requestId: "perm-abort",
@@ -139,8 +139,9 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
}; };
}); });
const cancelRunningTodos = (todos: AgentTodoUpdate[] | undefined) => const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
todos?.map((todoUpdate) => ({ todoUpdate
? {
...todoUpdate, ...todoUpdate,
todos: todoUpdate.todos.map((todo) => todos: todoUpdate.todos.map((todo) =>
todo.status === "pending" || todo.status === "in_progress" todo.status === "pending" || todo.status === "in_progress"
@@ -151,7 +152,8 @@ const cancelRunningTodos = (todos: AgentTodoUpdate[] | undefined) =>
} }
: todo, : todo,
), ),
})); }
: undefined;
const upsertPermission = ( const upsertPermission = (
permissions: AgentPermissionRequest[] | undefined, permissions: AgentPermissionRequest[] | undefined,
@@ -354,17 +356,64 @@ const applyQuestionResponse = (
: question, : question,
); );
const upsertTodoUpdate = ( const createTodoUpdateFromEvent = (
todos: AgentTodoUpdate[] | undefined,
event: StreamEvent & { type: "todo_update" }, event: StreamEvent & { type: "todo_update" },
) => [ ): AgentTodoUpdate => ({
{
sessionId: event.sessionId, sessionId: event.sessionId,
messageId: event.messageId, messageId: event.messageId,
todos: event.todos, todos: event.todos,
createdAt: event.createdAt, 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 = ( const rejectOpenPermissionsAfterAbort = (
permissions: AgentPermissionRequest[] | undefined, permissions: AgentPermissionRequest[] | undefined,
@@ -425,7 +474,7 @@ const finalizeAssistantMessageAfterAbort = (message: Message): Message => {
Boolean(rejectedPermissions?.length) || Boolean(rejectedPermissions?.length) ||
Boolean(rejectedQuestions?.length) || Boolean(rejectedQuestions?.length) ||
Boolean(completedProgress?.length) || Boolean(completedProgress?.length) ||
Boolean(cancelledTodos?.length); Boolean(cancelledTodos);
if (!hasVisibleOutput) { if (!hasVisibleOutput) {
return message; return message;
@@ -544,7 +593,9 @@ export const useAgentChatSession = ({
hydrationNonceRef.current += 1; hydrationNonceRef.current += 1;
titleUpdateNonceRef.current += 1; titleUpdateNonceRef.current += 1;
setMessages(dedupeQuestionsAcrossMessages(loadedState.messages)); setMessages(
normalizeSessionTodos(dedupeQuestionsAcrossMessages(loadedState.messages)),
);
setSessionTitle(loadedState.title); setSessionTitle(loadedState.title);
setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false); setIsSessionTitleManuallyEdited(loadedState.isTitleManuallyEdited ?? false);
setSessionId(loadedState.sessionId); setSessionId(loadedState.sessionId);
@@ -654,8 +705,8 @@ export const useAgentChatSession = ({
} }
if (event.type === "state") { if (event.type === "state") {
const nextMessages = dedupeQuestionsAcrossMessages( const nextMessages = normalizeSessionTodos(
cloneMessages(event.messages as Message[]), dedupeQuestionsAcrossMessages(cloneMessages(event.messages as Message[])),
); );
messagesRef.current = nextMessages; messagesRef.current = nextMessages;
setMessages(nextMessages); setMessages(nextMessages);
@@ -774,13 +825,10 @@ export const useAgentChatSession = ({
); );
} else if (event.type === "todo_update") { } else if (event.type === "todo_update") {
setMessages((prev) => setMessages((prev) =>
prev.map((message) => normalizeSessionTodos(
message.id === assistantMessageId prev,
? { createTodoUpdateFromEvent(event),
...message, assistantMessageId,
todos: upsertTodoUpdate(message.todos, event),
}
: message,
), ),
); );
} else if (event.type === "done") { } else if (event.type === "done") {
@@ -914,7 +962,7 @@ export const useAgentChatSession = ({
message.content.trim().length === 0 && message.content.trim().length === 0 &&
!(message.artifacts?.length) && !(message.artifacts?.length) &&
!(message.progress?.length) && !(message.progress?.length) &&
!(message.todos?.length) !message.todos
), ),
), ),
); );