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