feat(chat): refine shared todo card
This commit is contained in:
+231
-134
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user