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

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/README.md +38 -2
  4. package/api.ts +424 -0
  5. package/check-client.mjs +273 -0
  6. package/client/src/common.ts +179 -0
  7. package/client/src/entry.tsx +81 -0
  8. package/client/src/host-shim.ts +118 -0
  9. package/client/src/jsx-runtime.ts +2 -0
  10. package/client/src/panels.tsx +1377 -0
  11. package/client/src/trace-app.tsx +275 -0
  12. package/client/src/trace-files-row.pure.ts +88 -0
  13. package/client/src/trace-files-row.tsx +110 -0
  14. package/client/src/version-mention.ts +90 -0
  15. package/deploy-commit.mjs +52 -0
  16. package/dev-pack.mjs +246 -0
  17. package/dist/aiti-git-trace-client.mjs +4 -0
  18. package/dist/entry-C_le59dp.js +1594 -0
  19. package/dist/panels-BpC4XLni.js +1522 -0
  20. package/ext-icons-deploy.mjs +24 -0
  21. package/icon.svg +1 -0
  22. package/icons/git-branch.svg +1 -0
  23. package/icons/git-commit-horizontal.svg +1 -0
  24. package/index.ts +705 -0
  25. package/package.json +33 -4
  26. package/reconcile-deps.mjs +41 -0
  27. package/reload-instances.mjs +60 -0
  28. package/scripts/aiti-trace.mjs +117 -0
  29. package/skills/index.json +1 -0
  30. package/skills/rollback-session/SKILL.md +34 -0
  31. package/skills/trace-ops/SKILL.md +31 -0
  32. package/skills/watch-path/SKILL.md +34 -0
  33. package/test/fixtures/ops-sample.jsonl +3 -0
  34. package/tools/deploy.mjs +103 -0
  35. package/tools/trace-compact.mjs +58 -0
  36. package/trace-core.test.mjs +1120 -0
  37. package/trace-core.ts +1346 -0
  38. package/trace-files-row.test.mjs +66 -0
  39. package/tsconfig.check.json +21 -0
  40. package/version-mention.test.mjs +76 -0
  41. package/vite.config.ts +43 -0
@@ -0,0 +1,275 @@
1
+ /**
2
+ * aiti-git-trace · client 入口薄壳(2026-09-13 薄壳拆分)
3
+ *
4
+ * 薄壳只做「零/低成本」的事:会话状态接线、工具条入口、徽标、回撤后验横幅,
5
+ * 以及把重面板(关注路径 / 时间线 / 回撤对话框,~1.2k 行)换成**懒加载包装**:
6
+ * - 点工具条 / 徽标 / 横幅按钮 → openPanel 事件 → 薄壳桥(替面板翻 float 状态)
7
+ * → 动态 import ./panels → 面板挂载并重放事件数据;
8
+ * - 停靠面(rightFileDock / sidebarExplorerDock)→ 面渲染时才加载;
9
+ * - forkGate → requestForkDialog(用到才加载,弹窗就绪后照旧走三选项)。
10
+ * 拆包动机:开页不再为未使用的面板付 ~60KB;panels chunk 由服务端批量包外置为
11
+ * 相对引用(client-bundle 只在交互时按需拉取)。
12
+ *
13
+ * 跨扩展对接点(window):__aitiGitTraceForkDialog(兼容转调)、__aitiGitTraceBanner。
14
+ */
15
+ import { useState, useEffect, t } from "./host-shim";
16
+ import { usePresent, openFloat, isSidebarDockState } from "../../../_shared/ext-float-dock";
17
+ import {
18
+ OPEN_EVENT, TIMELINE_KEY, WATCH_KEY, openPanel,
19
+ bindSession, getSessionId, fetchOpsCached,
20
+ btnStyle, chipStyle,
21
+ } from "./common";
22
+
23
+ // 会话服务接线继续由薄壳承担(entry.tsx 调用;重面板只读同一份模块态)
24
+ export { installSessionService } from "./common";
25
+
26
+ // ---------- 重面板懒加载桥 ----------
27
+
28
+ type PanelsModule = {
29
+ WatchPanelSlot: any; WatchDockLeft: any; WatchDockRight: any;
30
+ TimelineSlot: any; TimelineDockRight: any; TimelineDockLeft: any;
31
+ ForkDialogSlot: any;
32
+ requestForkDialog: (params: any) => Promise<any>;
33
+ };
34
+
35
+ const panelsNeedSubs = new Set<() => void>();
36
+ let panelsPromise: Promise<PanelsModule> | null = null;
37
+ let panelsReady = false;
38
+ let forkNeeded = false;
39
+
40
+ function notifyPanelsNeed(): void {
41
+ for (const cb of [...panelsNeedSubs]) { try { cb(); } catch { /* noop */ } }
42
+ }
43
+
44
+ /** 加载重面板 chunk(幂等;失败允许重试——下次交互再触发)。 */
45
+ export function loadPanels(): Promise<PanelsModule> {
46
+ if (!panelsPromise) {
47
+ panelsPromise = (import("./panels") as Promise<PanelsModule>).then((m) => {
48
+ panelsReady = true;
49
+ notifyPanelsNeed();
50
+ return m;
51
+ }).catch((e) => {
52
+ panelsPromise = null;
53
+ throw e;
54
+ });
55
+ }
56
+ return panelsPromise;
57
+ }
58
+
59
+ function keyOfKind(kind: string): string {
60
+ return kind === "timeline" ? TIMELINE_KEY : kind === "watch" ? WATCH_KEY : "aiti-git-trace:" + kind;
61
+ }
62
+
63
+ /**
64
+ * open 事件桥:面板监听只在其「面」被渲染时才存在(closed 态没有任何面板组件挂载),
65
+ * 所以桥不能只看 chunk 是否已加载——必须看该 kind 当前是否有挂载中的面(mountedKinds):
66
+ * - 有面挂载:只翻状态(面板自己的监听会拿 data);
67
+ * - 无面挂载:翻状态 + 暂存 data(面板挂载后 useReplayOpen 重放)+ 触发 chunk 加载。
68
+ */
69
+ const pendingOpen = new Map<string, any>();
70
+ const mountedKinds = new Map<string, number>();
71
+ function markKindMounted(kind: string, active: boolean): () => void {
72
+ if (!active) return () => {};
73
+ mountedKinds.set(kind, (mountedKinds.get(kind) ?? 0) + 1);
74
+ return () => {
75
+ const n = (mountedKinds.get(kind) ?? 1) - 1;
76
+ if (n <= 0) mountedKinds.delete(kind);
77
+ else mountedKinds.set(kind, n);
78
+ };
79
+ }
80
+ if (typeof window !== "undefined") {
81
+ window.addEventListener(OPEN_EVENT, (e: any) => {
82
+ const d = e?.detail || {};
83
+ if (!d.kind) return;
84
+ const kind = String(d.kind);
85
+ if ((mountedKinds.get(kind) ?? 0) === 0) pendingOpen.set(kind, d.data);
86
+ openFloat(keyOfKind(kind));
87
+ void loadPanels().catch(() => { /* 加载失败:面板不可用(等价于从未打开) */ });
88
+ }, true);
89
+ }
90
+
91
+ /** enabled 时才加载并取回重面板组件(返回 null 表示尚未就绪)。 */
92
+ function useHeavyComponent(enabled: boolean, which: keyof PanelsModule): any {
93
+ const [Comp, setComp] = useState<any>(null);
94
+ useEffect(() => {
95
+ if (!enabled || Comp) return;
96
+ let alive = true;
97
+ void loadPanels().then((m) => { if (alive) setComp(() => (m as any)[which]); }).catch(() => { /* 下次再试 */ });
98
+ return () => { alive = false; };
99
+ }, [enabled, which, Comp]);
100
+ return Comp;
101
+ }
102
+
103
+ /** 面板挂载后重放「薄壳期捕获的 open 数据」(面板自己的 OPEN_EVENT 监听此刻已注册)。 */
104
+ function useReplayOpen(kind: string, Comp: any): void {
105
+ useEffect(() => {
106
+ if (!Comp || !pendingOpen.has(kind)) return;
107
+ const data = pendingOpen.get(kind);
108
+ pendingOpen.delete(kind);
109
+ try { window.dispatchEvent(new CustomEvent(OPEN_EVENT, { detail: { kind, data } })); } catch { /* noop */ }
110
+ }, [Comp, kind]);
111
+ }
112
+
113
+ /** 面激活判定(与重面板内部 gate 一致):float=浮窗态;right=右栏 tab;left=sidebar 族停靠。
114
+ * 只有「面真的会被渲染」才加载 chunk——例如 dockpref=right-panel 但右栏 tab 未激活时不加载。 */
115
+ function faceActive(face: "float" | "right" | "left", st: string): boolean {
116
+ if (face === "float") return st === "floating";
117
+ if (face === "right") return st === "docked:right-panel";
118
+ return isSidebarDockState(st);
119
+ }
120
+
121
+ /** 浮动面板包装(dialogs 槽常挂):浮窗态才加载渲染。 */
122
+ function floatPanel(kind: string, which: keyof PanelsModule) {
123
+ return function LazyFloatPanel(props?: any) {
124
+ const presented = usePresent(keyOfKind(kind));
125
+ const active = faceActive("float", presented);
126
+ useEffect(() => markKindMounted(kind, active), [kind, active]);
127
+ const Comp = useHeavyComponent(active, which);
128
+ useReplayOpen(kind, Comp);
129
+ return Comp ? <Comp {...(props ?? {})} /> : null;
130
+ };
131
+ }
132
+
133
+ /** 停靠面包装:面被宿主渲染 = 用户正在看 → 挂载即加载(仍按状态二次确认)。 */
134
+ function dockFace(kind: string, face: "right" | "left", which: keyof PanelsModule) {
135
+ return function LazyDockFace(props?: any) {
136
+ const presented = usePresent(keyOfKind(kind));
137
+ const active = faceActive(face, presented);
138
+ useEffect(() => markKindMounted(kind, active), [kind, active]);
139
+ const Comp = useHeavyComponent(active, which);
140
+ useReplayOpen(kind, Comp);
141
+ return Comp ? <Comp {...(props ?? {})} /> : null;
142
+ };
143
+ }
144
+
145
+ export const WatchPanelSlot = floatPanel("watch", "WatchPanelSlot");
146
+ export const WatchDockLeft = dockFace("watch", "left", "WatchDockLeft");
147
+ export const WatchDockRight = dockFace("watch", "right", "WatchDockRight");
148
+ export const TimelineSlot = floatPanel("timeline", "TimelineSlot");
149
+ export const TimelineDockRight = dockFace("timeline", "right", "TimelineDockRight");
150
+ export const TimelineDockLeft = dockFace("timeline", "left", "TimelineDockLeft");
151
+
152
+ /** 回撤对话框包装:forkGate 请求时加载渲染。 */
153
+ export function ForkDialogSlot(): any {
154
+ const [needed, setNeeded] = useState(forkNeeded);
155
+ useEffect(() => {
156
+ if (needed) return;
157
+ const cb = () => setNeeded(true);
158
+ panelsNeedSubs.add(cb);
159
+ return () => { panelsNeedSubs.delete(cb); };
160
+ }, [needed]);
161
+ const Comp = useHeavyComponent(needed, "ForkDialogSlot");
162
+ return Comp ? <Comp /> : null;
163
+ }
164
+
165
+ /** forkGate 入口(entry.tsx 调用):用到才加载重面板,弹窗 Promise 语义与原实现一致。 */
166
+ export async function requestForkDialog(params: any): Promise<any> {
167
+ forkNeeded = true;
168
+ notifyPanelsNeed();
169
+ const m = await loadPanels();
170
+ return m.requestForkDialog(params);
171
+ }
172
+
173
+ // ---------- 工具条按钮 ----------
174
+
175
+ const toolbarBtnStyle: any = {
176
+ display: "flex", alignItems: "center", justifyContent: "center",
177
+ width: 36, height: 36, padding: 0, background: "none",
178
+ borderRight: "1px solid var(--border)", color: "var(--text-muted)",
179
+ cursor: "pointer", flexShrink: 0, fontSize: 16, lineHeight: 1, transition: "color 0.12s",
180
+ };
181
+ const svgProps = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true } as any;
182
+
183
+ // 工具条按钮(⎇ 关注路径 / ⎇ 时间线):纯静态组件,与 config/git-review 等扩展按钮同款注册方式。
184
+ // feature 门控(trace.watchUI / trace.timelineUI,默认关)由宿主统一处理:registerItems 条目带 feature,
185
+ // 关 → 宿主过滤不渲染(顶栏行/⋯菜单/自定义面板都不进,extman 变更实时生效)——扩展无需自己注册/注销或在组件内门控。
186
+
187
+ export function WatchToolbarButton(props?: any) { bindSession(props);
188
+ return (
189
+ <button style={toolbarBtnStyle} title={t("gt.watch.title")} onClick={() => openPanel("watch")}>
190
+ <svg {...svgProps}><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" /></svg>
191
+ </button>
192
+ );
193
+ }
194
+
195
+ export function TimelineToolbarButton(props?: any) { bindSession(props);
196
+ return (
197
+ <button style={toolbarBtnStyle} title={t("gt.timeline.title")} onClick={() => openPanel("timeline", { sessionId: getSessionId() })}>
198
+ <svg {...svgProps}><circle cx="12" cy="12" r="10" /><polyline points="12 6 12 12 16 14" /></svg>
199
+ </button>
200
+ );
201
+ }
202
+
203
+ // composerTools 插槽入口(输入框下方控制行,与压缩/导入同排;32×32 图标按钮与原生兄弟一致)——时间线主入口
204
+ export function TimelineComposerButton(props?: any) { bindSession(props);
205
+ const sid = props?.sessionId || getSessionId(); // composerTools extra 直传 sessionId(chatInputTop 同款)
206
+ return (
207
+ <button
208
+ title={t("gt.timeline.title")}
209
+ aria-label={t("gt.timeline.title")}
210
+ onClick={() => openPanel("timeline", { sessionId: sid })}
211
+ style={{
212
+ display: "flex", alignItems: "center", justifyContent: "center", gap: 5,
213
+ width: 32, height: 32, padding: 0,
214
+ background: "none", border: "none", borderRadius: 9,
215
+ color: "var(--text-muted)", cursor: "pointer",
216
+ transition: "background 0.12s, color 0.12s",
217
+ }}
218
+ onMouseEnter={(e: any) => { e.currentTarget.style.background = "var(--bg-hover)"; e.currentTarget.style.color = "var(--text)"; }}
219
+ onMouseLeave={(e: any) => { e.currentTarget.style.background = "none"; e.currentTarget.style.color = "var(--text-muted)"; }}
220
+ >
221
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
222
+ <circle cx="12" cy="12" r="10" />
223
+ <polyline points="12 6 12 12 16 14" />
224
+ </svg>
225
+ </button>
226
+ );
227
+ }
228
+
229
+ // ---------- 回撤后验横幅(§5.4 兜底) ----------
230
+
231
+ export function BannerSlot(): any {
232
+ const [info, setInfo] = useState<any>(null);
233
+ useEffect(() => {
234
+ (window as any).__aitiGitTraceBanner = (i: any) => setInfo(i || null);
235
+ return () => { try { delete (window as any).__aitiGitTraceBanner; } catch {} };
236
+ }, []);
237
+ if (!info) return null;
238
+ return (
239
+ <div style={{ margin: "6px 12px", padding: "6px 12px", borderRadius: 8, border: "1px solid #d4a72c", color: "#9a6700", background: "var(--bg-panel)", display: "flex", gap: 10, alignItems: "center" }}>
240
+ <span>{String(t("gt.banner.text")).replace("{n}", String(info.count ?? "?"))}</span>
241
+ <button style={btnStyle} onClick={() => openPanel("timeline", { sessionId: info.sessionId })}>{t("gt.timeline.title")}</button>
242
+ <button style={btnStyle} onClick={() => setInfo(null)}>{t("gt.common.close")}</button>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ // ---------- 用户消息徽标 ----------
248
+
249
+ // 取数走 common.fetchOpsCached 共享缓存(§11.3 性能审计合并:此前徽标独立一份 TTL 缓存,
250
+ // 与 footer 各拉一次 /ops;现单缓存 + ETag 再校验,live 失效走 invalidateOpsCache)
251
+
252
+ export function MessageBadge(props?: any): any {
253
+ bindSession(props);
254
+ const entryId = String(props?.entryId || "");
255
+ const [ops, setOps] = useState<any[] | null>(null);
256
+ useEffect(() => {
257
+ if (!entryId) return;
258
+ let dead = false;
259
+ const sid = getSessionId();
260
+ if (!sid) return;
261
+ fetchOpsCached(sid).then((list) => { if (!dead) setOps(list); });
262
+ return () => { dead = true; };
263
+ }, [entryId]);
264
+ if (!entryId || ops === null) return null;
265
+ // 与时间线增量口径一致:纯基线 op(before=null)/无变更 op 不计入徽标
266
+ const hasDelta = (row: any) => (row.leaks || []).length > 0
267
+ || (row.stores || []).some((s: any) => s.before && (s.files ? (s.files.M || 0) + (s.files.A || 0) + (s.files.D || 0) : 0) > 0);
268
+ const mine = ops.filter((row: any) => (row.entryId === entryId || row.userEntryId === entryId) && hasDelta(row));
269
+ if (mine.length === 0) return null; // 该消息无被追踪操作:不渲染(避免噪音)
270
+ return (
271
+ // 场景 3:徽标带 entryId 打开 → 总览自动过滤到该轮
272
+ <button style={chipStyle} title={"aiti-git-trace:" + mine.length + " 个被追踪操作(点击看时间线)"}
273
+ onClick={() => openPanel("timeline", { sessionId: getSessionId(), entryId })}>⎇ {mine.length}</button>
274
+ );
275
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * aiti-git-trace · assistantMessageFooter 数据面(纯函数,无依赖可直测)
3
+ * (方案 docs/aiti-git-trace-message-files-ext-plan.md §3.3)
4
+ *
5
+ * 输入 = 台账 ops 行(带外事实源,pi-git-trace 落盘)+ 宿主插槽 extra 的
6
+ * assistantEntryIds(整轮 assistant entry id 集合)。输出 = 本轮 trace 捕捉的
7
+ * 变更文件聚合;渲染前由组件与宿主 writtenFiles(write/edit 口径)求差集。
8
+ */
9
+
10
+ /** 台账行(pi-git-trace/src/types.ts LedgerRow 的消费侧子集;宽松结构防漂移) */
11
+ export interface LedgerRowLike {
12
+ entryId?: string;
13
+ userEntryId?: string;
14
+ stores?: Array<{
15
+ root?: string;
16
+ before?: string | null;
17
+ files?: { M?: number; A?: number; D?: number; skipped?: number };
18
+ paths?: string[];
19
+ pathsTruncated?: boolean;
20
+ }>;
21
+ leaks?: unknown[];
22
+ [k: string]: unknown;
23
+ }
24
+
25
+ /** 增量口径(与时间线/徽标一致):纯基线 op(无 before)且无 leaks 不算变更。 */
26
+ export function rowHasDelta(row: LedgerRowLike): boolean {
27
+ return (row.leaks || []).length > 0
28
+ || (row.stores || []).some((s) => Boolean(s.before)
29
+ && (s.files ? (s.files.M || 0) + (s.files.A || 0) + (s.files.D || 0) : 0) > 0);
30
+ }
31
+
32
+ /** 台账 paths 是仓相对路径 → 拼仓库根成绝对路径(已是绝对形态则原样)。 */
33
+ export function toAbsPath(root: string | undefined, rel: string): string {
34
+ if (!rel) return "";
35
+ if (/^([a-zA-Z]:[\\/]|\/)/.test(rel)) return rel;
36
+ if (!root) return rel;
37
+ const sep = root.endsWith("/") || root.endsWith("\\") ? "" : "/";
38
+ return root + sep + rel;
39
+ }
40
+
41
+ export interface TraceTurnFiles {
42
+ /** 绝对路径(跨 store/跨 op 去重,首见序)。 */
43
+ absPaths: string[];
44
+ /** 任一 store 触发台账 paths 上限截断。 */
45
+ truncated: boolean;
46
+ /** 贡献文件的有增量 op 行数(title 展示用)。 */
47
+ ops: number;
48
+ }
49
+
50
+ /**
51
+ * 按整轮 assistant entry id 集合聚合台账 → 本轮 trace 捕捉的变更文件。
52
+ * 关联键 = row.entryId(携带该工具调用的 assistant/toolCall entry)。
53
+ * 无命中行 / 命中行全无增量 → null(渲染层整行不画)。
54
+ */
55
+ export function aggregateTraceTurnFiles(
56
+ rows: LedgerRowLike[] | null | undefined,
57
+ assistantEntryIds: string[],
58
+ ): TraceTurnFiles | null {
59
+ const idSet = new Set((assistantEntryIds || []).map(String).filter(Boolean));
60
+ if (idSet.size === 0) return null;
61
+ const seen = new Set<string>();
62
+ const absPaths: string[] = [];
63
+ let truncated = false;
64
+ let ops = 0;
65
+ for (const row of rows || []) {
66
+ if (!idSet.has(String(row.entryId || ""))) continue;
67
+ if (!rowHasDelta(row)) continue;
68
+ ops++;
69
+ for (const s of row.stores || []) {
70
+ if (s?.pathsTruncated) truncated = true;
71
+ for (const p of s?.paths || []) {
72
+ const abs = toAbsPath(s?.root, String(p));
73
+ if (!abs || seen.has(abs)) continue;
74
+ seen.add(abs);
75
+ absPaths.push(abs);
76
+ }
77
+ }
78
+ }
79
+ if (absPaths.length === 0) return null;
80
+ return { absPaths, truncated, ops };
81
+ }
82
+
83
+ /** 路径归一(分隔符/尾斜杠)后的差集:trace 有、宿主 writtenFiles 没有的文件。 */
84
+ export function diffAgainstWritten(absPaths: string[], writtenFiles: string[]): string[] {
85
+ const norm = (p: string) => String(p || "").replace(/\\/g, "/").replace(/\/+$/, "");
86
+ const written = new Set((writtenFiles || []).map(norm));
87
+ return (absPaths || []).filter((p) => !written.has(norm(p)));
88
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * aiti-git-trace · assistantMessageFooter 变更文件行
3
+ * (方案 docs/aiti-git-trace-message-files-ext-plan.md §3.3/§3.4)
4
+ *
5
+ * 宿主在 assistant 消息「改动的文件」chips 之后挂 assistantMessageFooter 插槽:
6
+ * turn 最终回答渲染点 extra 带整轮 assistantEntryIds + 宿主推导的 writtenFiles
7
+ * (write/edit 口径);普通 assistant 消息渲染点只有自身 entryId(消息级兜底)。
8
+ * 本组件把台账 ops 按这些 entryId 聚合、与 writtenFiles 求差集,只补 write/edit
9
+ * 之外的变更文件(bash 等),避免与宿主「改动的文件」重复;差集为空整行不画。
10
+ *
11
+ * 数据面:与徽标共用 common.fetchOpsCached 单缓存(§11.3 性能审计合并,TTL 4s + ETag
12
+ * 再校验);挂载时拉取非轮询(严禁 setInterval)。live 轮次的最后几条 op 常在 TTL
13
+ * 窗口内落账——refcount 共享一条 SSE /events(本扩展自有通道,panels.tsx 同款),
14
+ * op 事件 → invalidateOpsCache 清缓存 + 800ms trailing 防抖通知挂载行重取(事件驱动,
15
+ * 连接零挂载即断)。
16
+ */
17
+ import { useState, useEffect, apiUrl, t } from "./host-shim";
18
+ import { bindSession, getSessionId, openPanel, chipStyle, EXT_API, fetchOpsCached, invalidateOpsCache } from "./common";
19
+ import { aggregateTraceTurnFiles, diffAgainstWritten } from "./trace-files-row.pure";
20
+
21
+ // ---------- 台账 op 事件 → 失效重取(refcount 共享 SSE;事件驱动非轮询) ----------
22
+
23
+ const MAX_CHIPS = 8;
24
+ const OP_DEBOUNCE_MS = 800; // panels.tsx 时间线同款:busy 轮 op 连爆不逐条重取
25
+
26
+ const opSubs = new Set<(sid: string) => void>();
27
+ let opEs: EventSource | null = null;
28
+ let opDebounce: ReturnType<typeof setTimeout> | null = null;
29
+
30
+ function subscribeOps(cb: (sid: string) => void): () => void {
31
+ opSubs.add(cb);
32
+ if (!opEs) {
33
+ try {
34
+ opEs = new EventSource(apiUrl(EXT_API + "/events"));
35
+ opEs.addEventListener("op", (e: any) => {
36
+ let sid = "";
37
+ try { sid = String(JSON.parse(String(e?.data || "{}"))?.sessionId || ""); } catch { return; }
38
+ if (!sid) return;
39
+ invalidateOpsCache(sid);
40
+ if (opDebounce) clearTimeout(opDebounce);
41
+ opDebounce = setTimeout(() => {
42
+ opDebounce = null;
43
+ for (const fn of [...opSubs]) { try { fn(sid); } catch { /* noop */ } }
44
+ }, OP_DEBOUNCE_MS);
45
+ });
46
+ } catch { opEs = null; /* EventSource 不可用:退化为挂载时取数,仅失实时化 */ }
47
+ }
48
+ return () => {
49
+ opSubs.delete(cb);
50
+ if (opSubs.size === 0) {
51
+ if (opEs) { try { opEs.close(); } catch { /* ignore */ } opEs = null; }
52
+ if (opDebounce) { clearTimeout(opDebounce); opDebounce = null; }
53
+ }
54
+ };
55
+ }
56
+
57
+ export function TraceFilesRow(props?: any): any {
58
+ bindSession(props);
59
+ // turn 粒度优先(assistantEntryIds);缺省降级消息粒度(自身 entryId,§3.4 兜底)
60
+ const ids: string[] = Array.isArray(props?.assistantEntryIds) && props.assistantEntryIds.length
61
+ ? props.assistantEntryIds.map(String).filter(Boolean)
62
+ : props?.entryId ? [String(props.entryId)] : [];
63
+ const writtenFiles: string[] = Array.isArray(props?.writtenFiles)
64
+ ? props.writtenFiles.map(String).filter(Boolean)
65
+ : [];
66
+ const sessionId = String(props?.sessionId || "") || getSessionId();
67
+ const idKey = ids.join("|");
68
+ const [ops, setOps] = useState<any[] | null>(null);
69
+ const [expanded, setExpanded] = useState(false);
70
+ useEffect(() => {
71
+ if (!idKey || !sessionId) return;
72
+ let dead = false;
73
+ const refetch = () => { fetchOpsCached(sessionId).then((list) => { if (!dead) setOps(list); }); };
74
+ refetch();
75
+ const dispose = subscribeOps((sid) => { if (sid === sessionId) refetch(); });
76
+ return () => { dead = true; dispose(); };
77
+ }, [idKey, sessionId]);
78
+ if (!idKey || ops === null) return null;
79
+ const info = aggregateTraceTurnFiles(ops, ids);
80
+ if (!info) return null;
81
+ const extra = diffAgainstWritten(info.absPaths, writtenFiles);
82
+ if (extra.length === 0) return null;
83
+ const shown = expanded ? extra : extra.slice(0, MAX_CHIPS);
84
+ const hidden = extra.length - shown.length;
85
+ return (
86
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 4, alignItems: "center", marginTop: -2 }}>
87
+ <span aria-hidden title={t("gt.footer.title", { n: info.ops })}
88
+ style={{ fontSize: 11, color: "var(--text-dim)", cursor: "default" }}>⎇</span>
89
+ {shown.map((p: string) => (
90
+ <button key={p}
91
+ style={{ ...chipStyle, maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
92
+ title={p}
93
+ onClick={() => openPanel("timeline", { sessionId, view: "file", file: p })}>
94
+ {p.split("/").pop() || p}
95
+ </button>
96
+ ))}
97
+ {hidden > 0 && (
98
+ <button style={chipStyle} title={t("gt.footer.title", { n: info.ops })}
99
+ onClick={() => setExpanded(true)}>{t("gt.footer.more", { n: hidden })}</button>
100
+ )}
101
+ {expanded && extra.length > MAX_CHIPS && (
102
+ <button style={chipStyle} onClick={() => setExpanded(false)}>{t("gt.footer.collapse")}</button>
103
+ )}
104
+ {info.truncated && (
105
+ <span title={t("gt.footer.truncated")} aria-label={t("gt.footer.truncated")}
106
+ style={{ fontSize: 11, color: "var(--text-dim)", cursor: "default" }}>…</span>
107
+ )}
108
+ </div>
109
+ );
110
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * 版本引用文本构造(docs/aiti-git-trace-timeline-file-actions-plan.md §3)。
3
+ *
4
+ * 纯函数:无 React、无宿主 ctx 依赖,可直 node:test 单测。
5
+ * buildAtText(@ token 格式:空格加引号、目录尾斜杠)复用 _shared/new-session-cwd
6
+ * (与 aiti-doc-center / 宿主 buildAtMentionText 同源)。
7
+ */
8
+ import { buildAtText } from "../../../_shared/new-session-cwd.ts";
9
+
10
+ export interface VersionMentionInput {
11
+ /** 相对 root 的路径(@ token 用;buildAtText 决定引号格式) */
12
+ path: string;
13
+ /** 原仓 root(绝对路径,构造边路仓 gitDir=<root>/.aiti-trace) */
14
+ root: string;
15
+ /** before commit(完整 hash;可空=首触无基线) */
16
+ base?: string | null;
17
+ /** after commit(完整 hash;可空=loose 工作区无 commit) */
18
+ target?: string | null;
19
+ /** 行号(行级引用用;文件级引用不传) */
20
+ line?: number | null;
21
+ /** 行类型(决定行号归属 + 标注) */
22
+ lineKind?: "new" | "old" | "context" | "hunk" | "other";
23
+ }
24
+
25
+ const SHORT = (h?: string | null): string => (h ? String(h).slice(0, 7) : "");
26
+ const trimSlash = (p: string): string => String(p || "").replace(/\/+$/, "");
27
+
28
+ /**
29
+ * 构造带版本信息的引用文本:@ token(纯路径,宿主可点击解析)+ 附文(边路仓 git 命令)。
30
+ *
31
+ * 关键:after commit 在边路仓 .aiti-trace 里,原工作区仓没有此 commit——AI 在原仓跑 git show
32
+ * 会扑空。故附文给出指向边路仓的完整命令(--git-dir=<root>/.aiti-trace --work-tree=<root>),
33
+ * AI 直接复制可执行,能 show/diff 出历史版本。
34
+ */
35
+ export function buildVersionMention(inp: VersionMentionInput): string {
36
+ const at = buildAtText(inp.path);
37
+ const root = trimSlash(inp.root);
38
+ const gitDir = `${root}/.aiti-trace`;
39
+ const target = inp.target || null;
40
+ const base = inp.base || null;
41
+ if (!target) {
42
+ return at + `(⏱ 时间线工作区版本,无边路 commit;可能非当前文件)`;
43
+ }
44
+ const parts: string[] = [`after=${SHORT(target)}`];
45
+ if (base && base !== target) parts.push(`base=${SHORT(base)}`);
46
+ if (inp.line != null) {
47
+ const kindTag = inp.lineKind === "old" ? "(旧文件)" : inp.lineKind === "new" ? "(新文件)" : "";
48
+ parts.push(`行${inp.line}${kindTag}`);
49
+ }
50
+ const showCmd = `git --git-dir=${gitDir} --work-tree=${root} show ${target}:${inp.path}`;
51
+ const diffCmd = (base && base !== target)
52
+ ? `\n或查看区间 diff:\ngit --git-dir=${gitDir} --work-tree=${root} diff ${base}..${target} -- ${inp.path}`
53
+ : "";
54
+ return at + `\n(⏱ 时间线历史版本 ${parts.join(" ")};非当前文件,边路仓查看:\n${showCmd}${diffCmd})`;
55
+ }
56
+
57
+ export interface DiffLineInfo {
58
+ line: number | null;
59
+ lineKind: "new" | "old" | "context" | "hunk" | "other";
60
+ }
61
+
62
+ /**
63
+ * 从 unified diff 行列表 + 点击索引,推算行号信息。
64
+ * 解析 `@@ -a,b +c,d @@` hunk 头 + 行前缀(+/−/空)累加。
65
+ * hunk 头之前的行(diff --git / index 等)→ other。
66
+ */
67
+ export function resolveDiffLineInfo(lines: string[], index: number): DiffLineInfo {
68
+ let newLine = 0, oldLine = 0, inHunk = false;
69
+ for (let i = 0; i <= index && i < lines.length; i++) {
70
+ const ln = lines[i] || "";
71
+ const m = ln.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
72
+ if (m) {
73
+ oldLine = parseInt(m[1], 10);
74
+ newLine = parseInt(m[3], 10);
75
+ inHunk = true;
76
+ if (i === index) return { line: null, lineKind: "hunk" };
77
+ continue;
78
+ }
79
+ if (!inHunk) continue;
80
+ if (i === index) {
81
+ if (ln.startsWith("+")) return { line: newLine, lineKind: "new" };
82
+ if (ln.startsWith("-")) return { line: oldLine, lineKind: "old" };
83
+ return { line: newLine, lineKind: "context" };
84
+ }
85
+ if (ln.startsWith("+")) newLine++;
86
+ else if (ln.startsWith("-")) oldLine++;
87
+ else { newLine++; oldLine++; }
88
+ }
89
+ return { line: null, lineKind: "other" };
90
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * deploy 脚本共享「部署状态 commit」助手(pi-web-ext 各扩展 tools/deploy.mjs 统一用)。
3
+ *
4
+ * 背景(2026-08-30 排障,session 0cc6ad47 op264):重复部署同内容时
5
+ * `git add -A && git commit` 以 "nothing to commit" exit 1,而 stdio:"ignore" 吞掉
6
+ * stderr,输出「提交失败(忽略):Command failed: …」假报警(msg-smart 实证);
7
+ * 另有 --allow-empty 族每次部署都造空 commit 噪音。本助手统一三态语义:
8
+ * committed → 有暂存变更,已提交
9
+ * clean → 无变更,跳过(不算失败)
10
+ * failed → git 真失败(带 stderr 首行,不阻断部署——部署本体永远优先)
11
+ *
12
+ * 用法:
13
+ * import { deployCommit } from "../../_shared/deploy-commit.mjs";
14
+ * deployCommit({ repo: extRepo, name: "aiti-msg-smart", paths: ["aiti-msg-smart"],
15
+ * log: (m) => console.log("[deploy] " + m) });
16
+ *
17
+ * 红线:本助手永不 throw、不触碰部署产物路径之外的任何东西;只对 <repo> 做
18
+ * add/diff/commit(默认 --no-verify,extRepo 无 hooks,防未来误挂)。
19
+ */
20
+ import { execFileSync } from "node:child_process";
21
+
22
+ export function deployCommit({ repo, name, paths = null, extraMsg = "", ident = false, log = console.log }) {
23
+ const git = (args) =>
24
+ execFileSync("git", args, { cwd: repo, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
25
+ const firstLine = (s) => String(s || "").trim().split("\n")[0];
26
+ try {
27
+ // 暂存:默认只收本扩展子目录(与 add <ext> 行为对齐;避免把别的扩展的
28
+ // 未提交改动卷进本扩展的部署 commit);paths=null 时退回 add -A 全仓。
29
+ git(["add", ...(paths && paths.length ? ["--", ...paths] : ["-A"])]);
30
+ let dirty;
31
+ try {
32
+ git(["diff", "--cached", "--quiet"]);
33
+ dirty = false; // exit 0 = 暂存区与 HEAD 一致
34
+ } catch (e) {
35
+ if (e.status !== 1) throw e; // 1 = 有变更;其他才是真错误
36
+ dirty = true;
37
+ }
38
+ if (!dirty) {
39
+ log("扩展仓库无变化(跳过提交)");
40
+ return { status: "clean" };
41
+ }
42
+ const stamp = new Date().toISOString().replace("T", " ").slice(0, 19);
43
+ const msg = `[deploy] ${name} · ${stamp}${extraMsg ? " · " + extraMsg : ""}`;
44
+ git([...(ident ? ["-c", "user.name=deploy", "-c", "user.email=deploy@local"] : []), "commit", "--no-verify", "-m", msg]);
45
+ log("扩展仓库已提交:" + msg);
46
+ return { status: "committed", msg };
47
+ } catch (e) {
48
+ const reason = firstLine(e.stderr) || firstLine(e.message) || "git error";
49
+ log("提交失败(忽略,不影响部署):" + reason);
50
+ return { status: "failed", reason };
51
+ }
52
+ }