@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
@@ -7,6 +7,7 @@ import {
7
7
  CustomProvidersValidationError,
8
8
  discoverModels,
9
9
  listModels,
10
+ listModelsForPiWeb,
10
11
  loginState,
11
12
  providerUsage,
12
13
  readCustomProviders,
@@ -37,7 +38,7 @@ export async function GET(req: Request) {
37
38
  const full = custom || params.get("full") === "1";
38
39
  try {
39
40
  const customProviders = custom ? await readCustomProviders() : null;
40
- const data = await listModels();
41
+ const data = await (full ? listModels() : listModelsForPiWeb());
41
42
  // 页面挂载只需要选模型用的字段(1300+ 个模型带 api/baseUrl/cost/input 有 400KB);设置页用 custom=1 拿完整版
42
43
  const models = full
43
44
  ? data.models
@@ -221,11 +221,47 @@ textarea::placeholder {
221
221
  }
222
222
  }
223
223
 
224
+ .piweb-conversation-width-handle {
225
+ position: absolute;
226
+ top: 0;
227
+ bottom: 0;
228
+ z-index: 3;
229
+ width: 12px;
230
+ cursor: col-resize;
231
+ touch-action: none;
232
+ }
233
+ .piweb-conversation-width-handle[data-side="left"] {
234
+ left: calc(50% - var(--dsh-chat-content-width) / 2 - 20px);
235
+ }
236
+ .piweb-conversation-width-handle[data-side="right"] {
237
+ right: calc(50% - var(--dsh-chat-content-width) / 2 - 20px);
238
+ }
239
+ .piweb-conversation-width-handle::after {
240
+ content: "";
241
+ position: absolute;
242
+ top: 0;
243
+ bottom: 0;
244
+ left: 5px;
245
+ width: 2px;
246
+ border-radius: 2px;
247
+ background: var(--dsw-border-l3);
248
+ opacity: 0;
249
+ transition: opacity var(--ds-duration-fast);
250
+ }
251
+ .piweb-conversation-width-handle:hover::after,
252
+ .piweb-conversation-width-handle[data-dragging]::after,
253
+ .piweb-conversation-width-handle:focus-visible::after {
254
+ opacity: 1;
255
+ }
256
+
224
257
  @media (max-width: 840px) {
225
258
  :root {
226
259
  --dsh-chat-content-width: 100%;
227
260
  --dsh-composer-card-max-width: 100%;
228
261
  }
262
+ .piweb-conversation-width-handle {
263
+ display: none;
264
+ }
229
265
 
230
266
  .pi-main > .hairline-b {
231
267
  padding-left: 56px;
@@ -739,6 +775,18 @@ textarea::placeholder {
739
775
  font-size: 13px;
740
776
  line-height: 1.55;
741
777
  }
778
+ .md-code-block {
779
+ position: relative;
780
+ }
781
+ .md-code-copy {
782
+ position: absolute;
783
+ top: 12px;
784
+ right: 12px;
785
+ z-index: 1;
786
+ }
787
+ .md-code-block pre {
788
+ padding-top: 40px;
789
+ }
742
790
  .md table {
743
791
  border-collapse: collapse;
744
792
  margin: 0.7em 0;
@@ -1769,6 +1817,48 @@ body[data-pebrel-theme="pebrel"][data-ds-dark-theme] {
1769
1817
  .pw-timeline {
1770
1818
  flex: none;
1771
1819
  }
1820
+ /* 生长时间轴:每轮一根柱,底对齐,超出横向滚动;高度由内联样式给(= 改动行数) */
1821
+ .pw-tl-rail {
1822
+ display: flex;
1823
+ align-items: flex-end;
1824
+ gap: 3px;
1825
+ height: 34px;
1826
+ padding: 6px 10px 2px;
1827
+ overflow-x: auto;
1828
+ scrollbar-width: none;
1829
+ }
1830
+ .pw-tl-rail::-webkit-scrollbar {
1831
+ display: none;
1832
+ }
1833
+ .pw-tl-bar {
1834
+ flex: none;
1835
+ width: 8px;
1836
+ min-height: 3px;
1837
+ border-radius: 2px 2px 1px 1px;
1838
+ opacity: 0.5;
1839
+ transition: opacity var(--ds-duration-fast) var(--ds-ease-in-out);
1840
+ }
1841
+ .pw-tl-bar:hover {
1842
+ opacity: 0.85;
1843
+ }
1844
+ .pw-tl-bar[data-active] {
1845
+ opacity: 1;
1846
+ box-shadow: 0 0 0 1px var(--dsw-sidebar-fill), 0 0 0 2px var(--dsw-label-secondary);
1847
+ }
1848
+ .pw-tl-bar[data-kind="user"] {
1849
+ width: 4px;
1850
+ }
1851
+ .pw-tl-bar[data-running] {
1852
+ height: 14px;
1853
+ border: 1px dashed var(--dsw-label-caption);
1854
+ background: transparent;
1855
+ animation: pw-tl-breathe 1.6s var(--ds-ease-in-out) infinite;
1856
+ }
1857
+ @keyframes pw-tl-breathe {
1858
+ 50% {
1859
+ opacity: 0.2;
1860
+ }
1861
+ }
1772
1862
 
1773
1863
  /* 文件查看器:80vw × 80vh 居中,全屏切 100% */
1774
1864
  .pw-viewer {
@@ -7,7 +7,7 @@
7
7
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
8
  import { PiMark } from "@/components/PiMark";
9
9
  import { ChatInput, EMPTY_CHAT_DRAFT, type ChatDraft, type ChatDraftUpdate } from "@/components/ChatInput";
10
- import { ChatWindow, SessionStatsBar } from "@/components/ChatWindow";
10
+ import { ChatWindow, SessionStatsBar, type RoundBadge } from "@/components/ChatWindow";
11
11
  import { ExtensionDialogHost, ExtensionNotices } from "@/components/ExtensionUI";
12
12
  import dynamic from "next/dynamic";
13
13
  import { SessionSidebar } from "@/components/SessionSidebar";
@@ -574,29 +574,39 @@ export function AppShell() {
574
574
  useEffect(() => {
575
575
  resetViewer();
576
576
  }, [panelCwd, resetViewer]);
577
- const growthTurnStarts = useMemo(
578
- () => {
579
- const persisted = state.snapshot?.userTurns ?? [];
580
- const ids = new Set(persisted.map((turn) => turn.id));
581
- const lastTs = persisted[persisted.length - 1]?.ts ?? -Infinity;
582
- const live = state.messages
583
- .filter((message) => message.role === "user" && (!message.id || !ids.has(message.id)) && (message.timestamp ?? Date.now()) >= lastTs - 1000)
584
- .map((message) => message.timestamp ?? Date.now());
585
- return [...persisted.map((turn) => turn.ts), ...live];
586
- },
587
- [state.snapshot?.userTurns, state.messages],
588
- );
589
577
  const growth = useGrowth({
590
578
  cwd: panelCwd,
591
579
  sessionPath: currentPath,
592
- liveSteps: state.growth.steps,
593
- pending: state.growth.pending,
580
+ liveRounds: state.growth.rounds,
594
581
  runtimeError: state.growth.error,
595
582
  active: projectOpen || viewer.state.open,
583
+ watchRounds: Boolean(currentPath),
596
584
  connected: state.connected,
597
- turnStarts: growthTurnStarts,
598
585
  });
599
586
  const openFileNode = useCallback((node: TreeNode) => viewer.open(node.path, { from: node.from, lazy: node.lazy }), [viewer]);
587
+ const roundBadges = useMemo(() => {
588
+ const map = new Map<string, RoundBadge>();
589
+ for (const round of growth.rounds) {
590
+ if (round.kind !== "round" || !round.promptIds[0]) continue;
591
+ const st = round.stats;
592
+ map.set(round.promptIds[0], { commit: round.commit, files: st.added + st.modified + st.deleted + st.renamed, add: st.add, del: st.del });
593
+ }
594
+ return map;
595
+ }, [growth.rounds]);
596
+ const growthSelect = growth.select;
597
+ const showRound = useCallback((commit: string) => {
598
+ toggleProject(true);
599
+ growthSelect(commit);
600
+ }, [toggleProject, growthSelect]);
601
+ const jumpToChat = useCallback((messageId: string) => {
602
+ requestAnimationFrame(() => {
603
+ const node = document.querySelector<HTMLElement>(`[data-role="user"][data-message-id="${CSS.escape(messageId)}"]`);
604
+ if (!node) return;
605
+ node.scrollIntoView({ block: "center", behavior: "smooth" });
606
+ node.dataset.flash = "";
607
+ setTimeout(() => delete node.dataset.flash, 1600);
608
+ });
609
+ }, []);
600
610
 
601
611
  useEffect(() => {
602
612
  document.title = currentId && title && title !== "pi" ? `${title} · pi` : "pi";
@@ -622,6 +632,8 @@ export function AppShell() {
622
632
  workspaceName={panelCwd ? getWorkspaceName(panelCwd) : ""}
623
633
  cwd={panelCwd}
624
634
  hasSession={Boolean(currentPath)}
635
+ running={isStreaming}
636
+ onJumpToChat={jumpToChat}
625
637
  onOpenFile={openFileNode}
626
638
  onReference={insertIntoComposer}
627
639
  onOpenEditor={openInEditor}
@@ -845,6 +857,8 @@ export function AppShell() {
845
857
  onOpenFile={openInEditor}
846
858
  onFork={handleFork}
847
859
  onEditMessage={doEditMessage}
860
+ roundBadges={roundBadges}
861
+ onShowRound={showRound}
848
862
  />
849
863
  <div className="px-4 pb-3 pt-2">
850
864
  <div className="mx-auto w-full" style={{ maxWidth: "var(--dsh-composer-card-max-width)" }}>
@@ -76,13 +76,54 @@ function formatMessageClock(time: number, t: Dict): string {
76
76
  return `${md} ${clock}`;
77
77
  }
78
78
 
79
+ type MarkdownTextNode = { value?: unknown; children?: MarkdownTextNode[] };
80
+ function markdownNodeText(node: MarkdownTextNode | undefined): string {
81
+ if (!node) return "";
82
+ if (typeof node.value === "string") return node.value;
83
+ return node.children?.map(markdownNodeText).join("") ?? "";
84
+ }
85
+
86
+ function CopyButton({ text }: { text: string }) {
87
+ const [copied, setCopied] = useState(false);
88
+ const [copyFailed, setCopyFailed] = useState(false);
89
+ const { t } = useI18n();
90
+ return (
91
+ <>
92
+ <button
93
+ type="button"
94
+ className="icon-btn"
95
+ title={copyFailed ? t.copyFailed : t.copy}
96
+ aria-label={t.copy}
97
+ onClick={() => {
98
+ void copyText(text).then((ok) => {
99
+ setCopied(ok);
100
+ setCopyFailed(!ok);
101
+ setTimeout(() => { setCopied(false); setCopyFailed(false); }, 1200);
102
+ });
103
+ }}
104
+ >
105
+ {copied ? <IconCheckOutline14 size={14} /> : <IconCopyOutline16 size={14} />}
106
+ </button>
107
+ {copyFailed && <span role="alert" style={{ fontSize: 11, color: "var(--dsw-danger)" }}>{t.copyFailed}</span>}
108
+ </>
109
+ );
110
+ }
111
+
79
112
  const Markdown = memo(function Markdown({ text }: { text: string }) {
80
113
  return (
81
114
  <div className="md">
82
115
  <ReactMarkdown
83
116
  remarkPlugins={[remarkGfm]}
84
117
  rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
85
- components={{ a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" /> }}
118
+ components={{
119
+ a: ({ node: _node, ...props }) => <a {...props} target="_blank" rel="noopener noreferrer" />,
120
+ pre: ({ node, children, ...props }) => (
121
+ <div className="md-code-block">
122
+ <div className="md-code-copy"><CopyButton text={markdownNodeText(node)} /></div>
123
+ <pre {...props}>{children}</pre>
124
+ </div>
125
+ ),
126
+ }}
86
127
  >
87
128
  {text}
88
129
  </ReactMarkdown>
@@ -887,8 +928,16 @@ const FinalAnswer = memo(function FinalAnswer({
887
928
  );
888
929
  });
889
930
 
931
+ /** 对话里提问下方的「本轮改了 N 个文件」:来自项目生长这一轮的 commit */
932
+ export interface RoundBadge {
933
+ commit: string;
934
+ files: number;
935
+ add: number;
936
+ del: number;
937
+ }
938
+
890
939
  /** 行级 memo:流式期间只有最后一条消息变化,历史行全部跳过重渲染 */
891
- const UserMessage = memo(function UserMessage({ message, onEditMessage }: { message: WebMessage; onEditMessage?: (entryId: string, text: string) => void }) {
940
+ const UserMessage = memo(function UserMessage({ message, badge, onShowRound, onEditMessage }: { message: WebMessage; badge?: RoundBadge; onShowRound?: (commit: string) => void; onEditMessage?: (entryId: string, text: string) => void }) {
892
941
  const { t } = useI18n();
893
942
  const text = message.content
894
943
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
@@ -900,7 +949,7 @@ const UserMessage = memo(function UserMessage({ message, onEditMessage }: { mess
900
949
  const canEdit = Boolean(onEditMessage && message.id && text);
901
950
  if (editing) {
902
951
  return (
903
- <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
952
+ <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user" data-message-id={message.id}>
904
953
  <div className="msg-user-bubble w-full">
905
954
  <textarea
906
955
  className="w-full resize-none bg-transparent outline-none"
@@ -940,7 +989,7 @@ const UserMessage = memo(function UserMessage({ message, onEditMessage }: { mess
940
989
  }
941
990
  return (
942
991
  // 回合边界:用户消息前留 28px(比回合内 8px 大得多),长对话里一眼找到“这一轮从哪开始”
943
- <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user">
992
+ <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user" data-message-id={message.id}>
944
993
  {images.length > 0 && (
945
994
  <div className="mb-2 flex max-w-[85%] flex-wrap justify-end gap-2">
946
995
  {images.map((image, index) => (
@@ -956,6 +1005,23 @@ const UserMessage = memo(function UserMessage({ message, onEditMessage }: { mess
956
1005
  )}
957
1006
  {/* 用户消息也走 Markdown:贴进来的代码块/列表不再是一坨纯文本 */}
958
1007
  {text && <div className="msg-user-bubble"><Markdown text={text} /></div>}
1008
+ {badge && (
1009
+ <button
1010
+ type="button"
1011
+ className="pw-chip mt-1.5"
1012
+ title={t.chatRoundShow}
1013
+ onClick={() => onShowRound?.(badge.commit)}
1014
+ data-testid="round-badge"
1015
+ >
1016
+ {badge.files ? t.chatRoundChanged.replace("{n}", String(badge.files)) : t.chatRoundNoChanges}
1017
+ {badge.files > 0 && (
1018
+ <>
1019
+ <span style={{ color: "var(--dsw-success)" }}> +{badge.add}</span>
1020
+ <span style={{ color: "var(--dsw-danger)" }}> −{badge.del}</span>
1021
+ </>
1022
+ )}
1023
+ </button>
1024
+ )}
959
1025
  {/* 用户消息:复制 + 原地编辑重发(编辑后模型从这条消息重新回答);时钟在图标左侧(dsh clock=start) */}
960
1026
  {text && (
961
1027
  <MessageActions
@@ -1015,6 +1081,8 @@ function TurnBlockView({
1015
1081
  workingMessage,
1016
1082
  onFork,
1017
1083
  onEditMessage,
1084
+ roundBadge,
1085
+ onShowRound,
1018
1086
  onInspectTool,
1019
1087
  onOpenFile,
1020
1088
  }: {
@@ -1034,6 +1102,8 @@ function TurnBlockView({
1034
1102
  onFork?: (entryId: string) => void;
1035
1103
  /** 编辑该回合的用户消息并重新发送(模型从这条消息重新回答) */
1036
1104
  onEditMessage?: (entryId: string, text: string) => void;
1105
+ roundBadge?: RoundBadge;
1106
+ onShowRound?: (commit: string) => void;
1037
1107
  onInspectTool?: (toolCallId: string) => void;
1038
1108
  onOpenFile?: (path: string) => void;
1039
1109
  }) {
@@ -1085,7 +1155,7 @@ function TurnBlockView({
1085
1155
 
1086
1156
  return (
1087
1157
  <>
1088
- {user && <UserMessage message={user} onEditMessage={onEditMessage} />}
1158
+ {user && <UserMessage message={user} badge={roundBadge} onShowRound={onShowRound} onEditMessage={onEditMessage} />}
1089
1159
  {showProcess && (
1090
1160
  <div className="pw-turn">
1091
1161
  {contextFiles?.map((resource, i) => (
@@ -1200,6 +1270,11 @@ function turnBlockPropsEqual(prev: TurnBlockProps, next: TurnBlockProps): boolea
1200
1270
  prev.contextFiles !== next.contextFiles ||
1201
1271
  prev.onFork !== next.onFork ||
1202
1272
  prev.onEditMessage !== next.onEditMessage ||
1273
+ prev.onShowRound !== next.onShowRound ||
1274
+ prev.roundBadge?.commit !== next.roundBadge?.commit ||
1275
+ prev.roundBadge?.files !== next.roundBadge?.files ||
1276
+ prev.roundBadge?.add !== next.roundBadge?.add ||
1277
+ prev.roundBadge?.del !== next.roundBadge?.del ||
1203
1278
  prev.onInspectTool !== next.onInspectTool ||
1204
1279
  prev.onOpenFile !== next.onOpenFile
1205
1280
  ) return false;
@@ -1222,6 +1297,28 @@ function turnBlockPropsEqual(prev: TurnBlockProps, next: TurnBlockProps): boolea
1222
1297
 
1223
1298
  const TurnBlock = memo(TurnBlockView, turnBlockPropsEqual);
1224
1299
 
1300
+ const CHAT_WIDTH_KEY = "piweb.chatContentWidth";
1301
+ const CHAT_WIDTH_MIN = 480;
1302
+ const CHAT_WIDTH_MAX = 1200;
1303
+
1304
+ function resolveChatWidth(width: number, viewportWidth: number): number {
1305
+ const max = Math.max(320, Math.min(CHAT_WIDTH_MAX, viewportWidth - 64));
1306
+ return Math.max(Math.min(CHAT_WIDTH_MIN, max), Math.min(width, max));
1307
+ }
1308
+
1309
+ function setChatWidth(root: HTMLElement, width: number | null): void {
1310
+ const scope = root.closest<HTMLElement>(".pi-main");
1311
+ if (!scope) return;
1312
+ if (width === null) {
1313
+ scope.style.removeProperty("--dsh-chat-content-width");
1314
+ scope.style.removeProperty("--dsh-composer-card-max-width");
1315
+ return;
1316
+ }
1317
+ const resolved = resolveChatWidth(width, root.clientWidth);
1318
+ scope.style.setProperty("--dsh-chat-content-width", `${resolved}px`);
1319
+ scope.style.setProperty("--dsh-composer-card-max-width", `${resolved + 32}px`);
1320
+ }
1321
+
1225
1322
  export function ChatWindow({
1226
1323
  messages,
1227
1324
  tools,
@@ -1240,6 +1337,8 @@ export function ChatWindow({
1240
1337
  stats,
1241
1338
  onOpenTrajectory,
1242
1339
  onOpenFile,
1340
+ roundBadges,
1341
+ onShowRound,
1243
1342
  }: {
1244
1343
  messages: WebMessage[];
1245
1344
  tools: Record<string, ToolCardState>;
@@ -1266,10 +1365,31 @@ export function ChatWindow({
1266
1365
  /** 在本机编辑器打开工具行涉及的文件 */
1267
1366
  onEditMessage?: (entryId: string, text: string) => void;
1268
1367
  onOpenFile?: (path: string) => void;
1368
+ roundBadges?: ReadonlyMap<string, RoundBadge>;
1369
+ onShowRound?: (commit: string) => void;
1269
1370
  }) {
1270
1371
  const scrollRef = useRef<HTMLDivElement>(null);
1372
+ const rootRef = useRef<HTMLDivElement>(null);
1373
+ const widthPreference = useRef<number | null>(null);
1374
+ const widthDrag = useRef<{ side: "left" | "right"; origin: number; width: number; pointerId: number } | null>(null);
1271
1375
  const stickToBottom = useRef(true);
1272
1376
  const { t } = useI18n();
1377
+ useEffect(() => {
1378
+ const root = rootRef.current;
1379
+ if (!root) return;
1380
+ const saved = Number(localStorage.getItem(CHAT_WIDTH_KEY));
1381
+ widthPreference.current = Number.isFinite(saved) && saved >= CHAT_WIDTH_MIN ? saved : null;
1382
+ const syncWidth = () => setChatWidth(root, window.innerWidth <= 840 ? null : widthPreference.current);
1383
+ const observer = new ResizeObserver(syncWidth);
1384
+ observer.observe(root);
1385
+ window.addEventListener("resize", syncWidth);
1386
+ syncWidth();
1387
+ return () => {
1388
+ observer.disconnect();
1389
+ window.removeEventListener("resize", syncWidth);
1390
+ setChatWidth(root, null);
1391
+ };
1392
+ }, []);
1273
1393
  // 脱离底部后累计的新内容条数(回到底部按钮上的角标)
1274
1394
  const [unseen, setUnseen] = useState(0);
1275
1395
  /** 是否已离开底部(state 版:ref 变化不会触发渲染,静态会话翻页时按钮出不来) */
@@ -1315,7 +1435,71 @@ export function ChatWindow({
1315
1435
  };
1316
1436
 
1317
1437
  return (
1318
- <div className="relative flex h-full min-h-0 flex-col">
1438
+ <div ref={rootRef} className="relative flex h-full min-h-0 flex-col">
1439
+ {(["left", "right"] as const).map((side) => (
1440
+ <div
1441
+ key={side}
1442
+ className="piweb-conversation-width-handle"
1443
+ data-side={side}
1444
+ role="separator"
1445
+ aria-orientation="vertical"
1446
+ aria-label={t.chatWidthHandle}
1447
+ tabIndex={0}
1448
+ onPointerDown={(event) => {
1449
+ if (event.button !== 0 || !rootRef.current) return;
1450
+ event.preventDefault();
1451
+ const width = widthPreference.current ?? Math.max(544, Math.min(rootRef.current.clientWidth * 0.64, 736));
1452
+ widthDrag.current = { side, origin: event.clientX, width, pointerId: event.pointerId };
1453
+ event.currentTarget.setPointerCapture(event.pointerId);
1454
+ event.currentTarget.dataset.dragging = "true";
1455
+ }}
1456
+ onPointerMove={(event) => {
1457
+ const drag = widthDrag.current;
1458
+ if (!drag || drag.pointerId !== event.pointerId || !rootRef.current) return;
1459
+ const delta = (event.clientX - drag.origin) * (side === "right" ? 2 : -2);
1460
+ drag.width = resolveChatWidth((widthPreference.current ?? drag.width) + delta, rootRef.current.clientWidth);
1461
+ drag.origin = event.clientX;
1462
+ widthPreference.current = drag.width;
1463
+ setChatWidth(rootRef.current, drag.width);
1464
+ }}
1465
+ onPointerUp={(event) => {
1466
+ const drag = widthDrag.current;
1467
+ if (!drag || drag.pointerId !== event.pointerId) return;
1468
+ widthDrag.current = null;
1469
+ event.currentTarget.removeAttribute("data-dragging");
1470
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId);
1471
+ if (rootRef.current) {
1472
+ const width = resolveChatWidth(drag.width, rootRef.current.clientWidth);
1473
+ widthPreference.current = width;
1474
+ localStorage.setItem(CHAT_WIDTH_KEY, String(width));
1475
+ setChatWidth(rootRef.current, width);
1476
+ }
1477
+ }}
1478
+ onPointerCancel={(event) => {
1479
+ event.currentTarget.removeAttribute("data-dragging");
1480
+ widthDrag.current = null;
1481
+ if (rootRef.current) setChatWidth(rootRef.current, widthPreference.current);
1482
+ }}
1483
+ onLostPointerCapture={(event) => {
1484
+ event.currentTarget.removeAttribute("data-dragging");
1485
+ if (widthDrag.current) {
1486
+ widthDrag.current = null;
1487
+ if (rootRef.current) setChatWidth(rootRef.current, widthPreference.current);
1488
+ }
1489
+ }}
1490
+ onWheel={(event) => scrollRef.current?.scrollBy({ top: event.deltaY })}
1491
+ onKeyDown={(event) => {
1492
+ if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && rootRef.current) {
1493
+ event.preventDefault();
1494
+ const delta = event.key === "ArrowRight" ? 24 : -24;
1495
+ const width = resolveChatWidth((widthPreference.current ?? rootRef.current.clientWidth * 0.64) + delta, rootRef.current.clientWidth);
1496
+ widthPreference.current = width;
1497
+ localStorage.setItem(CHAT_WIDTH_KEY, String(width));
1498
+ setChatWidth(rootRef.current, width);
1499
+ }
1500
+ }}
1501
+ />
1502
+ ))}
1319
1503
  <div
1320
1504
  ref={scrollRef}
1321
1505
  className="min-h-0 flex-1 overflow-y-auto"
@@ -1349,6 +1533,8 @@ export function ChatWindow({
1349
1533
  workingMessage={workingMessage}
1350
1534
  onFork={onFork}
1351
1535
  onEditMessage={onEditMessage}
1536
+ roundBadge={turn.userIndex >= 0 ? roundBadges?.get(messages[turn.userIndex].id ?? "") : undefined}
1537
+ onShowRound={onShowRound}
1352
1538
  onInspectTool={onOpenTrajectory}
1353
1539
  onOpenFile={onOpenFile}
1354
1540
  />
@@ -167,11 +167,12 @@ export function FileViewer({
167
167
  } catch (error) {
168
168
  if ((error as { status?: number }).status === 404) {
169
169
  // 跟随中的文件可能在本轮后续工具里被删掉;从最近一次涉及它的快照取回最后内容。
170
- const prior = [...growth.steps].reverse().find((step) =>
171
- step.seq <= (growth.selected?.seq ?? Infinity) && step.changes.some((item) => item.path === tab.path || item.from === tab.path),
170
+ const upto = growth.selected ? growth.rounds.findIndex((round) => round.commit === growth.selected!.commit) : growth.rounds.length - 1;
171
+ const prior = growth.rounds.slice(0, upto + 1).reverse().find((round) =>
172
+ round.changes.some((item) => item.path === tab.path || item.from === tab.path),
172
173
  );
173
174
  const priorTree = prior?.changes.some((item) => (item.status === "D" && item.path === tab.path) || (item.status === "R" && item.from === tab.path))
174
- ? prior.parent
175
+ ? prior.parentTree
175
176
  : prior?.tree;
176
177
  if (priorTree && priorTree !== tree) {
177
178
  try {
@@ -192,7 +193,7 @@ export function FileViewer({
192
193
  try {
193
194
  if (tab.lazy || !range) {
194
195
  // 会话还没有任何快照时整棵树都是磁盘条目,「不在快照里」的提示会误导
195
- await diskContent(tab.lazy && growth.steps.length > 0 ? "disk" : null);
196
+ await diskContent(tab.lazy && growth.rounds.length > 0 ? "disk" : null);
196
197
  return;
197
198
  }
198
199
  if (mode === "diff") {
@@ -223,7 +224,7 @@ export function FileViewer({
223
224
  return () => {
224
225
  alive = false;
225
226
  };
226
- }, [activeTab, cwd, mode, range, change, growth.steps, growth.selected, growth.pending, t.viewerLoadFailed]);
227
+ }, [activeTab, cwd, mode, range, change, growth.rounds, growth.selected, t.viewerLoadFailed]);
227
228
 
228
229
  // 键盘:Esc 关(先关快速打开)、Ctrl/⌘+W 关页签、Ctrl/⌘+P 快速打开、[ ] 上一处下一处
229
230
  useEffect(() => {
@@ -289,9 +290,9 @@ export function FileViewer({
289
290
  setReveal({ path: dir, key: Date.now() });
290
291
  onSelectTab(null);
291
292
  };
292
- const roundNumber = growth.selectedRound?.id ?? 0;
293
- const stepBadge = roundNumber
294
- ? `${t.growthRoundLabel.replace("{n}", String(roundNumber))}${change ? ` · +${change.add ?? 0} −${change.del ?? 0}` : ""}`
293
+ const roundName = growth.selected ? (growth.selected.n ? t.growthRoundLabel.replace("{n}", String(growth.selected.n)) : t.growthKindUser) : "";
294
+ const stepBadge = roundName
295
+ ? `${roundName}${change ? ` · +${change.add ?? 0} −${change.del ?? 0}` : ""}`
295
296
  : "";
296
297
  const isDiffMode = mode === "diff" && Boolean(loaded?.diff);
297
298
  const noticeText = loaded?.notice === "deleted" ? t.viewerDeleted : loaded?.notice === "historical" ? t.viewerHistorical : loaded?.notice === "missing" ? t.viewerMissing : loaded?.notice === "disk" ? t.viewerNotInSnapshot : loaded?.notice === "nochange" ? t.viewerNoChanges : "";
@@ -466,7 +467,7 @@ export function FileViewer({
466
467
  <input value={treeFilter} onChange={(e) => setTreeFilter(e.target.value)} placeholder={t.growthFilter} className="min-w-0 flex-1 bg-transparent" style={{ fontSize: 12 }} />
467
468
  </div>
468
469
  <span style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
469
- {roundNumber ? t.growthRoundLabel.replace("{n}", String(roundNumber)) : ""}
470
+ {roundName}
470
471
  </span>
471
472
  <button type="button" className="pw-chip" onClick={growth.expandAll}>{t.growthExpandAll}</button>
472
473
  <button type="button" className="pw-chip" onClick={growth.collapseAll}>{t.growthCollapseAll}</button>