@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,40 +1,54 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * 项目栏(侧栏与对话之间的第四列):项目生长可视化。
5
- * 头部:工作区名 + 本会话总计 + 立即快照 / 收起;工具行:范围(本步 / 本会话)、只看变更、筛选;
6
- * 中间:项目树(GrowthTree);底部:按轮浏览历史,默认跟随最新一轮。
4
+ * 项目栏(侧栏与对话之间的第四列):项目生长 = 每轮一个 git commit。
5
+ * 头部:工作区名 + 立即记录 / 收起;所选轮:提问首行、时间、文件数与行数、在对话里看;
6
+ * 工具行:范围(本轮 / 本会话累计)、只看改动、筛选;中间:项目树(GrowthTree);
7
+ * 底部:时间轴每轮一根柱(高度 = 改动行数,灰色细柱 = 你在两轮之间自己的修改),默认跟随最新一轮。
7
8
  */
8
9
  import { useEffect, useMemo, useRef, useState } from "react";
9
10
  import { GrowthTree } from "@/components/GrowthTree";
10
11
  import { IconChevronLeft14, IconChevronRight14, IconCloseOutline14, IconGitOutline16, IconRefreshOutline14, IconSearchOutline16 } from "@/components/icons";
11
- import type { GrowthApi } from "@/hooks/useGrowth";
12
+ import type { GrowthApi, GrowthEntry } from "@/hooks/useGrowth";
12
13
  import { useI18n } from "@/i18n";
13
14
  import type { GitInfo } from "@/lib/git-service";
14
15
  import type { TreeNode } from "@/lib/growth-tree";
15
- import type { GrowthStep } from "@/lib/types";
16
+ import type { GrowthChange } from "@/lib/types";
16
17
 
17
-
18
- export function stepTitle(step: GrowthStep, t: Record<string, string>): string {
19
- if (step.kind === "baseline") return t.growthKindBaseline;
20
- if (step.kind === "turn") return t.growthKindTurn;
21
- if (step.kind === "external") return t.growthKindExternal;
22
- if (step.kind === "manual") return step.label || t.growthKindManual;
23
- return step.label || step.toolName || "";
18
+ /** 「第 7 轮 · 把保存按钮改成红色」/「你的修改」 */
19
+ export function roundTitle(round: GrowthEntry, t: Record<string, string>): string {
20
+ if (round.kind !== "round") return t.growthKindUser;
21
+ const label = t.growthRoundLabel.replace("{n}", String(round.n ?? ""));
22
+ return round.title ? `${label} · ${round.title}` : label;
24
23
  }
25
24
 
26
- function stepColor(step: GrowthStep): string {
27
- if (step.kind === "baseline") return "var(--dsw-label-caption)";
28
- if (step.kind === "external") return "var(--dsw-accent)";
29
- const st = step.stats;
25
+ /** 柱的颜色:你的修改为灰;pi 的一轮按主要改动类型(删除为主红、新增为主绿、否则黄) */
26
+ function roundColor(round: GrowthEntry): string {
27
+ if (round.kind !== "round") return "var(--dsw-label-caption)";
28
+ const st = round.stats;
29
+ if (!st.added && !st.modified && !st.deleted && !st.renamed) return "var(--dsw-border-l3)";
30
30
  if (st.deleted && st.deleted >= st.added && st.deleted >= st.modified) return "var(--dsw-danger)";
31
31
  if (st.added >= st.modified) return "var(--dsw-success)";
32
32
  return "var(--dsw-warn)";
33
33
  }
34
34
 
35
+ function changedLines(round: GrowthEntry): number {
36
+ return round.stats.add + round.stats.del;
37
+ }
38
+
35
39
  function fmtTime(ts: number): string {
36
40
  const d = new Date(ts);
37
- return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}:${String(d.getSeconds()).padStart(2, "0")}`;
41
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
42
+ }
43
+
44
+ function sumChanges(changes: GrowthChange[]): { files: number; add: number; del: number } {
45
+ let add = 0;
46
+ let del = 0;
47
+ for (const c of changes) {
48
+ add += c.add ?? 0;
49
+ del += c.del ?? 0;
50
+ }
51
+ return { files: changes.length, add, del };
38
52
  }
39
53
 
40
54
  export function ProjectPanel({
@@ -42,9 +56,11 @@ export function ProjectPanel({
42
56
  workspaceName,
43
57
  cwd,
44
58
  hasSession,
59
+ running = false,
45
60
  onOpenFile,
46
61
  onReference,
47
62
  onOpenEditor,
63
+ onJumpToChat,
48
64
  gitRefreshKey = 0,
49
65
  onAskCommit,
50
66
  onOpenGit,
@@ -55,9 +71,13 @@ export function ProjectPanel({
55
71
  workspaceName: string;
56
72
  cwd: string;
57
73
  hasSession: boolean;
74
+ /** 本会话正在运行:时间轴末尾显示「本轮进行中」 */
75
+ running?: boolean;
58
76
  onOpenFile: (node: TreeNode) => void;
59
77
  onReference?: (path: string) => void;
60
78
  onOpenEditor?: (path: string) => void;
79
+ /** 跳到对话里这一轮的提问(用户消息 entryId) */
80
+ onJumpToChat?: (messageId: string) => void;
61
81
  gitRefreshKey?: number;
62
82
  onAskCommit?: () => void;
63
83
  onOpenGit?: () => void;
@@ -67,14 +87,15 @@ export function ProjectPanel({
67
87
  const { t } = useI18n();
68
88
  const tt = t as unknown as Record<string, string>;
69
89
  const [filter, setFilter] = useState("");
70
- const [onlyChanges, setOnlyChanges] = useState(false);
71
- const [snapshotting, setSnapshotting] = useState(false);
90
+ const [onlyChanges, setOnlyChanges] = useState(true);
91
+ const [recording, setRecording] = useState(false);
72
92
  const [notice, setNotice] = useState("");
73
93
  const [gitState, setGitState] = useState<{ cwd: string; info: GitInfo | null; error?: string } | null>(null);
74
94
  const git = gitState?.cwd === cwd ? gitState.info : null;
75
95
  const gitError = gitState?.cwd === cwd ? gitState.error ?? git?.statusError : undefined;
76
- useEffect(() => setOnlyChanges(false), [cwd]);
77
- const latestSeq = growth.steps[growth.steps.length - 1]?.seq ?? 0;
96
+ useEffect(() => setOnlyChanges(true), [cwd]);
97
+ const { rounds, selected } = growth;
98
+ const latestCommit = rounds[rounds.length - 1]?.commit ?? "";
78
99
  useEffect(() => {
79
100
  if (!cwd) return;
80
101
  const controller = new AbortController();
@@ -93,28 +114,17 @@ export function ProjectPanel({
93
114
  clearTimeout(timer);
94
115
  controller.abort();
95
116
  };
96
- }, [cwd, gitRefreshKey, latestSeq]);
97
-
98
- const totals = useMemo(() => {
99
- const out = { added: 0, modified: 0, deleted: 0, renamed: 0 };
100
- for (const c of growth.scope === "step" ? growth.changes : growth.sessionChanges) {
101
- if (c.status === "A") out.added += 1;
102
- else if (c.status === "M") out.modified += 1;
103
- else if (c.status === "D") out.deleted += 1;
104
- else out.renamed += 1;
105
- }
106
- return out;
107
- }, [growth.scope, growth.changes, growth.sessionChanges]);
117
+ }, [cwd, gitRefreshKey, latestCommit]);
108
118
 
109
- const { steps, selectedRound, rounds } = growth;
119
+ const totals = useMemo(() => sumChanges(growth.changes), [growth.changes]);
120
+ const piRounds = useMemo(() => rounds.filter((round) => round.kind === "round").length, [rounds]);
121
+ const maxLines = useMemo(() => Math.max(1, ...rounds.map(changedLines)), [rounds]);
110
122
  const railRef = useRef<HTMLDivElement>(null);
111
- const maxSteps = useMemo(() => Math.max(1, ...rounds.map((r) => r.steps.filter((step) => step.kind !== "turn").length)), [rounds]);
112
123
  useEffect(() => {
113
- if (!selectedRound) return;
114
- railRef.current?.querySelector<HTMLElement>(`[data-round="${selectedRound.id}"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
115
- }, [selectedRound]);
116
- const selectedIdx = selectedRound ? rounds.findIndex((round) => round.id === selectedRound.id) : -1;
117
- const roundLabel = selectedRound ? !selectedRound.recorded ? t.growthUnrecorded : selectedRound.steps.every((step) => step.kind === "turn") ? t.growthNoChangesRound : stepTitle(selectedRound.steps.find((step) => step.kind !== "turn") ?? selectedRound.last!, tt) : "";
124
+ if (!selected) return;
125
+ railRef.current?.querySelector<HTMLElement>(`[data-commit="${selected.commit}"]`)?.scrollIntoView({ block: "nearest", inline: "nearest" });
126
+ }, [selected]);
127
+ const selectedIdx = selected ? rounds.findIndex((round) => round.commit === selected.commit) : -1;
118
128
  const conflicts = git?.files.filter((file) => file.kind === "conflict").length ?? 0;
119
129
  const staged = git?.files.filter((file) => file.kind !== "conflict" && file.indexStatus !== " ").length ?? 0;
120
130
  const unstaged = git?.files.filter((file) => file.kind !== "conflict" && file.workStatus !== " " && file.kind !== "untracked").length ?? 0;
@@ -126,24 +136,21 @@ export function ProjectPanel({
126
136
  return () => clearTimeout(timer);
127
137
  }, [notice]);
128
138
 
129
- const toggleOnly = () => {
130
- setOnlyChanges((value) => !value);
131
- };
132
-
133
- const snapshotNow = async () => {
134
- if (snapshotting) return;
135
- setSnapshotting(true);
139
+ const recordNow = async () => {
140
+ if (recording) return;
141
+ setRecording(true);
136
142
  try {
137
- const step = await growth.snapshotNow();
138
- if (!step) setNotice(t.growthSnapshotNone);
143
+ const round = await growth.recordNow();
144
+ if (!round) setNotice(t.growthRecordNone);
139
145
  } catch (e) {
140
146
  onError(e instanceof Error ? e.message : "failed");
141
147
  } finally {
142
- setSnapshotting(false);
148
+ setRecording(false);
143
149
  }
144
150
  };
145
151
 
146
- const emptyText = !cwd || !hasSession ? t.growthNoSession : growth.loading && !steps.length ? t.growthLoading : !growth.available ? t.growthUnavailable : steps.length ? undefined : t.growthNoSteps;
152
+ const emptyText = !cwd || !hasSession ? t.growthNoSession : growth.loading && !rounds.length ? t.growthLoading : !growth.available ? t.growthUnavailable : rounds.length ? undefined : t.growthNoRounds;
153
+ const statusText = selected?.status === "aborted" ? t.growthStatusAborted : selected?.status === "error" ? t.growthStatusError : "";
147
154
 
148
155
  return (
149
156
  <div className="pw-project flex h-full min-h-0 flex-col" data-testid="project-panel">
@@ -153,15 +160,15 @@ export function ProjectPanel({
153
160
  {workspaceName || t.projectPanel}
154
161
  </span>
155
162
  {hasSession && (
156
- <button className="icon-btn" style={{ width: 22, height: 22 }} title={t.growthSnapshotNow} onClick={() => void snapshotNow()} disabled={snapshotting}>
157
- <IconRefreshOutline14 size={13} className={snapshotting ? "piweb-spin" : undefined} />
163
+ <button className="icon-btn" style={{ width: 22, height: 22 }} title={t.growthRecordNow} onClick={() => void recordNow()} disabled={recording}>
164
+ <IconRefreshOutline14 size={13} className={recording ? "piweb-spin" : undefined} />
158
165
  </button>
159
166
  )}
160
167
  <button className="icon-btn" style={{ width: 22, height: 22 }} title={t.close} onClick={onClose}>
161
168
  <IconCloseOutline14 size={13} />
162
169
  </button>
163
170
  </div>
164
- {/* 生长快照不可用(找不到 git 等):明确提示 + 已退化为当前目录结构 */}
171
+ {/* 生长记录不可用(找不到 git 等):明确提示 + 已退化为当前目录结构 */}
165
172
  {cwd && hasSession && !growth.available && !growth.loading && (
166
173
  <div className="mx-3 mb-2 rounded-lg px-2 py-1" style={{ fontSize: 11.5, color: "var(--dsw-warn)", background: "var(--dsw-hover)" }} role="status">
167
174
  {t.growthUnavailableFallback}
@@ -190,30 +197,39 @@ export function ProjectPanel({
190
197
  {onOpenGit && <button type="button" className="pw-chip" onClick={onOpenGit}>{t.gitDetails}</button>}
191
198
  </div>
192
199
  )}
193
- {/* 总计 + 范围 */}
200
+ {/* 所选这一轮:提问首行 / 时间 · 文件数 · 行数 / 在对话里看 */}
201
+ {selected && (
202
+ <div className="px-3 pb-2" data-testid="growth-round-summary">
203
+ <div className="truncate" style={{ fontSize: 12.5, fontWeight: 600, color: "var(--dsw-label-primary)" }} title={roundTitle(selected, tt)}>
204
+ {roundTitle(selected, tt)}
205
+ </div>
206
+ <div className="mt-0.5 flex items-center gap-2" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
207
+ <span>{fmtTime(selected.ts)}</span>
208
+ <span>·</span>
209
+ <span>{t.growthRoundFiles.replace("{n}", String(totals.files))}</span>
210
+ <span style={{ color: "var(--dsw-success)" }}>+{totals.add}</span>
211
+ <span style={{ color: "var(--dsw-danger)" }}>−{totals.del}</span>
212
+ {statusText && <span style={{ color: "var(--dsw-warn)" }}>{statusText}</span>}
213
+ <span className="flex-1" />
214
+ {onJumpToChat && selected.promptIds[0] && (
215
+ <button type="button" className="pw-chip" onClick={() => onJumpToChat(selected.promptIds[0])}>
216
+ {t.growthJumpToChat}
217
+ </button>
218
+ )}
219
+ </div>
220
+ </div>
221
+ )}
222
+ {/* 范围 + 只看改动 */}
194
223
  <div className="flex items-center gap-2 px-3 pb-2" style={{ fontSize: 11.5 }}>
195
224
  <span className="pw-seg" role="radiogroup">
196
- {(["step", "session"] as const).map((s) => (
225
+ {(["round", "session"] as const).map((s) => (
197
226
  <button key={s} type="button" role="radio" aria-checked={growth.scope === s} data-active={growth.scope === s} onClick={() => growth.setScope(s)}>
198
- {s === "step" ? t.growthScopeStep : t.growthScopeSession}
227
+ {s === "round" ? t.growthScopeRound : t.growthScopeSession}
199
228
  </button>
200
229
  ))}
201
230
  </span>
202
- <span className="min-w-0 flex-1 truncate" style={{ color: "var(--dsw-label-caption)" }} title={`${t.growthStatA} ${totals.added} · ${t.growthStatM} ${totals.modified} · ${t.growthStatD} ${totals.deleted} · ${t.growthStatR} ${totals.renamed}`}>
203
- {notice ? (
204
- <span style={{ color: "var(--dsw-label-secondary)" }}>{notice}</span>
205
- ) : (
206
- <>
207
- <span style={{ color: "var(--dsw-success)" }}>+{totals.added}</span>
208
- {" "}
209
- <span style={{ color: "var(--dsw-warn)" }}>~{totals.modified}</span>
210
- {" "}
211
- <span style={{ color: "var(--dsw-danger)" }}>−{totals.deleted}</span>
212
- {totals.renamed ? <span style={{ color: "var(--dsw-accent)" }}> ⇄{totals.renamed}</span> : null}
213
- </>
214
- )}
215
- </span>
216
- <button type="button" className="pw-chip" data-active={onlyChanges} aria-pressed={onlyChanges} onClick={toggleOnly}>
231
+ <span className="min-w-0 flex-1 truncate" style={{ color: "var(--dsw-label-secondary)" }}>{notice}</span>
232
+ <button type="button" className="pw-chip" data-active={onlyChanges} aria-pressed={onlyChanges} onClick={() => setOnlyChanges((value) => !value)}>
217
233
  {t.growthOnlyChanges}
218
234
  </button>
219
235
  </div>
@@ -241,14 +257,14 @@ export function ProjectPanel({
241
257
  {growth.error}
242
258
  </div>
243
259
  )}
244
- {growth.selected?.truncated && growth.scope === "step" && (
260
+ {selected?.truncated && growth.scope === "round" && (
245
261
  <div className="mx-3 mb-1 rounded-lg px-2 py-1" style={{ fontSize: 11, color: "var(--dsw-warn)", background: "var(--dsw-hover)" }}>
246
262
  {t.growthTruncated}
247
263
  </div>
248
264
  )}
249
- {selectedRound && !selectedRound.recorded && (
265
+ {selected && growth.scope === "round" && !growth.changes.length && !emptyText && (
250
266
  <div className="mx-3 mb-1 rounded-lg px-2 py-1" style={{ fontSize: 11, color: "var(--dsw-label-caption)", background: "var(--dsw-hover)" }} role="status">
251
- {t.growthUnrecorded}
267
+ {t.growthNoChangesRound}
252
268
  </div>
253
269
  )}
254
270
  {/* 树 */}
@@ -257,7 +273,7 @@ export function ProjectPanel({
257
273
  expanded={growth.expanded}
258
274
  fresh={growth.fresh}
259
275
  filter={filter}
260
- onlyChanges={onlyChanges && steps.length > 1}
276
+ onlyChanges={onlyChanges && growth.changes.length > 0}
261
277
  onToggleDir={growth.toggleDir}
262
278
  onExpandLazy={growth.expandLazy}
263
279
  onOpenFile={onOpenFile}
@@ -265,29 +281,30 @@ export function ProjectPanel({
265
281
  onOpenEditor={onOpenEditor}
266
282
  emptyText={emptyText}
267
283
  />
268
- {/* 轮次导航 */}
284
+ {/* 时间轴 */}
269
285
  {hasSession && (
270
286
  <div className="pw-timeline hairline-t" data-testid="growth-timeline">
271
- {/* 时间轴:每轮一根柱,高度 = 本轮快照步数,颜色 = 首步的主要动作;点柱选轮 */}
272
- {rounds.length > 0 && (
287
+ {(rounds.length > 0 || running) && (
273
288
  <div ref={railRef} className="pw-tl-rail">
274
289
  {rounds.map((round) => {
275
- const activity = round.steps.filter((step) => step.kind !== "turn").length;
276
- const h = Math.max(4, Math.round((activity / maxSteps) * 24));
277
- const head = round.steps.find((step) => step.kind !== "turn") ?? round.last;
290
+ // 对数缩放:几十行和几千行的轮都看得出来,又不会一根柱子压扁其余
291
+ const lines = changedLines(round);
292
+ const h = round.changes.length || lines ? Math.max(5, Math.round(4 + (Math.log1p(lines) / Math.log1p(maxLines)) * 20)) : 3;
278
293
  return (
279
294
  <button
280
- key={round.id}
295
+ key={round.commit}
281
296
  type="button"
282
297
  className="pw-tl-bar"
283
- data-round={round.id}
284
- data-active={selectedRound?.id === round.id || undefined}
285
- style={{ height: h, background: head && round.recorded ? stepColor(head) : "var(--dsw-label-caption)" }}
286
- title={`${t.growthRoundLabel.replace("{n}", String(round.id))} · ${round.recorded ? activity ? stepTitle(head!, tt) : t.growthNoChangesRound : t.growthUnrecorded} · ${fmtTime(round.last?.ts ?? round.startedAt)} · ${t.growthStepsCount.replace("{n}", String(activity))}`}
287
- onClick={() => growth.selectRound(round.id)}
298
+ data-commit={round.commit}
299
+ data-kind={round.kind}
300
+ data-active={selected?.commit === round.commit || undefined}
301
+ style={{ height: h, background: roundColor(round) }}
302
+ title={`${roundTitle(round, tt)} · ${fmtTime(round.ts)} · ${t.growthRoundFiles.replace("{n}", String(round.changes.length))} +${round.stats.add} −${round.stats.del}`}
303
+ onClick={() => growth.select(round.commit)}
288
304
  />
289
305
  );
290
306
  })}
307
+ {running && <span className="pw-tl-bar" data-running="" title={t.growthRunning} aria-label={t.growthRunning} />}
291
308
  </div>
292
309
  )}
293
310
  <div className="flex items-center gap-1 px-2 pb-2 pt-1" style={{ fontSize: 11.5 }}>
@@ -297,12 +314,12 @@ export function ProjectPanel({
297
314
  <button className="icon-btn" style={{ width: 20, height: 20 }} title={t.growthNext} onClick={growth.next} disabled={selectedIdx < 0 || selectedIdx >= rounds.length - 1}>
298
315
  <IconChevronRight14 size={12} />
299
316
  </button>
300
- <span className="min-w-0 flex-1 truncate" style={{ color: "var(--dsw-label-secondary)" }} title={selectedRound ? `${roundLabel} · ${fmtTime(selectedRound.last?.ts ?? selectedRound.startedAt)}` : undefined}>
301
- {selectedRound ? (
302
- <>
303
- <span style={{ color: "var(--dsw-label-caption)" }}>{t.growthRoundPosition.replace("{current}", String(selectedIdx + 1)).replace("{total}", String(rounds.length))}</span> {roundLabel}
304
- </>
305
- ) : t.growthRoundPosition.replace("{current}", "0").replace("{total}", "0")}
317
+ <span className="min-w-0 flex-1 truncate" style={{ color: "var(--dsw-label-secondary)" }}>
318
+ {selected?.n
319
+ ? t.growthRoundPosition.replace("{current}", String(selected.n)).replace("{total}", String(piRounds))
320
+ : selected
321
+ ? t.growthKindUser
322
+ : t.growthRoundPosition.replace("{current}", "0").replace("{total}", String(piRounds))}
306
323
  </span>
307
324
  <button type="button" className="pw-chip" data-active={growth.following} onClick={growth.follow} title={t.growthFollow}>
308
325
  {growth.following ? t.growthFollowing : t.growthFollow}
@@ -36,8 +36,6 @@ interface ImportOutcome {
36
36
  reason?: string;
37
37
  messageCount?: number;
38
38
  workspace?: string;
39
- /** 已成功导入,但源里的运行时注入/加密内容等无法作为对话还原 */
40
- skipped?: string[];
41
39
  }
42
40
 
43
41
  interface ImportReport {
@@ -62,8 +60,6 @@ function dayOf(ms?: number): string {
62
60
  return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
63
61
  }
64
62
 
65
- const summaryKey = (summary: Pick<Summary, "source" | "externalId">) => `${summary.source}:${summary.externalId}`;
66
-
67
63
  export function SessionImportSection({ cwd }: { cwd: string }) {
68
64
  const { t } = useI18n();
69
65
  const [summaries, setSummaries] = useState<Summary[]>([]);
@@ -87,14 +83,9 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
87
83
  setFailure(json.error || t.toastError);
88
84
  return;
89
85
  }
90
- const nextSummaries = (json.data.sessions ?? []) as Summary[];
91
- const nextImported = new Set<string>(json.data.imported ?? []);
92
- const selectable = new Set(nextSummaries.map(summaryKey));
93
- setSummaries(nextSummaries);
86
+ setSummaries(json.data.sessions ?? []);
94
87
  setErrors(json.data.errors ?? []);
95
- setImportedKeys(nextImported);
96
- // 重新扫描后,源会话可能已经消失或被另一个请求导入;不要留下幽灵选择
97
- setSelected((previous) => new Set([...previous].filter((item) => selectable.has(item) && !nextImported.has(item))));
88
+ setImportedKeys(new Set<string>(json.data.imported ?? []));
98
89
  } catch (error) {
99
90
  setFailure(error instanceof Error ? error.message : String(error));
100
91
  } finally {
@@ -118,6 +109,8 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
118
109
  void scan();
119
110
  }, [scan]);
120
111
 
112
+ const key = (summary: Summary) => `${summary.source}:${summary.externalId}`;
113
+
121
114
  const groups = useMemo(() => {
122
115
  const bySource = new Map<string, Summary[]>();
123
116
  for (const summary of summaries) {
@@ -127,30 +120,27 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
127
120
  }
128
121
  return SOURCE_ORDER.filter((source) => bySource.has(source)).map((source) => {
129
122
  const rows = [...(bySource.get(source) as Summary[])].sort((left, right) => (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0));
130
- return { source, rows, imported: rows.filter((row) => importedKeys.has(summaryKey(row))).length };
123
+ return { source, rows, imported: rows.filter((row) => importedKeys.has(key(row))).length };
131
124
  });
132
125
  }, [summaries, importedKeys]);
133
126
 
134
127
  /**
135
128
  * 一次只显示一个来源:六个工具各列 15 条就是 90 行,全铺出来既难找也没必要。
136
- * 首次打开默认落到“最近活动会话”所在的来源;用户切换后保持选择。
137
- * 直接派生有效来源,不用 effect 回填,避免初始化 effect 覆盖用户刚完成的点击。
129
+ * 默认落在第一个有会话的来源上(SOURCE_ORDER 的顺序),用户切换后不被扫描结果重置。
138
130
  */
139
131
  const [activeSource, setActiveSource] = useState("");
140
- const defaultSource = groups.reduce<{ source: string; at: number } | null>((latest, group) => {
141
- const first = group.rows[0];
142
- const at = first?.updatedAt ?? first?.createdAt ?? 0;
143
- return !latest || at > latest.at ? { source: group.source, at } : latest;
144
- }, null)?.source ?? "";
145
- const visibleSource = activeSource && groups.some((group) => group.source === activeSource) ? activeSource : defaultSource;
146
- const activeGroup = groups.find((group) => group.source === visibleSource);
132
+ useEffect(() => {
133
+ if (activeSource && groups.some((group) => group.source === activeSource)) return;
134
+ setActiveSource(groups[0]?.source ?? "");
135
+ }, [groups, activeSource]);
136
+ const activeGroup = groups.find((group) => group.source === activeSource);
147
137
 
148
138
  const toggle = (summary: Summary) => {
149
- if (importedKeys.has(summaryKey(summary))) return;
139
+ if (importedKeys.has(key(summary))) return;
150
140
  setSelected((previous) => {
151
141
  const next = new Set(previous);
152
- if (next.has(summaryKey(summary))) next.delete(summaryKey(summary));
153
- else next.add(summaryKey(summary));
142
+ if (next.has(key(summary))) next.delete(key(summary));
143
+ else next.add(key(summary));
154
144
  return next;
155
145
  });
156
146
  };
@@ -159,9 +149,9 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
159
149
  setSelected((previous) => {
160
150
  const next = new Set(previous);
161
151
  for (const row of rows) {
162
- if (importedKeys.has(summaryKey(row))) continue;
163
- if (checked) next.add(summaryKey(row));
164
- else next.delete(summaryKey(row));
152
+ if (importedKeys.has(key(row))) continue;
153
+ if (checked) next.add(key(row));
154
+ else next.delete(key(row));
165
155
  }
166
156
  return next;
167
157
  });
@@ -169,7 +159,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
169
159
 
170
160
  const run = async () => {
171
161
  if (!selected.size) return;
172
- const items = summaries.filter((summary) => selected.has(summaryKey(summary))).map((summary) => ({ source: summary.source, externalId: summary.externalId }));
162
+ const items = summaries.filter((summary) => selected.has(key(summary))).map((summary) => ({ source: summary.source, externalId: summary.externalId }));
173
163
  setImporting(true);
174
164
  setFailure(null);
175
165
  setReport(null);
@@ -208,7 +198,6 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
208
198
 
209
199
  const failures = report?.results.filter((result) => result.status === "failed") ?? [];
210
200
  const skipped = report?.results.filter((result) => result.status === "skipped") ?? [];
211
- const importedWithNotes = report?.results.filter((result) => result.status === "imported" && result.skipped?.length) ?? [];
212
201
 
213
202
  return (
214
203
  <div className="flex flex-col gap-4" data-testid="session-import">
@@ -288,11 +277,6 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
288
277
  {sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
289
278
  </div>
290
279
  ))}
291
- {importedWithNotes.map((result) => (
292
- <div key={`note-${result.source}-${result.externalId}`} className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
293
- {sourceLabel(result.source)} · {result.title || result.externalId} — {result.skipped?.join(";")}
294
- </div>
295
- ))}
296
280
  {failures.map((result) => (
297
281
  <div key={`fail-${result.source}-${result.externalId}`} className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }}>
298
282
  {sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
@@ -307,7 +291,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
307
291
  {groups.length > 0 && (
308
292
  <div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label={t.importPickSource} data-testid="import-source-tabs">
309
293
  {groups.map((group) => {
310
- const active = group.source === visibleSource;
294
+ const active = group.source === activeSource;
311
295
  return (
312
296
  <button
313
297
  key={group.source}
@@ -348,7 +332,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
348
332
  </div>
349
333
  <div className="flex flex-col gap-1.5">
350
334
  {activeGroup.rows.map((row) => {
351
- const rowKey = summaryKey(row);
335
+ const rowKey = key(row);
352
336
  const done = importedKeys.has(rowKey);
353
337
  const checked = selected.has(rowKey);
354
338
  return (