@rexvane/piweb 0.3.18 → 0.3.19

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 (261) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +8 -6
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +4 -4
  5. package/.next/react-loadable-manifest.json +23 -9
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/routes-manifest.json +12 -0
  9. package/.next/server/app/_global-error/page.js +2 -2
  10. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_global-error.html +1 -1
  12. package/.next/server/app/_global-error.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/api/agent/[id]/events/route.js +3 -3
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/new/route.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  31. package/.next/server/app/api/config/route.js +1 -1
  32. package/.next/server/app/api/config/route.js.nft.json +1 -1
  33. package/.next/server/app/api/files/route.js +1 -1
  34. package/.next/server/app/api/files/route.js.nft.json +1 -1
  35. package/.next/server/app/api/fs/route.js +1 -0
  36. package/.next/server/app/api/fs/route.js.nft.json +1 -0
  37. package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/git/route.js +2 -2
  39. package/.next/server/app/api/git/route.js.nft.json +1 -1
  40. package/.next/server/app/api/growth/route.js +1 -1
  41. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  42. package/.next/server/app/api/health/route.js +1 -1
  43. package/.next/server/app/api/models/route.js +1 -1
  44. package/.next/server/app/api/models/route.js.nft.json +1 -1
  45. package/.next/server/app/api/pi-settings/route.js +1 -1
  46. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  47. package/.next/server/app/api/plugins/route.js +1 -1
  48. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  49. package/.next/server/app/api/prompts/route.js +1 -1
  50. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  51. package/.next/server/app/api/remote/route.js +8 -0
  52. package/.next/server/app/api/remote/route.js.nft.json +1 -0
  53. package/.next/server/app/api/remote/route_client-reference-manifest.js +1 -0
  54. package/.next/server/app/api/security/route.js +1 -1
  55. package/.next/server/app/api/security/route.js.nft.json +1 -1
  56. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  57. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  58. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  59. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  60. package/.next/server/app/api/sessions/import/route.js +2 -2
  61. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/route.js +1 -1
  63. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  64. package/.next/server/app/api/skills/route.js +1 -1
  65. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  66. package/.next/server/app/api/update/route.js +2 -2
  67. package/.next/server/app/api/update/route.js.nft.json +1 -1
  68. package/.next/server/app/api/version/route.js +1 -1
  69. package/.next/server/app/api/version/route.js.nft.json +1 -1
  70. package/.next/server/app/api/web-auth/route.js +3 -1
  71. package/.next/server/app/api/workspaces/route.js +1 -1
  72. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  73. package/.next/server/app/icon.svg/route.js +1 -1
  74. package/.next/server/app/index.html +1 -1
  75. package/.next/server/app/index.rsc +4 -4
  76. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  77. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  78. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  79. package/.next/server/app/login/page.js +1 -1
  80. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  81. package/.next/server/app/login.html +1 -1
  82. package/.next/server/app/login.rsc +3 -3
  83. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  84. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  85. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  86. package/.next/server/app/page.js +1 -1
  87. package/.next/server/app/page.js.nft.json +1 -1
  88. package/.next/server/app/page_client-reference-manifest.js +1 -1
  89. package/.next/server/app-paths-manifest.json +8 -6
  90. package/.next/server/chunks/229.js +1 -1
  91. package/.next/server/chunks/243.js +1 -0
  92. package/.next/server/chunks/393.js +1 -0
  93. package/.next/server/chunks/496.js +1 -1
  94. package/.next/server/chunks/538.js +1 -0
  95. package/.next/server/chunks/686.js +1 -4
  96. package/.next/server/chunks/802.js +2 -0
  97. package/.next/server/chunks/807.js +1 -0
  98. package/.next/server/chunks/920.js +4 -0
  99. package/.next/server/chunks/924.js +6 -0
  100. package/.next/server/instrumentation.js +1 -0
  101. package/.next/server/instrumentation.js.nft.json +1 -0
  102. package/.next/server/middleware-build-manifest.js +1 -1
  103. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  104. package/.next/server/middleware.js +4 -3
  105. package/.next/server/pages/404.html +1 -1
  106. package/.next/server/pages/500.html +1 -1
  107. package/.next/server/server-reference-manifest.json +1 -1
  108. package/.next/static/JDGSR-TIbk9qZWcUNYIX3/_buildManifest.js +1 -0
  109. package/.next/static/chunks/2716.7ad073038e94a802.js +1 -0
  110. package/.next/static/chunks/316-5ea443ee6b512aa9.js +1 -0
  111. package/.next/static/chunks/4673.8a5a9e6855350e34.js +1 -0
  112. package/.next/static/chunks/4686-e3825f3b6a384aa1.js +1 -0
  113. package/.next/static/chunks/4778.e4c9ed093a413bc4.js +1 -0
  114. package/.next/static/chunks/5173.91ddb803785009f8.js +1 -0
  115. package/.next/static/chunks/839.7403c6bdace4c56c.js +1 -0
  116. package/.next/static/chunks/8552.6bfe3e101250b20f.js +1 -0
  117. package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +1 -0
  118. package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +1 -0
  119. package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +1 -0
  120. package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +1 -0
  121. package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +1 -0
  122. package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +1 -0
  123. package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +1 -0
  124. package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +1 -0
  125. package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +1 -0
  126. package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +1 -0
  127. package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +1 -0
  128. package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +1 -0
  129. package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +1 -0
  130. package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +1 -0
  131. package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +1 -0
  132. package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +1 -0
  133. package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +1 -0
  134. package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +1 -0
  135. package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +1 -0
  136. package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +1 -0
  137. package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +1 -0
  138. package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +1 -0
  139. package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +1 -0
  140. package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +1 -0
  141. package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +1 -0
  142. package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +1 -0
  143. package/.next/static/chunks/app/login/page-5e276a1eeb8867df.js +1 -0
  144. package/.next/static/chunks/app/page-2e425ef83fba0782.js +1 -0
  145. package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +1 -0
  146. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +1 -0
  147. package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +1 -0
  148. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +1 -0
  149. package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +1 -0
  150. package/.next/static/css/7391c42e1ae617f9.css +1 -0
  151. package/.next/types/app/api/fs/route.ts +353 -0
  152. package/.next/types/app/api/remote/route.ts +353 -0
  153. package/.next/types/routes.d.ts +3 -1
  154. package/.next/types/validator.ts +18 -0
  155. package/README.md +56 -10
  156. package/next.config.ts +3 -2
  157. package/package.json +3 -3
  158. package/scripts/launcher.mjs +194 -13
  159. package/scripts/release.mjs +0 -2
  160. package/src/app/api/agent/[id]/events/route.ts +11 -6
  161. package/src/app/api/fs/route.ts +119 -0
  162. package/src/app/api/growth/route.ts +11 -6
  163. package/src/app/api/models/route.ts +21 -9
  164. package/src/app/api/remote/route.ts +67 -0
  165. package/src/app/api/sessions/[id]/export/route.ts +11 -1
  166. package/src/app/api/sessions/route.ts +3 -1
  167. package/src/app/api/web-auth/route.ts +45 -49
  168. package/src/app/api/workspaces/route.ts +16 -0
  169. package/src/app/globals.css +6 -0
  170. package/src/app/login/page.tsx +8 -60
  171. package/src/components/AppShell.tsx +202 -155
  172. package/src/components/ChatInput.tsx +61 -6
  173. package/src/components/ChatWindow.tsx +274 -132
  174. package/src/components/DirectoryPicker.tsx +189 -0
  175. package/src/components/DrawerPanel.tsx +164 -0
  176. package/src/components/FileViewer.tsx +2 -2
  177. package/src/components/GrowthTree.tsx +6 -5
  178. package/src/components/MarkdownBlock.tsx +134 -0
  179. package/src/components/OutlineRail.tsx +22 -1
  180. package/src/components/ProjectPanel.tsx +197 -14
  181. package/src/components/RemoteSection.tsx +232 -0
  182. package/src/components/SessionImportSection.tsx +60 -38
  183. package/src/components/SettingsPanel.tsx +104 -57
  184. package/src/components/icons.tsx +23 -0
  185. package/src/hooks/useGrowth.ts +47 -25
  186. package/src/hooks/usePiWeb.ts +100 -14
  187. package/src/i18n.tsx +115 -28
  188. package/src/instrumentation-node.ts +35 -0
  189. package/src/instrumentation.ts +18 -0
  190. package/src/lib/agent-config.ts +134 -0
  191. package/src/lib/agent-manager.ts +243 -61
  192. package/src/lib/auth-throttle.ts +102 -0
  193. package/src/lib/auth.ts +35 -27
  194. package/src/lib/files-service.ts +61 -1
  195. package/src/lib/font.ts +84 -0
  196. package/src/lib/git-service.ts +5 -3
  197. package/src/lib/growth-service.ts +78 -30
  198. package/src/lib/growth-tracker.ts +91 -9
  199. package/src/lib/models-service.ts +36 -11
  200. package/src/lib/pi-settings.ts +10 -12
  201. package/src/lib/pi.ts +145 -18
  202. package/src/lib/plugins-service.ts +67 -35
  203. package/src/lib/remote/assets.json +4 -0
  204. package/src/lib/remote/mobile-proxy.ts +149 -0
  205. package/src/lib/remote/supervisor.ts +482 -0
  206. package/src/lib/security-service.ts +6 -11
  207. package/src/lib/session-import/claude.ts +45 -21
  208. package/src/lib/session-reader.ts +106 -3
  209. package/src/lib/skills-service.ts +34 -12
  210. package/src/lib/text-sanitize.ts +12 -0
  211. package/src/lib/theme.ts +3 -0
  212. package/src/lib/types.ts +16 -2
  213. package/src/lib/update-service.ts +0 -1
  214. package/src/lib/web-auth-shared.ts +5 -1
  215. package/src/lib/web-auth.ts +139 -22
  216. package/src/lib/web-launch-token.ts +55 -0
  217. package/src/lib/web-secret.ts +182 -0
  218. package/src/lib/workspace-store.ts +91 -7
  219. package/src/proxy.ts +46 -12
  220. package/.next/server/chunks/532.js +0 -1
  221. package/.next/static/chunks/2155.2f27fe7098987699.js +0 -1
  222. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
  223. package/.next/static/chunks/2716.93be8ad5c970c516.js +0 -1
  224. package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
  225. package/.next/static/chunks/4673.c1fb1580efc6ace7.js +0 -1
  226. package/.next/static/chunks/4686-f4a037d609cea3f2.js +0 -1
  227. package/.next/static/chunks/692-7992db0ae9cc3021.js +0 -1
  228. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
  229. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
  230. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
  231. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
  232. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
  233. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
  234. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
  235. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
  236. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
  237. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
  238. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
  239. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
  240. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
  241. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
  242. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
  243. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
  244. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
  245. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
  246. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
  247. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
  248. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
  249. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
  250. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
  251. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
  252. package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +0 -1
  253. package/.next/static/chunks/app/page-b51087831582ee37.js +0 -1
  254. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
  255. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
  256. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
  257. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
  258. package/.next/static/chunks/webpack-c31263fd83d75ce6.js +0 -1
  259. package/.next/static/css/0cd968b9b62035b5.css +0 -1
  260. package/.next/static/en03hnPiuro0rnf8QkFEe/_buildManifest.js +0 -1
  261. /package/.next/static/{en03hnPiuro0rnf8QkFEe → JDGSR-TIbk9qZWcUNYIX3}/_ssgManifest.js +0 -0
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { useEffect, useRef, useState } from "react";
10
10
  import {
11
+ IconAtOutline16,
11
12
  IconFileOutline16,
12
13
  IconSendArrowUp14,
13
14
  IconStopFill16,
@@ -32,11 +33,13 @@ export interface ChatDraft {
32
33
  text: string;
33
34
  images: ImageAttachment[];
34
35
  uploads: Array<{ name: string; path: string; size: number }>;
36
+ /** 从文件树/文件面板「引用到输入框」加进来的路径引用:独立于正文,输入框里渲染成 chip */
37
+ references?: string[];
35
38
  adopted?: SlashCommand | null;
36
39
  }
37
40
 
38
41
  export type ChatDraftUpdate = ChatDraft | ((previous: ChatDraft) => ChatDraft);
39
- export const EMPTY_CHAT_DRAFT: ChatDraft = { text: "", images: [], uploads: [] };
42
+ export const EMPTY_CHAT_DRAFT: ChatDraft = { text: "", images: [], uploads: [], references: [] };
40
43
 
41
44
  type SendResult = { success: boolean; error?: string };
42
45
  const MAX_UPLOAD_FILES = 16;
@@ -131,6 +134,7 @@ export function ChatInput({
131
134
  onDraftChange?.(update);
132
135
  };
133
136
  const { text, images, uploads } = currentDraft;
137
+ const references = currentDraft.references ?? [];
134
138
  const adopted = currentDraft.adopted ?? null;
135
139
  const setText = (value: string | ((previous: string) => string)) => changeDraft((previous) => ({ ...previous, text: typeof value === "function" ? value(previous.text) : value }));
136
140
  const setAdopted = (value: SlashCommand | null) => changeDraft((previous) => ({ ...previous, adopted: value }));
@@ -222,6 +226,7 @@ export function ChatInput({
222
226
  setSending(true);
223
227
  onSendPendingChange?.(true);
224
228
  const raw = (forcedText ?? draftRef.current.text).trim();
229
+ const refs = [...(draftRef.current.references ?? [])];
225
230
  const trimmed = adopted ? `/${adopted.name}${raw ? ` ${raw}` : ""}` : raw;
226
231
  void (async () => {
227
232
  try {
@@ -229,8 +234,8 @@ export function ChatInput({
229
234
  const submitted = draftRef.current;
230
235
  const attachments = [...submitted.images];
231
236
  const files = [...submitted.uploads];
232
- if (!trimmed && attachments.length === 0 && files.length === 0) return;
233
- if (trimmed.startsWith("/") && attachments.length === 0 && files.length === 0) {
237
+ if (!trimmed && attachments.length === 0 && files.length === 0 && refs.length === 0) return;
238
+ if (trimmed.startsWith("/") && attachments.length === 0 && files.length === 0 && refs.length === 0) {
234
239
  const [head, ...rest] = trimmed.slice(1).split(/\s+/);
235
240
  const builtin = (commands ?? []).find((c) => c.kind === "builtin" && c.name.toLowerCase() === head.toLowerCase());
236
241
  if (builtin) {
@@ -241,8 +246,13 @@ export function ChatInput({
241
246
  return;
242
247
  }
243
248
  }
244
- const compose = (base: string) =>
245
- files.length ? `${base}${base ? "\n\n" : ""}${files.map((f) => t.attachmentLine.replace("{path}", f.path).replace("{size}", fmtUploadSize(f.size))).join("\n")}` : base;
249
+ // 引用 chip 在发送时拼成 @路径 行(和 pi 的 @ 语法一致),显示与正文分开互不干扰
250
+ const compose = (base: string) => {
251
+ const parts = [base];
252
+ if (refs.length) parts.push(refs.map((path) => `@${path}`).join("\n"));
253
+ if (files.length) parts.push(files.map((f) => t.attachmentLine.replace("{path}", f.path).replace("{size}", fmtUploadSize(f.size))).join("\n"));
254
+ return parts.filter(Boolean).join("\n\n");
255
+ };
246
256
  let result: SendResult;
247
257
  if (isStreaming) {
248
258
  const mode = override ?? (enterBehavior === "steer" ? "steer" : "followUp");
@@ -262,6 +272,7 @@ export function ChatInput({
262
272
  adopted: previous.adopted === submitted.adopted ? null : previous.adopted,
263
273
  images: previous.images.filter((image) => !attachments.includes(image)),
264
274
  uploads: previous.uploads.filter((file) => !files.some((sent) => sent.path === file.path)),
275
+ references: (previous.references ?? []).filter((path) => !refs.includes(path)),
265
276
  }));
266
277
  } catch (error) {
267
278
  if (forcedText) setText(forcedText);
@@ -403,7 +414,7 @@ export function ChatInput({
403
414
  // eslint-disable-next-line react-hooks/exhaustive-deps
404
415
  }, []);
405
416
 
406
- const hasDraft = text.trim().length > 0 || images.length > 0 || uploads.length > 0;
417
+ const hasDraft = text.trim().length > 0 || images.length > 0 || uploads.length > 0 || references.length > 0;
407
418
 
408
419
  return (
409
420
  <div ref={wrapRef} className="relative w-full" data-testid="composer">
@@ -491,6 +502,40 @@ export function ChatInput({
491
502
  )}
492
503
 
493
504
  {/* 附件轨:上传的普通文件(压缩包/文档等,路径引用) */}
505
+ {/* 路径引用(项目结构图/文件面板的「引用到输入框」):独立于正文,发送时拼成 @路径 */}
506
+ {references.length > 0 && (
507
+ <div className="mb-2 flex flex-wrap gap-1.5 px-1" data-testid="composer-references">
508
+ {references.map((path) => (
509
+ <span
510
+ key={path}
511
+ className="flex items-center gap-1 rounded-md px-2 py-1"
512
+ style={{
513
+ maxWidth: 240,
514
+ fontSize: 12,
515
+ fontFamily: "var(--font-mono)",
516
+ color: "var(--dsw-label-secondary)",
517
+ background: "var(--dsw-selector)",
518
+ border: "0.5px solid var(--dsw-border-l3)",
519
+ }}
520
+ title={path}
521
+ >
522
+ <IconAtOutline16 size={11} style={{ flex: "none", color: "var(--dsw-accent)" }} />
523
+ <span className="truncate">{referenceLabelOf(path)}</span>
524
+ <button
525
+ type="button"
526
+ className="flex h-4 w-4 flex-none items-center justify-center rounded-full text-[10px]"
527
+ style={{ color: "var(--dsw-label-caption)" }}
528
+ disabled={sendBusy}
529
+ onClick={() => changeDraft((previous) => ({ ...previous, references: (previous.references ?? []).filter((p) => p !== path) }))}
530
+ aria-label={`${t.delete} ${path}`}
531
+ >
532
+ ✕
533
+ </button>
534
+ </span>
535
+ ))}
536
+ </div>
537
+ )}
538
+
494
539
  {uploads.length > 0 && (
495
540
  <div className="mb-2 flex flex-wrap gap-2 px-1">
496
541
  {uploads.map((f, i) => (
@@ -731,3 +776,13 @@ function fmtUploadSize(bytes: number): string {
731
776
  if (bytes >= 1024) return `${Math.round(bytes / 1024)}KB`;
732
777
  return `${bytes}B`;
733
778
  }
779
+
780
+ /**
781
+ * 引用 chip 上的显示文本:只要最后一段(文件名/目录名),太挤就靠 CSS 截断。
782
+ * 目录引用带尾斜杠,和文件区分开(与项目结构图里的展示一致)。
783
+ */
784
+ function referenceLabelOf(path: string): string {
785
+ const trimmed = path.replace(/[\\/]+$/, "");
786
+ const base = trimmed.split(/[\\/]/).filter(Boolean).pop() ?? path;
787
+ return /[\\/]$/.test(path) ? `${base}/` : base;
788
+ }
@@ -5,10 +5,8 @@
5
5
  * 每个回合 = 用户消息 → 过程(叙述句是阶段标题,其下是带图标的步骤列表)→ 最终回答。
6
6
  * 进行中的思考 / 命令用闪光渐变的英文状态词;回合结束后思考与命令折成一行摘要,编辑保留 diff。
7
7
  */
8
- import { memo, useEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
9
- import ReactMarkdown from "react-markdown";
10
- import rehypeHighlight from "rehype-highlight";
11
- import remarkGfm from "remark-gfm";
8
+ import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
9
+ import dynamic from "next/dynamic";
12
10
  import {
13
11
  IconBranchOutline16,
14
12
  IconBrowseOutline14,
@@ -76,78 +74,18 @@ function formatMessageClock(time: number, t: Dict): string {
76
74
  return `${md} ${clock}`;
77
75
  }
78
76
 
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
-
112
- const Markdown = memo(function Markdown({ text }: { text: string }) {
113
- return (
114
- <div className="md">
115
- <ReactMarkdown
116
- remarkPlugins={[remarkGfm]}
117
- rehypePlugins={[[rehypeHighlight, { detect: false, ignoreMissing: true }]]}
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
- }}
127
- >
128
- {text}
129
- </ReactMarkdown>
130
- </div>
131
- );
132
- });
133
-
134
-
135
77
  /**
136
- * 流式正文:边流边按 Markdown 渲染(标题/列表/代码块在生成过程中就成形)。
137
- *
138
- * 不是每个 delta 都重解析整段——那样会随回答变长线性变慢;这里按 ~100ms 节流,
139
- * 只在最新文本稳定下来时才交给 Markdown,回合结束时外层会切到精确的最终渲染。
78
+ * Markdown 正文按需加载:react-markdown + remark-gfm + rehype-highlight(含 highlight.js
79
+ * 常用语言集)实测占首屏 JS 的 472KB / 1272KB,而 Hero 页根本用不到。AppShell 会在空闲时
80
+ * 预取这个分块,正常打开会话时已经就绪;万一还没加载完,这几毫秒里正文先留空,不阻塞其它内容。
140
81
  */
141
- const STREAM_MARKDOWN_THROTTLE_MS = 100;
142
-
143
- const StreamingMarkdown = memo(function StreamingMarkdown({ text }: { text: string }) {
144
- const [shown, setShown] = useState(text);
145
- useEffect(() => {
146
- if (text === shown) return;
147
- const timer = setTimeout(() => setShown(text), STREAM_MARKDOWN_THROTTLE_MS);
148
- return () => clearTimeout(timer);
149
- }, [text, shown]);
150
- return <Markdown text={shown} />;
82
+ const Markdown = dynamic(() => import("@/components/MarkdownBlock").then((m) => m.MarkdownBlock), {
83
+ ssr: false,
84
+ loading: () => null,
85
+ });
86
+ const StreamingMarkdown = dynamic(() => import("@/components/MarkdownBlock").then((m) => m.StreamingMarkdownBlock), {
87
+ ssr: false,
88
+ loading: () => null,
151
89
  });
152
90
 
153
91
  /** dsh TurnTimePanel 的简化版:耗时胶囊常驻,点击弹层看本回合 token 用量 */
@@ -303,8 +241,6 @@ const MONO = "var(--font-mono)";
303
241
  const RAIL = "⎿";
304
242
  /** 命令输出默认露出的行数 */
305
243
  const PREVIEW_LINES = 3;
306
- /** 写入 / 编辑默认露出的 diff 行数 */
307
- const DIFF_PREVIEW = 12;
308
244
  /** 进行中的状态词:Claude Code 同款用法,闪光渐变,按用户要求用英文(两种界面语言都一样) */
309
245
  const WORKING_VERBS = ["Thinking", "Pondering", "Cogitating", "Mulling", "Percolating", "Brewing", "Simmering", "Ruminating", "Synthesizing", "Noodling", "Marinating", "Crafting"];
310
246
  /** 有步骤在闪光时 π 行只报时长;这行和状态词一样固定英文,不随界面语言变 */
@@ -525,8 +461,9 @@ const ToolStep = memo(function ToolStep({
525
461
  const showPreview = Boolean(preview.head) || preview.lines.length > 0;
526
462
  // 失败且有 diff 时,出错信息要完整可见(此时 diff 占用了展开位)
527
463
  const showFullOutput = hasOutput && (open && canExpand || (failed && hasDiff));
528
- const diffLines = hasDiff && diff ? (open ? diff : diff.slice(0, DIFF_PREVIEW)) : null;
529
- const diffHidden = diff && diffLines && !open ? diff.length - diffLines.length : 0;
464
+ // 折叠时只留标题行:diff 正文整块不渲染(原来固定露出前 12 行,手机上十几次编辑
465
+ // 会把整段对话淹没)。标题里已经有「+N −M 行」,点标题行展开看全文。
466
+ const diffLines = hasDiff && diff ? (open ? diff : null) : null;
530
467
  const diffStat = hasDiff && diff
531
468
  ? isWriteTool && !state?.patch
532
469
  ? t.diffSummary.replace("{add}", String(diff.length)).replace("{del}", "0")
@@ -541,7 +478,16 @@ const ToolStep = memo(function ToolStep({
541
478
  };
542
479
  return (
543
480
  <Step kind={kind} tone={running ? "live" : failed ? "fail" : "dim"} live={running} className="group/tool">
544
- <div className="pw-line">
481
+ <div
482
+ className="pw-line"
483
+ role={hasDiff ? "button" : undefined}
484
+ tabIndex={hasDiff ? 0 : undefined}
485
+ aria-expanded={hasDiff ? open : undefined}
486
+ style={hasDiff ? { cursor: "pointer" } : undefined}
487
+ title={hasDiff ? (open ? t.collapseOutput : t.expandDiff) : undefined}
488
+ onClick={hasDiff ? toggle : undefined}
489
+ onKeyDown={hasDiff ? onKey : undefined}
490
+ >
545
491
  {running ? <span className="pw-shimmer">{t.stepRunning}</span> : <span className="pw-verb" data-failed={failed || undefined}>{stepVerb(tt, kind, name)}</span>}
546
492
  {summary.text ? (
547
493
  <span className="pw-arg" title={argsTitle}>{summary.text}</span>
@@ -549,6 +495,15 @@ const ToolStep = memo(function ToolStep({
549
495
  <span className="pw-arg" style={{ color: "var(--dsw-label-caption)" }}>{displayToolName(name)}</span>
550
496
  )}
551
497
  {diffStat && <span className="pw-meta">{diffStat}</span>}
498
+ {hasDiff && (
499
+ <span
500
+ className="pw-meta"
501
+ aria-hidden
502
+ style={{ display: "inline-flex", transform: open ? "rotate(90deg)" : undefined, transition: "transform var(--ds-duration-fast)" }}
503
+ >
504
+ <IconChevronRight14 size={11} />
505
+ </span>
506
+ )}
552
507
  {deletedTarget && (
553
508
  <span className="pw-meta" style={{ color: "var(--dsw-danger)" }} title={deletedTarget}>
554
509
  {t.deletedFile.replace("{path}", relativizeInText(deletedTarget, cwd || ""))}
@@ -611,16 +566,9 @@ const ToolStep = memo(function ToolStep({
611
566
  {diffLines && diffLines.length > 0 && (
612
567
  <div className="pw-diff">
613
568
  <DiffView lines={diffLines} language={languageForPath(argPath)} />
614
- {diffHidden > 0 && (
615
- <button type="button" className="pw-more" onClick={() => setOpen(true)}>
616
- … {t.moreLines.replace("{n}", String(diffHidden))}
617
- </button>
618
- )}
619
- {open && diff && diff.length > DIFF_PREVIEW && (
620
- <button type="button" className="pw-more" onClick={() => setOpen(false)}>
621
- {t.collapseOutput}
622
- </button>
623
- )}
569
+ <button type="button" className="pw-more" onClick={() => setOpen(false)}>
570
+ {t.collapseOutput}
571
+ </button>
624
572
  </div>
625
573
  )}
626
574
  {showFullOutput && (
@@ -898,12 +846,18 @@ const FinalAnswer = memo(function FinalAnswer({
898
846
  onFork,
899
847
  showActions,
900
848
  turnStartMs,
849
+ roundBadge,
850
+ onShowRound,
901
851
  }: {
902
852
  message: WebMessage;
903
853
  onFork?: (entryId: string) => void;
904
854
  showActions: boolean;
905
855
  turnStartMs?: number;
856
+ /** 这一轮改了哪些文件(项目生长那一轮的 commit) */
857
+ roundBadge?: RoundBadge;
858
+ onShowRound?: (commit: string) => void;
906
859
  }) {
860
+ const { t } = useI18n();
907
861
  const textAll = message.content
908
862
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
909
863
  .map((c) => c.text)
@@ -924,6 +878,27 @@ const FinalAnswer = memo(function FinalAnswer({
924
878
  time={message.timestamp}
925
879
  />
926
880
  )}
881
+ {/* 本轮改了 N 个文件:跟在耗时 / 时间戳那一行后面。原先是挂在用户消息下面,
882
+ 放在这里才读得通——先看到答案,再看到这一轮动了什么。 */}
883
+ {showActions && roundBadge && (
884
+ <div className="msg-actions">
885
+ <button
886
+ type="button"
887
+ className="pw-chip"
888
+ title={t.chatRoundShow}
889
+ onClick={() => onShowRound?.(roundBadge.commit)}
890
+ data-testid="round-badge"
891
+ >
892
+ {roundBadge.files ? t.chatRoundChanged.replace("{n}", String(roundBadge.files)) : t.chatRoundNoChanges}
893
+ {roundBadge.files > 0 && (
894
+ <>
895
+ <span style={{ color: "var(--dsw-success)" }}> +{roundBadge.add}</span>
896
+ <span style={{ color: "var(--dsw-danger)" }}> −{roundBadge.del}</span>
897
+ </>
898
+ )}
899
+ </button>
900
+ </div>
901
+ )}
927
902
  </div>
928
903
  );
929
904
  });
@@ -937,7 +912,7 @@ export interface RoundBadge {
937
912
  }
938
913
 
939
914
  /** 行级 memo:流式期间只有最后一条消息变化,历史行全部跳过重渲染 */
940
- const UserMessage = memo(function UserMessage({ message, badge, onShowRound, onEditMessage }: { message: WebMessage; badge?: RoundBadge; onShowRound?: (commit: string) => void; onEditMessage?: (entryId: string, text: string) => void }) {
915
+ const UserMessage = memo(function UserMessage({ message, onEditMessage }: { message: WebMessage; onEditMessage?: (entryId: string, text: string) => void }) {
941
916
  const { t } = useI18n();
942
917
  const text = message.content
943
918
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
@@ -1005,23 +980,6 @@ const UserMessage = memo(function UserMessage({ message, badge, onShowRound, onE
1005
980
  )}
1006
981
  {/* 用户消息也走 Markdown:贴进来的代码块/列表不再是一坨纯文本 */}
1007
982
  {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
- )}
1025
983
  {/* 用户消息:复制 + 原地编辑重发(编辑后模型从这条消息重新回答);时钟在图标左侧(dsh clock=start) */}
1026
984
  {text && (
1027
985
  <MessageActions
@@ -1155,7 +1113,7 @@ function TurnBlockView({
1155
1113
 
1156
1114
  return (
1157
1115
  <>
1158
- {user && <UserMessage message={user} badge={roundBadge} onShowRound={onShowRound} onEditMessage={onEditMessage} />}
1116
+ {user && <UserMessage message={user} onEditMessage={onEditMessage} />}
1159
1117
  {showProcess && (
1160
1118
  <div className="pw-turn">
1161
1119
  {contextFiles?.map((resource, i) => (
@@ -1184,7 +1142,98 @@ function TurnBlockView({
1184
1142
  {turnLive && streamStartedAt && <WorkingStep startedAt={streamStartedAt} outputTokens={outputTokens} workingMessage={workingMessage} hasLiveStep={hasLiveStep} />}
1185
1143
  </div>
1186
1144
  )}
1187
- {finalIdx >= 0 && <FinalAnswer message={messages[finalIdx]} onFork={onFork} showActions={!isStreaming || !isLast} turnStartMs={user?.timestamp} />}
1145
+ {finalIdx >= 0 && <FinalAnswer message={messages[finalIdx]} onFork={onFork} showActions={!isStreaming || !isLast} turnStartMs={user?.timestamp} roundBadge={roundBadge} onShowRound={onShowRound} />}
1146
+ </>
1147
+ );
1148
+ }
1149
+
1150
+ /**
1151
+ * 最终回答的占位排版:只认 ATX 标题(# ~ ####,围栏代码块里的不算),其余原样成段。
1152
+ * 标题输出成 .md 里的 h1-h4,大纲(OutlineRail)照常收得到;没升级的回合也不缺目录项。
1153
+ */
1154
+ function liteAnswerBlocks(text: string): ReactNode[] {
1155
+ const out: ReactNode[] = [];
1156
+ let para: string[] = [];
1157
+ let fence: string | null = null;
1158
+ const flush = () => {
1159
+ if (para.join("").trim()) out.push(<div key={out.length}>{para.join("\n")}</div>);
1160
+ para = [];
1161
+ };
1162
+ for (const line of text.split("\n")) {
1163
+ const marker = /^\s*(`{3,}|~{3,})/.exec(line)?.[1];
1164
+ if (marker && (!fence || marker.startsWith(fence))) fence = fence ? null : marker.slice(0, 3);
1165
+ const heading = fence || marker ? null : /^ {0,3}(#{1,4})\s+(.+?)\s*#*\s*$/.exec(line);
1166
+ if (!heading) {
1167
+ para.push(line);
1168
+ continue;
1169
+ }
1170
+ flush();
1171
+ const Tag = `h${heading[1].length}` as "h1" | "h2" | "h3" | "h4";
1172
+ out.push(<Tag key={out.length}>{heading[2]}</Tag>);
1173
+ }
1174
+ flush();
1175
+ return out;
1176
+ }
1177
+
1178
+ /**
1179
+ * 远离视口的回合占位:纯文本近似(无 Markdown/高亮)。
1180
+ * DOM 骨架和完整渲染对齐:用户消息带 data-role="user" / data-message-id(跳转定位),
1181
+ * 只有最终回答带 data-role="assistant"(大纲只从这里取标题),过程只出一行摘要。
1182
+ */
1183
+ function TurnLite({ turn, messages }: { turn: Turn; messages: WebMessage[] }) {
1184
+ const { t } = useI18n();
1185
+ const user = turn.userIndex >= 0 ? messages[turn.userIndex] : undefined;
1186
+ const lastIdx = turn.assistantIndexes[turn.assistantIndexes.length - 1];
1187
+ // 占位回合不会是正在流式的那一轮(最后一轮永远完整渲染),所以不用管 isStreaming
1188
+ const finalIdx = lastIdx !== undefined && isFinalAnswer(messages[lastIdx]) ? lastIdx : -1;
1189
+ const plain = (m: WebMessage) =>
1190
+ m.content
1191
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
1192
+ .map((c) => c.text)
1193
+ .join("\n");
1194
+ return (
1195
+ <>
1196
+ {user && (
1197
+ <div className="group mt-7 flex w-full flex-col items-end first:mt-0" data-role="user" data-message-id={user.id}>
1198
+ <div className="msg-user-bubble pw-turn-lite">
1199
+ <div>{plain(user)}</div>
1200
+ </div>
1201
+ </div>
1202
+ )}
1203
+ {turn.assistantIndexes.map((k) => {
1204
+ const m = messages[k];
1205
+ if (!m) return null;
1206
+ const think = m.content.some((c) => c.type === "thinking");
1207
+ const tools = m.content.filter((c) => c.type === "toolCall").length;
1208
+ const text = plain(m);
1209
+ if (k === finalIdx) {
1210
+ return (
1211
+ <div key={k} className="group w-full" style={{ marginTop: 14 }} data-role="assistant">
1212
+ <div className="md pw-turn-lite">{liteAnswerBlocks(text)}</div>
1213
+ </div>
1214
+ );
1215
+ }
1216
+ return (
1217
+ <div key={k} className="group w-full" style={{ marginTop: 14 }}>
1218
+ {(think || tools > 0) && (
1219
+ <div className="pw-line" style={{ color: "var(--dsw-label-tertiary)" }}>
1220
+ {think && <span>{t.thinkLabel}</span>}
1221
+ {think && tools > 0 && <span> · </span>}
1222
+ {tools > 0 && (
1223
+ <span>
1224
+ {t.toolCalls} × {tools}
1225
+ </span>
1226
+ )}
1227
+ </div>
1228
+ )}
1229
+ {text && (
1230
+ <div className="pw-turn-lite">
1231
+ <div>{text}</div>
1232
+ </div>
1233
+ )}
1234
+ </div>
1235
+ );
1236
+ })}
1188
1237
  </>
1189
1238
  );
1190
1239
  }
@@ -1300,6 +1349,9 @@ const TurnBlock = memo(TurnBlockView, turnBlockPropsEqual);
1300
1349
  const CHAT_WIDTH_KEY = "piweb.chatContentWidth";
1301
1350
  const CHAT_WIDTH_MIN = 480;
1302
1351
  const CHAT_WIDTH_MAX = 1200;
1352
+ /** 回合懒渲染:首屏完整渲染最近 N 回合;距视口 M px 内升级完整渲染 */
1353
+ const TURN_TAIL_FULL = 8;
1354
+ const TURN_NEAR_MARGIN = 1800;
1303
1355
 
1304
1356
  function resolveChatWidth(width: number, viewportWidth: number): number {
1305
1357
  const max = Math.max(320, Math.min(CHAT_WIDTH_MAX, viewportWidth - 64));
@@ -1412,6 +1464,89 @@ export function ChatWindow({
1412
1464
  const turns = useMemo(() => buildTurns(messages), [messages]);
1413
1465
  const firstUser = useMemo(() => messages.findIndex((m) => m.role === "user"), [messages]);
1414
1466
 
1467
+ // ---------- 回合懒渲染 ----------
1468
+ // 打开长会话时快照一次性带来上百回合,每回合的 react-markdown + 代码高亮同步执行,
1469
+ // 首挂可达数秒(154 回合实测 6.5s)。远离视口的回合先渲染纯文本占位(TurnLite),
1470
+ // 滚动进入视口附近再升级完整渲染。升级判定按槽元素实measure位置计算(scroll 驱动),
1471
+ // 不用 IntersectionObserver:占位槽无 CV 时几何虽真实,但 IO 的隐式 root/rootMargin
1472
+ // 在托管 webview 等环境里行为不稳,scroll 驱动的位置判定是确定性的。
1473
+ const [nearTurns, setNearTurns] = useState<Set<number>>(() => new Set());
1474
+ const nearTurnsRef = useRef(nearTurns);
1475
+ nearTurnsRef.current = nearTurns;
1476
+ // 尾部起点:第一次拿到回合时定下,之后追加的回合一律完整渲染(不用等视口判定)。
1477
+ // 在渲染里惰性赋值:同一批回合算出来的值相同,重复渲染也幂等。
1478
+ const tailStartRef = useRef<number | null>(null);
1479
+ if (tailStartRef.current === null && turns.length) tailStartRef.current = Math.max(0, turns.length - TURN_TAIL_FULL);
1480
+ const slotElsRef = useRef(new Map<number, HTMLElement>());
1481
+ const anchorRef = useRef<{ index: number; top: number } | null>(null);
1482
+ const upgradeCheckRef = useRef(0);
1483
+ const scheduleUpgradeCheck = useCallback(() => {
1484
+ if (upgradeCheckRef.current) return;
1485
+ upgradeCheckRef.current = requestAnimationFrame(() => {
1486
+ upgradeCheckRef.current = 0;
1487
+ const scroller = scrollRef.current;
1488
+ if (!scroller) return;
1489
+ const top = -TURN_NEAR_MARGIN;
1490
+ const bottom = window.innerHeight + TURN_NEAR_MARGIN;
1491
+ const viewTop = scroller.getBoundingClientRect().top;
1492
+ const fresh: number[] = [];
1493
+ let anchor: { index: number; top: number } | null = null;
1494
+ for (const index of [...slotElsRef.current.keys()].sort((a, b) => a - b)) {
1495
+ const rect = slotElsRef.current.get(index)!.getBoundingClientRect();
1496
+ // 视口里第一个槽当锚点:升级只换槽里的内容,槽元素本身不变,位置可以前后对比
1497
+ if (!anchor && rect.bottom > viewTop) anchor = { index, top: rect.top };
1498
+ if (nearTurnsRef.current.has(index) || rect.top > bottom || rect.bottom < top) continue;
1499
+ fresh.push(index);
1500
+ }
1501
+ if (!fresh.length) return;
1502
+ anchorRef.current = anchor;
1503
+ setNearTurns((prev) => {
1504
+ const next = new Set(prev);
1505
+ for (const index of fresh) next.add(index);
1506
+ return next;
1507
+ });
1508
+ });
1509
+ }, []);
1510
+ useLayoutEffect(() => {
1511
+ // 升级提交后(绘制前)。贴底状态下直接重新贴底:升级常发生在视口下方,会把底部推远。
1512
+ // 否则视口上方的回合变高会把正在看的内容往下顶。Chrome / Edge / Firefox 的滚动锚定
1513
+ // 会自己补偿,而且不打断平滑滚动(大纲跳转、搜索定位);这里再写 scrollTop 反而会
1514
+ // 打断它们。只有不支持锚定的浏览器(Safari)才按锚点槽的位移手动补偿。
1515
+ const anchor = anchorRef.current;
1516
+ anchorRef.current = null;
1517
+ const scroller = scrollRef.current;
1518
+ if (!scroller) return;
1519
+ if (stickToBottom.current) {
1520
+ scroller.scrollTop = scroller.scrollHeight;
1521
+ return;
1522
+ }
1523
+ if (!anchor || (typeof CSS !== "undefined" && CSS.supports("overflow-anchor", "auto"))) return;
1524
+ const el = slotElsRef.current.get(anchor.index);
1525
+ const delta = el ? el.getBoundingClientRect().top - anchor.top : 0;
1526
+ if (Math.abs(delta) >= 1) scroller.scrollTop += delta;
1527
+ }, [nearTurns]);
1528
+ // 快照到达 / 跳转(scrollIntoView、回到底部)后的初次判定;滚动中的判定挂在 onScroll 上
1529
+ useEffect(() => {
1530
+ scheduleUpgradeCheck();
1531
+ }, [scheduleUpgradeCheck, messages.length]);
1532
+ const registerSlot = useCallback((index: number, el: HTMLElement | null) => {
1533
+ if (!el) {
1534
+ slotElsRef.current.delete(index);
1535
+ return;
1536
+ }
1537
+ slotElsRef.current.set(index, el);
1538
+ }, []);
1539
+ const slotCbRef = useRef(new Map<number, (el: HTMLElement | null) => void>());
1540
+ const slotRef = useCallback((index: number) => {
1541
+ let cb = slotCbRef.current.get(index);
1542
+ if (!cb) {
1543
+ cb = (el) => registerSlot(index, el);
1544
+ slotCbRef.current.set(index, cb);
1545
+ }
1546
+ return cb;
1547
+ }, [registerSlot]);
1548
+ const isNear = useCallback((index: number) => index >= (tailStartRef.current ?? 0) || nearTurns.has(index), [nearTurns]);
1549
+
1415
1550
  useEffect(() => {
1416
1551
  const grew = messages.length > prevLenRef.current;
1417
1552
  prevLenRef.current = messages.length;
@@ -1512,32 +1647,39 @@ export function ChatWindow({
1512
1647
  stickToBottom.current = atBottom;
1513
1648
  setAwayFromBottom(!atBottom);
1514
1649
  if (atBottom) setUnseen(0);
1650
+ scheduleUpgradeCheck();
1515
1651
  }}
1516
1652
  >
1517
1653
  <div className="mx-auto flex w-full flex-col px-4 py-6" style={{ maxWidth: "var(--dsh-chat-content-width)" }}>
1518
1654
  {turns.map((turn, ti) => (
1519
- <TurnBlock
1520
- key={turn.key}
1521
- turn={turn}
1522
- messages={messages}
1523
- tools={tools}
1524
- cwd={cwd}
1525
- isStreaming={isStreaming}
1526
- isLast={ti === turns.length - 1}
1527
- lastAssistantIndex={lastAssistantIndex}
1528
- contextFiles={turn.userIndex >= 0 && turn.userIndex === firstUser ? contextFiles : undefined}
1529
- retryNotice={retryNotice}
1530
- streamStartedAt={streamStartedAt}
1531
- runningTool={runningTool}
1532
- outputTokens={stats?.tokens.output}
1533
- workingMessage={workingMessage}
1534
- onFork={onFork}
1535
- onEditMessage={onEditMessage}
1536
- roundBadge={turn.userIndex >= 0 ? roundBadges?.get(messages[turn.userIndex].id ?? "") : undefined}
1537
- onShowRound={onShowRound}
1538
- onInspectTool={onOpenTrajectory}
1539
- onOpenFile={onOpenFile}
1540
- />
1655
+ // 回合间 28px 的分隔挂在槽上:用户消息现在总是槽里的第一个子元素,它自己的 mt-7 会被 first:mt-0 吃掉
1656
+ <div key={turn.key} data-turn-slot={ti} ref={slotRef(ti)} className="mt-7 first:mt-0">
1657
+ {isNear(ti) ? (
1658
+ <TurnBlock
1659
+ turn={turn}
1660
+ messages={messages}
1661
+ tools={tools}
1662
+ cwd={cwd}
1663
+ isStreaming={isStreaming}
1664
+ isLast={ti === turns.length - 1}
1665
+ lastAssistantIndex={lastAssistantIndex}
1666
+ contextFiles={turn.userIndex >= 0 && turn.userIndex === firstUser ? contextFiles : undefined}
1667
+ retryNotice={retryNotice}
1668
+ streamStartedAt={streamStartedAt}
1669
+ runningTool={runningTool}
1670
+ outputTokens={stats?.tokens.output}
1671
+ workingMessage={workingMessage}
1672
+ onFork={onFork}
1673
+ onEditMessage={onEditMessage}
1674
+ roundBadge={turn.userIndex >= 0 ? roundBadges?.get(messages[turn.userIndex].id ?? "") : undefined}
1675
+ onShowRound={onShowRound}
1676
+ onInspectTool={onOpenTrajectory}
1677
+ onOpenFile={onOpenFile}
1678
+ />
1679
+ ) : (
1680
+ <TurnLite turn={turn} messages={messages} />
1681
+ )}
1682
+ </div>
1541
1683
  ))}
1542
1684
  {/* 还没有任何消息就已在流式(极少见):单独给一条工作指示 */}
1543
1685
  {isStreaming && streamStartedAt && turns.length === 0 && (
@@ -1577,7 +1719,7 @@ export function ChatWindow({
1577
1719
  </div>
1578
1720
  </div>
1579
1721
  {/* 右侧大纲导航:从助手回答的标题和用户消息生成,随滚动高亮,悬停展开 */}
1580
- <OutlineRail scrollRef={scrollRef} revision={`${messages.length}:${isStreaming ? 1 : 0}`} />
1722
+ <OutlineRail scrollRef={scrollRef} revision={`${messages.length}:${nearTurns.size}:${isStreaming ? 1 : 0}`} />
1581
1723
  {/* 脱离底部时的“回到底部”浮钮:与输入框卡片同轴,挂在右上角顶部,图标带新内容角标 */}
1582
1724
  {(unseen > 0 || awayFromBottom) && (
1583
1725
  <div