@aiti-ai/aiti-git-trace 0.0.0-stage → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +28 -0
  2. package/LICENSE +21 -0
  3. package/api.ts +424 -0
  4. package/check-client.mjs +273 -0
  5. package/client/src/common.ts +179 -0
  6. package/client/src/entry.tsx +81 -0
  7. package/client/src/host-shim.ts +118 -0
  8. package/client/src/jsx-runtime.ts +2 -0
  9. package/client/src/panels.tsx +1377 -0
  10. package/client/src/trace-app.tsx +275 -0
  11. package/client/src/trace-files-row.pure.ts +88 -0
  12. package/client/src/trace-files-row.tsx +110 -0
  13. package/client/src/version-mention.ts +90 -0
  14. package/deploy-commit.mjs +52 -0
  15. package/dev-pack.mjs +246 -0
  16. package/dist/aiti-git-trace-client.mjs +4 -0
  17. package/dist/entry-C_le59dp.js +1594 -0
  18. package/dist/panels-BpC4XLni.js +1522 -0
  19. package/ext-icons-deploy.mjs +24 -0
  20. package/icon.svg +1 -0
  21. package/icons/git-branch.svg +1 -0
  22. package/icons/git-commit-horizontal.svg +1 -0
  23. package/index.ts +705 -0
  24. package/package.json +33 -4
  25. package/reconcile-deps.mjs +41 -0
  26. package/reload-instances.mjs +60 -0
  27. package/scripts/aiti-trace.mjs +117 -0
  28. package/skills/index.json +1 -0
  29. package/skills/rollback-session/SKILL.md +34 -0
  30. package/skills/trace-ops/SKILL.md +31 -0
  31. package/skills/watch-path/SKILL.md +34 -0
  32. package/test/fixtures/ops-sample.jsonl +3 -0
  33. package/tools/deploy.mjs +103 -0
  34. package/tools/trace-compact.mjs +58 -0
  35. package/trace-core.test.mjs +1120 -0
  36. package/trace-core.ts +1346 -0
  37. package/trace-files-row.test.mjs +66 -0
  38. package/tsconfig.check.json +21 -0
  39. package/version-mention.test.mjs +76 -0
  40. package/vite.config.ts +43 -0
  41. package/README.md +0 -3
@@ -0,0 +1,1377 @@
1
+ /**
2
+ * aiti-git-trace · 重面板层(懒加载 chunk,薄壳拆分 2026-09-13)
3
+ *
4
+ * 只有用户真的打开关注路径/时间线,或 forkGate 需要回撤对话框时,才由 trace-app.tsx
5
+ * (薄壳)动态 import 本模块——开页不再代价(服务端批量包外置本 chunk)。
6
+ * 依赖方向:panels → common(严禁 common/shell 反向静态 import 本文件)。
7
+ */
8
+ import { useState, useEffect, useRef, apiUrl, t, extSvc, appendText, openFile } from "./host-shim";
9
+ import { buildAtText, openNewSessionWithExtSvc } from "../../../_shared/new-session-cwd.ts";
10
+ import { buildVersionMention, resolveDiffLineInfo } from "./version-mention.ts";
11
+ import {
12
+ DockBody, isSidebarDockState, sidebarZoneOf,
13
+ useDockCommands, useDockHeaderState, useDockHeaderStateOf, requestDockCommand,
14
+ } from "../../../_shared/ext-float-dock";
15
+ import type { DockHeaderAction } from "../../../_shared/ext-float-dock";
16
+ import {
17
+ apiGet, apiPost, bindSession, getSessionId, getCwd, useOpenKind, openPanel,
18
+ TIMELINE_KEY, WATCH_KEY,
19
+ panelStyle, btnStyle, chipStyle, inputStyle, scrollLayerStyle, badgeStyle, fmtTime,
20
+ } from "./common";
21
+
22
+ function RefreshSvg() {
23
+ return (
24
+ <svg width={12} height={12} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
25
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
26
+ <path d="M3 3v5h5" />
27
+ </svg>
28
+ );
29
+ }
30
+
31
+ // ---------- 关注路径面板(§7.2) ----------
32
+
33
+ interface WatchPath { path: string; origin: string; repoRoot: string | null; addedAt: number }
34
+
35
+ // dock v3:两面注册(dialogs 浮窗 / sidebarExplorerDock 左栏);face 参数保证只有激活面渲染+加载
36
+ export function WatchPanelSlot(props?: any): any { return renderWatch(props, "float"); }
37
+ export function WatchDockLeft(props?: any): any { return renderWatch(props, "left"); }
38
+ export function WatchDockRight(props?: any): any { return renderWatch(props, "right"); }
39
+
40
+ function renderWatch(props?: any, face?: string): any { bindSession(props);
41
+ const [, setOpen, st] = useOpenKind("watch");
42
+ // face 激活判定(同 renderTimeline):float=浮窗态;left=docked:sidebar-explorer
43
+ // 三面(本批 P2):float=浮窗 / right=右栏 tab / left=左栏区块(sidebar 族任意落位)
44
+ const open = face === "float" ? st === "floating"
45
+ : face === "right" ? st === "docked:right-panel"
46
+ : isSidebarDockState(st);
47
+ const [cwd, setCwd] = useState(getCwd());
48
+ const [key, setKey] = useState("");
49
+ const [config, setConfig] = useState<any>(null);
50
+ const [nogit, setNogit] = useState(false);
51
+ const [loose, setLoose] = useState<{ allowed: boolean; reason: string } | null>(null);
52
+ const [addPath, setAddPath] = useState("");
53
+ const [persist, setPersist] = useState(true);
54
+ const [msg, setMsg] = useState("");
55
+ const [cleanupArm, setCleanupArm] = useState(false);
56
+ const [wsRoot, setWsRoot] = useState("");
57
+ const [sessionStores, setSessionStores] = useState<any[] | null>(null); // 本会话识别的 git 仓(§7.2 session 级)
58
+
59
+ async function resolve(cwdArg?: string) {
60
+ const c = (cwdArg ?? cwd).trim();
61
+ if (!c) { setMsg("cwd?"); return; }
62
+ const r = await apiPost("/workspace/resolve", { cwd: c, session: getSessionId() || undefined });
63
+ if (!r.ok) { setMsg(r.error || "resolve failed"); return; }
64
+ setKey(r.key); setConfig(r.config); setNogit(!!r.nogit); setLoose(r.loose || null); setWsRoot(r.repoRoot || c); setSessionStores(r.sessionStores || []); setMsg("");
65
+ }
66
+ // 每次打开都自动 resolve:cwd 空时取宿主当前会话 cwd / 注册时 cwd(不再一次性闩锁——那会导致面板永远空白)
67
+ useEffect(() => {
68
+ if (!open) return;
69
+ let c = cwd.trim();
70
+ if (!c) {
71
+ const host = SVC_SESSION?.cwd || CURRENT_SESSION?.cwd || "";
72
+ if (host) { setCwd(host); c = host; }
73
+ }
74
+ if (c) resolve(c);
75
+ else setMsg(t("gt.watch.needCwd"));
76
+ }, [open, props?.currentSession?.id]);
77
+
78
+ async function save(next: any) {
79
+ if (!key) return;
80
+ const r = await apiPost("/workspace", { key, ...next });
81
+ if (r.ok) { setConfig(r.config); setMsg(t("gt.watch.saved")); setTimeout(() => setMsg(""), 1500); }
82
+ }
83
+ async function addWatchPath() {
84
+ const p = addPath.trim();
85
+ if (!p || !config) return;
86
+ const r = await apiPost("/watch/resolve", { path: p });
87
+ const entry: WatchPath = {
88
+ path: r.path || p, origin: "user", repoRoot: r.repoRoot || (r.nested && r.nested[0]) || null, addedAt: Date.now(),
89
+ };
90
+ const paths = [...(config.paths || []).filter((x: WatchPath) => x.path !== entry.path), entry];
91
+ setAddPath("");
92
+ await save({ autoDiscover: config.autoDiscover, paths });
93
+ }
94
+ async function delPath(p: string) {
95
+ if (!config) return;
96
+ await save({ autoDiscover: config.autoDiscover, paths: (config.paths || []).filter((x: WatchPath) => x.path !== p) });
97
+ }
98
+ async function toggleAuto() {
99
+ if (!config) return;
100
+ await save({ enabled: config.enabled !== false, autoDiscover: !config.autoDiscover, paths: config.paths || [] });
101
+ }
102
+ // 本工作区文件变更追踪总开关(enabled=false → 引擎彻底不追踪;实时生效,见 gt.watch.traceOffHint)
103
+ async function toggleTrace() {
104
+ if (!config) return;
105
+ await save({ enabled: config.enabled === false, autoDiscover: config.autoDiscover, paths: config.paths || [] });
106
+ }
107
+
108
+ // workspace 深清理:删除本工作区全部会话台账 + 追踪仓(运行中会话引擎自愈重建空仓)
109
+ async function doCleanup() {
110
+ if (!wsRoot) return;
111
+ const r = await apiPost("/cleanup", { scope: "workspace", root: wsRoot });
112
+ setCleanupArm(false);
113
+ setMsg(r.ok ? t("gt.watch.cleanupDone") + ` (${t("gt.watch.cleanupSessions")}: ${r.sessions}${r.storeRemoved ? " · " + t("gt.watch.cleanupStore") : ""})` : (r.error || "cleanup failed"));
114
+ resolve();
115
+ }
116
+
117
+ // dock 头工具条 v3:仅左栏区块 face 注册命令(刷新 = 重新解析工作区配置)
118
+ const watchDockKey = face === "left" ? WATCH_KEY : undefined;
119
+ const hsWatch = useDockHeaderStateOf(WATCH_KEY);
120
+ useDockCommands(watchDockKey, watchDockKey ? { refresh: () => { void resolve(); } } : null);
121
+ useDockHeaderState(watchDockKey, watchDockKey ? { cwd } : null);
122
+
123
+ if (!open) return null;
124
+ const watchFloating = st === "floating";
125
+ const watchContent = (
126
+ <>
127
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: 8 }}>
128
+ {watchFloating && <b>⎇ {t("gt.watch.title")}</b>}
129
+ {watchFloating && <button title={t("gt.common.close")} onClick={() => setOpen(false)} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 18, lineHeight: 1, padding: "0 4px" }}>×</button>}
130
+ </div>
131
+ <div style={scrollLayerStyle}>
132
+ {nogit && loose?.allowed ? (
133
+ <div style={{ border: "1px solid #2da44e", color: "#1a7f37", background: "var(--bg-subtle)", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>
134
+ ⏣ {t("gt.watch.loose")}
135
+ </div>
136
+ ) : nogit ? (
137
+ <div style={{ border: "1px solid #d4a72c", color: "#9a6700", background: "var(--bg-subtle)", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>
138
+ ⚠ {loose?.reason === "no-git-oversize" ? t("gt.watch.oversize") : loose?.reason === "no-git-denylist" ? t("gt.watch.denied") : t("gt.watch.nogit")}
139
+ </div>
140
+ ) : null}
141
+ <div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
142
+ <input style={inputStyle} value={cwd} onChange={(e: any) => setCwd(e.target.value)} placeholder="cwd…" />
143
+ <button style={btnStyle} onClick={resolve}>resolve</button>
144
+ </div>
145
+ {config ? (
146
+ <div>
147
+ <label title={t("gt.watch.traceOffHint")} style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 6, fontWeight: 600 }}>
148
+ <input type="checkbox" checked={config.enabled !== false} onChange={toggleTrace} />
149
+ <span>{t("gt.watch.traceEnabled")}</span>
150
+ </label>
151
+ {config.enabled === false && (
152
+ <div style={{ border: "1px solid #d4a72c", color: "#9a6700", background: "var(--bg-subtle)", borderRadius: 6, padding: "6px 10px", marginBottom: 8 }}>
153
+ ⏸ {t("gt.watch.traceOff")}
154
+ </div>
155
+ )}
156
+ <div style={{ border: "1px dashed var(--border)", opacity: 0.75, borderRadius: 6, padding: "6px 10px", marginBottom: 8, fontSize: 12 }}>
157
+ {t("gt.watch.experimental")}
158
+ </div>
159
+ <label style={{ display: "flex", gap: 6, alignItems: "center", marginBottom: 8 }}>
160
+ <input type="checkbox" checked={!!config.autoDiscover} onChange={toggleAuto} />
161
+ <span>{t("gt.watch.auto")}</span>
162
+ </label>
163
+ {(sessionStores || []).length > 0 && (
164
+ <div style={{ marginBottom: 8 }}>
165
+ <div style={{ fontSize: 10.5, opacity: 0.7, marginBottom: 4 }}>{t("gt.watch.sessionRepos")}</div>
166
+ <div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
167
+ {sessionStores.map((x: any) => {
168
+ const pinned = (config.paths || []).some((p: WatchPath) => p.repoRoot === x.root || p.path === x.root);
169
+ return (
170
+ <div key={x.root} style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 11.5 }}>
171
+ <span style={{ ...badgeStyle("cross"), cursor: "default", marginLeft: 0 }}>{x.role === "primary" ? t("gt.watch.rolePrimary") : t("gt.watch.roleSecondary")}</span>
172
+ <span style={{ fontFamily: "monospace", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", flex: 1, minWidth: 0 }} title={x.root}>{String(x.root).split("/").pop()}</span>
173
+ {pinned ? (
174
+ <span style={{ fontSize: 10.5, opacity: 0.6 }}>{t("gt.watch.pinned")}</span>
175
+ ) : (
176
+ <button style={{ ...btnStyle, padding: "2px 8px", fontSize: 11 }} title={t("gt.watch.pinTip")} onClick={() => {
177
+ const entry: WatchPath = { path: x.root, origin: "user", repoRoot: x.root, addedAt: Date.now() };
178
+ save({ enabled: config.enabled !== false, autoDiscover: config.autoDiscover, paths: [...(config.paths || []), entry] });
179
+ }}>+ {t("gt.watch.pin")}</button>
180
+ )}
181
+ </div>
182
+ );
183
+ })}
184
+ </div>
185
+ </div>
186
+ )}
187
+ <div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
188
+ <input style={inputStyle} value={addPath} onChange={(e: any) => setAddPath(e.target.value)}
189
+ placeholder={t("gt.watch.placeholder")} />
190
+ <button style={btnStyle} onClick={addWatchPath}>{t("gt.watch.add")}</button>
191
+ </div>
192
+ {(config.paths || []).length === 0 ? <div style={{ opacity: 0.7 }}>{t("gt.watch.empty")}</div> : null}
193
+ <div style={{ borderTop: "1px solid var(--border,#eee)", paddingTop: 8, marginTop: 4 }}>
194
+ {!cleanupArm ? (
195
+ <button style={{ ...btnStyle, color: "#c62828", borderColor: "rgba(198,40,40,.5)" }} title={t("gt.watch.cleanupTip")} onClick={() => setCleanupArm(true)}>{t("gt.watch.cleanup")}</button>
196
+ ) : (
197
+ <div style={{ display: "flex", gap: 6, alignItems: "center", flexWrap: "wrap" }}>
198
+ <span style={{ fontSize: 11, color: "#c62828" }}>{t("gt.watch.cleanupConfirm")}</span>
199
+ <button style={{ ...btnStyle, color: "#fff", background: "#c62828", borderColor: "#c62828" }} onClick={doCleanup}>{t("gt.common.confirm")}</button>
200
+ <button style={btnStyle} onClick={() => setCleanupArm(false)}>{t("gt.common.cancel")}</button>
201
+ </div>
202
+ )}
203
+ <div style={{ fontSize: 10.5, opacity: 0.7, marginTop: 4 }}>{t("gt.watch.cleanupTip")}</div>
204
+ </div>
205
+ {(config.paths || []).length > 0 && (
206
+ <table style={{ width: "100%", borderCollapse: "collapse" }}>
207
+ <thead><tr style={{ opacity: 0.7, textAlign: "left" }}>
208
+ <th style={thTd}>{t("gt.watch.origin")}</th><th style={thTd}>{t("gt.watch.repo")}</th><th style={thTd}></th>
209
+ </tr></thead>
210
+ <tbody>
211
+ {(config.paths || []).map((p: WatchPath) => (
212
+ <tr key={p.path}>
213
+ <td style={{ ...thTd, wordBreak: "break-all" }}>
214
+ {p.path} <span style={{ opacity: 0.6 }}>[{p.origin}]</span>
215
+ {!p.repoRoot ? <span style={badgeStyle("nongit")}>{t("gt.watch.nongit")}</span> : null}
216
+ </td>
217
+ <td style={{ ...thTd, opacity: 0.75, whiteSpace: "nowrap" }}>{p.repoRoot ? "✓" : "—"}</td>
218
+ <td style={thTd}><button style={btnStyle} onClick={() => delPath(p.path)}>{t("gt.watch.del")}</button></td>
219
+ </tr>
220
+ ))}
221
+ </tbody>
222
+ </table>
223
+ )}
224
+ {msg ? <div style={{ marginTop: 6, color: "#1a7f37" }}>{msg}</div> : null}
225
+ <div style={{ marginTop: 8, opacity: 0.65, fontSize: 11 }}>key = {key || "—"}</div>
226
+ </div>
227
+ ) : <div style={{ opacity: 0.7 }}>resolve cwd → …</div>}
228
+ </div>
229
+ </>
230
+ );
231
+ // dock v3 分面:floating = 浮窗壳(仅左栏可选、无 default);docked:sidebar-explorer = 左栏 section
232
+ if (watchFloating) {
233
+ return (
234
+ <div style={{ ...panelStyle, zIndex: 10060 }} data-ext-float="1" data-ext-float-full="1" data-ext-float-w={460} data-ext-float-h={640} data-ext-float-key={WATCH_KEY} data-ext-float-dock={`${sidebarZoneOf(WATCH_KEY)} right-panel`} data-ext-float-dock-default={sidebarZoneOf(WATCH_KEY)}>
235
+ {watchContent}
236
+ </div>
237
+ );
238
+ }
239
+ const watchZones: any[] = [sidebarZoneOf(WATCH_KEY), "right-panel"];
240
+ const watchInner = (
241
+ <div style={{ display: "flex", flexDirection: "column", minHeight: 0, height: "100%", color: "var(--text)", fontSize: 12, padding: "8px 10px", boxSizing: "border-box" }}>
242
+ {watchContent}
243
+ </div>
244
+ );
245
+ // 右栏 tab face(chrome="tab":无头行)
246
+ if (face === "right") {
247
+ return (
248
+ <DockBody itemKey={WATCH_KEY} zone="right-panel" title="关注路径" chrome="tab"
249
+ zones={watchZones} defaultZone={sidebarZoneOf(WATCH_KEY)}>{watchInner}</DockBody>
250
+ );
251
+ }
252
+ const watchActions: DockHeaderAction[] = [
253
+ { id: "refresh", title: t("gt.common.refresh") || "刷新", icon: <RefreshSvg/>, visibility: "always", onClick: () => { requestDockCommand(WATCH_KEY, "refresh"); } },
254
+ ];
255
+ return (
256
+ <DockBody itemKey={WATCH_KEY} title="关注路径" chrome="section" kind="dockable"
257
+ zones={watchZones} defaultZone={sidebarZoneOf(WATCH_KEY)}
258
+ headerActions={watchActions}>
259
+ {watchInner}
260
+ </DockBody>
261
+ );
262
+ }
263
+ const thTd: any = { padding: "4px 6px", borderBottom: "1px solid var(--border)", textAlign: "left", verticalAlign: "top" };
264
+
265
+ // ---------- ops 时间线(§8.2) ---------- -->
266
+
267
+ /** [P1-6 链④] 单文件过滤匹配:绝对路径相等,或相对路径后缀命中(root 拼接比对) */
268
+ function fileMatches(e: any, filter: string): boolean {
269
+ const f = String(filter || "");
270
+ if (!f) return true;
271
+ if (e.root && (e.root + "/" + e.path) === f) return true;
272
+ return ("/" + e.path).endsWith("/" + f) || f.endsWith("/" + e.path);
273
+ }
274
+
275
+ // ---------- 文件行操作按钮组(对齐 aiti-doc-center 详情操作范式 §1) ----------
276
+ const actBtnStyle = {
277
+ display: "inline-flex", alignItems: "center", gap: 3,
278
+ padding: "2px 8px", borderRadius: 6, fontSize: 10.5,
279
+ cursor: "pointer", border: "1px solid var(--border)",
280
+ background: "transparent", color: "var(--text-muted)",
281
+ };
282
+ /** 打开按钮高亮(主操作:蓝色背景白字,与 statusColor R/C 同 #0969da) */
283
+ const openBtnStyle = { ...actBtnStyle, background: "#0969da", color: "#fff", borderColor: "#0969da" };
284
+ /** @ 提及文本:cwd 相对路径(与 doc-center docAtText 同源 buildAtText)。
285
+ * path 绝对则原样;相对则拼 root。 */
286
+ function atTextFor(root: string, path: string): string {
287
+ const p = String(path || "");
288
+ const abs = p.startsWith("/") || /^[A-Za-z]:[\\/]/.test(p) ? p : String(root || "").replace(/\/+$/, "") + "/" + p;
289
+ const cwd = String(getCwd() || "");
290
+ const shown = cwd && abs.startsWith(cwd) ? abs.slice(cwd.length).replace(/^[/\\]+/, "") : abs;
291
+ return buildAtText(shown);
292
+ }
293
+ /** 复制路径 + 按钮临时反馈(文案「✓ 已复制」1.2s 后恢复;DOM 直改,免 per-row state) */
294
+ function copyWithFeedback(text: string, btn: any): void {
295
+ try { navigator.clipboard.writeText(text); } catch { /* ignore */ }
296
+ if (!btn) return;
297
+ const orig = btn.textContent;
298
+ btn.textContent = "✓ " + t("gt.timeline.copied");
299
+ btn.style.color = "#1a7f37";
300
+ setTimeout(() => { try { btn.textContent = orig; btn.style.color = ""; } catch { /* ignore */ } }, 1200);
301
+ }
302
+ /** 文件级 @ 到新会话(带版本附文:base..target + 边路仓 show 命令;§3) */
303
+ async function atNewSessionFile(f: any, st: any): Promise<void> {
304
+ const text = buildVersionMention({
305
+ path: String(f.path || ""), root: String(f.root || ""),
306
+ base: st?.base || null, target: st?.target || null,
307
+ });
308
+ const abs = String(f.root || "").replace(/\/+$/, "") + "/" + f.path;
309
+ try {
310
+ await openNewSessionWithExtSvc({
311
+ text, targetDir: String(f.root || ""), targetPath: abs,
312
+ currentCwd: getCwd(), apiUrl, extSvc,
313
+ });
314
+ } catch { /* 新会话派发失败不阻塞 */ }
315
+ }
316
+ /** leaks(外溢)行级 @ 到新会话:无边路 commit,纯路径 @ + 工作区版本附文(§3 无 target 回退) */
317
+ async function atNewSessionLeak(l: any): Promise<void> {
318
+ const text = buildVersionMention({ path: String(l.path || ""), root: "" });
319
+ const dir = String(l.path || "").replace(/[/\\][^/\\]+$/, "") || "/";
320
+ try {
321
+ await openNewSessionWithExtSvc({
322
+ text, targetDir: dir, targetPath: String(l.path || ""),
323
+ currentCwd: getCwd(), apiUrl, extSvc,
324
+ });
325
+ } catch { /* 新会话派发失败不阻塞 */ }
326
+ }
327
+
328
+ // ---------- 变更总览(overview 方案 §2/§4:视图迁移 + MiniDiff + 单文件 diff 拉取) ----------
329
+
330
+ /** 视图状态 v2(§4.2):旧 key 一次性迁移(tree 保留;file/msg → overview,按文件 tab 已退役 Y4) */
331
+ const VIEW_KEY = "aiti-git-trace.timeline.view2";
332
+ const VIEW_OLD_KEY = "aiti-git-trace.timeline.view";
333
+ function loadInitialView(): string {
334
+ try {
335
+ const v2 = localStorage.getItem(VIEW_KEY);
336
+ if (v2 === "overview" || v2 === "msg" || v2 === "tree") return v2;
337
+ const old = localStorage.getItem(VIEW_OLD_KEY);
338
+ if (old === "tree") { localStorage.setItem(VIEW_KEY, "tree"); return "tree"; }
339
+ if (old === "msg" || old === "file") { localStorage.setItem(VIEW_KEY, "overview"); return "overview"; }
340
+ } catch { /* ignore */ }
341
+ return "overview";
342
+ }
343
+
344
+ const repoBase = (root: string) => { const parts = String(root || "").split("/").filter(Boolean); return parts.length ? parts[parts.length - 1] : String(root || ""); };
345
+ const fmtN = (n: any) => Number(n || 0).toLocaleString();
346
+ const statusColor = (s: string) => (s === "A" ? "#1a7f37" : s === "D" ? "#cf222e" : (s === "R" || s === "C") ? "#0969da" : "#9a6700");
347
+
348
+ /** 轻量 unified diff 渲染器(§4.4):hunk 头灰条 / + 绿底 / − 红底 / 上下文淡色;
349
+ * max-height 320px 内滚动;增量渲染(Y2):初渲染 400 行,「显示更多」每批 +400(服务端截断线 2000 行)。 */
350
+ function MiniDiff(props: { text: string; truncated: boolean; total?: number; onOpenReview?: () => void;
351
+ root?: string; base?: string | null; target?: string | null; path?: string }): any {
352
+ const [limit, setLimit] = useState(400);
353
+ const lines = String(props.text || "").split("\n");
354
+ const shown = lines.slice(0, limit);
355
+ const rowStyle = (ln: string): any =>
356
+ ln.startsWith("@@") ? { color: "var(--text-dim)", background: "var(--bg-subtle)" }
357
+ : ln.startsWith("+") ? { color: "#1a7f37", background: "rgba(46,160,67,.13)" }
358
+ : ln.startsWith("-") ? { color: "#cf222e", background: "rgba(207,34,46,.10)" }
359
+ : { color: "var(--text-muted)" };
360
+ // 行级 @ 到新会话(§3):resolveDiffLineInfo 算行号 + buildVersionMention 带边路仓 git 命令
361
+ const canMention = !!(props.root && props.path);
362
+ const onRowMention = (i: number): void => {
363
+ if (!canMention) return;
364
+ const info = resolveDiffLineInfo(lines, i);
365
+ const text = buildVersionMention({
366
+ path: props.path!, root: props.root!,
367
+ base: props.base || null, target: props.target || null,
368
+ line: info.line, lineKind: info.lineKind,
369
+ });
370
+ const abs = String(props.root || "").replace(/\/+$/, "") + "/" + props.path;
371
+ openNewSessionWithExtSvc({
372
+ text, targetDir: props.root!, targetPath: abs,
373
+ currentCwd: getCwd(), apiUrl, extSvc,
374
+ }).catch(() => { /* ignore */ });
375
+ };
376
+ const miniAtBtn = { flexShrink: 0, marginLeft: 4, padding: "0 4px", fontSize: 10, cursor: "pointer", border: "1px solid var(--border)", background: "transparent", color: "var(--text-muted)", borderRadius: 4 } as const;
377
+ return (
378
+ <div style={{ border: "1px solid var(--border)", borderRadius: 6, marginBottom: 8, overflow: "hidden" }}>
379
+ <div style={{ maxHeight: 320, overflowY: "auto", background: "var(--bg)", fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", fontSize: 11.5, lineHeight: 1.45 }}>
380
+ {shown.map((ln: string, i: number) => (
381
+ <div key={i} style={{ ...rowStyle(ln), padding: "0 8px", display: "flex", alignItems: "center" }}>
382
+ <span style={{ flex: 1, whiteSpace: "pre-wrap", wordBreak: "break-all" }}>{ln === "" ? " " : ln}</span>
383
+ {canMention ? <button style={miniAtBtn} title={t("gt.tl.rowMention")} onClick={() => onRowMention(i)}>@</button> : null}
384
+ </div>
385
+ ))}
386
+ </div>
387
+ <div style={{ display: "flex", gap: 8, alignItems: "center", padding: "4px 8px", borderTop: "1px solid var(--border)", fontSize: 11, flexWrap: "wrap" }}>
388
+ {lines.length > limit ? (
389
+ <button style={chipStyle} onClick={() => setLimit((l) => l + 400)}>{t("gt.ov.more")}</button>
390
+ ) : null}
391
+ {props.truncated ? <span style={{ opacity: 0.7 }}>{t("gt.ov.diffTruncated", { n: String(props.total ?? lines.length) })}</span> : null}
392
+ {props.onOpenReview ? <button style={chipStyle} onClick={props.onOpenReview}>{t("gt.ov.openReview")}</button> : null}
393
+ </div>
394
+ </div>
395
+ );
396
+ }
397
+
398
+ /** 单文件区间 diff(§2 展开懒拉 /file-diff;挂载即取,卸载即弃) */
399
+ function FileDiffBox(props: { root: string; sid: string; base: string; target: string; path: string; onOpenReview: () => void }): any {
400
+ const [st, setSt] = useState<any>({ loading: true });
401
+ useEffect(() => {
402
+ let dead = false;
403
+ setSt({ loading: true });
404
+ const q = "/file-diff?root=" + encodeURIComponent(props.root)
405
+ + "&session=" + encodeURIComponent(props.sid || "")
406
+ + "&base=" + encodeURIComponent(props.base) + "&target=" + encodeURIComponent(props.target)
407
+ + "&path=" + encodeURIComponent(props.path);
408
+ apiGet(q).then((r: any) => {
409
+ if (dead) return;
410
+ if (r && r.ok) setSt({ loading: false, text: r.text || "", truncated: !!r.truncated, total: r.total || 0 });
411
+ else setSt({ loading: false, error: (r && r.error) || "diff failed" });
412
+ }).catch(() => { if (!dead) setSt({ loading: false, error: "diff failed" }); });
413
+ return () => { dead = true; };
414
+ }, [props.root, props.sid, props.base, props.target, props.path]);
415
+ if (st.loading) return <div style={{ opacity: 0.6, fontSize: 11, padding: "2px 0 6px" }}>{t("gt.ov.loading")}</div>;
416
+ if (st.error) return <div style={{ color: "#cf222e", fontSize: 11, padding: "2px 0 6px" }}>{st.error}</div>;
417
+ if (!String(st.text || "").trim()) return <div style={{ opacity: 0.6, fontSize: 11, padding: "2px 0 6px" }}>{t("gt.ov.noFiles")}</div>;
418
+ return <MiniDiff text={st.text} truncated={st.truncated} total={st.total} onOpenReview={props.onOpenReview} root={props.root} base={props.base} target={props.target} path={props.path} />;
419
+ }
420
+
421
+ // ---------- ops 时间线(§8.2) ----------
422
+ // dock v3:同一内容三面注册(dialogs 浮窗 / rightFileDock 右栏 / sidebarExplorerDock 左栏)——
423
+ // face 参数保证「只有激活面的实例渲染 + 跑数据加载」,三实例互不重复渲染。
424
+
425
+ export function TimelineSlot(props?: any): any { return renderTimeline(props, "float"); }
426
+ export function TimelineDockRight(props?: any): any { return renderTimeline(props, "right"); }
427
+ export function TimelineDockLeft(props?: any): any { return renderTimeline(props, "left"); }
428
+
429
+ function renderTimeline(props?: any, face?: string): any { bindSession(props);
430
+ const [sessionId, setSessionId] = useState<string>(() => getSessionId());
431
+ // 统一会话服务 active / 宿主 currentSession 晚到:挂载早期 sessionId 为空时补全
432
+ //(去 URL 兑底后初始值可能为空;props 下发与 active 同步)。
433
+ const hostSid = props?.currentSession?.id || "";
434
+ useEffect(() => {
435
+ if (!sessionId && hostSid) setSessionId(hostSid);
436
+ }, [hostSid, sessionId]);
437
+ const [ops, setOps] = useState<any[] | null>(null);
438
+ const [showAll, setShowAll] = useState<boolean>(() => { try { return localStorage.getItem("aiti-git-trace.timeline.showAll") === "1"; } catch { return false; } });
439
+ // 视图模式 v2(§4.2):overview = 变更总览(新默认);msg = 按操作;tree = 家谱。
440
+ // 旧 key 一次性迁移(tree 保留,file/msg → overview);「按文件」tab 已退役(Y4,总览展开区全覆盖)。
441
+ const [view, setView] = useState<string>(loadInitialView);
442
+ const [lineage, setLineage] = useState<any | null>(null); // 家谱(§3.3):ancestors/self/descendants
443
+ const [expandedFiles, setExpandedFiles] = useState<string[]>([]); // 总览展开行(root\u0000path)
444
+ // 变更总览(§2):/files 聚合数据 + 轮次过滤(entryIds)+ 手动刷新 tick + 外部自动展开钩子(场景 7)
445
+ const [filesData, setFilesData] = useState<any | null>(null);
446
+ const [filesLoading, setFilesLoading] = useState(false);
447
+ const [turnFilter, setTurnFilter] = useState(""); // userEntryId(徽标入口自动定位到该轮,场景 3)
448
+ const [filesTick, setFilesTick] = useState(0); // 手动刷新 / SSE debounce / 回撤后强制重拉 /files
449
+ const [ovRbOpen, setOvRbOpen] = useState(false); // 总览「↩ 回撤记录」折叠条
450
+ const autoExpandRef = useRef<string | null>(null);
451
+ const [selGroups, setSelGroups] = useState<string[]>([]); // 勾选参与跨组评审的 prompt(范围 = 最小~最大组)
452
+ const [wsSessions, setWsSessions] = useState<any[]>([]); // 当前 workspace 全部会话(与左侧列表同源 /api/sessions)
453
+ const [cleanArm, setCleanArm] = useState<null | { beforeOp?: number }>(null); // 清理确认态(§15)
454
+ const [fileFilter, setFileFilter] = useState<string | null>(null); // [P1-6 链④] 单文件过滤(绝对路径或相对路径后缀匹配)
455
+ const [planRel, setPlanRel] = useState<string | null>(null); // [P1-3 意图维 join] 本会话方案绑定(services.plans)
456
+ const externalOpenRef = useRef(false); // [P1-6] 外部指定会话打开:抑制一次宿主会话重同步
457
+ const [, setOpen, st] = useOpenKind("timeline", (d) => {
458
+ if (d?.sessionId) { setSessionId(d.sessionId); externalOpenRef.current = true; }
459
+ // 场景 3:消息徽标带 entryId → 总览自动过滤到该轮(§5 入口映射)
460
+ if (d?.entryId) setTurnFilter(String(d.entryId));
461
+ // 场景 7(Y4 兼容映射):core FileViewer 的 view:"file" → 总览 + 单文件过滤 + 自动展开(core 零改动)
462
+ if (d?.view === "file") {
463
+ setView("overview");
464
+ try { localStorage.setItem(VIEW_KEY, "overview"); } catch { /* ignore */ }
465
+ }
466
+ if (d?.file != null) { setFileFilter(String(d.file)); autoExpandRef.current = String(d.file); }
467
+ });
468
+ // face 激活判定:只有激活面的实例渲染/加载数据(dialogs=浮窗态;right/left=对应停靠态)
469
+ const open = face === "float" ? st === "floating"
470
+ : face === "right" ? st === "docked:right-panel"
471
+ : face === "left" ? isSidebarDockState(st) : false;
472
+
473
+ async function load(sid?: string) { const s = sid ?? sessionId ?? getSessionId();
474
+ if (!s) return;
475
+ setSessionId(s);
476
+ const r = await apiGet("/ops?session=" + encodeURIComponent(s));
477
+ if (r.ok) { setOps(r.ops || []); setSelGroups([]); setPlanRel(r.plan?.planRel ?? null); }
478
+ }
479
+
480
+ // 当前 workspace 全部会话(与左侧列表同源):以当前会话 projectKey 归组,modified 倒序
481
+ useEffect(() => {
482
+ if (!open) return;
483
+ let dead = false;
484
+ fetch(apiUrl("/api/sessions")).then((r) => r.json()).then((d) => {
485
+ if (dead) return;
486
+ const all = (d && d.sessions) || [];
487
+ const curId = SVC_SESSION?.id || CURRENT_SESSION?.id || sessionId;
488
+ const cur = all.find((x: any) => x.id === curId);
489
+ const pk = cur?.projectKey;
490
+ const cwd = cur?.cwd || SVC_SESSION?.cwd || CURRENT_SESSION?.cwd || "";
491
+ const list = all
492
+ .filter((x: any) => (pk ? x.projectKey === pk : cwd ? (x.cwd || "").startsWith(cwd) : true))
493
+ .sort((a: any, b: any) => (b.modified || 0) - (a.modified || 0));
494
+ setWsSessions(list);
495
+ // 欢迎页(宿主无 currentSession):自动落到 workspace 最近一个会话
496
+ if (!sessionId && list.length) { setSessionId(list[0].id); load(list[0].id); }
497
+ }).catch(() => {});
498
+ return () => { dead = true; };
499
+ }, [open, sessionId]);
500
+ const sessName = (x: any) => x.name || x.firstMessage || x.id.slice(0, 8);
501
+ // 家谱数据:进 tree 视图 / 切会话时拉取(parent 链聚合,服务端 lineage)
502
+ useEffect(() => {
503
+ if (!open || view !== "tree" || !sessionId) return;
504
+ let dead = false;
505
+ apiGet("/lineage?session=" + encodeURIComponent(sessionId)).then((r: any) => {
506
+ if (!dead && r && r.ok) setLineage(r);
507
+ }).catch(() => {});
508
+ return () => { dead = true; };
509
+ }, [open, view, sessionId]);
510
+
511
+ // 变更总览数据(§2/§3.1):/files 聚合(轮次过滤 = entryIds,切换重算一次);只读端点
512
+ useEffect(() => {
513
+ if (!open || view !== "overview" || !sessionId) return;
514
+ let dead = false;
515
+ setFilesLoading(true);
516
+ const q = "/files?session=" + encodeURIComponent(sessionId) + (turnFilter ? "&entryIds=" + encodeURIComponent(turnFilter) : "");
517
+ apiGet(q).then((r: any) => {
518
+ if (dead) return;
519
+ const ok = r && r.ok ? r : null;
520
+ setFilesData(ok);
521
+ setFilesLoading(false);
522
+ // 场景 7:外部单文件过滤(view:"file" 映射)→ 自动展开命中文件
523
+ const f = autoExpandRef.current;
524
+ if (f && ok && Array.isArray(ok.files)) {
525
+ const hit = ok.files.find((x: any) => fileMatches(x, f));
526
+ if (hit) {
527
+ const key = hit.root + "\u0000" + hit.path;
528
+ setExpandedFiles((s) => (s.includes(key) ? s : [...s, key]));
529
+ }
530
+ autoExpandRef.current = null;
531
+ }
532
+ }).catch(() => { if (!dead) { setFilesData(null); setFilesLoading(false); } });
533
+ return () => { dead = true; };
534
+ }, [open, view, sessionId, turnFilter, filesTick]);
535
+
536
+ // 实时化(§4.6 + Y1):面板开着时接 /events SSE,当前会话 op 事件 → 800ms trailing debounce
537
+ // 刷新总览与操作视图(事件驱动非轮询,防 op 事件连爆每事件重跑 git diff);关面板/卸载/换会话即断开。
538
+ useEffect(() => {
539
+ if (!open || !sessionId) return;
540
+ let dead = false;
541
+ let timer: ReturnType<typeof setTimeout> | null = null;
542
+ let es: EventSource | null = null;
543
+ try {
544
+ es = new EventSource(apiUrl(EXT_API + "/events"));
545
+ es.addEventListener("op", (e: any) => {
546
+ try {
547
+ const d = JSON.parse(String(e?.data || "{}"));
548
+ if (d?.sessionId !== sessionId) return;
549
+ } catch { return; }
550
+ if (timer) clearTimeout(timer);
551
+ timer = setTimeout(() => {
552
+ if (dead) return;
553
+ load(sessionId);
554
+ setFilesTick((x) => x + 1);
555
+ }, 800);
556
+ });
557
+ } catch { /* EventSource 不可用:仅失实时化,手动刷新不受影响 */ }
558
+ return () => {
559
+ dead = true;
560
+ if (timer) clearTimeout(timer);
561
+ try { es?.close(); } catch { /* ignore */ }
562
+ };
563
+ }, [open, sessionId]);
564
+
565
+ // 每次打开都重同步宿主当前会话并重新加载(旧实现 ops===null 一次性条件导致重开显示上一个会话的旧数据)
566
+ useEffect(() => {
567
+ if (!open) return;
568
+ const sid = getSessionId();
569
+ // [P1-6] 外部指定会话打开(⎇/OPEN_EVENT):不重同步宿主会话——但仍要加载指定会话
570
+ // (2026-09-08 回归修复:此前直接 return,而所有入口 openPanel("timeline",{sessionId}) 都会置位 ref,
571
+ // 导致打开时间线从不加载,ops 恒 null 面板恒空)
572
+ if (externalOpenRef.current) { externalOpenRef.current = false; load(sessionId || sid); return; }
573
+ if (sid && sid !== sessionId) setSessionId(sid); // 换会话:重同步(不清空 ops——加载完成即整体替换,避免竞态空窗)
574
+ load(sid || sessionId); // 每次打开都加载(旧实现 ops===null 一次性条件导致重开显示上一个会话的旧数据)
575
+ }, [open, props?.currentSession?.id]);
576
+
577
+ async function rollbackOp(op: any) {
578
+ // [P2-7] 跨仓 op:与 viewDiff 同款选仓策略——优先「before 窗口内实际有增量」的仓,
579
+ // 退而求其次 before≠after 的仓,最后才 stores[0](跨仓 op 此前一律回滚首仓,
580
+ // 增量在副仓时按钮「成功」却什么都没回滚)
581
+ const stores = op.stores || [];
582
+ const filesN = (x: any) => (x.files ? x.files.M + x.files.A + x.files.D : 0);
583
+ const store = stores.find((x: any) => x.before && filesN(x) > 0)
584
+ || stores.find((x: any) => x.before && x.after && x.before !== x.after) || stores[0];
585
+ if (!store) return;
586
+ // 逐 op patch 式部分回滚(§6.3/M4-1):只反应用本 op 的文件变更,不整树 reset——
587
+ // 对共享工作区(并行会话/用户手改)更安全;partial 仓(before=null)不可逆向,提示最早可恢复点
588
+ if (!store.before) { (window as any).alert(t("gt.timeline.partialHint") || "该 op 为首触(无 before),最早可恢复点为其 after 提交"); return; }
589
+ if (!(window as any).confirm(t("gt.timeline.confirm"))) return;
590
+ const r = await apiPost("/rollback-partial", {
591
+ storeRoot: store.root, sessionId,
592
+ base: store.before, target: store.after,
593
+ files: store.paths || [], hunkIdx: [],
594
+ });
595
+ if (r && r.ok) load();
596
+ else (window as any).alert((r && r.error) || "rollback failed");
597
+ }
598
+ // 撤销回撤:对回撤行 stores[].before→after(= safety→记账 commit 的回撤净效果)反向 patch,
599
+ // 恢复到回撤前状态;自身也走 trace 记账(方案 §7:回撤全程双向可逆)。
600
+ async function undoLedgerRollback(store: any) {
601
+ if (!store.before || !store.after) { (window as any).alert(t("gt.timeline.partialHint") || "无可逆基线"); return; }
602
+ if (!(window as any).confirm(t("gt.timeline.confirm"))) return;
603
+ const r = await apiPost("/rollback-reversed", {
604
+ storeRoot: store.root, sessionId,
605
+ refs: [{ op: -1, before: store.before, after: store.after }],
606
+ });
607
+ if (r && r.ok) load();
608
+ else (window as any).alert((r && r.error) || "撤销回撤失败");
609
+ }
610
+ function viewDiff(op: any, store?: any, files?: string[]) {
611
+ // 打开 aiti-git-review 的 trace 任务(§6.2/6.3):优先全局开启器,未挂载则 URL 深链
612
+ // 跨仓 op:优先选「before 窗口内实际有变更」的仓——before=null 基线仓计数再大也不是增量,且无 base 打不开任务
613
+ const stores = op.stores || [];
614
+ const filesN = (x: any) => (x.files ? x.files.M + x.files.A + x.files.D : 0);
615
+ const s = store || stores.find((x: any) => x.before && filesN(x) > 0)
616
+ || stores.find((x: any) => x.before && x.after && x.before !== x.after) || stores[0];
617
+ if (!s || !s.after) { (window as any).alert(t("gt.timeline.empty")); return; }
618
+ if (!s.before) { (window as any).alert(t("gt.timeline.partialHint")); return; } // 纯基线 op:无 before 可看 diff
619
+ const sid = op.sessionId || sessionId || getSessionId();
620
+ const payload: any = { source: "trace", storeRoot: s.root, sessionId: sid, base: s.before, target: s.after };
621
+ if (files && files.length) payload.files = files;
622
+ const opener = extSvc<(o?: any) => void>("aiti-git-review.open") || (window as any).__aitiGitReviewOpen; // DI 方案 P2 双轨
623
+ if (typeof opener === "function") { opener(payload); return; }
624
+ // git-review 客户端未就绪:URL 深链(其客户端挂载时自动 applyOpen;数组序列化为 JSON)
625
+ const q = new URLSearchParams(location.search);
626
+ for (const [k, v] of Object.entries(payload)) q.set(k, Array.isArray(v) ? JSON.stringify(v) : String(v));
627
+ location.href = location.pathname + "?" + q.toString();
628
+ }
629
+
630
+ // prompt(组)范围评审:keys = 选中 msgSeq 组;范围 = 最小~最大组(含中间连续组,台账分支追加式)
631
+ function groupAnchor(op: any) {
632
+ const stores = op.stores || [];
633
+ return stores.find((x: any) => x.before && x.after && x.before !== x.after) || stores[0] || null;
634
+ }
635
+ function reviewGroups(keys: string[], groups: Record<string, any[]>) {
636
+ const nums = keys.map(Number).filter((n) => !isNaN(n)).sort((a, b) => a - b);
637
+ if (!nums.length) return;
638
+ const first = String(nums[0]); const last = String(nums[nums.length - 1]);
639
+ const opsA = groups[first] || []; const opsB = groups[last] || opsA;
640
+ const a0 = opsA.map(groupAnchor).find((x: any) => x && (x.before || x.after));
641
+ const bN = [...opsB].reverse().map(groupAnchor).find((x: any) => x && x.after);
642
+ if (!a0 || !bN || !bN.after) { (window as any).alert(t("gt.timeline.empty")); return; }
643
+ const base = a0.before || a0.after;
644
+ if (base === bN.after) { (window as any).alert(t("gt.timeline.empty")); return; }
645
+ const sid = opsA[0]?.sessionId || sessionId || getSessionId();
646
+ const label = nums.length === 1 ? ("prompt #" + first) : ("prompt #" + first + " - #" + last + "(含中间连续组)");
647
+ const payload: any = { source: "trace", storeRoot: a0.root || bN.root, sessionId: sid, base, target: bN.after, label };
648
+ const opener = extSvc<(o?: any) => void>("aiti-git-review.open") || (window as any).__aitiGitReviewOpen; // DI 方案 P2 双轨
649
+ if (typeof opener === "function") { opener(payload); return; }
650
+ const q = new URLSearchParams(location.search);
651
+ for (const [k, v] of Object.entries(payload)) q.set(k, String(v));
652
+ location.href = location.pathname + "?" + q.toString();
653
+ }
654
+ function toggleSelGroup(k: string) { setSelGroups((s0) => (s0.includes(k) ? s0.filter((x) => x !== k) : [...s0, k])); }
655
+
656
+ // 台账清理(§15):整会话 / 某 op 之前;服务端改写台账,前端本地同步(总览一并刷新)
657
+ async function doClean(beforeOp?: number) {
658
+ if (!sessionId) return;
659
+ const r = await apiPost("/cleanup", beforeOp ? { scope: "session", session: sessionId, beforeOp } : { scope: "session", session: sessionId });
660
+ setCleanArm(null);
661
+ if (r.ok) { setOps(beforeOp ? (ops || []).filter((o: any) => o.op >= beforeOp) : []); setSelGroups([]); setFilesTick((x) => x + 1); }
662
+ }
663
+
664
+ // ── 视图切换(v2 key 持久化,§4.2)──
665
+ function switchView(v: string) {
666
+ setView(v);
667
+ try { localStorage.setItem(VIEW_KEY, v); } catch { /* ignore */ }
668
+ }
669
+ function toggleFile(k: string) { setExpandedFiles((s) => (s.includes(k) ? s.filter((x) => x !== k) : [...s, k])); }
670
+ // 总览「在评审中打开完整 diff」(§2):文件级区间(服务端解析的 store base/target)→ git-review
671
+ function viewRangeDiff(f: any, st: any) {
672
+ const label = (ovMultiRoot ? repoBase(f.root) + "/" : "") + f.path;
673
+ const payload: any = { source: "trace", storeRoot: f.root, sessionId: sessionId || getSessionId(), base: st.base, target: st.target, label, files: [f.path] };
674
+ const opener = extSvc<(o?: any) => void>("aiti-git-review.open") || (window as any).__aitiGitReviewOpen; // DI 方案 P2 双轨
675
+ if (typeof opener === "function") { opener(payload); return; }
676
+ // git-review 客户端未就绪:URL 深链(files 序列化为 JSON,其客户端挂载时自动 applyOpen)
677
+ const q = new URLSearchParams(location.search);
678
+ for (const [k, v] of Object.entries(payload)) q.set(k, Array.isArray(v) ? JSON.stringify(v) : String(v));
679
+ location.href = location.pathname + "?" + q.toString();
680
+ }
681
+
682
+ // dock 头工具条 v3:仅左栏区块 face 注册命令(刷新 = 重拉变更总览/时间线)
683
+ const tlDockKey = face === "left" ? TIMELINE_KEY : undefined;
684
+ const hsTl = useDockHeaderStateOf(TIMELINE_KEY);
685
+ useDockCommands(tlDockKey, tlDockKey ? { refresh: () => { setFilesTick((v: number) => v + 1); void load(); } } : null);
686
+ useDockHeaderState(tlDockKey, tlDockKey ? { ops: (ops || []).length } : null);
687
+
688
+ if (!open) return null;
689
+ // 只认 before→after 窗口内的增量:before=null 是首触全树基线(非增量),不计入 ±N、
690
+ // 不参与默认过滤——否则全仓库文件都会被当成“变更”(并行污染同理被窗口自然排除)
691
+ const filesNOf = (op: any) => (op.stores || []).reduce((a: number, x: any) =>
692
+ a + (x.before && x.files ? (x.files.M || 0) + (x.files.A || 0) + (x.files.D || 0) : 0), 0);
693
+ // 默认只看有文件变更的操作(无变更读类操作噪音大);开关显示全部——操作视图过滤
694
+ // 回撤记账行(kind:"rollback")单独抽出来:不进 msgSeq 分组,独立「↩ 回撤记录」区块展示。
695
+ // [P1-1 尾巴] plan-accept/plan-discard(op-less 记账行)同样抽出:独立「📋 方案采认记录」区块。
696
+ const rollbackRows = (ops || []).filter((op: any) => op?.kind === "rollback");
697
+ const planRows = (ops || []).filter((op: any) => op?.kind === "plan-accept" || op?.kind === "plan-discard");
698
+ const visibleOps = (ops || []).filter((op: any) => op?.kind !== "rollback" && op?.kind !== "plan-accept" && op?.kind !== "plan-discard" && (showAll || filesNOf(op) > 0 || (op.leaks || []).length > 0));
699
+ // 分组头:#N + 用户 prompt(一行截断,title 全文;无 prompt 回退 prompt #N)
700
+ const groups: Record<string, any[]> = {};
701
+ for (const op of visibleOps) {
702
+ const k = String(op.msgSeq ?? "?");
703
+ (groups[k] = groups[k] || []).push(op);
704
+ }
705
+ // ── 变更总览派生(§2):服务端 /files 聚合 → 行/汇总/多仓标注(fileMap/fileIndex 客户端聚合已退役,Y4)──
706
+ const ovFiles: any[] = Array.isArray(filesData?.files) ? filesData.files : [];
707
+ const ovTotals: any = filesData?.totals || null;
708
+ const ovStores: any[] = Array.isArray(filesData?.stores) ? filesData.stores : [];
709
+ const storeByRoot = new Map(ovStores.map((s: any) => [s.root, s]));
710
+ const ovMultiRoot = new Set(ovFiles.map((f: any) => f.root)).size > 1; // 跨仓会话路径带仓根标注消歧
711
+ const ovHits = fileFilter ? ovFiles.filter((f: any) => fileMatches(f, fileFilter)) : ovFiles;
712
+ const ovMissing = ovStores.length > 0 && ovStores.every((s: any) => s.storeMissing);
713
+ const ovPerRoot = (() => {
714
+ const m = new Map<string, { root: string; adds: number; dels: number; files: number }>();
715
+ for (const f of ovFiles) {
716
+ const e = m.get(f.root) || { root: f.root, adds: 0, dels: 0, files: 0 };
717
+ e.adds += f.adds || 0; e.dels += f.dels || 0; e.files += 1;
718
+ m.set(f.root, e);
719
+ }
720
+ return [...m.values()];
721
+ })();
722
+ // 轮次过滤下拉选项(§2):从台账 prompt 分组构建(徽标入口自动定位到该轮)
723
+ const turnOptions: { id: string; label: string }[] = [];
724
+ {
725
+ const seenT = new Set<string>();
726
+ for (const op of ops || []) {
727
+ const id = String(op?.userEntryId || op?.entryId || "");
728
+ if (!id || seenT.has(id)) continue;
729
+ seenT.add(id);
730
+ turnOptions.push({ id, label: "#" + String(op.msgSeq ?? "?") + " · " + String(op.prompt || t("gt.timeline.group")).slice(0, 40) });
731
+ }
732
+ }
733
+ // 「↩ 回撤记录」区块(总览折叠条与操作视图共用)
734
+ const rollbackRowsBlock = (
735
+ <div style={{ border: "1px solid #f0c674", borderRadius: 6, overflow: "hidden" }}>
736
+ <div style={{ padding: "6px 10px", background: "rgba(212,167,44,.12)", fontWeight: 600, fontSize: 12, color: "#9a6700" }}>↩ {rollbackRows.length} 次回撤记录</div>
737
+ {rollbackRows.map((op: any) => {
738
+ const rb = op.rollback || {};
739
+ const stores = op.stores || [];
740
+ return (
741
+ <div key={String(op.t1) + String(op.summary || "")} style={{ padding: "6px 10px", borderBottom: "1px solid var(--border)" }}>
742
+ <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
743
+ <b style={{ color: "#9a6700" }}>↩</b>
744
+ <span title={String(op.summary || "")} style={{ opacity: 0.9, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1, minWidth: 0 }}>{String(op.summary || "")}</span>
745
+ <span style={{ opacity: 0.5, fontSize: 11, flexShrink: 0 }}>{fmtTime(op.t1)}</span>
746
+ </div>
747
+ <div style={{ display: "flex", gap: 6, marginTop: 4, alignItems: "center", flexWrap: "wrap" }}>
748
+ {(rb.targetOps || []).length ? <span style={{ fontSize: 11, opacity: 0.7 }}>撤销 op:{rb.targetOps.join(",")}</span> : null}
749
+ {stores.slice(0, 2).map((s: any) => (
750
+ <span key={s.root} style={{ fontSize: 11, fontFamily: "monospace", opacity: 0.7 }}>{String(s.root).split("/").pop()}: {s.before?.slice(0, 7)}→{s.after?.slice(0, 7)}</span>
751
+ ))}
752
+ <button style={chipStyle} title={t("gt.timeline.viewdiff")} onClick={() => viewDiff({ stores }, stores[0])}>{t("gt.timeline.viewdiff")}</button>
753
+ <button style={chipStyle} title={t("gt.timeline.rollback")} onClick={() => stores[0] && undoLedgerRollback(stores[0])}>{t("gt.timeline.rollback")}</button>
754
+ </div>
755
+ </div>
756
+ );
757
+ })}
758
+ </div>
759
+ );
760
+ // [P1-1 尾巴] 方案采认/丢弃记账行区块(op-less 行;沙箱采认已入血缘台账,时间线可见可答
761
+ // 「这行是沙箱采认来的」)。样式与回撤区块同构(紫调区分)。
762
+ const planRowsBlock = (
763
+ <div style={{ border: "1px solid #b6a6e8", borderRadius: 6, overflow: "hidden" }}>
764
+ <div style={{ padding: "6px 10px", background: "rgba(122,98,220,.10)", fontWeight: 600, fontSize: 12, color: "#5b45b8" }}>{planRows.length} 条方案采认记录</div>
765
+ {planRows.map((op: any, i: number) => (
766
+ <div key={String(op.t) + String(i)} style={{ padding: "6px 10px", borderBottom: "1px solid var(--border)" }}>
767
+ <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
768
+ <b style={{ color: op.kind === "plan-accept" ? "#0a7d33" : "#9a6700", flexShrink: 0 }}>{op.kind === "plan-accept" ? "采认" : "丢弃"}</b>
769
+ <span title={String(op.summary || "")} style={{ opacity: 0.9, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1, minWidth: 0 }}>{String(op.summary || "")}</span>
770
+ <span style={{ opacity: 0.5, fontSize: 11, flexShrink: 0 }}>{fmtTime(op.t)}</span>
771
+ </div>
772
+ <div style={{ display: "flex", gap: 6, marginTop: 4, alignItems: "center", flexWrap: "wrap" }}>
773
+ {(op.files || []).length ? <span style={{ fontSize: 11, opacity: 0.7 }} title={(op.files || []).join("\n")}>{op.files.length} 文件</span> : null}
774
+ {op.mission ? <span style={{ fontSize: 11, fontFamily: "monospace", opacity: 0.6 }}>mission {String(op.mission).slice(0, 8)}</span> : null}
775
+ {op.sandbox ? <span style={{ fontSize: 11, fontFamily: "monospace", opacity: 0.5 }} title={String(op.sandbox)}>sandbox</span> : null}
776
+ </div>
777
+ </div>
778
+ ))}
779
+ </div>
780
+ );
781
+ const panelW = view === "overview" ? 640 : 560; // §4.3 总览视图加宽
782
+ const floating = face === "float";
783
+ const panelContent = (
784
+ <>
785
+ {/* 行 1(§4.1):标题 + 会话选择器(flex 拉伸)+ ↻ + 🧹 + 📋 方案绑定 + 关注路径 + ×(✕/标题仅浮窗态——停靠 chrome 已带) */}
786
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 8 }}>
787
+ {floating && <b style={{ flexShrink: 0 }}>⎇ {t("gt.timeline.title")}</b>}
788
+ <select
789
+ value={sessionId}
790
+ onChange={(e: any) => { setSessionId(e.target.value); setTurnFilter(""); load(e.target.value); }}
791
+ title={sessionId}
792
+ style={{ ...inputStyle, flex: 1, minWidth: 0, cursor: "pointer" }}
793
+ >
794
+ {sessionId && !wsSessions.some((x: any) => x.id === sessionId) && (
795
+ <option value={sessionId}>{sessionId.slice(0, 8) + "…"}</option>
796
+ )}
797
+ {wsSessions.map((x: any) => (
798
+ <option key={x.id} value={x.id}>{sessName(x).slice(0, 40) + " · " + new Date(x.modified || 0).toLocaleString()}</option>
799
+ ))}
800
+ </select>
801
+ <button style={btnStyle} title={t("gt.timeline.load")} onClick={() => { load(); if (view === "overview") setFilesTick((x) => x + 1); }}>↻</button>
802
+ <button style={btnStyle} title={t("gt.tl.cleanAllTip")} onClick={() => setCleanArm({})}><svg width={13} height={13} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d="m7 21-4.3-4.3c-1-1-1-2.5 0-3.4l9.6-9.6c1-1 2.5-1 3.4 0l5.6 5.6c1 1 1 2.5 0 3.4L13 21"/><path d="M22 21H7"/><path d="m5 11 9 9"/></svg></button>
803
+ {planRel && (
804
+ <span title={"方案绑定:" + planRel} style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 11, padding: "2px 8px", borderRadius: 6, background: "color-mix(in srgb, var(--warn, #f59e0b) 12%, transparent)", color: "#f59e09", border: "1px solid color-mix(in srgb, var(--warn, #f59e0b) 35%, transparent)", whiteSpace: "nowrap", maxWidth: 160, overflow: "hidden", textOverflow: "ellipsis", flexShrink: 0 }}>
805
+ {planRel}
806
+ </span>
807
+ )}
808
+ <button title={t("gt.watch.title")} aria-label={t("gt.watch.title")} onClick={() => openPanel("watch")} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", padding: "0 4px", lineHeight: 1, display: "flex", alignItems: "center", flexShrink: 0 }}>
809
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
810
+ <line x1="6" x2="6" y1="3" y2="15" /><circle cx="18" cy="6" r="3" /><circle cx="6" cy="18" r="3" /><path d="M18 9a9 9 0 0 1-9 9" />
811
+ </svg>
812
+ </button>
813
+ {floating && <button title={t("gt.common.close")} onClick={() => setOpen(false)} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 18, lineHeight: 1, padding: "0 4px", flexShrink: 0 }}>×</button>}
814
+ </div>
815
+ {/* 行 2(§4.1):视图分段 tab + 轮次过滤(总览)/ showAll(操作视图)/ 文件过滤 chip / 跨组评审(操作视图) */}
816
+ <div style={{ display: "flex", gap: 6, marginBottom: 10, alignItems: "center", flexWrap: "wrap" }}>
817
+ <div title={t("gt.timeline.viewTip2")} style={{ display: "flex", border: "1px solid var(--border)", borderRadius: 6, overflow: "hidden", flexShrink: 0 }}>
818
+ {["overview", "msg", "tree"].map((v: string) => (
819
+ <button key={v} onClick={() => switchView(v)}
820
+ style={{ ...chipStyle, border: "none", borderRadius: 0, marginLeft: 0,
821
+ background: view === v ? "var(--bg-hover)" : "transparent",
822
+ fontWeight: view === v ? 600 : 400, opacity: view === v ? 1 : 0.6 }}>
823
+ {v === "overview" ? t("gt.timeline.viewOverview") : v === "msg" ? t("gt.timeline.viewMsg") : t("gt.timeline.viewTree")}
824
+ </button>
825
+ ))}
826
+ </div>
827
+ {view === "overview" && (
828
+ <select value={turnFilter} onChange={(e: any) => setTurnFilter(e.target.value)}
829
+ title={t("gt.timeline.turnFilter")} style={{ ...inputStyle, width: "auto", maxWidth: 280, minWidth: 120, cursor: "pointer", flexShrink: 1 }}>
830
+ <option value="">⏱ {t("gt.timeline.allTurns")}</option>
831
+ {turnOptions.map((x) => <option key={x.id} value={x.id}>{x.label}</option>)}
832
+ </select>
833
+ )}
834
+ {view === "msg" && (
835
+ <label title={t("gt.timeline.showAll")} style={{ display: "flex", alignItems: "center", gap: 4, fontSize: 11, opacity: 0.8, cursor: "pointer", whiteSpace: "nowrap" }}>
836
+ <input type="checkbox" checked={showAll} onChange={(e: any) => { setShowAll(e.target.checked); try { localStorage.setItem("aiti-git-trace.timeline.showAll", e.target.checked ? "1" : "0"); } catch { /* ignore */ } }} />
837
+ {t("gt.timeline.showAll")}
838
+ </label>
839
+ )}
840
+ {view === "overview" && fileFilter && (
841
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4, fontSize: 11, padding: "2px 8px", borderRadius: 6, background: "color-mix(in srgb, var(--warn, #f59e0b) 12%, transparent)", color: "#f59e09", border: "1px solid color-mix(in srgb, var(--warn, #f59e0b) 35%, transparent)", whiteSpace: "nowrap", maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis" }}>
842
+ 📄 {fileFilter.split("/").pop()}
843
+ <button onClick={() => setFileFilter(null)} title="清除单文件过滤" style={{ background: "transparent", border: "none", color: "inherit", cursor: "pointer", padding: 0, fontWeight: 700 }}>×</button>
844
+ </span>
845
+ )}
846
+ {view === "msg" && selGroups.length > 0 && (
847
+ <button style={btnStyle} title={t("gt.timeline.reviewSelTip")} onClick={() => reviewGroups(selGroups, groups)}>
848
+ 📋 {t("gt.timeline.reviewSel") + " (" + selGroups.length + ")"}
849
+ </button>
850
+ )}
851
+ </div>
852
+ <div style={scrollLayerStyle}>
853
+ {cleanArm && (
854
+ <div style={{ border: "1px solid #c62828", color: "#c62828", borderRadius: 6, padding: "6px 10px", marginBottom: 8, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
855
+ <span style={{ fontSize: 11.5, flex: 1, minWidth: 0 }}>{cleanArm.beforeOp ? t("gt.tl.cleanBefore", { n: cleanArm.beforeOp }) : t("gt.tl.cleanAll")}</span>
856
+ <button style={{ ...btnStyle, color: "#fff", background: "#c62828", borderColor: "#c62828" }} onClick={() => doClean(cleanArm.beforeOp)}>{t("gt.common.confirm")}</button>
857
+ <button style={btnStyle} onClick={() => setCleanArm(null)}>{t("gt.common.cancel")}</button>
858
+ </div>
859
+ )}
860
+ {view === "tree" ? (
861
+ lineage && (lineage.ancestors.length + lineage.descendants.length > 0 || lineage.self.opCount > 0) ? (
862
+ <div>
863
+ <div style={{ fontSize: 10.5, opacity: 0.7, marginBottom: 10 }}>
864
+ {t("gt.timeline.treeSummary").replace("{n}", String(lineage.ancestors.length + 1 + lineage.descendants.length))}
865
+ </div>
866
+ {[
867
+ ...lineage.ancestors.map((n: any, i: number) => ({ n, gen: i, kind: "ancestor" })),
868
+ { n: lineage.self, gen: lineage.ancestors.length, kind: "self" },
869
+ ...lineage.descendants.map((n: any, i: number) => ({ n, gen: n.gen, kind: "descendant" })),
870
+ ].map(({ n, gen, kind }: any) => {
871
+ const ws = wsSessions.find((x: any) => x.id === n.id);
872
+ const name = ws ? sessName(ws) : (n.id.slice(0, 8) + "…");
873
+ const isCur = kind === "self";
874
+ const time = n.t1 ? new Date(n.t1).toLocaleString() : "";
875
+ return (
876
+ <div key={n.id}
877
+ onClick={() => { if (!isCur) { setSessionId(n.id); load(n.id); } }}
878
+ style={{ display: "flex", alignItems: "center", gap: 8, padding: "6px 8px", paddingLeft: 8 + gen * 18, marginBottom: 2, borderRadius: 6,
879
+ cursor: isCur ? "default" : "pointer",
880
+ background: isCur ? "var(--bg-selected,rgba(25,118,210,.12))" : "transparent",
881
+ border: isCur ? "1px solid var(--border)" : "1px solid transparent" }}
882
+ title={n.id + (n.cwd ? " · " + n.cwd : "")}>
883
+ <span style={{ flexShrink: 0 }}>{kind === "self" ? "⭐" : kind === "ancestor" ? "📄" : "🌿"}</span>
884
+ <span style={{ fontFamily: "ui-monospace, monospace", fontSize: 11.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap", minWidth: 0, flex: 1, fontWeight: isCur ? 700 : 500 }}>
885
+ {name}{isCur ? `(${t("gt.timeline.treeCurrent")})` : ""}
886
+ </span>
887
+ {n.planRel && <span title={"方案绑定:" + n.planRel} style={{ fontSize: 10.5, flexShrink: 0, color: "#f59e09" }}>📋</span>}
888
+ <span style={{ fontSize: 10.5, opacity: 0.65, flexShrink: 0 }}>{n.opCount} ops{time ? " · " + time : ""}</span>
889
+ {!isCur && (
890
+ <button style={{ ...chipStyle, flexShrink: 0 }} title={t("gt.timeline.treeOpen")}
891
+ onClick={(e: any) => { e.stopPropagation(); setSessionId(n.id); load(n.id); }}>{t("gt.timeline.treeOpen")}</button>
892
+ )}
893
+ {!isCur && (
894
+ <button style={{ ...chipStyle, flexShrink: 0 }} title="跳转到该会话的消息流"
895
+ onClick={(e: any) => { e.stopPropagation(); try { window.dispatchEvent(new CustomEvent("pi-web:open-session", { detail: { id: n.id } })); } catch { /* noop */ } }}>💬</button>
896
+ )}
897
+ </div>
898
+ );
899
+ })}
900
+ <div style={{ fontSize: 10.5, opacity: 0.6, marginTop: 8 }}>{t("gt.timeline.treeTip")}</div>
901
+ </div>
902
+ ) : (
903
+ <div style={{ opacity: 0.7 }}>{t("gt.timeline.treeEmpty")}</div>
904
+ )
905
+ ) : view === "overview" ? (
906
+ <>
907
+ {filesLoading && !filesData ? <div style={{ opacity: 0.6 }}>{t("gt.ov.loading")}</div> : null}
908
+ {/* 汇总条(§2):N 次操作 · M 个文件 · +A −B 行 · ↩R 次回撤(+绿/−红);leaks chip 点击切按操作视图 */}
909
+ {ovTotals ? (
910
+ <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", border: "1px solid var(--border)", borderRadius: 6, padding: "6px 10px", marginBottom: 8, fontSize: 12 }}>
911
+ <span><b>{fmtN(ovTotals.ops)}</b> {t("gt.ov.ops")}</span>
912
+ <span style={{ opacity: 0.4 }}>·</span>
913
+ <span><b>{fmtN(ovTotals.files)}</b> {t("gt.ov.files")}</span>
914
+ <span style={{ opacity: 0.4 }}>·</span>
915
+ <span style={{ color: "#1a7f37", fontWeight: 600 }}>+{fmtN(ovTotals.adds)}</span>
916
+ <span style={{ color: "#cf222e", fontWeight: 600 }}>−{fmtN(ovTotals.dels)}</span>
917
+ {ovTotals.rollbacks > 0 ? (<><span style={{ opacity: 0.4 }}>·</span><span style={{ color: "#9a6700" }}>↩ {fmtN(ovTotals.rollbacks)} {t("gt.ov.rollbacks")}</span></>) : null}
918
+ {ovTotals.binary > 0 ? (<><span style={{ opacity: 0.4 }}>·</span><span style={{ opacity: 0.6, fontSize: 11 }}>{t("gt.ov.binary")} {fmtN(ovTotals.binary)}</span></>) : null}
919
+ {ovTotals.leaks > 0 ? (
920
+ <span style={{ ...badgeStyle("leak"), marginLeft: 0, cursor: "pointer" }} title={t("gt.timeline.leak")} onClick={() => switchView("msg")}>{t("gt.timeline.leak")} {fmtN(ovTotals.leaks)}</span>
921
+ ) : null}
922
+ <span style={{ flex: 1 }} />
923
+ <span style={{ opacity: 0.55, fontSize: 10.5 }}>{t("gt.ov.netNote")}</span>
924
+ </div>
925
+ ) : null}
926
+ {filesData?.filesTruncated ? <div style={{ color: "#9a6700", fontSize: 11, marginBottom: 6 }}>{t("gt.ov.filesTruncated")}</div> : null}
927
+ {/* 跨仓会话:按仓分列小标(§2) */}
928
+ {ovPerRoot.length > 1 ? (
929
+ <div style={{ display: "flex", gap: 6, flexWrap: "wrap", marginBottom: 8 }}>
930
+ {ovPerRoot.map((r) => (
931
+ <span key={r.root} style={chipStyle} title={r.root}>
932
+ {repoBase(r.root)}: <span style={{ color: "#1a7f37" }}>+{fmtN(r.adds)}</span> <span style={{ color: "#cf222e" }}>−{fmtN(r.dels)}</span> · {r.files} {t("gt.ov.files")}
933
+ </span>
934
+ ))}
935
+ </div>
936
+ ) : null}
937
+ {/* 空态三文案(§4.5):仅在加载完成且数据已返回后展示,避免与加载提示同时出现。 */}
938
+ {filesLoading || !filesData ? null : !ops || ops.length === 0 ? <div style={{ opacity: 0.7 }}>{t("gt.timeline.empty")}</div>
939
+ : ovMissing ? <div style={{ opacity: 0.7 }}>{t("gt.ov.storeMissing")}</div>
940
+ : ovHits.length === 0 ? <div style={{ opacity: 0.7 }}>{t("gt.ov.noFiles")}</div>
941
+ : ovHits.map((f: any) => {
942
+ const key = f.root + "\u0000" + f.path;
943
+ const expanded = expandedFiles.includes(key);
944
+ const st = storeByRoot.get(f.root);
945
+ const hasRange = !!(st && st.base && st.target && st.base !== st.target && !st.storeMissing && !st.rejected);
946
+ return (
947
+ <div key={key} style={{ borderBottom: "1px solid var(--border)", padding: "6px 0" }}>
948
+ {/* 文件行(整行可点 = 展开/收起):状态 A绿/M珀/D红/R蓝 · 路径 · 触达 ops 数 · ±行数 · 最后触达 */}
949
+ <div onClick={() => toggleFile(key)} title={t("gt.timeline.fileExpand")}
950
+ style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap", cursor: "pointer" }}>
951
+ <span style={{ ...chipStyle, flexShrink: 0, padding: "0 4px" }}>{expanded ? "▾" : "▸"}</span>
952
+ <span title={f.status + (f.renamedFrom ? " ← " + f.renamedFrom : "")} style={{ color: statusColor(f.status), fontWeight: 700, fontSize: 11.5, flexShrink: 0 }}>{f.status}</span>
953
+ <span title={f.root + "/" + f.path} style={{ fontFamily: "monospace", fontSize: 12, wordBreak: "break-all", flex: 1, minWidth: 0 }}>
954
+ {f.path}{ovMultiRoot ? <span style={{ opacity: 0.55 }}> · {repoBase(f.root)}</span> : null}
955
+ </span>
956
+ <span style={{ ...badgeStyle("dim"), marginLeft: 0, flexShrink: 0 }} title={t("gt.timeline.fileTouch")}>{f.touches.length + " " + t("gt.timeline.fileTouch")}</span>
957
+ <span style={{ color: "#1a7f37", fontSize: 11, flexShrink: 0 }}>+{fmtN(f.adds)}</span>
958
+ <span style={{ color: "#cf222e", fontSize: 11, flexShrink: 0 }}>−{fmtN(f.dels)}</span>
959
+ <span style={{ opacity: 0.5, fontSize: 11, flexShrink: 0 }}>{fmtTime(f.lastT)}</span>
960
+ <span style={{ display: "inline-flex", gap: 3, flexShrink: 0, flexWrap: "wrap" }} onClick={(e: any) => e.stopPropagation()}>
961
+ {f.status !== "D" ? (
962
+ <button style={openBtnStyle} title={t("gt.timeline.openFileTip") + " · " + f.root + "/" + f.path}
963
+ onClick={() => openFile(f.root + "/" + f.path, String(f.path || "").split("/").pop() || f.path)}>{t("gt.timeline.openFile")}</button>
964
+ ) : null}
965
+ <button style={actBtnStyle} title={t("gt.timeline.atRefTip")} onClick={() => appendText(atTextFor(f.root, f.path))}><b style={{ fontWeight: 700 }}>@</b>{t("gt.timeline.atRef")}</button>
966
+ <button style={actBtnStyle} title={t("gt.timeline.atNewTip")} onClick={() => atNewSessionFile(f, st)}><b style={{ fontWeight: 700 }}>@</b>{t("gt.timeline.atNew")}</button>
967
+ <button style={actBtnStyle} title={t("gt.timeline.copyPathTip")} onClick={(e: any) => copyWithFeedback(f.root + "/" + f.path, e.currentTarget)}>{t("gt.timeline.copyPath")}</button>
968
+ </span>
969
+ </div>
970
+ {expanded ? (
971
+ <div style={{ margin: "4px 0 0 24px" }}>
972
+ {/* 内嵌轻量 diff(懒拉 /file-diff;截断保护 + 一键跳评审) */}
973
+ {hasRange ? (
974
+ <FileDiffBox root={f.root} sid={sessionId} base={st.base} target={st.target} path={f.path}
975
+ onOpenReview={() => viewRangeDiff(f, st)} />
976
+ ) : (
977
+ <div style={{ opacity: 0.6, fontSize: 11, padding: "2px 0 6px" }}>{t("gt.timeline.partialHint")}</div>
978
+ )}
979
+ {/* 触达 op 时间线(原按文件视图细节保留在此)+ 单文件回滚 */}
980
+ {f.touches.map((x: any) => (
981
+ <div key={String(x.op) + String(x.t1) + String(x.kind || "")} style={{ display: "flex", alignItems: "center", gap: 6, padding: "2px 0", flexWrap: "wrap" }}>
982
+ <span style={{ opacity: 0.6, fontSize: 11, flexShrink: 0 }}>#{x.msgSeq ?? "?"} · {fmtTime(x.t1)}</span>
983
+ {x.kind === "rollback" ? <span title={t("gt.fork.title")} style={{ color: "#9a6700", fontWeight: 600, flexShrink: 0 }}>↩</span> : null}
984
+ <b>#{x.op ?? "—"}</b>
985
+ <span title={x.summary || ""} style={{ opacity: 0.85, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1, minWidth: 0 }}>{x.tool}: {String(x.summary || "").slice(0, 60)}</span>
986
+ {x.partial ? <span style={badgeStyle("partial")}>{t("gt.timeline.partial")}</span> : null}
987
+ {x.truncated ? <span title={t("gt.timeline.truncTip")} style={{ opacity: 0.6, fontSize: 11 }}>…</span> : null}
988
+ <button style={chipStyle} title={t("gt.timeline.viewdiff")} onClick={() => viewDiff({ stores: [{ root: f.root, before: x.before, after: x.after }] }, { root: f.root, before: x.before, after: x.after }, [f.path])}>{t("gt.timeline.viewdiff")}</button>
989
+ {x.kind === "rollback"
990
+ ? <button style={chipStyle} title={t("gt.timeline.rollback")} onClick={() => undoLedgerRollback({ root: f.root, before: x.before, after: x.after })}>{t("gt.timeline.rollback")}</button>
991
+ : <button style={chipStyle} title={t("gt.timeline.rollback")} onClick={() => rollbackOp({ stores: [{ root: f.root, before: x.before, after: x.after, paths: [f.path] }] })}>{t("gt.timeline.rollback")}</button>}
992
+ </div>
993
+ ))}
994
+ </div>
995
+ ) : null}
996
+ </div>
997
+ );
998
+ })}
999
+ {/* ↩ 回撤记录(折叠条;点开见回撤区块,§2) */}
1000
+ {rollbackRows.length > 0 ? (
1001
+ <div style={{ marginTop: 10 }}>
1002
+ <div onClick={() => setOvRbOpen((o) => !o)} style={{ padding: "6px 10px", background: "rgba(212,167,44,.12)", fontWeight: 600, fontSize: 12, color: "#9a6700", border: "1px solid #f0c674", borderRadius: 6, cursor: "pointer" }}>
1003
+ {ovRbOpen ? "▾" : "▸"} ↩ {rollbackRows.length} 次回撤记录
1004
+ </div>
1005
+ {ovRbOpen ? <div style={{ marginTop: 8 }}>{rollbackRowsBlock}</div> : null}
1006
+ </div>
1007
+ ) : null}
1008
+ {/* 📋 方案采认记录(P1-1 尾巴:沙箱采认/丢弃已入台账,总览可见) */}
1009
+ {planRows.length > 0 ? <div style={{ marginTop: 10 }}>{planRowsBlock}</div> : null}
1010
+ </>
1011
+ ) : !ops || ops.length === 0 ? <div style={{ opacity: 0.7 }}>{t("gt.timeline.empty")}</div> : (
1012
+ <>
1013
+ {rollbackRowsBlock}
1014
+ {planRows.length > 0 ? <div style={{ marginTop: 8 }}>{planRowsBlock}</div> : null}
1015
+ {Object.keys(groups).map((k) => (
1016
+ <div key={k} style={{ marginBottom: 10 }}>
1017
+ <div style={{ display: "flex", alignItems: "center", gap: 6, marginBottom: 4 }}>
1018
+ <input type="checkbox" checked={selGroups.includes(k)} onChange={() => toggleSelGroup(k)} style={{ margin: 0, flexShrink: 0 }} title={t("gt.timeline.selGroup")} />
1019
+ <div title={groups[k][0]?.prompt || ""} style={{ opacity: 0.85, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", fontWeight: 600, flex: 1, minWidth: 0 }}>
1020
+ #{k} · {groups[k][0]?.prompt || t("gt.timeline.group")}
1021
+ </div>
1022
+ <button style={chipStyle} onClick={() => reviewGroups([k], groups)} title={t("gt.timeline.reviewGroup")}>{t("gt.timeline.review")}</button>
1023
+ </div>
1024
+ {groups[k].map((op: any) => {
1025
+ const filesN = filesNOf(op);
1026
+ const cross = (op.stores || []).length > 1;
1027
+ const leak = (op.leaks || []).length > 0;
1028
+ const partial = (op.stores || []).some((x: any) => x.partial);
1029
+ return (
1030
+ <div key={String(op.op) + String(op.t1)} style={{ borderBottom: "1px solid var(--border)", padding: "6px 0" }}>
1031
+ <div style={{ display: "flex", alignItems: "center", gap: 6, flexWrap: "wrap" }}>
1032
+ <b>#{op.op}</b>
1033
+ <span style={{ opacity: 0.85 }}>{op.tool}: {String(op.summary || "").slice(0, 60)}</span>
1034
+ <span style={{ opacity: 0.6 }} title={(op.stores || []).some((x: any) => x.before && x.pathsTruncated) ? t("gt.timeline.truncTip") : undefined}>±{filesN}</span>
1035
+ {cross ? <span style={badgeStyle("cross")} title={t("gt.timeline.cross")}>{t("gt.timeline.cross")}</span> : null}
1036
+ {leak ? <span style={badgeStyle("leak")} title={t("gt.timeline.leak")}>{t("gt.timeline.leak")} {op.leaks.length}</span> : null}
1037
+ {partial ? <span style={badgeStyle("partial")}>{t("gt.timeline.partial")}</span> : null}
1038
+ {op.suspect ? <span style={badgeStyle("leak")} title="疑似并行污染:非核心写工具的 op,变更可能是并行会话所为(已照记、回滚可用;绑定向导已忽略)">👻</span> : null}
1039
+ <span style={{ flex: 1 }}></span>
1040
+ {(op.stores || []).filter((x: any) => x.before && (x.files ? x.files.M + x.files.A + x.files.D : 0) > 0).length > 1
1041
+ ? (op.stores || []).filter((x: any) => x.before && (x.files ? x.files.M + x.files.A + x.files.D : 0) > 0).map((x: any) => (
1042
+ <button key={x.root} style={{ ...chipStyle, color: "#0a7d33", borderColor: "rgba(10,125,51,.5)" }} title={x.root} onClick={() => viewDiff(op, x)}>{t("gt.timeline.viewdiff")}·{String(x.root).split("/").pop()}</button>
1043
+ ))
1044
+ : <button style={chipStyle} onClick={() => viewDiff(op)}>{t("gt.timeline.viewdiff")}</button>}
1045
+ <button style={chipStyle} onClick={() => rollbackOp(op)}>{t("gt.timeline.rollback")}</button>
1046
+ {/* op→AI 回复入口(lineage P3-②):跳产生该操作的消息流——载荷带 entryId/userEntryId,
1047
+ 宿主支持 entryId 精确定位前退化为会话级打开(与树视图 💬 同通道) */}
1048
+ {(op.entryId || op.userEntryId) ? (
1049
+ <button style={chipStyle} title={"跳转到产生该操作的消息流(entry " + String(op.entryId || op.userEntryId) + ")"}
1050
+ onClick={() => { try { window.dispatchEvent(new CustomEvent("pi-web:open-session", { detail: { id: sessionId, entryId: op.entryId || op.userEntryId || undefined } })); } catch { /* noop */ } }}>💬</button>
1051
+ ) : null}
1052
+ <button style={chipStyle} title={t("gt.tl.cleanBeforeTip")} onClick={() => setCleanArm({ beforeOp: op.op })}>⌫</button>
1053
+ </div>
1054
+ {leak ? (
1055
+ <div style={{ opacity: 0.85, fontSize: 11, marginTop: 2, display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap" }}>
1056
+ {op.leaks.map((l: any, i: number) => (
1057
+ <span key={String(l.path) + i} style={{ display: "inline-flex", alignItems: "center", gap: 4, minWidth: 0 }}>
1058
+ <span title={l.path} style={{ fontFamily: "monospace", wordBreak: "break-all" }}>{l.path}</span>
1059
+ <span style={{ display: "inline-flex", gap: 3, flexShrink: 0, flexWrap: "wrap" }}>
1060
+ <button style={openBtnStyle} title={t("gt.timeline.openFile")}
1061
+ onClick={() => openFile(l.path, String(l.path || "").split("/").pop() || l.path)}>{t("gt.timeline.openFile")}</button>
1062
+ <button style={actBtnStyle} title={t("gt.timeline.atRefTip")} onClick={() => appendText(atTextFor("", l.path))}><b style={{ fontWeight: 700 }}>@</b>{t("gt.timeline.atRef")}</button>
1063
+ <button style={actBtnStyle} title={t("gt.timeline.atNewTip")} onClick={() => atNewSessionLeak(l)}><b style={{ fontWeight: 700 }}>@</b>{t("gt.timeline.atNew")}</button>
1064
+ <button style={actBtnStyle} title={t("gt.timeline.copyPathTip")} onClick={(e: any) => copyWithFeedback(l.path, e.currentTarget)}>{t("gt.timeline.copyPath")}</button>
1065
+ </span>
1066
+ </span>
1067
+ ))}
1068
+ </div>
1069
+ ) : null}
1070
+ </div>
1071
+ );
1072
+ })}
1073
+ </div>
1074
+ ))}
1075
+ </>
1076
+ )}
1077
+ </div>
1078
+ </>
1079
+ );
1080
+ // dock v3 分面:active 的 face 才会走到这里——float 出浮窗壳,right/left 出对应 DockBody
1081
+ if (floating) {
1082
+ return (
1083
+ <div style={{ ...panelStyle, left: 18, right: "auto", top: 64, width: panelW }} data-ext-float="1" data-ext-float-full="1" data-ext-float-w={panelW} data-ext-float-h={480} data-ext-float-key={TIMELINE_KEY} data-ext-float-dock={`${sidebarZoneOf(TIMELINE_KEY)} right-panel`} data-ext-float-dock-default="right-panel">
1084
+ {panelContent}
1085
+ </div>
1086
+ );
1087
+ }
1088
+ const tlZones: any[] = [sidebarZoneOf(TIMELINE_KEY), "right-panel"];
1089
+ const tlInner = (
1090
+ <div style={{ display: "flex", flexDirection: "column", minHeight: 0, height: "100%", color: "var(--text)", padding: "8px 10px", boxSizing: "border-box" }}>
1091
+ {panelContent}
1092
+ </div>
1093
+ );
1094
+ if (face === "left") {
1095
+ const tlActions: DockHeaderAction[] = [
1096
+ { id: "refresh", title: "刷新", icon: <RefreshSvg/>, visibility: "always", onClick: () => { requestDockCommand(TIMELINE_KEY, "refresh"); } },
1097
+ ];
1098
+ return (
1099
+ <DockBody itemKey={TIMELINE_KEY} title="时间线" chrome="section" kind="dockable"
1100
+ count={typeof hsTl.ops === "number" && hsTl.ops > 0 ? hsTl.ops : undefined}
1101
+ zones={tlZones} defaultZone="right-panel" headerActions={tlActions}>{tlInner}</DockBody>
1102
+ );
1103
+ }
1104
+ return (
1105
+ <DockBody itemKey={TIMELINE_KEY} zone="right-panel" title="时间线" chrome="tab" zones={tlZones} defaultZone="right-panel">{tlInner}</DockBody>
1106
+ );
1107
+ }
1108
+
1109
+ // ---------- 回撤三选项对话框(§5.2,全局 Promise API) ----------
1110
+
1111
+ let forkResolve: ((v: any) => void) | null = null;
1112
+
1113
+ // ── fork 弹窗请求 store(shell 的 forkGate 调用;组件挂载时机与请求到达时机解耦)──
1114
+ let pendingFork: { params: { sessionId: string; entryId: string; cutUserEntryIds: string[] }; resolve: (v: any) => void } | null = null;
1115
+ const forkSubs = new Set<() => void>();
1116
+ export function requestForkDialog(params: any): Promise<any> {
1117
+ return new Promise((resolve) => {
1118
+ pendingFork = {
1119
+ params: {
1120
+ sessionId: String(params?.sessionId || ""),
1121
+ entryId: String(params?.entryId || ""),
1122
+ cutUserEntryIds: Array.isArray(params?.cutUserEntryIds) ? params.cutUserEntryIds.map(String).filter(Boolean) : [],
1123
+ },
1124
+ resolve,
1125
+ };
1126
+ for (const cb of [...forkSubs]) { try { cb(); } catch { /* noop */ } }
1127
+ });
1128
+ }
1129
+
1130
+ /** op 的已追踪文件变更数(只认 before→after 增量口径,与时间线/徽标一致) */
1131
+ const forkFilesN = (o: any) => (o.stores || []).reduce((a: number, x: any) =>
1132
+ a + (x.before && x.files ? (x.files.M || 0) + (x.files.A || 0) + (x.files.D || 0) : 0), 0);
1133
+
1134
+ /** 是否有 gap(无基线 before=null)且有文件变更的 store——这类 op 无法自动撤销,须在弹窗明示 */
1135
+ const forkHasGap = (o: any) => (o.stores || []).some((x: any) =>
1136
+ !x.before && x.files && (x.files.M || 0) + (x.files.A || 0) + (x.files.D || 0) > 0);
1137
+
1138
+ /** 按轮(prompt/userEntryId)分组,供弹窗列表用:组 [{ msgSeq, prompt, ops }]。
1139
+ * 按组内最小 op 序排序(反映会话执行顺序;entryId 字符串序 ≠ 时间序)。 */
1140
+ function groupOpsByTurn(ops: any[]): any[] {
1141
+ const m = new Map<string, any>();
1142
+ for (const op of ops) {
1143
+ const k = String(op.userEntryId || op.msgSeq || "?");
1144
+ if (!m.has(k)) m.set(k, { key: k, prompt: op.prompt || "", msgSeq: op.msgSeq, ops: [] });
1145
+ m.get(k).ops.push(op);
1146
+ }
1147
+ const minOp = (g: any) => (g.ops || []).reduce((a: number, o: any) => Math.min(a, Number(o.op) || 0), Infinity);
1148
+ return [...m.values()].sort((a, b) => minOp(a) - minOp(b));
1149
+ }
1150
+
1151
+ /** 该轮组触达文件数(场景 8:每轮组头「M 文件」小标;从 ops stores 窗口增量聚合,无需行数) */
1152
+ function turnFilesOf(g: any): number {
1153
+ const s = new Set<string>();
1154
+ for (const o of g.ops || []) {
1155
+ for (const st of o.stores || []) {
1156
+ if (!st.before) continue;
1157
+ for (const p of st.paths || []) s.add(st.root + "\u0000" + p);
1158
+ }
1159
+ }
1160
+ return s.size;
1161
+ }
1162
+
1163
+ /** 把失败现场组装成完整 prompt,注入 AI 输入框让当前会话 AI 接手收尾(方案 §3.4/§6)。
1164
+ * roots = 涉及仓根列表(真实路径,AI 定位 store 必需);sid 短 16 = 会话分支名。 */
1165
+ function buildRollbackFailPrompt(p: any): string {
1166
+ const sid = p.sessionId || "";
1167
+ const shortSid = String(sid).replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 16) || "s";
1168
+ const roots = Array.isArray(p.roots) && p.roots.length ? p.roots : ["<repoRoot:未提供>"];
1169
+ const rootLines = roots.map((r: string) => `- ${r}/.aiti-trace`).join("\n");
1170
+ const refs = (p.refs || []).map((r: any) => `#${r.op} ${r.before ? r.before.slice(0, 8) : "(无基线)"} → ${r.after ? r.after.slice(0, 8) : ""}`).join("\n");
1171
+ const failed = p.failed ? `- 失败于 #${p.failed.op}:${p.failed.before?.slice(0, 8)} → ${p.failed.after?.slice(0, 8)}
1172
+ git apply 报错:${p.failed.stderr || "(无)"}` : "";
1173
+ return `【请处理一次未完成的「从这里编辑」回撤】
1174
+
1175
+ 上下文:会话 ${sid || "?"} 里,用户「从这里编辑」;随后选择撤销被裁轮次的文件变更,但逐操作反向 patch 中途失败,剩余变更未能撤销。请你接手收尾。
1176
+
1177
+ 已成功撤销:${p.applied} 个操作;跳过(无基线 gap):${(p.skipped || []).join(",") || "无"}。
1178
+ ${failed}
1179
+
1180
+ 涉及的 trace store(hermetic,别动用户 .git):
1181
+ - store 目录(每仓):
1182
+ ${rootLines}
1183
+ - 会话分支:refs/heads/aiti/${shortSid}
1184
+ - safety commit(回撤前完整现状,可整树还原):${p.safety || "?"}
1185
+ - 已生成的回撤记账 commit:${p.rollback || "?"}
1186
+ - 回撤目标 op:${p.targetOps ? p.targetOps.join(",") : "?"};每 op 快照对:
1187
+ ${refs}
1188
+
1189
+ 细看要求:
1190
+ 1. 用 \`git --git-dir=<repoRoot>/.aiti-trace --work-tree=<repoRoot> diff <before> <after> | git apply -R --whitespace=nowarn\` 逆序逐 op 撤销(<repoRoot> 用上方真实路径,先 \`git status\` 确认位置);失败加 \`--3way\` 三方兜底。
1191
+ 2. 只移除这些被裁 op 引入的变更,保留用户手工改动与其他来源。
1192
+ 3. 完成后 \`git status\`(用户仓)核对:应只剩未被撤除的合法残留;如仍冲突,先说明冲突文件与原因再请示。
1193
+ 4. 红线:绝不动用户仓 .git,只用 <repoRoot>/.aiti-trace;任何一步先看 safety commit 上下文。`;
1194
+ }
1195
+
1196
+ export function ForkDialogSlot(): any {
1197
+ const [st, setSt] = useState<{ visible: boolean; ops: any[]; busy: string; fail: any; refs: any[]; sessionId: string; roots: string[]; totals: any }>({ visible: false, ops: [], busy: "", fail: null, refs: [], sessionId: "", roots: [], totals: null });
1198
+ // 打开即默认最大化(aiti-float 全局 API;面板增强完成后调用)。hooks 必须在顶层无条件调用。
1199
+ const forkPanelRef = useRef<any>(null);
1200
+ useEffect(() => {
1201
+ const el = forkPanelRef.current;
1202
+ if (!el) return;
1203
+ const t = window.setTimeout(() => {
1204
+ try { (window as any).__piweFloat?.setMax?.(el, true); } catch { /* float 未就绪时跳过 */ }
1205
+ }, 100);
1206
+ return () => window.clearTimeout(t);
1207
+ }, [st.visible]);
1208
+
1209
+ // 懒加载对接:shell(forkGate)经 requestForkDialog 投递请求——挂载前/后都能消费;
1210
+ // 组件未挂载时请求驻留 pendingFork,挂载 effect 立即消费。
1211
+ useEffect(() => {
1212
+ const consume = () => {
1213
+ if (!pendingFork) return;
1214
+ const req = pendingFork;
1215
+ pendingFork = null;
1216
+ void (async () => {
1217
+ let r: any = null;
1218
+ const { sessionId, entryId, cutUserEntryIds } = req.params;
1219
+ // 方案 §3.2:优先按被裁轮集合精确圈定(含被编辑轮);core 旧版无 cutUserEntryIds → 降级 GET 单 entryId
1220
+ const ids = Array.isArray(cutUserEntryIds) ? cutUserEntryIds.map(String).filter(Boolean) : [];
1221
+ try {
1222
+ r = ids.length
1223
+ ? await apiPost("/fork-preview", { session: sessionId || "", entryIds: ids })
1224
+ : await apiGet("/fork-preview?session=" + encodeURIComponent(sessionId || "") + "&entryId=" + encodeURIComponent(entryId || ""));
1225
+ } catch { r = null; }
1226
+ // 只列出有文件变更的操作(±0 的纯读/无增量 op 不进处置清单);
1227
+ // gap(无基线)op 即使 ±0 也进清单——变更无法自动撤销,须让用户看到并知情。
1228
+ // 满足条件的操作为 0 时不弹窗,直接放行(keep = 不回滚,无需用户处置)。
1229
+ const ops = ((r && r.ops) || []).filter((o: any) => forkFilesN(o) > 0 || forkHasGap(o));
1230
+ if (ops.length === 0) { req.resolve({ action: "keep" }); return; }
1231
+ forkResolve = req.resolve;
1232
+ // R4:汇总行数据来自 fork-preview 响应附带的 files/totals 聚合(不另发 /files)
1233
+ setSt({ visible: true, ops, busy: "", fail: null, refs: [], sessionId: sessionId || "", roots: [], totals: (r && r.totals) || null });
1234
+ })();
1235
+ };
1236
+ consume();
1237
+ forkSubs.add(consume);
1238
+ return () => { forkSubs.delete(consume); };
1239
+ }, []);
1240
+ // 兼容旧全局入口(外部脚本):转调模块级 requestForkDialog
1241
+ useEffect(() => {
1242
+ (window as any).__aitiGitTraceForkDialog = (p: any) => requestForkDialog(p);
1243
+ return () => { try { delete (window as any).__aitiGitTraceForkDialog; } catch { /* noop */ } };
1244
+ }, []);
1245
+
1246
+ // 从预览 ops 收集「按仓分组、组内升序」的 refs;
1247
+ // gap store(before=null)也传(服务端 skip 并计入 skipped)——其变更无法自动撤销,撤销后明示。
1248
+ function collectRefs(): { storeRoot: string; refs: any[] }[] {
1249
+ const byStore = new Map<string, any[]>();
1250
+ for (const o of st.ops) for (const s of (o.stores || [])) {
1251
+ if (!byStore.has(s.root)) byStore.set(s.root, []);
1252
+ byStore.get(s.root).push({ op: o.op, before: s.before || null, after: s.after || null });
1253
+ }
1254
+ return [...byStore.entries()].map(([storeRoot, refs]) => ({ storeRoot, refs: refs.sort((a, b) => a.op - b.op) }));
1255
+ }
1256
+
1257
+ async function doRollback() {
1258
+ const groups = collectRefs();
1259
+ if (groups.length === 0) { (window as any).alert("no restorable baseline"); return; }
1260
+ setSt({ ...st, busy: "rollback", fail: null });
1261
+ let applied = 0; let failed: any = null; const skipped: any[] = []; let safety: string = ""; let rollback: string = "";
1262
+ for (const g of groups) {
1263
+ const rr = await apiPost("/rollback-reversed", { storeRoot: g.storeRoot, sessionId: getSessionId(), refs: g.refs });
1264
+ if (rr && rr.failed) { failed = rr.failed; }
1265
+ if (rr && rr.safety) safety = rr.safety;
1266
+ if (rr && rr.rollback) rollback = rr.rollback;
1267
+ applied += (rr && rr.applied) || 0;
1268
+ if (Array.isArray(rr && rr.skipped)) for (const sk of rr.skipped) skipped.push(sk);
1269
+ if (rr && rr.failed) break; // 一仓失败:停止后再交给用户处置
1270
+ }
1271
+ if (failed) {
1272
+ setSt({ ...st, busy: "", fail: { failed, applied, skipped, safety, rollback, refs: groups.flatMap((g) => g.refs) }, refs: groups.flatMap((g) => g.refs), roots: groups.map((g) => g.storeRoot), sessionId: st.sessionId });
1273
+ return;
1274
+ }
1275
+ if (skipped.length) {
1276
+ // 方案 §7:成功回撤但跳过 gap(无基线)op 时明示,避免用户误以为全部撤净
1277
+ (window as any).alert(t("gt.fork.skippedHint", { n: String(skipped.length) }));
1278
+ }
1279
+ resolveFork({ action: "rollback", done: t("gt.fork.done") });
1280
+ }
1281
+
1282
+ function resolveFork(v: any) {
1283
+ setSt({ visible: false, ops: [], busy: "", fail: null, refs: [], sessionId: "", roots: [], totals: null });
1284
+ try { forkResolve && forkResolve(v); } catch {}
1285
+ forkResolve = null;
1286
+ }
1287
+
1288
+ // 失败处置:交给 AI → 组装 prompt 注入输入框,放行编辑;忽略 → 保留现状,放行编辑
1289
+ function resolveFail(v: any) {
1290
+ if (v.action === "ai") {
1291
+ const prompt = buildRollbackFailPrompt({ sessionId: st.sessionId, roots: st.roots, refs: st.refs, applied: st.fail?.applied, skipped: st.fail?.skipped, failed: st.fail?.failed, safety: st.fail?.safety, rollback: st.fail?.rollback, targetOps: st.fail?.refs ? st.fail.refs.map((r: any) => r.op) : [] });
1292
+ appendText(prompt);
1293
+ }
1294
+ resolveFork({ action: "keep" });
1295
+ }
1296
+
1297
+ const groups = groupOpsByTurn(st.ops);
1298
+ if (!st.visible) return null;
1299
+ return (
1300
+ <div style={{ position: "fixed", inset: 0, zIndex: 10060, background: "rgba(0,0,0,.5)", display: "flex", alignItems: "center", justifyContent: "center" }}>
1301
+ <div
1302
+ ref={forkPanelRef}
1303
+ style={{ ...panelStyle, position: "static", width: 720, maxWidth: "94vw", maxHeight: "86vh", padding: 0, display: "flex", flexDirection: "column", overflow: "hidden" }}
1304
+ data-ext-float="1" data-ext-float-full="1" data-ext-float-w={720} data-ext-float-h={560}
1305
+ >
1306
+ {/* 标题栏 = 拖拽把手(面板第一个子元素);关闭 ✕ = 取消 */}
1307
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 14px", borderBottom: "1px solid var(--border)", flexShrink: 0, cursor: "move" }}>
1308
+ <b style={{ fontSize: 13 }}>{t("gt.fork.title")}</b>
1309
+ <button
1310
+ type="button"
1311
+ title={t("gt.fork.cancel")}
1312
+ aria-label={t("gt.fork.cancel")}
1313
+ onClick={() => resolveFork({ action: "cancel" })}
1314
+ style={{ ...btnStyle, border: "none", background: "transparent", padding: "2px 8px", fontSize: 14, lineHeight: 1 }}
1315
+ >✕</button>
1316
+ </div>
1317
+ <div style={scrollLayerStyle}>
1318
+ {st.fail ? (
1319
+ <div style={{ padding: "12px 14px 0" }}>
1320
+ <div style={{ color: "#cf222e", fontWeight: 600, marginBottom: 8 }}>{t("gt.fork.failTitle")}</div>
1321
+ <div style={{ fontSize: 12, opacity: 0.85, marginBottom: 6 }}>{t("gt.fork.failDetail", { applied: String(st.fail.applied), skipped: (st.fail.skipped || []).map((s: any) => "#" + s.op).join(",") || "—", op: String(st.fail.failed.op), err: String(st.fail.failed.stderr || "").slice(0, 120) })}</div>
1322
+ <div style={{ border: "1px solid var(--border)", borderRadius: 6, marginBottom: 10, padding: "6px 10px", fontSize: 12, fontFamily: "monospace", whiteSpace: "pre-wrap", wordBreak: "break-all", maxHeight: 140, overflow: "auto", opacity: 0.9 }}>{buildRollbackFailPrompt({ sessionId: st.sessionId, roots: st.roots, refs: st.refs, applied: st.fail.applied, skipped: st.fail.skipped, failed: st.fail.failed, safety: st.fail.safety, rollback: st.fail.rollback, targetOps: st.refs.map((r: any) => r.op) })}</div>
1323
+ <div style={{ fontSize: 12, opacity: 0.7 }}>{t("gt.fork.failHint")}</div>
1324
+ </div>
1325
+ ) : (
1326
+ <div style={{ padding: "12px 14px 0" }}>
1327
+ <div style={{ opacity: 0.8, marginBottom: 10 }}>{t("gt.fork.desc")}</div>
1328
+ {/* 场景 8(R4):顶部汇总条「N 次操作 · M 个文件 · +A −B」(服务端 fork-preview 附带聚合) */}
1329
+ {st.totals ? (
1330
+ <div style={{ display: "flex", gap: 8, alignItems: "center", flexWrap: "wrap", border: "1px solid var(--border)", borderRadius: 6, padding: "6px 10px", marginBottom: 10, fontSize: 12 }}>
1331
+ <span><b>{fmtN(st.totals.ops)}</b> {t("gt.ov.ops")}</span>
1332
+ <span style={{ opacity: 0.4 }}>·</span>
1333
+ <span><b>{fmtN(st.totals.files)}</b> {t("gt.ov.files")}</span>
1334
+ <span style={{ opacity: 0.4 }}>·</span>
1335
+ <span style={{ color: "#1a7f37", fontWeight: 600 }}>+{fmtN(st.totals.adds)}</span>
1336
+ <span style={{ color: "#cf222e", fontWeight: 600 }}>−{fmtN(st.totals.dels)}</span>
1337
+ </div>
1338
+ ) : null}
1339
+ {groups.map((g) => (
1340
+ <div key={g.key} style={{ border: "1px solid var(--border)", borderRadius: 6, marginBottom: 10 }}>
1341
+ <div style={{ display: "flex", alignItems: "center", gap: 6, padding: "6px 10px", borderBottom: "1px solid var(--border)", fontSize: 12, fontWeight: 600 }}>
1342
+ <span title={g.prompt || ""} style={{ opacity: 0.9, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis", flex: 1, minWidth: 0 }}>🗣 {String(g.prompt || "").slice(0, 60) || "(无 prompt)"}</span>
1343
+ {turnFilesOf(g) > 0 ? <span style={{ fontSize: 11, opacity: 0.65, flexShrink: 0, whiteSpace: "nowrap" }}>{t("gt.fork.turnFiles", { n: String(turnFilesOf(g)) })}</span> : null}
1344
+ </div>
1345
+ {g.ops.map((op: any) => (
1346
+ <div key={String(op.op) + String(op.t1)} style={{ padding: "6px 10px", borderBottom: "1px solid var(--border)" }}>
1347
+ <b>#{op.op}</b> <span>{op.tool}: {String(op.summary || "").slice(0, 70)}</span>
1348
+ <span style={{ opacity: 0.6 }}> · ±{forkFilesN(op)}</span>
1349
+ {forkHasGap(op) ? <span title={t("gt.fork.gapTip")} style={{ color: "#9a6700", border: "1px solid #f0c674", borderRadius: 4, padding: "0 5px", fontSize: 11, marginLeft: 4 }}>⏭ {t("gt.fork.gap")}</span> : null}
1350
+ </div>
1351
+ ))}
1352
+ </div>
1353
+ ))}
1354
+ </div>
1355
+ )}
1356
+ {st.busy ? <div style={{ padding: "0 14px 4px", opacity: 0.7 }}>{st.busy}…</div> : null}
1357
+ </div>
1358
+ {/* 底部操作区:固定不随内容滚动,始终可见 */}
1359
+ <div style={{ display: "flex", gap: 8, justifyContent: "flex-end", padding: "10px 14px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
1360
+ {st.fail ? (
1361
+ <>
1362
+ <button style={btnStyle} disabled={!!st.busy} onClick={() => resolveFail({ action: "ai" })}>🤖 {t("gt.fork.aiTakeover")}</button>
1363
+ <button style={btnStyle} disabled={!!st.busy} onClick={() => resolveFail({ action: "ignore" })}>{t("gt.fork.ignore")}</button>
1364
+ </>
1365
+ ) : (
1366
+ <>
1367
+ <button style={btnStyle} disabled={!!st.busy} onClick={() => doRollback()}>↩ {t("gt.fork.rollback")}</button>
1368
+ <button style={btnStyle} disabled={!!st.busy} onClick={() => resolveFork({ action: "keep" })}>{t("gt.fork.keep")}</button>
1369
+ <button style={btnStyle} disabled={!!st.busy} onClick={() => resolveFork({ action: "cancel" })}>{t("gt.fork.cancel")}</button>
1370
+ </>
1371
+ )}
1372
+ </div>
1373
+ </div>
1374
+ </div>
1375
+ );
1376
+ }
1377
+