✨ feat(vscode): React 文件照源码一块一张卡片,组件卡片只留名称、说明、构造参数
- 组件体的语句不再并进组件卡片,照源码顺序是它的子块;状态、引用、数据源、派生值、副作用、事件处理函数、渲染输出各成一张卡片,回调和函数里的语句是这些卡片下面的子卡片 - 普通变量、提前返回的 if、注释照语言默认的卡片;表格格子里只写名字和位置,去掉「改它的」「来自」格子里单独打开源码的那条路 - 用词改成书面叫法(更新位置、使用位置、计算表达式、依赖项、触发时机、清理函数、绑定事件、样式取决于……),新增 toggle-right、anchor、download、sigma、layout-template 五个种类图标 - 夹具加上 luffy 的 QueuedBubble.tsx、PlanChecklist.tsx,规格的 React 一节照新样子改写
This commit is contained in:
1 parent
194544b4ba
commit
6ce4ef7ee2
20 files changed
+1327
-989
No files matched your search
@@ -0,0 +1,148 @@
|
||||
"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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,223 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Clock, FileText, Pause, Pencil, X } from "lucide-react";
|
||||
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import { FIELD_TEXT } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import type { QueuedMessage } from "../events";
|
||||
import { USER_BUBBLE_SHAPE_CLASS, USER_FILE_CHIP_CLASS } from "./UserBubble";
|
||||
|
||||
const QUEUE_ACTION_CLASS = buttonVariants({ variant: "ghost", size: "icon-xs", className: "text-ink-4" });
|
||||
|
||||
/**
|
||||
* 消息流底部的排队消息组: 上方一行说明, 下面每条一个描边气泡, 靠右排.
|
||||
* 有条目在原地修改时说明换成「修改中」, 保存之前整队先不发出.
|
||||
*/
|
||||
export const QueuedMessages = ({
|
||||
queue,
|
||||
editingId,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
queue: QueuedMessage[];
|
||||
/** 正在原地修改的那条; null 表示没有. */
|
||||
editingId: string | null;
|
||||
onCancel?: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
onSaveEdit?: (id: string, text: string) => void;
|
||||
onCancelEdit?: () => void;
|
||||
}) => {
|
||||
if (queue.length === 0) return null;
|
||||
const editing = editingId !== null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-1 text-caption text-muted-foreground">
|
||||
{editing ? <Pause size={12} aria-hidden="true" /> : <Clock size={12} aria-hidden="true" />}
|
||||
<span>{editing ? "修改中 · 保存之前先不发出" : "排队中 · 点停止可立即发出第 1 条"}</span>
|
||||
</div>
|
||||
{queue.map((q) => (
|
||||
<QueuedBubble
|
||||
key={q.id}
|
||||
item={q}
|
||||
editing={q.id === editingId}
|
||||
onCancel={onCancel}
|
||||
onEdit={onEdit}
|
||||
onSaveEdit={onSaveEdit}
|
||||
onCancelEdit={onCancelEdit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 一条排队消息: 描边气泡, 字用弱色, 气泡内右侧是编辑 (铅笔) 与取消排队 (×).
|
||||
* 不给 onEdit / onCancel 时对应按钮不显示. editing 时气泡原地变成修改框, 附件照常显示.
|
||||
*/
|
||||
export const QueuedBubble = ({
|
||||
item,
|
||||
editing = false,
|
||||
onCancel,
|
||||
onEdit,
|
||||
onSaveEdit,
|
||||
onCancelEdit,
|
||||
}: {
|
||||
item: QueuedMessage;
|
||||
editing?: boolean;
|
||||
onCancel?: (id: string) => void;
|
||||
onEdit?: (id: string) => void;
|
||||
onSaveEdit?: (id: string, text: string) => void;
|
||||
onCancelEdit?: () => void;
|
||||
}) => {
|
||||
const images = item.attachments.filter((a) => a.kind === "image");
|
||||
const files = item.attachments.filter((a) => a.kind === "file");
|
||||
const hasText = item.text.trim().length > 0;
|
||||
const hasActions = onEdit != null || onCancel != null;
|
||||
|
||||
return (
|
||||
<div className={cn("flex max-w-[85%] flex-col items-end gap-2 md:max-w-[75%]", editing && "w-full")}>
|
||||
{files.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2 w-full">
|
||||
{files.map((f) => (
|
||||
<span key={f.url} className={USER_FILE_CHIP_CLASS}>
|
||||
<FileText size={16} className="shrink-0 text-muted-foreground" />
|
||||
<span className="max-w-[200px] truncate">{f.name ?? "文件附件"}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{images.length > 0 && (
|
||||
<div className="flex flex-wrap justify-end gap-2 w-full">
|
||||
{images.map((img) => (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
key={img.url}
|
||||
src={img.url}
|
||||
alt={img.name ?? "图片附件"}
|
||||
className="block max-w-[160px] max-h-[160px] w-auto h-auto object-cover rounded-xl border border-border"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<QueuedEditor item={item} onSave={onSaveEdit} onCancel={onCancelEdit} />
|
||||
) : (
|
||||
(hasText || hasActions) && (
|
||||
<div
|
||||
className={cn(
|
||||
USER_BUBBLE_SHAPE_CLASS,
|
||||
"flex items-start gap-1.5 border border-border-strong text-muted-foreground",
|
||||
hasActions && "pr-1.5",
|
||||
)}
|
||||
>
|
||||
{hasText && <span className="min-w-0">{item.text}</span>}
|
||||
{hasActions && (
|
||||
<div className="flex shrink-0 items-center">
|
||||
{onEdit && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
onClick={() => onEdit(item.id)}
|
||||
aria-label="编辑这条排队消息"
|
||||
className={QUEUE_ACTION_CLASS}
|
||||
>
|
||||
<Pencil className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>编辑</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{onCancel && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
type="button"
|
||||
onClick={() => onCancel(item.id)}
|
||||
aria-label="取消这条排队消息"
|
||||
className={QUEUE_ACTION_CLASS}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>取消排队</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 排队消息的原地修改框: 只改文字, 附件不动. 文本框随内容长高.
|
||||
* Cmd/Ctrl+Enter 保存, Esc 取消; 文字为空且没有附件时不能保存.
|
||||
*/
|
||||
const QueuedEditor = ({
|
||||
item,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
item: QueuedMessage;
|
||||
onSave?: (id: string, text: string) => void;
|
||||
onCancel?: () => void;
|
||||
}) => {
|
||||
const [draft, setDraft] = useState(item.text);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const canSave = draft.trim().length > 0 || item.attachments.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
}, []);
|
||||
|
||||
const save = () => {
|
||||
if (canSave) onSave?.(item.id, draft);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === "Escape") {
|
||||
// 修改框里的 Esc 只用来取消修改, 不再往外传: 页面上「连按两次 Esc 停止这一轮」不该把它算进去
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onCancel?.();
|
||||
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-label="修改排队消息"
|
||||
onKeyDown={handleKeyDown}
|
||||
className={cn(USER_BUBBLE_SHAPE_CLASS, "flex w-full flex-col gap-2.5 border border-primary bg-card text-foreground")}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="排队消息内容"
|
||||
className={cn(FIELD_TEXT, "block w-full resize-none bg-transparent outline-none field-sizing-content")}
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button size="sm" disabled={!canSave} onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user