- 组件体的语句不再并进组件卡片,照源码顺序是它的子块;状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出各成一张卡片,回调和函数里的语句是这些卡片下面的子卡片 - 普通变量、提前返回的 if、注释照语言默认的卡片;表格格子里只写名字和位置,去掉「改它的」「来自」格子里单独打开源码的那条路 - 用词改成书面叫法(更新位置、使用位置、计算表达式、依赖项、触发时机、清理函数、绑定事件、样式取决于……),新增 toggle-right、anchor、download、sigma、layout-template 五个种类图标 - 夹具加上 luffy 的 QueuedBubble.tsx、PlanChecklist.tsx,规格的 React 一节照新样子改写
149 lines
5.9 KiB
Plaintext
149 lines
5.9 KiB
Plaintext
"use client";
|
|
import { Circle, CircleDot, Pause } from "lucide-react";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
|
|
import { CARD_SURFACE } from "@/components/ui/card";
|
|
import { cn } from "@/lib/utils";
|
|
import { Collapsible, CollapsibleChevron, CollapsiblePanel, CollapsibleTrigger } from "@/components/ui/collapsible";
|
|
import { FLUSH_ROW_INTERACTIVE } from "@/components/ui/interactive";
|
|
import { StatusMark } from "@/components/ui/status-mark";
|
|
|
|
import type { PlanTodo } from "./events";
|
|
|
|
interface PlanChecklistProps {
|
|
/** 执行期计划清单 (覆盖式刷新); 空数组时不渲染. */
|
|
todos: PlanTodo[];
|
|
/**
|
|
* 本轮是否仍在流式执行中. ``in_progress`` 仅在 ``active`` 时转圈;
|
|
* turn 结束/报错后转 ``false``, 当前步停转 (显示"停在此步"), 不再空转误导.
|
|
*/
|
|
active: boolean;
|
|
}
|
|
|
|
/**
|
|
* 执行期"计划清单"实时勾选 (TodoWrite 式): code-agent 批准计划后边执行边刷新各步
|
|
* 状态, 用户看到"做到哪一步了". 渲染在 composer 上方, 由 planTodos 驱动.
|
|
*
|
|
* 进度叙事三件套: 顶部分段进度条 (一段一步) · 左侧轨道串起各步 · 当前步整行铺中性底,
|
|
* 不看文字也知道走到哪. 设计稿见 docs/prototype/todolist/openpencil.
|
|
*
|
|
* 可折叠: 七八步的清单会把对话区挤成一小截, 点标题栏折成一行 (分段条 + 当前步 + 计数).
|
|
* 偏好全局粘性 (localStorage): 折过一次以后所有会话默认折叠, 展开同理.
|
|
* SSR / 首屏恒为展开, 水合后 effect 读偏好校正 (与 ComposerSizeProvider 同款).
|
|
*/
|
|
export const PlanChecklist = ({ todos, active }: PlanChecklistProps) => {
|
|
const [collapsed, setCollapsed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
if (localStorage.getItem(PLAN_CHECKLIST_COLLAPSED_KEY) === "1") {
|
|
queueMicrotask(() => setCollapsed(true));
|
|
}
|
|
} catch {
|
|
/* localStorage 不可用 (隐私模式等) 时静默用默认 */
|
|
}
|
|
}, []);
|
|
|
|
const toggle = useCallback(() => {
|
|
const next = !collapsed;
|
|
setCollapsed(next);
|
|
try {
|
|
localStorage.setItem(PLAN_CHECKLIST_COLLAPSED_KEY, next ? "1" : "0");
|
|
} catch {
|
|
/* 忽略写入失败 */
|
|
}
|
|
}, [collapsed]);
|
|
|
|
if (todos.length === 0) return null;
|
|
const whole = wholeStatusOf(todos, active);
|
|
const doneCount = todos.filter((t) => t.status === "done").length;
|
|
const counter = `${doneCount} / ${todos.length}`;
|
|
const current = todos.find((t) => t.status === "in_progress");
|
|
// 折叠行永远带一步的名字: 跑着是当前步, 做完了是最后一步, 不留光秃秃的计数
|
|
const headline = (current ?? todos[todos.length - 1]).title;
|
|
|
|
return (
|
|
<Collapsible
|
|
open={!collapsed}
|
|
onOpenChange={toggle}
|
|
className={cn(CARD_SURFACE, "overflow-hidden")}
|
|
>
|
|
<CollapsibleTrigger
|
|
data-testid="plan-checklist-toggle"
|
|
className={cn(FLUSH_ROW_INTERACTIVE, "block w-full text-left")}
|
|
>
|
|
<div data-testid="plan-progress" className="flex gap-1 px-3.5 pt-3">
|
|
{todos.map((todo) => (
|
|
<span
|
|
key={todo.title}
|
|
className={cn("h-[3px] flex-1 rounded-full", segmentClass(todo.status, whole))}
|
|
/>
|
|
))}
|
|
</div>
|
|
{collapsed ? (
|
|
<div className="flex items-center gap-2.5 px-3.5 pt-2.5 pb-3">
|
|
<StatusIcon status={current ? "in_progress" : "done"} active={active} />
|
|
<span
|
|
className={cn(
|
|
"min-w-0 flex-1 truncate text-ui leading-[22px] font-medium",
|
|
whole === "done" ? "text-muted-foreground" : "text-foreground",
|
|
)}
|
|
>
|
|
{headline}
|
|
</span>
|
|
<span className="text-caption text-ink-4 tabular-nums">{counter}</span>
|
|
<span className="flex items-center gap-0.5 text-caption text-ink-4">
|
|
展开
|
|
<CollapsibleChevron strokeWidth={1.8} />
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-2 px-3.5 pt-2.5 pb-2">
|
|
<span className="text-caption font-medium tracking-wide text-ink-2">计划清单</span>
|
|
<span className="text-caption text-ink-4 tabular-nums">{counter}</span>
|
|
<span className="flex-1" />
|
|
<WholeStatusLabel whole={whole} />
|
|
<span className="flex items-center gap-0.5 text-caption text-ink-4">
|
|
收起
|
|
<CollapsibleChevron strokeWidth={1.8} />
|
|
</span>
|
|
</div>
|
|
)}
|
|
</CollapsibleTrigger>
|
|
<CollapsiblePanel>
|
|
<ol className="max-h-[40vh] overflow-y-auto border-t border-border px-2 pt-1.5 pb-2">
|
|
{todos.map((todo, i) => {
|
|
const focus = todo.status === "in_progress";
|
|
const last = i === todos.length - 1;
|
|
return (
|
|
<li
|
|
key={todo.title}
|
|
className={cn("flex gap-2.5 rounded-md px-2", focus && "bg-surface-2")}
|
|
>
|
|
{/* 轨道: 首行顶端留空, 末行不画下段; 9px 上段让 16px 图标对齐 22px 行高的第一行 */}
|
|
<span className="flex w-4 shrink-0 flex-col items-center">
|
|
<span
|
|
className={cn("h-[9px] w-0.5", i > 0 && railClass(todos[i - 1].status === "done"))}
|
|
/>
|
|
<StatusIcon status={todo.status} active={active} />
|
|
{!last && (
|
|
<span className={cn("w-0.5 flex-1", railClass(todo.status === "done"))} />
|
|
)}
|
|
</span>
|
|
<span
|
|
className={cn(
|
|
"min-w-0 flex-1 py-1.5 text-ui leading-[22px]",
|
|
titleClass(todo.status),
|
|
)}
|
|
>
|
|
{todo.title}
|
|
</span>
|
|
</li>
|
|
);
|
|
})}
|
|
</ol>
|
|
</CollapsiblePanel>
|
|
</Collapsible>
|
|
);
|
|
};
|