@rexvane/piweb 0.3.15 → 0.3.17

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 (143) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +4 -4
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/react-loadable-manifest.json +3 -3
  6. package/.next/required-server-files.js +1 -3
  7. package/.next/required-server-files.json +1 -3
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +1 -1
  16. package/.next/server/app/_not-found.rsc +2 -2
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  19. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  20. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  23. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  24. package/.next/server/app/api/growth/route.js +1 -1
  25. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  26. package/.next/server/app/api/models/route.js +1 -1
  27. package/.next/server/app/api/models/route.js.nft.json +1 -1
  28. package/.next/server/app/api/pi-settings/route.js +1 -1
  29. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  30. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  31. package/.next/server/app/api/security/route.js.nft.json +1 -1
  32. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  33. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  34. package/.next/server/app/api/sessions/import/route.js +3 -3
  35. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  36. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  37. package/.next/server/app/api/update/route.js +1 -1
  38. package/.next/server/app/api/update/route.js.nft.json +1 -1
  39. package/.next/server/app/api/version/route.js +1 -1
  40. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  41. package/.next/server/app/index.html +1 -1
  42. package/.next/server/app/index.rsc +4 -4
  43. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  44. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  45. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  46. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  47. package/.next/server/app/login.html +1 -1
  48. package/.next/server/app/login.rsc +3 -3
  49. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  50. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  51. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  52. package/.next/server/app/page.js +1 -1
  53. package/.next/server/app/page_client-reference-manifest.js +1 -1
  54. package/.next/server/app-paths-manifest.json +4 -4
  55. package/.next/server/chunks/496.js +1 -1
  56. package/.next/server/chunks/686.js +4 -4
  57. package/.next/server/middleware-build-manifest.js +1 -1
  58. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  59. package/.next/server/pages/404.html +1 -1
  60. package/.next/server/pages/500.html +1 -1
  61. package/.next/server/server-reference-manifest.json +1 -1
  62. package/.next/static/chunks/2716.93be8ad5c970c516.js +1 -0
  63. package/.next/static/chunks/4632.112d2e893d48d188.js +1 -0
  64. package/.next/static/chunks/4673.c1fb1580efc6ace7.js +1 -0
  65. package/.next/static/chunks/4686-f4a037d609cea3f2.js +1 -0
  66. package/.next/static/chunks/app/page-b51087831582ee37.js +1 -0
  67. package/.next/static/chunks/{webpack-42c5aae79b0e5d8c.js → webpack-c31263fd83d75ce6.js} +1 -1
  68. package/.next/static/css/850c2fd130ffa2fc.css +1 -0
  69. package/README.md +17 -19
  70. package/assets/archive.jpg +0 -0
  71. package/assets/growth-1.jpg +0 -0
  72. package/assets/growth-2.jpg +0 -0
  73. package/assets/import.jpg +0 -0
  74. package/assets/main.jpg +0 -0
  75. package/assets/outline.jpg +0 -0
  76. package/assets/prompts-1.jpg +0 -0
  77. package/assets/prompts-2.jpg +0 -0
  78. package/assets/providers-1.jpg +0 -0
  79. package/assets/providers-2.jpg +0 -0
  80. package/assets/providers-3.jpg +0 -0
  81. package/assets/trace-1.jpg +0 -0
  82. package/assets/trace-2.png +0 -0
  83. package/assets/trace-3.png +0 -0
  84. package/assets/trace-4.png +0 -0
  85. package/assets/viewer-1.jpg +0 -0
  86. package/assets/viewer-2.jpg +0 -0
  87. package/bin/pi.js +50 -0
  88. package/next.config.ts +32 -7
  89. package/package.json +2 -6
  90. package/scripts/launcher.mjs +1 -1
  91. package/scripts/verify-package.mjs +0 -53
  92. package/src/app/api/growth/route.ts +14 -14
  93. package/src/app/api/models/route.ts +2 -1
  94. package/src/app/globals.css +90 -0
  95. package/src/components/AppShell.tsx +30 -16
  96. package/src/components/ChatWindow.tsx +192 -6
  97. package/src/components/FileViewer.tsx +10 -9
  98. package/src/components/ProjectPanel.tsx +111 -94
  99. package/src/components/SessionImportSection.tsx +20 -36
  100. package/src/hooks/useGrowth.ts +123 -156
  101. package/src/hooks/usePiWeb.ts +7 -10
  102. package/src/i18n.tsx +39 -39
  103. package/src/lib/agent-manager.ts +10 -8
  104. package/src/lib/growth-service.ts +390 -166
  105. package/src/lib/growth-tracker.ts +64 -264
  106. package/src/lib/growth-turns.ts +31 -1
  107. package/src/lib/models/model-scope.ts +15 -0
  108. package/src/lib/models-service.ts +16 -0
  109. package/src/lib/pi-settings.ts +7 -0
  110. package/src/lib/session-import/claude.ts +2 -13
  111. package/src/lib/session-import/codex.ts +8 -44
  112. package/src/lib/session-import/files.ts +0 -14
  113. package/src/lib/session-import/grok.ts +24 -54
  114. package/src/lib/session-import/types.ts +0 -12
  115. package/src/lib/types.ts +27 -22
  116. package/tsconfig.json +2 -1
  117. package/.next/static/chunks/2716.a9e808190a4e2424.js +0 -1
  118. package/.next/static/chunks/4632.0ed92ae42905ce40.js +0 -1
  119. package/.next/static/chunks/4673.d3d0a7f2599080fb.js +0 -1
  120. package/.next/static/chunks/4686-7d694def920ff093.js +0 -1
  121. package/.next/static/chunks/app/page-9a5440047c5a415c.js +0 -1
  122. package/.next/static/css/b2d35abc828d4713.css +0 -1
  123. package/assets/archive.png +0 -0
  124. package/assets/growth-1.png +0 -0
  125. package/assets/growth-2.png +0 -0
  126. package/assets/import.png +0 -0
  127. package/assets/main.png +0 -0
  128. package/assets/outline.png +0 -0
  129. package/assets/prompts-1.png +0 -0
  130. package/assets/prompts-2.png +0 -0
  131. package/assets/providers-1.png +0 -0
  132. package/assets/providers-2.png +0 -0
  133. package/assets/providers-3.png +0 -0
  134. package/assets/trace-1.png +0 -0
  135. package/assets/viewer-1.png +0 -0
  136. package/assets/viewer-2.png +0 -0
  137. package/scripts/managed-niubash-assets.mjs +0 -146
  138. package/scripts/prepare-niubash-packages.mjs +0 -182
  139. package/scripts/smoke-managed-niubash.mjs +0 -62
  140. package/src/lib/growth-rounds.ts +0 -84
  141. package/src/lib/managed-niubash.ts +0 -227
  142. /package/.next/static/{DdBwPYoHBKtaJ97QIr6Co → zsynuO_9Kx6nrXgkTUJ04}/_buildManifest.js +0 -0
  143. /package/.next/static/{DdBwPYoHBKtaJ97QIr6Co → zsynuO_9Kx6nrXgkTUJ04}/_ssgManifest.js +0 -0
@@ -1,16 +1,21 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * useGrowth:项目生长视图的状态。
5
- * 账本(拉取 + 实时步按 seq 合并)、轮次导航 / 默认跟随、范围(本步 / 本会话)、
6
- * 所选快照的文件与变更、当前磁盘全量目录懒加载、展开状态与新变更动画。
4
+ * useGrowth:项目生长视图的状态(每轮一个 git commit)。
5
+ * 时间轴(拉取 + 实时 commit 去重合并)、轮次导航 / 默认跟随、范围(本轮 / 本会话累计)、
6
+ * 所选轮的文件与变更、当前磁盘全量目录懒加载、展开状态与新变更动画。
7
7
  */
8
8
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
9
9
  import { allDirPaths, buildTree, dirsToReveal, graftLazy, type TreeNode } from "@/lib/growth-tree";
10
- import { buildGrowthRounds, type GrowthRound } from "@/lib/growth-rounds";
11
- import type { GrowthChange, GrowthStep } from "@/lib/types";
10
+ import type { GrowthChange, GrowthRound } from "@/lib/types";
12
11
 
13
- export type GrowthScope = "step" | "session";
12
+ export type GrowthScope = "round" | "session";
13
+
14
+ /** 时间轴上的一项:pi 的一轮(带轮号)或用户在两轮之间自己的修改(n = null) */
15
+ export interface GrowthEntry extends GrowthRound {
16
+ /** pi 的第几轮(从 1 起);「你的修改」为 null */
17
+ n: number | null;
18
+ }
14
19
 
15
20
  export interface TreeFile {
16
21
  path: string;
@@ -21,30 +26,24 @@ export interface GrowthApi {
21
26
  available: boolean;
22
27
  loading: boolean;
23
28
  error: string | null;
24
- steps: GrowthStep[];
25
- rounds: GrowthRound[];
26
- /** 本会话第一步(基线) */
27
- baseline: GrowthStep | null;
28
- latest: GrowthStep | null;
29
- selected: GrowthStep | null;
30
- selectedRound: GrowthRound | null;
29
+ /** 本会话的时间轴(时间正序,不含工作区基线) */
30
+ rounds: GrowthEntry[];
31
+ selected: GrowthEntry | null;
31
32
  following: boolean;
32
33
  scope: GrowthScope;
33
34
  /** 所选范围的 from/to tree(查看器取 diff 用) */
34
35
  range: { from: string; to: string } | null;
36
+ /** 所选范围的变更 */
35
37
  changes: GrowthChange[];
36
- /** 相对会话基线的全部变更(头部总计) */
37
- sessionChanges: GrowthChange[];
38
38
  tree: TreeNode;
39
- /** 所选步的全部文件路径(快速打开用) */
39
+ /** 所选轮的全部文件路径(快速打开用) */
40
40
  filePaths: string[];
41
41
  expanded: Set<string>;
42
- /** 最新一步刚改动的路径(入场动画) */
42
+ /** 最新一轮刚改动的路径(入场动画) */
43
43
  fresh: Set<string>;
44
- pending: string[];
45
44
  follow(): void;
46
45
  /** 选中某一轮(时间轴柱);选到最后一轮等于回到跟随 */
47
- selectRound(id: number): void;
46
+ select(commit: string): void;
48
47
  prev(): void;
49
48
  next(): void;
50
49
  setScope(scope: GrowthScope): void;
@@ -54,7 +53,8 @@ export interface GrowthApi {
54
53
  collapseAll(): void;
55
54
  expandLazy(path: string): Promise<void>;
56
55
  refresh(): Promise<void>;
57
- snapshotNow(): Promise<GrowthStep | null>;
56
+ /** 立即记录:把当前改动提交为「你的修改」;没改动返回 null */
57
+ recordNow(): Promise<GrowthRound | null>;
58
58
  }
59
59
 
60
60
  export function sessionIdOf(sessionPath: string): string {
@@ -69,19 +69,23 @@ function cwdKey(cwd: string): string {
69
69
  return /^[A-Za-z]:\//.test(norm) || norm.startsWith("//") ? norm.toLowerCase() : norm;
70
70
  }
71
71
 
72
- function mergeSteps(fetched: GrowthStep[], live: GrowthStep[]): GrowthStep[] {
72
+ /** 拉取结果在前,本次连接收到的实时 commit 按 commit 哈希去重后接在后面 */
73
+ function mergeRounds(fetched: GrowthRound[], live: GrowthRound[]): GrowthRound[] {
73
74
  if (!live.length) return fetched;
74
- const bySeq = new Map<number, GrowthStep>();
75
- for (const s of fetched) bySeq.set(s.seq, s);
76
- for (const s of live) bySeq.set(s.seq, s);
77
- return [...bySeq.values()].sort((a, b) => a.seq - b.seq);
75
+ const known = new Set(fetched.map((round) => round.commit));
76
+ const extra = live.filter((round) => !known.has(round.commit));
77
+ return extra.length ? [...fetched, ...extra] : fetched;
78
+ }
79
+
80
+ /** 去掉工作区基线,给 pi 的轮次编号(「你的修改」不占号) */
81
+ export function numberRounds(rounds: GrowthRound[]): GrowthEntry[] {
82
+ let n = 0;
83
+ return rounds.filter((round) => round.kind !== "baseline").map((round) => ({ ...round, n: round.kind === "round" ? (n += 1) : null }));
78
84
  }
79
85
 
80
86
  const REVEAL_LIMIT = 200;
81
87
  const FRESH_MS = 2500;
82
- /** 非跟随态给出的稳定空数组:每次渲染都 new 一个会让下游 effect(查看器取数)反复重跑 */
83
- const NO_PENDING: string[] = [];
84
- /** 清单 / 变更缓存条数上限(按 tree 哈希键;长会话几百步也就几 MB,超过就淘汰最早的) */
88
+ /** 清单 / 变更缓存条数上限(按 tree 哈希键;长会话几百轮也就几 MB,超过就淘汰最早的) */
85
89
  const CACHE_LIMIT = 300;
86
90
  function remember<K, V>(map: Map<K, V>, key: K, value: V): void {
87
91
  if (map.size >= CACHE_LIMIT) map.delete(map.keys().next().value as K);
@@ -94,7 +98,7 @@ interface DiskEntry {
94
98
  size?: number;
95
99
  }
96
100
 
97
- /** 磁盘列表里永远不画的目录:版本库内部(快照本来就排除它,节点也没有可看的东西) */
101
+ /** 磁盘列表里永远不画的目录:版本库内部(提交本来就排除它,节点也没有可看的东西) */
98
102
  const HIDDEN_DISK_DIRS = new Set([".git"]);
99
103
 
100
104
  function diskNodes(parent: string, entries: DiskEntry[]): TreeNode[] {
@@ -133,27 +137,24 @@ async function listDiskEntries(cwd: string, dir: string, signal: AbortSignal): P
133
137
  export function useGrowth({
134
138
  cwd,
135
139
  sessionPath,
136
- liveSteps,
137
- pending,
140
+ liveRounds,
138
141
  runtimeError,
139
142
  active,
143
+ watchRounds = false,
140
144
  connected,
141
- turnStarts = [],
142
145
  }: {
143
146
  cwd: string;
144
147
  sessionPath: string | null;
145
- /** usePiWeb 在本次连接期间收到的实时步 */
146
- liveSteps: GrowthStep[];
147
- /** 改盘类工具运行期间目录监听看到的路径 */
148
- pending: string[];
149
- /** 实时快照失败;成功记录后由事件清除。 */
148
+ /** usePiWeb 在本次连接期间收到的实时 commit */
149
+ liveRounds: GrowthRound[];
150
+ /** 实时提交失败;成功提交后由事件清除。 */
150
151
  runtimeError?: string | null;
151
- /** 项目栏或查看器打开时才拉数据 */
152
+ /** 项目栏或查看器打开:拉磁盘目录、文件清单与范围变更 */
152
153
  active: boolean;
153
- /** SSE 重连后补拉断线期间可能漏掉的生长步。 */
154
+ /** 有会话就拉时间轴(对话里「本轮改了 N 个文件」要用),不必等面板打开 */
155
+ watchRounds?: boolean;
156
+ /** SSE 重连后补拉断线期间可能漏掉的 commit。 */
154
157
  connected: boolean;
155
- /** 用户消息的时间戳;用于在旧快照缺少回合标记时恢复轮次边界。 */
156
- turnStarts?: number[];
157
158
  }): GrowthApi {
158
159
  const key = JSON.stringify([cwdKey(cwd), sessionPath ?? ""]);
159
160
  const requestScope = useMemo(() => ({
@@ -181,13 +182,12 @@ export function useGrowth({
181
182
  requestScope.loadingDirs.clear();
182
183
  };
183
184
  }, [requestScope]);
184
- const [fetched, setFetched] = useState<{ key: string; steps: GrowthStep[]; available: boolean; error: string | null }>({ key: "", steps: [], available: true, error: null });
185
+ const [fetched, setFetched] = useState<{ key: string; rounds: GrowthRound[]; available: boolean; error: string | null }>({ key: "", rounds: [], available: true, error: null });
185
186
  const [loading, setLoading] = useState(false);
186
- const [selectedRoundId, setSelectedRoundId] = useState<number | null>(null);
187
- const [scope, setScopeState] = useState<GrowthScope>("step");
187
+ const [selectedCommit, setSelectedCommit] = useState<string | null>(null);
188
+ const [scope, setScopeState] = useState<GrowthScope>("round");
188
189
  const [files, setFiles] = useState<{ key: string; tree: string; files: TreeFile[] } | null>(null);
189
190
  const [rangeChanges, setRangeChanges] = useState<{ key: string; changes: GrowthChange[]; error?: string } | null>(null);
190
- const [sessionRange, setSessionRange] = useState<{ key: string; changes: GrowthChange[]; error?: string } | null>(null);
191
191
  const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
192
192
  const [fresh, setFresh] = useState<Set<string>>(() => new Set());
193
193
  const [lazyState, setLazyState] = useState(() => ({ scope: requestScope, children: new Map<string, TreeNode[]>() }));
@@ -204,12 +204,12 @@ export function useGrowth({
204
204
  const changesCache = useRef(new Map<string, GrowthChange[]>());
205
205
  const collapsedByUser = useRef(new Set<string>());
206
206
  const freshTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
207
- const lastRevealedSeq = useRef<number>(0);
207
+ const lastRevealed = useRef<string>("");
208
208
  const previousConnected = useRef(connected);
209
209
 
210
210
  useEffect(() => {
211
211
  const saved = localStorage.getItem("piweb.growth.scope");
212
- if (saved === "step" || saved === "session") setScopeState(saved);
212
+ if (saved === "round" || saved === "session") setScopeState(saved);
213
213
  }, []);
214
214
  const setScope = useCallback((s: GrowthScope) => {
215
215
  setScopeState(s);
@@ -241,12 +241,12 @@ export function useGrowth({
241
241
 
242
242
  // 切会话:回到跟随、清实时相关状态
243
243
  useEffect(() => {
244
- setSelectedRoundId(null);
244
+ setSelectedCommit(null);
245
245
  setFresh(new Set());
246
246
  setLoading(false);
247
247
  setLazyState({ scope: requestScope, children: new Map() });
248
248
  if (freshTimer.current) clearTimeout(freshTimer.current);
249
- lastRevealedSeq.current = 0;
249
+ lastRevealed.current = "";
250
250
  }, [requestScope]);
251
251
 
252
252
  const refresh = useCallback(async () => {
@@ -255,7 +255,7 @@ export function useGrowth({
255
255
  requestScope.refresh?.abort();
256
256
  requestScope.refresh = controller;
257
257
  if (!cwd) {
258
- setFetched({ key, steps: [], available: true, error: null });
258
+ setFetched({ key, rounds: [], available: true, error: null });
259
259
  updateLazy(controller, () => new Map());
260
260
  setLoading(false);
261
261
  requestScope.controllers.delete(controller);
@@ -265,17 +265,20 @@ export function useGrowth({
265
265
  try {
266
266
  const params = new URLSearchParams({ cwd });
267
267
  if (sessionPath) params.set("session", sessionPath);
268
- // 两个请求解耦:磁盘目录先到先画,growth 慢(git 探测超时等)不拖住目录树
268
+ // 两个请求解耦:磁盘目录先到先画,git 慢(探测超时等)不拖住目录树
269
269
  const growthRequest = sessionPath
270
- ? getJson<{ available: boolean; steps: GrowthStep[] }>(`/api/growth?${params}`, controller.signal).then(
270
+ ? getJson<{ available: boolean; rounds: GrowthRound[] }>(`/api/growth?${params}`, controller.signal).then(
271
+ (value) => ({ status: "fulfilled" as const, value }),
272
+ (reason: unknown) => ({ status: "rejected" as const, reason }),
273
+ )
274
+ : Promise.resolve({ status: "fulfilled" as const, value: { available: true, rounds: [] as GrowthRound[] } });
275
+ // 面板关着时只拉时间轴,不列磁盘
276
+ const diskRequest = active
277
+ ? listDiskEntries(cwd, "", controller.signal).then(
271
278
  (value) => ({ status: "fulfilled" as const, value }),
272
279
  (reason: unknown) => ({ status: "rejected" as const, reason }),
273
280
  )
274
- : Promise.resolve({ status: "fulfilled" as const, value: { available: true, steps: [] } });
275
- const diskRequest = listDiskEntries(cwd, "", controller.signal).then(
276
- (value) => ({ status: "fulfilled" as const, value }),
277
- (reason: unknown) => ({ status: "rejected" as const, reason }),
278
- );
281
+ : Promise.resolve({ status: "skipped" as const });
279
282
  void diskRequest.then((diskResult) => {
280
283
  if (!isCurrent(controller)) return;
281
284
  if (diskResult.status === "fulfilled") {
@@ -285,9 +288,9 @@ export function useGrowth({
285
288
  const growthResult = await growthRequest;
286
289
  if (!isCurrent(controller)) return;
287
290
  if (growthResult.status === "fulfilled") {
288
- setFetched({ key, steps: growthResult.value.steps ?? [], available: growthResult.value.available !== false, error: null });
291
+ setFetched({ key, rounds: growthResult.value.rounds ?? [], available: growthResult.value.available !== false, error: null });
289
292
  } else {
290
- setFetched({ key, steps: [], available: true, error: growthResult.reason instanceof Error ? growthResult.reason.message : "failed to load" });
293
+ setFetched({ key, rounds: [], available: true, error: growthResult.reason instanceof Error ? growthResult.reason.message : "failed to load" });
291
294
  }
292
295
  const diskResult = await diskRequest;
293
296
  if (!isCurrent(controller)) return;
@@ -298,22 +301,22 @@ export function useGrowth({
298
301
  if (isCurrent(controller)) setLoading(false);
299
302
  requestScope.controllers.delete(controller);
300
303
  }
301
- }, [cwd, sessionPath, key, beginRequest, isCurrent, requestScope, updateLazy]);
304
+ }, [cwd, sessionPath, key, active, beginRequest, isCurrent, requestScope, updateLazy]);
302
305
 
306
+ const watching = active || watchRounds;
303
307
  useEffect(() => {
304
- if (active) void refresh();
308
+ if (watching) void refresh();
305
309
  return () => requestScope.refresh?.abort();
306
- }, [active, refresh, requestScope]);
310
+ }, [watching, refresh, requestScope]);
307
311
  useEffect(() => {
308
- if (active && connected && !previousConnected.current) void refresh();
312
+ if (watching && connected && !previousConnected.current) void refresh();
309
313
  previousConnected.current = connected;
310
- }, [active, connected, refresh]);
314
+ }, [watching, connected, refresh]);
311
315
 
312
- const steps = useMemo(() => mergeSteps(fetched.key === key ? fetched.steps : [], liveSteps), [fetched, key, liveSteps]);
313
- const baseline = steps[0] ?? null;
314
- const latest = steps[steps.length - 1] ?? null;
315
- // 磁盘目录只在记下新步时重拉:工具运行中的新路径由目录监听直接画成「生成中」节点,不用每 300ms 重拉一遍全部目录
316
- const diskPulse = latest?.seq ?? 0;
316
+ const rounds = useMemo(() => numberRounds(mergeRounds(fetched.key === key ? fetched.rounds : [], liveRounds)), [fetched, key, liveRounds]);
317
+ const latest = rounds[rounds.length - 1] ?? null;
318
+ // 磁盘目录只在提交了新的一轮时重拉,不用每次渲染都重拉一遍全部目录
319
+ const diskPulse = latest?.commit ?? "";
317
320
  useEffect(() => {
318
321
  if (!active || !cwd || !latest) return;
319
322
  const controller = beginRequest();
@@ -338,16 +341,17 @@ export function useGrowth({
338
341
  clearTimeout(timer);
339
342
  };
340
343
  }, [active, cwd, diskPulse, beginRequest, isCurrent, requestScope, updateLazy]);
341
- const rounds = useMemo(() => buildGrowthRounds(steps, turnStarts), [steps, turnStarts]);
342
- const following = selectedRoundId === null;
343
- const selectedRound = useMemo(
344
- () => (following ? rounds[rounds.length - 1] : rounds.find((round) => round.id === selectedRoundId) ?? rounds[rounds.length - 1]) ?? null,
345
- [following, rounds, selectedRoundId],
344
+
345
+ const following = selectedCommit === null;
346
+ const selected = useMemo(
347
+ () => (following ? latest : rounds.find((round) => round.commit === selectedCommit) ?? latest) ?? null,
348
+ [following, latest, rounds, selectedCommit],
346
349
  );
347
- const selected = selectedRound ? selectedRound.last : baseline;
350
+ const sessionStart = rounds[0]?.parentTree ?? null;
348
351
 
349
- // 所选步的文件清单;缓存身份包含工作区与会话,迟到结果不写缓存。
352
+ // 所选轮的文件清单;缓存身份包含工作区与会话,迟到结果不写缓存。
350
353
  useEffect(() => {
354
+ if (!active) return;
351
355
  if (!selected || !cwd) {
352
356
  setFiles(null);
353
357
  return;
@@ -374,27 +378,27 @@ export function useGrowth({
374
378
  controller.abort();
375
379
  requestScope.controllers.delete(controller);
376
380
  };
377
- }, [selected, cwd, key, beginRequest, isCurrent, requestScope]);
381
+ }, [active, selected, cwd, key, beginRequest, isCurrent, requestScope]);
378
382
 
379
- // 所选范围的变更:本步优先用账本内嵌清单;本会话 / 截断时按 tree 对取
383
+ // 所选范围:本轮 = git diff 上一轮 这一轮;本会话累计 = git diff 会话起点 所选这一轮
380
384
  const range = useMemo(() => {
381
- if (!selected || (selectedRound && !selectedRound.recorded)) return null;
382
- if (scope === "step") return selectedRound?.fromTree && selectedRound.toTree ? { from: selectedRound.fromTree, to: selectedRound.toTree } : null;
383
- return { from: (baseline ?? selected).tree, to: selected.tree };
384
- }, [selected, selectedRound, scope, baseline]);
385
+ if (!selected) return null;
386
+ if (scope === "round") return { from: selected.parentTree, to: selected.tree };
387
+ return sessionStart ? { from: sessionStart, to: selected.tree } : null;
388
+ }, [selected, scope, sessionStart]);
389
+ // 本轮优先用 commit 自带的变更清单;本会话累计 / 被截断时按 tree 对取
385
390
  const inlineChanges = useMemo<GrowthChange[] | null>(() => {
386
- if (!selected || !selectedRound || !selectedRound.recorded) return [];
387
- if (scope === "step") return selectedRound.steps.length === 1 && selectedRound.fromTree === selected.parent && !selected.truncated ? selected.changes : null;
388
- if (baseline && baseline.seq === selected.seq) return [];
391
+ if (!selected || !range) return [];
392
+ if (range.from === range.to) return [];
393
+ if (scope === "round" && !selected.truncated) return selected.changes;
389
394
  return null;
390
- }, [selected, selectedRound, scope, baseline]);
395
+ }, [selected, range, scope]);
391
396
 
392
397
  const loadRange = useCallback(
393
398
  async (from: string, to: string, controller: AbortController): Promise<GrowthChange[]> => {
394
399
  const k = `${key}|${from}..${to}`;
395
400
  const cached = changesCache.current.get(k);
396
401
  if (cached) return cached;
397
- if (from === to) return [];
398
402
  const data = await getJson<{ changes: GrowthChange[] }>(`/api/growth?cwd=${encodeURIComponent(cwd)}&from=${from}&to=${to}&changes=1`, controller.signal);
399
403
  if (isCurrent(controller)) remember(changesCache.current, k, data.changes);
400
404
  return data.changes;
@@ -403,7 +407,7 @@ export function useGrowth({
403
407
  );
404
408
 
405
409
  useEffect(() => {
406
- if (!range || inlineChanges !== null || !cwd) return;
410
+ if (!active || !range || inlineChanges !== null || !cwd) return;
407
411
  const k = `${key}|${range.from}..${range.to}`;
408
412
  const controller = beginRequest();
409
413
  void loadRange(range.from, range.to, controller)
@@ -418,7 +422,7 @@ export function useGrowth({
418
422
  controller.abort();
419
423
  requestScope.controllers.delete(controller);
420
424
  };
421
- }, [range, inlineChanges, cwd, key, loadRange, beginRequest, isCurrent, requestScope]);
425
+ }, [active, range, inlineChanges, cwd, key, loadRange, beginRequest, isCurrent, requestScope]);
422
426
 
423
427
  const changes = useMemo<GrowthChange[]>(() => {
424
428
  if (inlineChanges !== null) return inlineChanges;
@@ -426,42 +430,24 @@ export function useGrowth({
426
430
  return rangeChanges?.key === `${key}|${range.from}..${range.to}` ? rangeChanges.changes : [];
427
431
  }, [inlineChanges, range, rangeChanges, key]);
428
432
 
429
- // 本会话总计(基线 → 所选步)
430
- useEffect(() => {
431
- if (!baseline || !selected || !cwd) return;
432
- const k = `${key}|${baseline.tree}..${selected.tree}`;
433
- const controller = beginRequest();
434
- void loadRange(baseline.tree, selected.tree, controller)
435
- .then((list) => {
436
- if (isCurrent(controller)) setSessionRange({ key: k, changes: list });
437
- })
438
- .catch((error) => {
439
- if (isCurrent(controller)) setSessionRange({ key: k, changes: [], error: error instanceof Error ? error.message : "failed to load session changes" });
440
- })
441
- .finally(() => requestScope.controllers.delete(controller));
442
- return () => {
443
- controller.abort();
444
- requestScope.controllers.delete(controller);
445
- };
446
- }, [baseline, selected, cwd, key, loadRange, beginRequest, isCurrent, requestScope]);
447
- const sessionChanges = useMemo(() => (baseline && selected && sessionRange?.key === `${key}|${baseline.tree}..${selected.tree}` ? sessionRange.changes : []), [baseline, selected, sessionRange, key]);
448
-
449
- const shownPending = following ? pending : NO_PENDING;
450
433
  const tree = useMemo(() => {
451
- const base = buildTree(files?.key === key && selected && files.tree === selected.tree ? files.files : [], changes, shownPending);
434
+ const base = buildTree(files?.key === key && selected && files.tree === selected.tree ? files.files : [], changes);
452
435
  return graftLazy(base, lazyChildren);
453
- }, [files, selected, changes, shownPending, lazyChildren, key]);
436
+ }, [files, selected, changes, lazyChildren, key]);
454
437
  const filePaths = useMemo(() => {
455
438
  const paths = new Set(files?.key === key && selected && files.tree === selected.tree ? files.files.map((file) => file.path) : []);
456
439
  for (const children of lazyChildren.values()) for (const child of children) if (child.kind === "file") paths.add(child.path);
457
440
  return [...paths];
458
441
  }, [files, selected, lazyChildren, key]);
459
442
 
460
- // 新步到来(跟随中):自动展开改动路径上的目录(用户手动收起过的除外)+ 入场动画
443
+ // 新的一轮到来(跟随中):自动展开改动路径上的目录(用户手动收起过的除外)+ 入场动画
461
444
  useEffect(() => {
462
- if (!latest || !following || latest.seq === lastRevealedSeq.current) return;
463
- lastRevealedSeq.current = latest.seq;
464
- const list = latest.initial ? [] : latest.changes;
445
+ if (!latest || !following || latest.commit === lastRevealed.current) return;
446
+ const first = lastRevealed.current === "";
447
+ lastRevealed.current = latest.commit;
448
+ // 打开面板时已有的历史不播动画,只对之后新提交的一轮
449
+ if (first && !liveRounds.some((round) => round.commit === latest.commit)) return;
450
+ const list = latest.changes;
465
451
  if (!list.length) return;
466
452
  const dirs = dirsToReveal(list.slice(0, REVEAL_LIMIT)).filter((d) => !collapsedByUser.current.has(d));
467
453
  if (dirs.length) {
@@ -475,25 +461,12 @@ export function useGrowth({
475
461
  setFresh(new Set(list.map((c) => c.path)));
476
462
  if (freshTimer.current) clearTimeout(freshTimer.current);
477
463
  freshTimer.current = setTimeout(() => setFresh(new Set()), FRESH_MS);
478
- }, [latest, following, persistExpanded]);
464
+ }, [latest, following, liveRounds, persistExpanded]);
479
465
  useEffect(() => () => {
480
466
  if (freshTimer.current) clearTimeout(freshTimer.current);
481
467
  }, []);
482
468
 
483
- // 生成中的路径也把目录撑开
484
- useEffect(() => {
485
- if (!shownPending.length) return;
486
- const dirs = dirsToReveal([], shownPending.slice(0, REVEAL_LIMIT)).filter((d) => !collapsedByUser.current.has(d));
487
- if (!dirs.length) return;
488
- setExpanded((prev) => {
489
- if (dirs.every((d) => prev.has(d))) return prev;
490
- const next = new Set(prev);
491
- for (const d of dirs) next.add(d);
492
- return next;
493
- });
494
- }, [shownPending]);
495
-
496
- // 手动选步:展开这一步改动的目录
469
+ // 手动选轮:展开这一轮改动的目录
497
470
  useEffect(() => {
498
471
  if (following || !selected) return;
499
472
  const list = changes.slice(0, REVEAL_LIMIT);
@@ -508,23 +481,23 @@ export function useGrowth({
508
481
  }, [following, selected, changes]);
509
482
 
510
483
  const follow = useCallback(() => {
511
- setSelectedRoundId(null);
484
+ setSelectedCommit(null);
512
485
  }, []);
513
- const selectRound = useCallback(
514
- (id: number) => setSelectedRoundId(rounds.length && rounds[rounds.length - 1].id === id ? null : id),
486
+ const select = useCallback(
487
+ (commit: string) => setSelectedCommit(rounds.length && rounds[rounds.length - 1].commit === commit ? null : commit),
515
488
  [rounds],
516
489
  );
517
490
  const prev = useCallback(() => {
518
- if (!selectedRound) return;
519
- const idx = rounds.findIndex((round) => round.id === selectedRound.id);
520
- if (idx > 0) setSelectedRoundId(rounds[idx - 1].id);
521
- }, [selectedRound, rounds]);
491
+ if (!selected) return;
492
+ const idx = rounds.findIndex((round) => round.commit === selected.commit);
493
+ if (idx > 0) setSelectedCommit(rounds[idx - 1].commit);
494
+ }, [selected, rounds]);
522
495
  const next = useCallback(() => {
523
- if (!selectedRound) return;
524
- const idx = rounds.findIndex((round) => round.id === selectedRound.id);
496
+ if (!selected) return;
497
+ const idx = rounds.findIndex((round) => round.commit === selected.commit);
525
498
  if (idx < 0 || idx >= rounds.length - 1) return;
526
- setSelectedRoundId(idx + 1 === rounds.length - 1 ? null : rounds[idx + 1].id);
527
- }, [selectedRound, rounds]);
499
+ setSelectedCommit(idx + 1 === rounds.length - 1 ? null : rounds[idx + 1].commit);
500
+ }, [selected, rounds]);
528
501
 
529
502
  const toggleDir = useCallback(
530
503
  (path: string) => {
@@ -591,7 +564,7 @@ export function useGrowth({
591
564
  [cwd, requestScope, beginRequest, isCurrent, updateLazy],
592
565
  );
593
566
 
594
- const snapshotNow = useCallback(async (): Promise<GrowthStep | null> => {
567
+ const recordNow = useCallback(async (): Promise<GrowthRound | null> => {
595
568
  if (!sessionPath) return null;
596
569
  const controller = beginRequest();
597
570
  if (!isCurrent(controller)) return null;
@@ -599,15 +572,15 @@ export function useGrowth({
599
572
  const r = await fetch("/api/growth", {
600
573
  method: "POST",
601
574
  headers: { "Content-Type": "application/json" },
602
- body: JSON.stringify({ action: "snapshot", session: sessionIdOf(sessionPath) }),
575
+ body: JSON.stringify({ action: "record", session: sessionIdOf(sessionPath) }),
603
576
  signal: controller.signal,
604
577
  });
605
578
  const j = await r.json();
606
579
  if (!isCurrent(controller)) return null;
607
580
  if (!r.ok || !j.success) throw new Error(j.error || `request failed (${r.status})`);
608
- const step = (j.data?.step ?? null) as GrowthStep | null;
609
- if (step) setFetched((previous) => isCurrent(controller) && previous.key === key ? { ...previous, steps: mergeSteps(previous.steps, [step]) } : previous);
610
- return step;
581
+ const round = (j.data?.round ?? null) as GrowthRound | null;
582
+ if (round) setFetched((previous) => isCurrent(controller) && previous.key === key ? { ...previous, rounds: mergeRounds(previous.rounds, [round]) } : previous);
583
+ return round;
611
584
  } finally {
612
585
  requestScope.controllers.delete(controller);
613
586
  }
@@ -616,25 +589,19 @@ export function useGrowth({
616
589
  return {
617
590
  available: fetched.key === key ? fetched.available : true,
618
591
  loading,
619
- error: runtimeError ?? (fetched.key === key ? fetched.error : null) ?? (range && rangeChanges?.key === `${key}|${range.from}..${range.to}` ? rangeChanges.error : null) ?? (baseline && selected && sessionRange?.key === `${key}|${baseline.tree}..${selected.tree}` ? sessionRange.error : null) ?? null,
620
- steps,
592
+ error: runtimeError ?? (fetched.key === key ? fetched.error : null) ?? (range && rangeChanges?.key === `${key}|${range.from}..${range.to}` ? rangeChanges.error : null) ?? null,
621
593
  rounds,
622
- baseline,
623
- latest,
624
594
  selected,
625
- selectedRound,
626
595
  following,
627
596
  scope,
628
597
  range,
629
598
  changes,
630
- sessionChanges,
631
599
  tree,
632
600
  filePaths,
633
601
  expanded,
634
602
  fresh,
635
- pending: shownPending,
636
603
  follow,
637
- selectRound,
604
+ select,
638
605
  prev,
639
606
  next,
640
607
  setScope,
@@ -644,6 +611,6 @@ export function useGrowth({
644
611
  collapseAll,
645
612
  expandLazy,
646
613
  refresh,
647
- snapshotNow,
614
+ recordNow,
648
615
  };
649
616
  }
@@ -6,7 +6,7 @@
6
6
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
7
7
  import { createEventBatcher, type EventBatcher } from "@/lib/event-batcher";
8
8
  import type {
9
- GrowthStep,
9
+ GrowthRound,
10
10
  SessionSummary,
11
11
  TrajEntry,
12
12
  WebEvent,
@@ -60,8 +60,8 @@ export interface PiWebState {
60
60
  extensionStatuses: Record<string, string>;
61
61
  /** 扩展覆盖的「工作中」文案(setWorkingMessage) */
62
62
  workingMessage: string | null;
63
- /** 项目生长:本次连接期间收到的步(完整账本由 useGrowth 拉取后合并),以及运行中工具正在生成的路径 */
64
- growth: { steps: GrowthStep[]; pending: string[]; error: string | null };
63
+ /** 项目生长:本次连接期间收到的 commit(完整时间轴由 useGrowth 拉取后按 commit 去重合并) */
64
+ growth: { rounds: GrowthRound[]; error: string | null };
65
65
  }
66
66
 
67
67
  export interface SessionListItem extends SessionSummary {
@@ -233,15 +233,12 @@ export const foldPiWebEvent = (state: PiWebState, evt: WebEvent): PiWebState =>
233
233
  }
234
234
  return s;
235
235
  case "growth": {
236
- const steps = s.growth.steps.some((x) => x.seq === evt.step.seq) ? s.growth.steps.map((x) => (x.seq === evt.step.seq ? evt.step : x)) : [...s.growth.steps, evt.step];
237
- s.growth = { steps, pending: [], error: null };
236
+ const rounds = s.growth.rounds.some((x) => x.commit === evt.round.commit) ? s.growth.rounds : [...s.growth.rounds, evt.round];
237
+ s.growth = { rounds, error: null };
238
238
  return s;
239
239
  }
240
- case "growth_pending":
241
- s.growth = { ...s.growth, pending: evt.paths };
242
- return s;
243
240
  case "growth_error":
244
- s.growth = { ...s.growth, pending: [], error: evt.message };
241
+ s.growth = { ...s.growth, error: evt.message };
245
242
  return s;
246
243
  case "retry":
247
244
  // 只保留最后一次尝试:重试过程在界面上是「重试 3/5」这一条,而不是五行报错
@@ -318,7 +315,7 @@ const emptyState = (toolPreset: ToolPreset = "standard"): PiWebState => ({
318
315
  extensionNotices: [],
319
316
  extensionStatuses: {},
320
317
  workingMessage: null,
321
- growth: { steps: [], pending: [], error: null },
318
+ growth: { rounds: [], error: null },
322
319
  });
323
320
 
324
321
  function pathKey(value: string): string {