feat(chat): refine shared todo card
This commit is contained in:
+231
-134
@@ -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,138 +1365,138 @@ 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
|
||||||
|
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
|
<Box
|
||||||
sx={{
|
sx={{
|
||||||
width: 30,
|
height: 6,
|
||||||
height: 30,
|
borderRadius: 999,
|
||||||
borderRadius: "50%",
|
overflow: "hidden",
|
||||||
display: "grid",
|
|
||||||
placeItems: "center",
|
|
||||||
flex: "0 0 auto",
|
|
||||||
color: "#00838f",
|
|
||||||
bgcolor: alpha("#00838f", 0.1),
|
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>
|
||||||
<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>
|
</Stack>
|
||||||
|
|
||||||
{visibleTodos.length ? (
|
<Stack spacing={0} sx={{ px: 1.4, pb: 1.1 }}>
|
||||||
<Stack spacing={0} sx={{ px: 1.5, pb: 1.2 }}>
|
{pinnedTodos.map((todo, index) => renderTodoRow(todo, index))}
|
||||||
{visibleTodos.map((todo, index) => {
|
{canCollapse ? (
|
||||||
const visual = getTodoVisual(todo.status);
|
<Collapse in={expanded} timeout={220} unmountOnExit={false}>
|
||||||
return (
|
<Stack spacing={0}>
|
||||||
<Stack
|
{collapsibleTodos.map((todo, index) =>
|
||||||
key={`${todo.id}-${index}`}
|
renderTodoRow(todo, index + pinnedTodos.length),
|
||||||
direction="row"
|
)}
|
||||||
alignItems="center"
|
</Stack>
|
||||||
spacing={1}
|
</Collapse>
|
||||||
sx={{
|
) : null}
|
||||||
py: 0.75,
|
{hiddenCount > 0 ? (
|
||||||
borderTop: index === 0 ? `1px solid ${alpha("#000", 0.05)}` : "none",
|
<Typography
|
||||||
color: todo.status === "cancelled" ? "text.disabled" : "text.primary",
|
variant="caption"
|
||||||
}}
|
color="text.secondary"
|
||||||
>
|
sx={{
|
||||||
<Box
|
pt: 0.8,
|
||||||
sx={{
|
borderTop: `1px solid ${alpha("#00838f", 0.08)}`,
|
||||||
width: 24,
|
}}
|
||||||
height: 24,
|
>
|
||||||
borderRadius: "50%",
|
还有 {hiddenCount} 项,展开查看全部
|
||||||
display: "grid",
|
</Typography>
|
||||||
placeItems: "center",
|
) : null}
|
||||||
flex: "0 0 auto",
|
</Stack>
|
||||||
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}
|
|
||||||
</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
|
||||||
|
|||||||
@@ -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,22 +937,20 @@ 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",
|
status: "cancelled",
|
||||||
status: "cancelled",
|
updatedAt: expect.any(Number),
|
||||||
updatedAt: expect.any(Number),
|
}),
|
||||||
}),
|
expect.objectContaining({
|
||||||
expect.objectContaining({
|
id: "todo-2",
|
||||||
id: "todo-2",
|
status: "cancelled",
|
||||||
status: "cancelled",
|
updatedAt: expect.any(Number),
|
||||||
updatedAt: expect.any(Number),
|
}),
|
||||||
}),
|
],
|
||||||
],
|
}),
|
||||||
}),
|
|
||||||
],
|
|
||||||
permissions: [
|
permissions: [
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
requestId: "perm-abort",
|
requestId: "perm-abort",
|
||||||
|
|||||||
@@ -139,19 +139,21 @@ const completeRunningProgress = (progress: ChatProgress[] | undefined) =>
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const cancelRunningTodos = (todos: AgentTodoUpdate[] | undefined) =>
|
const cancelRunningTodos = (todoUpdate: AgentTodoUpdate | undefined) =>
|
||||||
todos?.map((todoUpdate) => ({
|
todoUpdate
|
||||||
...todoUpdate,
|
? {
|
||||||
todos: todoUpdate.todos.map((todo) =>
|
...todoUpdate,
|
||||||
todo.status === "pending" || todo.status === "in_progress"
|
todos: todoUpdate.todos.map((todo) =>
|
||||||
? {
|
todo.status === "pending" || todo.status === "in_progress"
|
||||||
...todo,
|
? {
|
||||||
status: "cancelled" as const,
|
...todo,
|
||||||
updatedAt: Date.now(),
|
status: "cancelled" as const,
|
||||||
}
|
updatedAt: Date.now(),
|
||||||
: 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
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user