Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions docs/agent-edit-wash.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Agent 正文改动扫光

仅为表现层,不写入 Board、ConceptNode、存档或导出格式。

- `src/model/agent-edit.ts`:集中时间参数 `P`;中英文句末标点、换行切分;句级 LCS diff;按实际画布 y/x 排序;400ms 起步、150ms 错峰、最多 8 张扫光,第 9 张起 120ms 直接替换。减少动态效果走独立时间线。
- `src/components/AgentEditProse.tsx`:旧/新正文同宽叠放。测量旧句第一行顶部并固定扫线触发时间;未变句子的旧节点全程保留,新层只用不可见排版占位符。rAF 只写透明度和高度。结束清除动画元素、排版占位符与 aria-hidden 属性。辅助技术通过临时隐藏正文读取最终值,不使用 aria-live。
- `src/styles.css`:浅色 `#c8442a / 8%`、深色 `#f06a48 / 10%`;下沿 14px mask 渐隐。减少动态效果时深色底色也用 8%。

## 触发入口

`App.tsx` 的 `putBoard(next, record, agent)` 默认为非 Agent。仅以下 Agent 落地调用传入 `true`:

1. canvas agent 的 `commitCanvasRun`(整理、聊天 canvas 请求共用);草稿结构仍可预览,已有正文保留到提交,防止动画前先闪出新字。
2. AI map/expand 完成后的 gate/applyGate、buildMapFromAI 落地。覆盖合并已有卡片的补充、reExpand 和 gate 内 regen 的最终结果。reExpand 归档/新建的卡片以及尚未上画布的 regen 草稿不播放扫光。
3. 聊天 `editRoute` 应用(包括原 ID 卡片正文被清空)。标题、顺序、重点等未改正文时不触发;移除原先的整卡 route-flash。

`onPatch` 是学习者四拍进度/答案更新,不标记为 Agent。普通编辑、撤销、导入和新建也不标记。正文替换立即进入正常保存数据流;正文改写成功时不额外弹完成提示条。

画布保留改动前的节点坐标,避免 React Flow 高度测量使其他卡跟着重排;结构、标题或下一次非 Agent 正文编辑发生变化后恢复正常布局。紧凑卡面沿用现有两行截断,全文和列表沿用抽屉正文呈现,不把卡面改成全文。

## 验证与截图

- `tests/agent-edit.test.ts`:标点/换行、替换/插入/删除/重复句、连续改句、排序/错峰/8 张上限、来源过滤、减少动态效果。
- `tests/e2e/agent-edit.spec.ts`:使用 fake-atlas 完整 Agent 提交流程,检查新值立即存入、未变句子 DOM 身份、其他卡坐标、行数变化、正文清空、扫光和无障碍层清理、撤销、减少动态效果。
- 截图使用 Playwright 时钟暂停、逐段 `runFor`,定格约提交后 500/1100/2400ms。浅/深各 start、middle、fade 三张,含两张同时改动的卡。开发环境写入 `/workspace/studyboard-agent-edit/shots/`,其他环境写入 Playwright 测试产物目录。

macOS 真机系统代理、签名打包及实际辅助技术朗读需在 macOS 验证;本次没有更改主进程或任何网络路径。

本次 Linux 验证结果:两套 tsc 通过;Vitest 49 个文件 / 454 项通过;Vite build 通过;全套 Playwright 单 worker 120 项通过、53 项按既有平台或截图条件跳过、0 失败。另一次改卡与侧栏专项回归 16 项通过。
96 changes: 78 additions & 18 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { AgentEdits } from "./components/AgentEditProse";
import { planAgentEdits, editDuration, existingBodyChanges, type AgentEdit } from "./model/agent-edit";
import {
useCallback,
useEffect,
Expand Down Expand Up @@ -324,8 +326,6 @@ export default function App() {
completionQueues = useRef(new Map<string, Promise<void>>());
/** The chat turn in flight per note (its own stickyId), so 停止 can stop it. */
const chatActive = useRef(new Map<string, string>());
/** Stops a just-applied route edit changed: highlighted for 1.2s. */
const [flash, setFlash] = useState<Set<string>>(() => new Set());
/** Act on a chat proposal: 应用 / 撤销 / 取消 / 开始第 N 站. Set once the handlers exist. */
const proposalActRef = useRef<(noteId: string, messageId: string, act: "apply" | "undo" | "cancel" | "start") => void>(() => {});
/** Route snapshots from before each applied proposal, for its 撤销 (this session only). */
Expand Down Expand Up @@ -384,8 +384,49 @@ export default function App() {
),
);
}, []);
const putBoard = useCallback((next: Board, record = true) => {
const [agentEdits, setAgentEdits] = useState<ReadonlyMap<string, AgentEdit>>(new Map());
useEffect(() => {
if (!agentEdits.size) return;
const end = Math.min(...[...agentEdits.values()].map(e => e.start + editDuration(e)));
const timer = setTimeout(() => {
setAgentEdits(current => new Map(
[...current].filter(([, e]) => e.start + editDuration(e) > performance.now()),
));
}, Math.max(0, end - performance.now()) + 1);
return () => clearTimeout(timer);
}, [agentEdits]);
useEffect(() => setAgentEdits(new Map()), [board?.id]);
const editPositions = useRef(new Map<string, { x: number; y: number }>());
// Keep canvas coordinates through body-only changes, including final height measurement.
const stablePositions = useRef<{
signature: string;
positions: Map<string, { x: number; y: number }>;
} | null>(null);
const positionSignature = (b: Board) => b.id + JSON.stringify(
b.nodes.map(n => [n.id, n.parentId, n.order, n.title]),
);
const putBoard = useCallback((next: Board, record = true, agent = false) => {
const previous = boardRef.current;
if (previous) {
const planned = planAgentEdits(
previous, next, editPositions.current, agent,
matchMedia("(prefers-reduced-motion: reduce)").matches, performance.now(),
);
if (!agent && previous.nodes.some(n => next.nodes.find(x => x.id === n.id)?.body !== n.body))
stablePositions.current = null;
if (planned.size) {
stablePositions.current = {
signature: positionSignature(next), positions: new Map(editPositions.current),
};
}
setAgentEdits(current => {
const retained = new Map([...current].filter(([id, e]) =>
previous.id === next.id && next.nodes.find(n => n.id === id)?.body === e.after,
));
for (const [id, e] of planned) retained.set(id, e);
return retained;
});
}
if (record && previous?.id === next.id)
setPast((p) => [...p.slice(-29), structuredClone(previous)]);
const value = { ...next, updatedAt: now() };
Expand Down Expand Up @@ -457,7 +498,14 @@ export default function App() {
}
if (isCurrent && run.changed && run.status === "running" && run.draft) {
canvasPreviewRunId.current = run.id;
setCanvasPreview(reconcileCanvasResult(boardRef.current!, run.draft));
const current = boardRef.current!;
const preview = reconcileCanvasResult(current, run.draft!);
// Structural preview stays live; existing prose changes only at commit, under its wash.
const bodies = new Map(current.nodes.map(n => [n.id, n.body]));
setCanvasPreview({
...preview,
nodes: preview.nodes.map(n => ({ ...n, body: bodies.get(n.id) ?? n.body })),
});
}
if (run.status === "ready" && !canvasCommitting.current.has(run.id)) {
canvasCommitting.current.add(run.id);
Expand All @@ -480,12 +528,12 @@ export default function App() {
? reconcileCanvasResult(local, result, current ?? undefined)
: result;
if (boardRef.current?.id === run.boardId) {
putBoard(next, run.changed);
putBoard(next, run.changed, true);
setCanvasPreview(null);
canvasWriteLocked.current = false;
} else boardCache.current.set(run.boardId, next);
}
showToast(run.summary || "画布修改已完成,可撤销恢复。");
if (!current || !existingBodyChanges(current, result).length) showToast(run.summary || "画布修改已完成,可撤销恢复。");
} catch (error) {
setCanvasPreview(null);
canvasWriteLocked.current = false;
Expand Down Expand Up @@ -851,7 +899,7 @@ export default function App() {
next = withProposal(next, job.noteId, job.messageId, { state: "applied", added: applied.added.length });
reExpandDone = `已按你的要求重做下层:新增 ${applied.added.length} 张,原来的 ${oldCount} 张已归档,可以在这条回复里撤销。`;
} else if (job) reExpandDone = "这次没有生成可用的新卡,原来的下层没动。可以换个说法,再点应用重试。";
showToast(
if (!existingBodyChanges(target, next).length) showToast(
reExpandDone ? reExpandDone :
applied.added.length
? applied.mergedInto.length
Expand All @@ -871,7 +919,7 @@ export default function App() {
meta.request.mode === "expand" ? "expand" : "map",
meta.request.context,
);
showToast(
if (!existingBodyChanges(target, next).length) showToast(
next.nodes.length === target.nodes.length
? "这次没有找到值得补充的新概念。你仍可以继续提问或稍后再试。"
: "已展开新的概念。你可以继续调整它们。",
Expand Down Expand Up @@ -906,7 +954,7 @@ export default function App() {
enrichRef.current(next, meta.anchor, added);
if (boardRef.current?.id === target.id) {
// A re-expansion that landed is one ⌘Z step; a plain expansion has never been.
putBoard(next, !!meta?.reExpand);
putBoard(next, !!meta?.reExpand, true);
} else {
next = { ...next, updatedAt: now() };
boardCache.current.set(next.id, next);
Expand Down Expand Up @@ -1789,6 +1837,8 @@ export default function App() {
: snapshot;
boardRef.current = previous;
boardCache.current.set(previous.id, previous);
setAgentEdits(new Map());
stablePositions.current = null;
setBoard(previous);
setSaveStatus("saving");
return p.slice(0, -1);
Expand Down Expand Up @@ -2027,7 +2077,7 @@ export default function App() {
if (p.state !== "pending") return;
if (p.type === "editRoute") {
const rows = previewRoute(b, p.ops);
const { board: next, changed } = applyRouteOps(b, p.ops, `chat-${messageId}`, now(), () => newId("concept"));
const { board: next } = applyRouteOps(b, p.ops, `chat-${messageId}`, now(), () => newId("concept"));
if (next === b) return setProposal(noteId, messageId, { state: "expired" });
routeSnapshots.current.set(messageId, {
nodes: b.nodes,
Expand All @@ -2036,10 +2086,8 @@ export default function App() {
cardArchives: b.cardArchives,
links: noteLinks(b),
});
edit(() => next);
putBoard(next, true, true);
setProposal(noteId, messageId, { state: "applied", rows } as Partial<ChatProposal>);
setFlash(new Set(changed));
setTimeout(() => setFlash(new Set()), 1200);
return;
}
if (p.type === "reExpand") {
Expand Down Expand Up @@ -2306,13 +2354,27 @@ export default function App() {
() => layoutTree(board?.nodes ?? [], layoutOptions),
[board?.nodes, layoutOptions],
);
const layout = useMemo(
const computedLayout = useMemo(
() =>
shown === board
? baseLayout
: layoutTree(shown?.nodes ?? [], layoutOptions),
[shown, board, baseLayout, layoutOptions],
);
const layout = useMemo(() => {
const frozen = stablePositions.current;
if (!board || frozen?.signature !== positionSignature(board)) {
stablePositions.current = null;
return computedLayout;
}
return {
...computedLayout,
positions: new Map([...computedLayout.positions].map(([id, pos]) =>
[id, { ...pos, ...frozen.positions.get(id) }],
)),
};
}, [computedLayout, board]);
editPositions.current = new Map([...layout.positions].map(([id, p]) => [id, { x: p.x, y: p.y }]));
const slotRef = useRef<{ id: string; slot: Slot } | null>(null);
configured.current = settings.configured;
/**
Expand Down Expand Up @@ -2620,7 +2682,6 @@ export default function App() {
isNext: nextId === n.id,
lit: !!board.wrap?.lit.includes(n.id),
...(routePreview.get(n.id) ? { preview: routePreview.get(n.id) } : {}),
...(flash.has(n.id) ? { flash: true } : {}),
...(nextId === n.id ? { onStartNext: () => startNextRef.current() } : {}),
// Ghost hints only on the current stop or a card the learner picked (no 大海感).
...(settings.configured && tool === "select" && (!board.route?.length || nextId === n.id || selectedNodeId === n.id)
Expand Down Expand Up @@ -2659,7 +2720,6 @@ export default function App() {
return out;
}, [
routePreview,
flash,
shown,
board,
layout,
Expand Down Expand Up @@ -2844,7 +2904,7 @@ export default function App() {
</div>
);
return (
<main
<AgentEdits.Provider value={agentEdits}><main
style={{ "--sidebar-expanded-w": `${sidebarWidth}px` } as CSSProperties}
className={`app tool-${tool} ${sidebarResizing ? "sidebar-resizing" : ""} ${!mobile ? "with-sidebar" : ""} ${sidebarCollapsed ? "sidebar-collapsed" : ""} ${drawerNote ? "with-drawer" : ""} ${spacePan ? "space-pan" : ""} ${panning ? "is-panning" : ""}`}
>
Expand Down Expand Up @@ -3587,6 +3647,6 @@ export default function App() {
</section>
</div>
)}
</main>
</main></AgentEdits.Provider>
);
}
Loading
Loading