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
+231 -134
View File
@@ -1220,45 +1220,142 @@ const QuestionRequestGroup = ({
const TodoPlanCard = ({
todoUpdate,
}: {
todoUpdate: NonNullable<Message["todos"]>[number];
todoUpdate: NonNullable<Message["todos"]>;
}) => {
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<Message["todos"]>[number]["todos"][number]["status"]) => {
const getTodoVisual = (status: NonNullable<Message["todos"]>["todos"][number]["status"]) => {
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") {
return { icon: <CircularProgress size={16} />, color: "#0288d1", label: "进行中" };
return { icon: <CircularProgress size={15} thickness={5} />, color: "#0288d1", label: "进行中" };
}
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) {
@@ -1268,138 +1365,138 @@ const TodoPlanCard = ({
return (
<Box
sx={{
borderRadius: 3,
borderRadius: 2,
overflow: "hidden",
border: `1px solid ${alpha("#fff", 0.72)}`,
bgcolor: alpha("#fff", 0.48),
boxShadow: `0 8px 24px ${alpha("#000", 0.045)}`,
backdropFilter: "blur(20px)",
border: `1px solid ${alpha("#00838f", 0.16)}`,
bgcolor: alpha("#f8fbfc", 0.82),
}}
>
<Stack
direction="row"
alignItems="center"
spacing={1}
role="button"
tabIndex={0}
onClick={() => 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,
}}
>
<Stack direction="row" alignItems="center" spacing={1}>
<Box
sx={{
width: 28,
height: 28,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: "#00838f",
bgcolor: alpha("#00838f", 0.1),
border: `1px solid ${alpha("#00838f", 0.14)}`,
}}
>
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
</Box>
<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>
<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">
{statusSummary}{updatedAtLabel ? ` · ${updatedAtLabel} 更新` : ""}
</Typography>
</Box>
{canCollapse ? (
<IconButton
size="small"
aria-label={expanded ? "收起会话任务" : "展开会话任务"}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
) : null}
</Stack>
<Box
sx={{
width: 30,
height: 30,
borderRadius: "50%",
display: "grid",
placeItems: "center",
flex: "0 0 auto",
color: "#00838f",
height: 6,
borderRadius: 999,
overflow: "hidden",
bgcolor: alpha("#00838f", 0.1),
border: `1px solid ${alpha("#00838f", 0.15)}`,
}}
>
<AssignmentTurnedInRounded sx={{ fontSize: 18 }} />
<Box
sx={{
width: `${progress}%`,
height: "100%",
borderRadius: 999,
bgcolor: isAborted ? theme.palette.text.disabled : "#00838f",
transition: "width 0.25s ease",
}}
/>
</Box>
<Box sx={{ minWidth: 0, flex: 1 }}>
<Typography variant="subtitle2" fontWeight={800} noWrap sx={{ lineHeight: 1.25 }}>
</Typography>
<Typography variant="caption" color="text.secondary">
{isAborted
? `${completed}/${total} 已完成,${cancelled} 项已中止`
: `${completed}/${total} 已完成${running ? "1 项进行中" : ""}`}
</Typography>
</Box>
<IconButton
size="small"
aria-label={expanded ? "收起任务规划" : "展开任务规划"}
sx={{
width: 28,
height: 28,
color: "text.secondary",
bgcolor: alpha("#000", 0.035),
"&:hover": { bgcolor: alpha("#000", 0.07) },
}}
>
{expanded ? (
<KeyboardArrowUpRounded sx={{ fontSize: 18 }} />
) : (
<KeyboardArrowDownRounded sx={{ fontSize: 18 }} />
)}
</IconButton>
</Stack>
{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={{
py: 0.75,
borderTop: index === 0 ? `1px solid ${alpha("#000", 0.05)}` : "none",
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
}}
>
<Box
sx={{
width: 24,
height: 24,
borderRadius: "50%",
display: "grid",
placeItems: "center",
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 },
}}
/>
</Stack>
);
})}
</Stack>
) : null}
<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>
</Collapse>
) : 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>
);
};
@@ -1568,8 +1665,8 @@ export const AgentTurn = React.memo(
/>
) : null}
{message.todos?.length ? (
<TodoPlanCard todoUpdate={message.todos[message.todos.length - 1]} />
{message.todos ? (
<TodoPlanCard todoUpdate={message.todos} />
) : null}
<Box
+1 -1
View File
@@ -61,7 +61,7 @@ export type Message = {
artifacts?: AgentArtifact[];
permissions?: AgentPermissionRequest[];
questions?: AgentQuestionRequest[];
todos?: AgentTodoUpdate[];
todos?: AgentTodoUpdate;
};
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 () => {
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
),
),
);