@rexvane/piweb 0.3.8 → 0.3.10

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 (64) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +8 -8
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/react-loadable-manifest.json +1 -1
  6. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  7. package/.next/server/app/_global-error.html +1 -1
  8. package/.next/server/app/_global-error.rsc +1 -1
  9. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  12. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/server/app/_not-found.html +1 -1
  14. package/.next/server/app/_not-found.rsc +1 -1
  15. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  16. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  18. package/.next/server/app/api/update/route.js +2 -2
  19. package/.next/server/app/api/version/route.js +1 -1
  20. package/.next/server/app/api/workspaces/route.js +1 -1
  21. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  22. package/.next/server/app/index.html +1 -1
  23. package/.next/server/app/index.rsc +3 -3
  24. package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  25. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  26. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  27. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  28. package/.next/server/app/login.html +1 -1
  29. package/.next/server/app/login.rsc +2 -2
  30. package/.next/server/app/login.segments/_full.segment.rsc +2 -2
  31. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  32. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  33. package/.next/server/app/page.js +1 -1
  34. package/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/server/app-paths-manifest.json +8 -8
  36. package/.next/server/chunks/496.js +1 -1
  37. package/.next/server/chunks/686.js +1 -1
  38. package/.next/server/middleware-build-manifest.js +1 -1
  39. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  40. package/.next/server/pages/404.html +1 -1
  41. package/.next/server/pages/500.html +1 -1
  42. package/.next/server/server-reference-manifest.json +1 -1
  43. package/.next/static/chunks/18.8b025ee3303c3dc7.js +1 -0
  44. package/.next/static/chunks/686-97c833615d1565c5.js +1 -0
  45. package/.next/static/chunks/app/page-19e37fe240ae3e20.js +1 -0
  46. package/.next/static/chunks/{webpack-eb6e3e22d2f9faa2.js → webpack-eebc3d300017fa47.js} +1 -1
  47. package/README.md +7 -1
  48. package/package.json +1 -1
  49. package/scripts/release.mjs +27 -1
  50. package/src/app/api/workspaces/route.ts +4 -0
  51. package/src/components/AppShell.tsx +47 -41
  52. package/src/components/ChatWindow.tsx +75 -7
  53. package/src/components/SettingsPanel.tsx +13 -1
  54. package/src/hooks/usePiWeb.ts +14 -10
  55. package/src/i18n.tsx +10 -0
  56. package/src/lib/agent-manager.ts +65 -3
  57. package/src/lib/process-format.ts +27 -10
  58. package/src/lib/types.ts +2 -0
  59. package/src/lib/update-service.ts +80 -4
  60. package/.next/static/chunks/18.ec64a3f85c5103fc.js +0 -1
  61. package/.next/static/chunks/686-3eeba9445fecdd06.js +0 -1
  62. package/.next/static/chunks/app/page-8ef773ce62ce3aaa.js +0 -1
  63. /package/.next/static/{HEI7XSH5vSn7KCIANvzFC → CLy0QZWDpEExcItT1yudl}/_buildManifest.js +0 -0
  64. /package/.next/static/{HEI7XSH5vSn7KCIANvzFC → CLy0QZWDpEExcItT1yudl}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -62,9 +62,15 @@ The package registers only the `piweb` command, so it never conflicts with the o
62
62
  ### Update
63
63
 
64
64
  ```bash
65
- npm update -g @rexvane/piweb
65
+ npm install -g @rexvane/piweb@latest
66
66
  ```
67
67
 
68
+ npm replaces the installed version in place, so there is nothing to uninstall first. This is
69
+ the same command the in-app "Check for updates" button runs. `latest` is npm's default tag,
70
+ so `npm install -g @rexvane/piweb` behaves identically; if your registry is a mirror that
71
+ syncs with a delay, add `--registry=https://registry.npmjs.org` to get a release the moment
72
+ it is published. Restart `piweb` afterwards to serve the new version.
73
+
68
74
  Or install a specific version: `npm install -g @rexvane/piweb@0.3.4`.
69
75
 
70
76
  ### Uninstall
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rexvane/piweb",
3
- "version": "0.3.8",
3
+ "version": "0.3.10",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "description": "Modern web UI for the pi coding agent, driven by the official pi SDK without modifying pi",
@@ -1,4 +1,5 @@
1
1
  import fs from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
2
3
  import path from "node:path";
3
4
  import { randomUUID } from "node:crypto";
4
5
  import { createRequire } from "node:module";
@@ -7,7 +8,32 @@ import { assertInstallation, assertLocalPath, readProductionBuild, resolveBuildO
7
8
  import { runCommand } from "./process-runner.mjs";
8
9
  import { isMainModule } from "./entrypoint.mjs";
9
10
 
10
- const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
11
+ /**
12
+ * Root of the PiWeb installation this module belongs to.
13
+ *
14
+ * A Next server bundle substitutes `import.meta.url` with the URL of the SOURCE file on
15
+ * the machine that ran the build, so a published package carries the CI path. That both
16
+ * throws (`fileURLToPath` rejects a POSIX path inside a file URL on Windows —
17
+ * ERR_INVALID_FILE_URL_PATH) and points at a directory that does not exist on the user's
18
+ * machine. The launcher's PI_WEB_ROOT and the working directory are trustworthy; the
19
+ * module URL is consulted only while it still points at a real checkout.
20
+ * @param {{env?: Record<string, string | undefined>, cwd?: string, moduleUrl?: string}} [context]
21
+ * @returns {string}
22
+ */
23
+ export function resolveReleaseRoot({ env = process.env, cwd = process.cwd(), moduleUrl } = {}) {
24
+ if (env.PI_WEB_ROOT) return path.resolve(env.PI_WEB_ROOT);
25
+ if (typeof moduleUrl === "string") {
26
+ try {
27
+ const fromModule = path.resolve(path.dirname(fileURLToPath(moduleUrl)), "..");
28
+ if (existsSync(path.join(fromModule, "scripts", "release.mjs"))) return fromModule;
29
+ } catch {
30
+ /* a URL for another platform's layout: fall through to the working directory */
31
+ }
32
+ }
33
+ return path.resolve(cwd);
34
+ }
35
+
36
+ const ROOT = resolveReleaseRoot({ moduleUrl: import.meta.url });
11
37
  const LOCK_PATH = `${RELEASES_DIR}/update.lock`;
12
38
  const CONFIG_PATH = `${RELEASES_DIR}/tsconfig.json`;
13
39
 
@@ -11,6 +11,7 @@ import {
11
11
  setAlias,
12
12
  } from "@/lib/workspace-store";
13
13
  import { encodeSessionId } from "@/lib/pi";
14
+ import { disposeSessionPath } from "@/lib/agent-manager";
14
15
  import { BoundaryError, resolveSessionPath, resolveWorkspacePath } from "@/lib/path-security";
15
16
 
16
17
  export const dynamic = "force-dynamic";
@@ -53,6 +54,9 @@ export async function POST(req: Request) {
53
54
  if (body.action === "archiveSession") {
54
55
  if (!body.path) return NextResponse.json({ success: false, error: "missing path" }, { status: 400 });
55
56
  const sessionPath = await resolveSessionPath(encodeSessionId(body.path), { allowPending: true });
57
+ // 归档即收工:会话随即从工作区消失,不能让它继续在后台跑(看不见却仍消耗 token)。
58
+ // 只影响这一个会话,其它运行中的会话不受影响。
59
+ await disposeSessionPath(sessionPath);
56
60
  const archivedSessions = await archiveSession(sessionPath);
57
61
  return NextResponse.json({ success: true, data: { archivedSessions } });
58
62
  }
@@ -14,8 +14,11 @@ import { SessionSidebar } from "@/components/SessionSidebar";
14
14
 
15
15
  // 首屏不需要的重组件按需加载(设置面板含供应商配置与代码高亮,轨迹/文件只在打开时才用)
16
16
  const SettingsPanel = dynamic(() => import("@/components/SettingsPanel").then((m) => m.SettingsPanel), { ssr: false });
17
- const TrajectoryView = dynamic(() => import("@/components/TrajectoryView").then((m) => m.TrajectoryView), { ssr: false });
18
17
  const TrajInspector = dynamic(() => import("@/components/TrajectoryView").then((m) => m.TrajInspector), { ssr: false });
18
+
19
+ /** 会话/工作区路径比较(Windows 大小写与分隔符差异不该影响判断) */
20
+ const samePath = (a: string, b: string): boolean =>
21
+ a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() === b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
19
22
  const ProjectPanel = dynamic(() => import("@/components/ProjectPanel").then((m) => m.ProjectPanel), { ssr: false });
20
23
  const FileViewer = dynamic(() => import("@/components/FileViewer").then((m) => m.FileViewer), { ssr: false });
21
24
  const GitPanel = dynamic(() => import("@/components/GitPanel").then((m) => m.GitPanel), { ssr: false });
@@ -132,7 +135,6 @@ export function AppShell() {
132
135
  const saveHeroDraft = useCallback((update: ChatDraftUpdate) => updateDraft(heroDraftKey, update), [heroDraftKey, updateDraft]);
133
136
  const [dragging, setDragging] = useState<"sidebar" | "details" | "project" | null>(null);
134
137
  const [settingsOpen, setSettingsOpen] = useState(false);
135
- const [tab, setTab] = useState<"chat" | "traj">("chat");
136
138
  const [selected, setSelected] = useState<TrajEntry | null>(null);
137
139
  const [gitDetailsOpen, setGitDetailsOpen] = useState(false);
138
140
  const [heroCwd, setHeroCwd] = useState("");
@@ -342,17 +344,49 @@ export function AppShell() {
342
344
  [currentPath, currentId, sendCommand, openSession, resync],
343
345
  );
344
346
 
347
+ /**
348
+ * 原地编辑用户消息后重新发送:服务端在同一会话文件内回到该消息(navigateTree),
349
+ * 再用新文本重新提问——被编辑消息之后的分支作废,模型从这里重新回答。
350
+ * 旧的插话(steering)撤回/编辑/发送不变。
351
+ */
352
+ const doEditMessage = useCallback(
353
+ async (entryId: string, text: string) => {
354
+ if (!currentId) return;
355
+ if (isStreaming) {
356
+ setError(t.editWhileRunning);
357
+ return;
358
+ }
359
+ const moved = await sendCommand({ cmd: "navigate", entryId });
360
+ if (!moved?.success || moved.data?.cancelled) {
361
+ setError(moved?.error ?? t.editMessageFailed);
362
+ return;
363
+ }
364
+ // 被编辑消息之后的尾部要立刻从视图里消失,再发新提问
365
+ resync();
366
+ const sent = await sendCommand({ cmd: "prompt", text });
367
+ if (!sent?.success) setError(sent?.error ?? t.editMessageFailed);
368
+ },
369
+ [currentId, isStreaming, resync, sendCommand, setError, t.editMessageFailed, t.editWhileRunning],
370
+ );
371
+
345
372
  const doArchiveSession = useCallback(
346
373
  async (path: string) => {
347
374
  try {
348
- // dsh 合同:归档不关会话——当前会话被归档后保持打开且在主列表可见
349
- // (usePiWeb 的可见性规则对 currentPath 豁免),继续可聊。
350
375
  await archiveSession(path);
351
376
  } catch {
352
377
  // usePiWeb exposes the request failure in the shared error banner.
353
378
  }
379
+ // 归档后会话立刻离开工作区列表(服务端同时让它收工)。正在看的会话被归档时
380
+ // 必须切走:先取同一工作区的另一个可见会话,没有就回到空态(工作区仍留在侧栏)。
381
+ if (!currentPath || !samePath(path, currentPath)) return;
382
+ const cwd = sessions.find((s) => samePath(s.path, currentPath))?.cwd;
383
+ const next =
384
+ sessions.find((s) => !samePath(s.path, path) && cwd && samePath(s.cwd, cwd)) ??
385
+ sessions.find((s) => !samePath(s.path, path));
386
+ if (next) openSession(next.path);
387
+ else closeSession();
354
388
  },
355
- [archiveSession],
389
+ [archiveSession, closeSession, currentPath, openSession, sessions],
356
390
  );
357
391
  const doUnarchiveSession = useCallback(
358
392
  async (path: string) => {
@@ -444,7 +478,6 @@ export function AppShell() {
444
478
  if (name === "compact") void sendCommand({ cmd: "compact", instructions: args || undefined });
445
479
  else if (name === "export" && currentId) window.open(`/api/sessions/${currentId}/export?format=jsonl`, "_blank");
446
480
  else if (name === "new") {
447
- setTab("chat");
448
481
  closeSession();
449
482
  } else if (name === "fork" && currentId) {
450
483
  void sendCommand({ cmd: "fork" }).then((r) => {
@@ -646,14 +679,12 @@ export function AppShell() {
646
679
  }}
647
680
  onNew={() => {
648
681
  // 对齐 dsh startSession:新会话默认落在当前会话的工作区(无则取最近工作区)
649
- setTab("chat");
650
682
  const cur = sessions.find((s) => s.path === currentPath)?.cwd;
651
683
  setHeroCwd(cur || heroCwd || knownCwds[0] || "");
652
684
  closeSession();
653
685
  }}
654
686
  onNewInWorkspace={(cwd) => {
655
687
  // dsh 惰性新建:只预选工作区进草稿态,发送第一条消息才真正创建会话
656
- setTab("chat");
657
688
  setHeroCwd(cwd);
658
689
  closeSession();
659
690
  }}
@@ -757,26 +788,11 @@ export function AppShell() {
757
788
  )}
758
789
  </div>
759
790
  )}
760
- <div className="mt-1.5 flex items-center gap-5">
761
- <button className="tab-underline" data-active={tab === "chat"} onClick={() => setTab("chat")}>
762
- {t.tabChat}
763
- </button>
764
- <button
765
- className="tab-underline"
766
- data-active={tab === "traj"}
767
- onClick={() => {
768
- setTab("traj");
769
- void sendCommand({ cmd: "prepare" });
770
- }}
771
- >
772
- {t.tabTrajectory}
773
- </button>
774
- </div>
791
+ {/* 轨迹页签已移除:轨迹按需查看(对话里的工具行 → 轨迹详情),不再占用主区域 */}
775
792
  </div>
776
793
 
777
- {/* 内容 */}
778
- {tab === "chat" ? (
779
- !snapshot ? (
794
+ {/* 内容:只有对话区,轨迹按需在详情栏查看 */}
795
+ {!snapshot ? (
780
796
  // 切换会话时快照未到:居中加载指示,避免空白/占位符闪现
781
797
  <div className="flex min-h-0 flex-1 items-center justify-center">
782
798
  <span
@@ -813,6 +829,7 @@ export function AppShell() {
813
829
  onOpenTrajectory={openToolTrajectory}
814
830
  onOpenFile={openInEditor}
815
831
  onFork={handleFork}
832
+ onEditMessage={doEditMessage}
816
833
  />
817
834
  <div className="px-4 pb-3 pt-2">
818
835
  <div className="mx-auto w-full" style={{ maxWidth: "var(--dsh-composer-card-max-width)" }}>
@@ -861,22 +878,11 @@ export function AppShell() {
861
878
  {Object.entries(state.extensionStatuses).map(([k, v]) => <span key={k}>{v}</span>)}
862
879
  </div>
863
880
  )}
864
- </div>
881
+ </div>
865
882
  </div>
866
- </div>
867
- )
868
- ) : (
869
- <div className="min-h-0 flex-1">
870
- <TrajectoryView
871
- entries={snapshot?.trajectory ?? []}
872
- selected={selected}
873
- onSelect={(entry) => {
874
- setSelected(entry);
875
- setGitDetailsOpen(false);
876
- }}
877
- />
878
- </div>
879
- )}
883
+ </div>
884
+ )
885
+ }
880
886
  </>
881
887
  )}
882
888
  </div>
@@ -193,6 +193,7 @@ function TurnMetaPill({ durationMs, usage, t }: { durationMs: number; usage?: Tr
193
193
  function MessageActions({
194
194
  text,
195
195
  onFork,
196
+ onEdit,
196
197
  durationMs,
197
198
  usage,
198
199
  time,
@@ -200,6 +201,8 @@ function MessageActions({
200
201
  }: {
201
202
  text: string;
202
203
  onFork?: () => void | Promise<unknown>;
204
+ /** 用户消息:原地编辑后重新发送(模型从这条消息重新回答) */
205
+ onEdit?: () => void;
203
206
  /** 回合墙钟耗时(最终回答时间戳 − 回合首条用户消息时间戳),dsh runMs 的对应物 */
204
207
  durationMs?: number;
205
208
  usage?: TrajTokens & { cost?: { total?: number } };
@@ -235,6 +238,11 @@ function MessageActions({
235
238
  {copied ? <IconCheckOutline14 size={14} /> : <IconCopyOutline16 size={14} />}
236
239
  </button>
237
240
  {copyFailed && <span role="alert" style={{ fontSize: 11, color: "var(--dsw-danger)" }}>{t.copyFailed}</span>}
241
+ {onEdit && (
242
+ <button className="icon-btn" title={t.editMessage} aria-label={t.editMessage} onClick={onEdit}>
243
+ <IconEditOutline16 size={14} />
244
+ </button>
245
+ )}
238
246
  {onFork && (
239
247
  <button
240
248
  className="icon-btn"
@@ -456,7 +464,8 @@ const ToolStep = memo(function ToolStep({
456
464
  const output = state?.result ?? state?.partialResult ?? "";
457
465
  const isWriteTool = name.toLowerCase() === "write";
458
466
  const diff = useMemo(() => {
459
- if (kind !== "write" || !state || state.state !== "done" || state.isError) return null;
467
+ // 失败的编辑同样渲染 diff:出错信息单独展示,不因为失败就把红绿藏掉
468
+ if (kind !== "write" || !state || state.state !== "done") return null;
460
469
  const parsed = parseUnifiedDiff(state.patch || output);
461
470
  if (parsed) return parsed;
462
471
  // pi 的 write 工具不带 patch:新文件按新增行展示(全绿 +),与 edit 的 diff 口径一致
@@ -483,11 +492,13 @@ const ToolStep = memo(function ToolStep({
483
492
  // 删除文件的命令:完成后给红色标记(内容级 diff 不强求)
484
493
  const deletedTarget = kind === "cmd" && !running && !failed && isDeleteCommand(fullCommand) ? deleteTargetOf(fullCommand) : "";
485
494
  const preview = state ? toolPreview(kind, state, tt, cwd) : { lines: [], hidden: 0 };
486
- const hasDiff = Boolean(diff && diff.length) && !failed;
495
+ const hasDiff = Boolean(diff && diff.length);
487
496
  const hasOutput = Boolean(output || state?.encodingLoss);
488
497
  const canExpand = hasOutput && !hasDiff && !running;
489
498
  const duration = state?.startedAt && state?.endedAt ? state.endedAt - state.startedAt : 0;
490
499
  const showPreview = Boolean(preview.head) || preview.lines.length > 0;
500
+ // 失败且有 diff 时,出错信息要完整可见(此时 diff 占用了展开位)
501
+ const showFullOutput = hasOutput && (open && canExpand || (failed && hasDiff));
491
502
  const diffLines = hasDiff && diff ? (open ? diff : diff.slice(0, DIFF_PREVIEW)) : null;
492
503
  const diffHidden = diff && diffLines && !open ? diff.length - diffLines.length : 0;
493
504
  const diffStat = hasDiff && diff
@@ -586,7 +597,7 @@ const ToolStep = memo(function ToolStep({
586
597
  )}
587
598
  </div>
588
599
  )}
589
- {open && canExpand && (
600
+ {showFullOutput && (
590
601
  <div className="pw-output">
591
602
  {fullCommand && <div className="cmdline">{fullCommand}</div>}
592
603
  {state?.encodingLoss && <p className="mb-2 whitespace-normal" style={{ color: "var(--dsw-warn)" }}>{t.encodingLossWarning}</p>}
@@ -892,12 +903,56 @@ const FinalAnswer = memo(function FinalAnswer({
892
903
  });
893
904
 
894
905
  /** 行级 memo:流式期间只有最后一条消息变化,历史行全部跳过重渲染 */
895
- const UserMessage = memo(function UserMessage({ message }: { message: WebMessage }) {
906
+ const UserMessage = memo(function UserMessage({ message, onEditMessage }: { message: WebMessage; onEditMessage?: (entryId: string, text: string) => void }) {
907
+ const { t } = useI18n();
896
908
  const text = message.content
897
909
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
898
910
  .map((c) => c.text)
899
911
  .join("\n");
900
912
  const images = message.content.filter((content) => content.type === "image");
913
+ const [editing, setEditing] = useState(false);
914
+ const [draft, setDraft] = useState(text);
915
+ const canEdit = Boolean(onEditMessage && message.id && text);
916
+ if (editing) {
917
+ return (
918
+ <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
919
+ <div className="msg-user-bubble w-full">
920
+ <textarea
921
+ className="w-full resize-none bg-transparent outline-none"
922
+ style={{ minHeight: 68, font: "inherit", color: "inherit" }}
923
+ value={draft}
924
+ aria-label={t.editMessage}
925
+ autoFocus
926
+ onChange={(e) => setDraft(e.target.value)}
927
+ onKeyDown={(e) => {
928
+ if (e.key === "Escape") { e.preventDefault(); setEditing(false); }
929
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); submit(); }
930
+ }}
931
+ />
932
+ </div>
933
+ <div className="mt-1.5 flex items-center gap-2">
934
+ <button type="button" className="btn-outline" style={{ height: 26, padding: "0 10px", fontSize: 12 }} onClick={() => setEditing(false)}>
935
+ {t.cancel}
936
+ </button>
937
+ <button
938
+ type="button"
939
+ className="btn-primary-white"
940
+ style={{ height: 26, padding: "0 12px", fontSize: 12 }}
941
+ disabled={!draft.trim() || draft.trim() === text.trim()}
942
+ onClick={submit}
943
+ >
944
+ {t.sendEdit}
945
+ </button>
946
+ </div>
947
+ </div>
948
+ );
949
+ }
950
+ function submit() {
951
+ const next = draft.trim();
952
+ if (!next || next === text.trim() || !onEditMessage || !message.id) return;
953
+ setEditing(false);
954
+ onEditMessage(message.id, next);
955
+ }
901
956
  return (
902
957
  // 回合边界:用户消息前留 28px(比回合内 8px 大得多),长对话里一眼找到“这一轮从哪开始”
903
958
  <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
@@ -916,8 +971,15 @@ const UserMessage = memo(function UserMessage({ message }: { message: WebMessage
916
971
  )}
917
972
  {/* 用户消息也走 Markdown:贴进来的代码块/列表不再是一坨纯文本 */}
918
973
  {text && <div className="msg-user-bubble"><Markdown text={text} /></div>}
919
- {/* 用户消息只有复制操作,不提供分支;时钟在图标左侧(dsh clock=start) */}
920
- {text && <MessageActions text={text} clockStart={message.timestamp !== undefined} time={message.timestamp} />}
974
+ {/* 用户消息:复制 + 原地编辑重发(编辑后模型从这条消息重新回答);时钟在图标左侧(dsh clock=start) */}
975
+ {text && (
976
+ <MessageActions
977
+ text={text}
978
+ clockStart={message.timestamp !== undefined}
979
+ time={message.timestamp}
980
+ onEdit={canEdit ? () => { setDraft(text); setEditing(true); } : undefined}
981
+ />
982
+ )}
921
983
  </div>
922
984
  );
923
985
  });
@@ -967,6 +1029,7 @@ function TurnBlock({
967
1029
  outputTokens,
968
1030
  workingMessage,
969
1031
  onFork,
1032
+ onEditMessage,
970
1033
  onInspectTool,
971
1034
  onOpenFile,
972
1035
  }: {
@@ -984,6 +1047,8 @@ function TurnBlock({
984
1047
  outputTokens?: number;
985
1048
  workingMessage?: string | null;
986
1049
  onFork?: (entryId: string) => void;
1050
+ /** 编辑该回合的用户消息并重新发送(模型从这条消息重新回答) */
1051
+ onEditMessage?: (entryId: string, text: string) => void;
987
1052
  onInspectTool?: (toolCallId: string) => void;
988
1053
  onOpenFile?: (path: string) => void;
989
1054
  }) {
@@ -1035,7 +1100,7 @@ function TurnBlock({
1035
1100
 
1036
1101
  return (
1037
1102
  <>
1038
- {user && <UserMessage message={user} />}
1103
+ {user && <UserMessage message={user} onEditMessage={onEditMessage} />}
1039
1104
  {showProcess && (
1040
1105
  <div className="pw-turn">
1041
1106
  {contextFiles?.map((resource, i) => (
@@ -1136,6 +1201,7 @@ export function ChatWindow({
1136
1201
  contextFiles,
1137
1202
  cwd,
1138
1203
  onFork,
1204
+ onEditMessage,
1139
1205
  isStreaming = false,
1140
1206
  error,
1141
1207
  connected = true,
@@ -1171,6 +1237,7 @@ export function ChatWindow({
1171
1237
  trajectory?: TrajEntry[];
1172
1238
  onOpenTrajectory?: (toolCallId: string) => void;
1173
1239
  /** 在本机编辑器打开工具行涉及的文件 */
1240
+ onEditMessage?: (entryId: string, text: string) => void;
1174
1241
  onOpenFile?: (path: string) => void;
1175
1242
  }) {
1176
1243
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -1254,6 +1321,7 @@ export function ChatWindow({
1254
1321
  outputTokens={stats?.tokens.output}
1255
1322
  workingMessage={workingMessage}
1256
1323
  onFork={onFork}
1324
+ onEditMessage={onEditMessage}
1257
1325
  onInspectTool={onOpenTrajectory}
1258
1326
  onOpenFile={onOpenFile}
1259
1327
  />
@@ -509,11 +509,13 @@ function VersionLink({ href, value }: { href: string; value?: string }) {
509
509
 
510
510
  type UpdatePhase = "idle" | "checking" | "available" | "updating" | "latest" | "updated" | "error";
511
511
 
512
- /** 检查更新 / 更新控件:piweb 走 git pull + npm install,pi 走 npm install @latest(服务端固定参数) */
512
+ /** 检查更新 / 更新控件:npm 安装的 piweb 走 npm install -g @rexvane/piweb@latest(服务端固定参数),
513
+ * Git 检出仍走 git pull + 本地构建;pi 走 npm install @latest。 */
513
514
  function UpdateControl({ target }: { target: "piweb" | "pi" }) {
514
515
  const { t } = useI18n();
515
516
  const [phase, setPhase] = useState<UpdatePhase>("idle");
516
517
  const [latest, setLatest] = useState("");
518
+ const [command, setCommand] = useState("");
517
519
  const [error, setError] = useState("");
518
520
  const busy = phase === "checking" || phase === "updating";
519
521
 
@@ -534,6 +536,7 @@ function UpdateControl({ target }: { target: "piweb" | "pi" }) {
534
536
  if (!j.success) throw new Error(j.error);
535
537
  if (j.data.canUpdate) {
536
538
  setLatest(j.data.latest);
539
+ setCommand(j.data.command ?? "");
537
540
  setPhase("available");
538
541
  } else {
539
542
  setPhase("latest");
@@ -574,6 +577,15 @@ function UpdateControl({ target }: { target: "piweb" | "pi" }) {
574
577
  );
575
578
  }
576
579
  if (phase === "available") {
580
+ // Windows 无法替换运行中的安装目录(npm 会 EBUSY),此时给命令而不是按钮。
581
+ if (command) {
582
+ return (
583
+ <span className="flex flex-col items-end gap-0.5" style={{ maxWidth: 300 }}>
584
+ <code style={{ fontSize: 11, color: "var(--dsw-label-primary)", whiteSpace: "nowrap", overflowX: "auto", maxWidth: "100%" }}>{command}</code>
585
+ <span style={{ fontSize: 11, color: "var(--dsw-label-tertiary)", textAlign: "right" }}>{t.updateManualHint}</span>
586
+ </span>
587
+ );
588
+ }
577
589
  return (
578
590
  <button type="button" className="btn-primary-white" style={{ height: 26, padding: "0 12px", fontSize: 12 }} onClick={() => void run()}>
579
591
  {t.updateTo.replace("{v}", latest)}
@@ -242,9 +242,15 @@ export const foldPiWebEvent = (state: PiWebState, evt: WebEvent): PiWebState =>
242
242
  case "growth_error":
243
243
  s.growth = { ...s.growth, pending: [], error: evt.message };
244
244
  return s;
245
+ case "retry":
246
+ // 只保留最后一次尝试:重试过程在界面上是「重试 3/5」这一条,而不是五行报错
247
+ s.retryNotice = evt.maxAttempts
248
+ ? `重试 ${evt.attempt}/${evt.maxAttempts}${evt.message ? `:${evt.message}` : ""}`
249
+ : `重试 ${evt.attempt}${evt.message ? `:${evt.message}` : ""}`;
250
+ return s;
245
251
  case "error":
246
- // 自动重试属流程内通知:随消息流显示、流结束清除,不走 6 秒错误条
247
- if (/^自动重试/.test(evt.message)) s.retryNotice = evt.message;
252
+ // 旧服务端把自动重试塞在 error 里:同样按通知处理,不占错误条
253
+ if (/^(自动重试|重试)/.test(evt.message)) s.retryNotice = evt.message;
248
254
  else s.error = evt.message;
249
255
  return s;
250
256
  default:
@@ -847,18 +853,16 @@ export function usePiWeb() {
847
853
  presetRestorePromiseRef.current = restore;
848
854
  }, [currentId, snapshotEpoch, state.connected, state.snapshot, sendCommand]);
849
855
 
850
- // 会话可见性规则:当前会话豁免归档过滤——归档当前会话不关闭、
851
- // 主列表保持可见可聊,已归档区也不显示它(取消归档前菜单按真实归档态切换)。
856
+ // 会话可见性规则:归档即离开工作区列表——包括当前会话(归档后切到相邻会话或空态),
857
+ // 归档区显示全部已归档会话。
852
858
  const visibleSessions = useMemo(() => {
853
859
  const set = new Set(archivedSessionPaths.map(pathKey));
854
- const currentKey = currentPath ? pathKey(currentPath) : null;
855
- return sessions.filter((s) => !set.has(pathKey(s.path)) || (currentKey != null && pathKey(s.path) === currentKey));
856
- }, [sessions, archivedSessionPaths, currentPath]);
860
+ return sessions.filter((s) => !set.has(pathKey(s.path)));
861
+ }, [sessions, archivedSessionPaths]);
857
862
  const archivedSessions = useMemo(() => {
858
863
  const set = new Set(archivedSessionPaths.map(pathKey));
859
- const currentKey = currentPath ? pathKey(currentPath) : null;
860
- return sessions.filter((s) => set.has(pathKey(s.path)) && !(currentKey != null && pathKey(s.path) === currentKey));
861
- }, [sessions, archivedSessionPaths, currentPath]);
864
+ return sessions.filter((s) => set.has(pathKey(s.path)));
865
+ }, [sessions, archivedSessionPaths]);
862
866
 
863
867
  return {
864
868
  sessions: visibleSessions,
package/src/i18n.tsx CHANGED
@@ -19,6 +19,10 @@ const zh = {
19
19
  error: "出错",
20
20
  copy: "复制",
21
21
  copyFailed: "复制失败",
22
+ editMessage: "编辑这条消息",
23
+ sendEdit: "重新发送",
24
+ editWhileRunning: "会话正在运行,先停止再编辑",
25
+ editMessageFailed: "编辑重发失败",
22
26
  export: "导出",
23
27
  settings: "设置",
24
28
  // 侧栏
@@ -430,6 +434,7 @@ const zh = {
430
434
  updatingNow: "更新中…",
431
435
  updatedTo: "已更新到 {v},重启 PiWeb 后生效",
432
436
  updateFailed: "更新失败",
437
+ updateManualHint: "先停止 piweb,再执行上面的命令(Windows 无法替换运行中的安装目录)",
433
438
  skillsDesc: "关闭后技能不会出现在系统提示中,仅可通过 /skill:名称 手动调用",
434
439
  confirmDeleteSkill: "确定删除技能“{name}”吗?将删除其所在技能目录,不可恢复。",
435
440
  deleteSkillHint: "删除技能目录",
@@ -550,6 +555,10 @@ const en: Dict = {
550
555
  error: "Error",
551
556
  copy: "Copy",
552
557
  copyFailed: "Copy failed",
558
+ editMessage: "Edit this message",
559
+ sendEdit: "Send again",
560
+ editWhileRunning: "This session is running; stop it before editing",
561
+ editMessageFailed: "Edit and resend failed",
553
562
  export: "Export",
554
563
  settings: "Settings",
555
564
  newChat: "New session",
@@ -954,6 +963,7 @@ const en: Dict = {
954
963
  updatingNow: "Updating…",
955
964
  updatedTo: "Updated to {v}; restart PiWeb to apply",
956
965
  updateFailed: "Update failed",
966
+ updateManualHint: "Stop piweb, then run the command above (Windows cannot replace a running installation)",
957
967
  skillsDesc: "Disabled skills are hidden from the system prompt and only callable via /skill:name",
958
968
  confirmDeleteSkill: "Delete skill \"{name}\"? Its skill directory will be removed and cannot be recovered.",
959
969
  deleteSkillHint: "Delete the skill directory",
@@ -2,9 +2,11 @@
2
2
  * agent-manager:AgentSession 池 + pi 事件 → Web 事件翻译 + SSE 订阅管理。
3
3
  * 打开会话即惰性冷启动(createAgentSession),空闲回收,事件先快照后增量。
4
4
  */
5
- import { existsSync } from "node:fs";
5
+ import { existsSync, readFileSync, statSync } from "node:fs";
6
6
  import { randomUUID } from "node:crypto";
7
+ import path from "node:path";
7
8
  import type { AgentSession, AgentSessionEvent } from "@earendil-works/pi-coding-agent";
9
+ import { generateUnifiedPatch } from "@earendil-works/pi-coding-agent";
8
10
  import {
9
11
  getResourceLoader,
10
12
  createSessionResourceLoader,
@@ -182,6 +184,51 @@ function patchOf(details: unknown): string | undefined {
182
184
  return typeof patch === "string" && patch.trim() ? patch.slice(0, 60_000) : undefined;
183
185
  }
184
186
 
187
+ /**
188
+ * write 工具不产 patch(SDK 的 write.js 里没有 patch 字段),覆盖已有文件时前端只能把
189
+ * 全部内容当新增行——删掉的行永远不会标红。这里在工具开始执行(文件还没被写)时读一份
190
+ * 旧内容,结束时用 SDK 的 generateUnifiedPatch 生成和 edit 同格式的 patch,
191
+ * 于是「新增绿 / 删除红」在两条写入路径上一致。新文件没有旧内容,仍按新增行展示。
192
+ */
193
+ const WRITE_BASE_MAX_BYTES = 512 * 1024;
194
+ const writeBases = new Map<string, string>();
195
+
196
+ function writeArgsOf(args: unknown): { target: string; content: string | undefined } {
197
+ const a = args as { path?: unknown; file_path?: unknown; content?: unknown } | undefined;
198
+ const target = typeof a?.path === "string" ? a.path : typeof a?.file_path === "string" ? a.file_path : "";
199
+ return { target, content: typeof a?.content === "string" ? a.content : undefined };
200
+ }
201
+
202
+ export function captureWriteBase(cwd: string, sessionPath: string, toolCallId: string, args: unknown): void {
203
+ const { target } = writeArgsOf(args);
204
+ if (!target) return;
205
+ try {
206
+ const abs = path.isAbsolute(target) ? target : path.resolve(cwd, target);
207
+ const stat = statSync(abs);
208
+ if (!stat.isFile() || stat.size > WRITE_BASE_MAX_BYTES) return;
209
+ const previous = readFileSync(abs, "utf8");
210
+ if (previous.includes("\u0000")) return; // 二进制不做行级 diff
211
+ if (writeBases.size > 256) writeBases.clear();
212
+ writeBases.set(`${sessionPath}\u0000${toolCallId}`, previous);
213
+ } catch {
214
+ /* 新文件或读不到:没有旧内容,走全绿兜底 */
215
+ }
216
+ }
217
+
218
+ export function takeWritePatch(cwd: string, sessionPath: string, toolCallId: string, args: unknown): string | undefined {
219
+ const key = `${sessionPath}\u0000${toolCallId}`;
220
+ const previous = writeBases.get(key);
221
+ writeBases.delete(key);
222
+ if (previous === undefined) return undefined;
223
+ const { target, content } = writeArgsOf(args);
224
+ if (content === undefined || content === previous) return undefined;
225
+ try {
226
+ return generateUnifiedPatch(target, previous, content).slice(0, 60_000);
227
+ } catch {
228
+ return undefined;
229
+ }
230
+ }
231
+
185
232
  function coercePartial(p: unknown): string | undefined {
186
233
  if (p == null) return undefined;
187
234
  if (typeof p === "string") return p.slice(-4000);
@@ -426,6 +473,8 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
426
473
  break;
427
474
  }
428
475
  case "tool_execution_start":
476
+ // write 覆盖前先留一份旧内容,结束时才能给出真实的新增/删除 diff
477
+ if (evt.toolName?.toLowerCase() === "write") captureWriteBase(m.cwd, m.sessionPath, evt.toolCallId, evt.args);
429
478
  publish(m, {
430
479
  type: "tool",
431
480
  id: evt.toolCallId,
@@ -460,7 +509,7 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
460
509
  result: result.text || undefined,
461
510
  isError: evt.isError,
462
511
  encodingLoss: result.encodingLoss || undefined,
463
- patch: patchOf((evt as any).result?.details),
512
+ patch: patchOf((evt as any).result?.details) ?? (evt.toolName?.toLowerCase() === "write" ? takeWritePatch(m.cwd, m.sessionPath, evt.toolCallId, (evt as any).args) : undefined),
464
513
  ts: now,
465
514
  });
466
515
  publishUsage(m);
@@ -514,7 +563,15 @@ function translate(m: Managed, evt: AgentSessionEvent): void {
514
563
  else publish(m, { type: "model", thinkingLevel: (evt as any).level, ts: now });
515
564
  break;
516
565
  case "auto_retry_start":
517
- publish(m, { type: "error", message: `自动重试 ${(evt as any).attempt}/${(evt as any).maxAttempts}:${(evt as any).errorMessage ?? ""}`, ts: now });
566
+ // 重试是流程内通知:发一条结构化 retry 事件(界面聚合成「重试 n/max」一行),
567
+ // 不再走 error 通道,免得每次尝试都多出一条报错。
568
+ publish(m, {
569
+ type: "retry",
570
+ attempt: Number((evt as any).attempt) || 0,
571
+ maxAttempts: Number((evt as any).maxAttempts) || 0,
572
+ message: String((evt as any).errorMessage ?? ""),
573
+ ts: now,
574
+ });
518
575
  break;
519
576
  default:
520
577
  break;
@@ -881,6 +938,11 @@ export function disposeSession(m: Managed): void {
881
938
  sessions.delete(m.sessionPath);
882
939
  }
883
940
 
941
+ /**
942
+ * 让某个会话立刻收工:中断正在跑的回合并回收 agent。
943
+ * 归档用它——归档后会话从工作区消失,若它的回合还在后台跑,用户看不见却仍在消耗 token。
944
+ * 只影响这一个会话,其它会话的运行不受影响(LRU/空闲回收同样跳过 busy 的会话)。
945
+ */
884
946
  export async function disposeSessionPath(sessionPath: string): Promise<void> {
885
947
  const m = sessions.get(sessionPath);
886
948
  if (!m) return;