p-spore 0.1.4 → 0.1.42

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 (393) hide show
  1. package/README.md +110 -15
  2. package/dist/agents/agent-manager.d.ts +3 -3
  3. package/dist/agents/agent-manager.d.ts.map +1 -1
  4. package/dist/agents/agent-manager.js +3 -2
  5. package/dist/agents/agent-manager.js.map +1 -1
  6. package/dist/application/agent-application.d.ts +29 -5
  7. package/dist/application/agent-application.d.ts.map +1 -1
  8. package/dist/application/agent-application.js +444 -14
  9. package/dist/application/agent-application.js.map +1 -1
  10. package/dist/articles/diff.d.ts +70 -0
  11. package/dist/articles/diff.d.ts.map +1 -0
  12. package/dist/articles/diff.js +330 -0
  13. package/dist/articles/diff.js.map +1 -0
  14. package/dist/articles/docx.d.ts +10 -0
  15. package/dist/articles/docx.d.ts.map +1 -0
  16. package/dist/articles/docx.js +219 -0
  17. package/dist/articles/docx.js.map +1 -0
  18. package/dist/articles/draft.d.ts +18 -0
  19. package/dist/articles/draft.d.ts.map +1 -0
  20. package/dist/articles/draft.js +88 -0
  21. package/dist/articles/draft.js.map +1 -0
  22. package/dist/articles/edits.d.ts +24 -0
  23. package/dist/articles/edits.d.ts.map +1 -0
  24. package/dist/articles/edits.js +54 -0
  25. package/dist/articles/edits.js.map +1 -0
  26. package/dist/articles/html.d.ts +10 -0
  27. package/dist/articles/html.d.ts.map +1 -0
  28. package/dist/articles/html.js +122 -0
  29. package/dist/articles/html.js.map +1 -0
  30. package/dist/articles/plain.d.ts +8 -0
  31. package/dist/articles/plain.d.ts.map +1 -0
  32. package/dist/articles/plain.js +69 -0
  33. package/dist/articles/plain.js.map +1 -0
  34. package/dist/articles/preview.d.ts +43 -0
  35. package/dist/articles/preview.d.ts.map +1 -0
  36. package/dist/articles/preview.js +90 -0
  37. package/dist/articles/preview.js.map +1 -0
  38. package/dist/articles/publish.d.ts +10 -0
  39. package/dist/articles/publish.d.ts.map +1 -0
  40. package/dist/articles/publish.js +54 -0
  41. package/dist/articles/publish.js.map +1 -0
  42. package/dist/articles/receipt.d.ts +16 -0
  43. package/dist/articles/receipt.d.ts.map +1 -0
  44. package/dist/articles/receipt.js +27 -0
  45. package/dist/articles/receipt.js.map +1 -0
  46. package/dist/articles/store.d.ts +78 -0
  47. package/dist/articles/store.d.ts.map +1 -0
  48. package/dist/articles/store.js +308 -0
  49. package/dist/articles/store.js.map +1 -0
  50. package/dist/articles/tool.d.ts +14 -0
  51. package/dist/articles/tool.d.ts.map +1 -0
  52. package/dist/articles/tool.js +175 -0
  53. package/dist/articles/tool.js.map +1 -0
  54. package/dist/articles/writer-context.d.ts +5 -0
  55. package/dist/articles/writer-context.d.ts.map +1 -0
  56. package/dist/articles/writer-context.js +56 -0
  57. package/dist/articles/writer-context.js.map +1 -0
  58. package/dist/articles/writer.d.ts +18 -0
  59. package/dist/articles/writer.d.ts.map +1 -0
  60. package/dist/articles/writer.js +79 -0
  61. package/dist/articles/writer.js.map +1 -0
  62. package/dist/articles/zip-write.d.ts +11 -0
  63. package/dist/articles/zip-write.d.ts.map +1 -0
  64. package/dist/articles/zip-write.js +63 -0
  65. package/dist/articles/zip-write.js.map +1 -0
  66. package/dist/bundled-skills/README.md +17 -0
  67. package/dist/bundled-skills/aegis-search/SKILL.md +59 -0
  68. package/dist/bundled-skills/aegis-search/bin/.gitkeep +0 -0
  69. package/dist/bundled-skills/article-canvas/SKILL.md +28 -0
  70. package/dist/bundled-skills/binaries.json +23 -0
  71. package/dist/bundled-skills/doc-extract/SKILL.md +56 -0
  72. package/dist/cli/main.d.ts.map +1 -1
  73. package/dist/cli/main.js +407 -54
  74. package/dist/cli/main.js.map +1 -1
  75. package/dist/config/config.d.ts +43 -0
  76. package/dist/config/config.d.ts.map +1 -1
  77. package/dist/config/config.js +150 -6
  78. package/dist/config/config.js.map +1 -1
  79. package/dist/daemon/daemon.d.ts.map +1 -1
  80. package/dist/daemon/daemon.js +80 -11
  81. package/dist/daemon/daemon.js.map +1 -1
  82. package/dist/desktop/application.d.ts +4 -0
  83. package/dist/desktop/application.d.ts.map +1 -0
  84. package/dist/desktop/application.js +1688 -0
  85. package/dist/desktop/application.js.map +1 -0
  86. package/dist/desktop/article-export.d.ts +12 -0
  87. package/dist/desktop/article-export.d.ts.map +1 -0
  88. package/dist/desktop/article-export.js +87 -0
  89. package/dist/desktop/article-export.js.map +1 -0
  90. package/dist/desktop/assets/pspore.png +0 -0
  91. package/dist/desktop/assets/pspore.svg +24 -0
  92. package/dist/desktop/conversation-smoke.d.ts +5 -0
  93. package/dist/desktop/conversation-smoke.d.ts.map +1 -0
  94. package/dist/desktop/conversation-smoke.js +93 -0
  95. package/dist/desktop/conversation-smoke.js.map +1 -0
  96. package/dist/desktop/daemon-bridge.d.ts.map +1 -1
  97. package/dist/desktop/daemon-bridge.js +62 -17
  98. package/dist/desktop/daemon-bridge.js.map +1 -1
  99. package/dist/desktop/ipc-contract.d.ts +124 -0
  100. package/dist/desktop/ipc-contract.d.ts.map +1 -1
  101. package/dist/desktop/ipc-contract.js +191 -12
  102. package/dist/desktop/ipc-contract.js.map +1 -1
  103. package/dist/desktop/local-path.d.ts +15 -0
  104. package/dist/desktop/local-path.d.ts.map +1 -0
  105. package/dist/desktop/local-path.js +59 -0
  106. package/dist/desktop/local-path.js.map +1 -0
  107. package/dist/desktop/main.d.ts +10 -1
  108. package/dist/desktop/main.d.ts.map +1 -1
  109. package/dist/desktop/main.js +122 -530
  110. package/dist/desktop/main.js.map +1 -1
  111. package/dist/desktop/native-startup.d.ts +4 -0
  112. package/dist/desktop/native-startup.d.ts.map +1 -0
  113. package/dist/desktop/native-startup.js +57 -0
  114. package/dist/desktop/native-startup.js.map +1 -0
  115. package/dist/desktop/preload.cjs +28 -1
  116. package/dist/desktop/renderer/article-panel.d.ts +45 -0
  117. package/dist/desktop/renderer/article-panel.d.ts.map +1 -0
  118. package/dist/desktop/renderer/article-panel.js +1126 -0
  119. package/dist/desktop/renderer/article-panel.js.map +1 -0
  120. package/dist/desktop/renderer/article-split.d.ts +32 -0
  121. package/dist/desktop/renderer/article-split.d.ts.map +1 -0
  122. package/dist/desktop/renderer/article-split.js +48 -0
  123. package/dist/desktop/renderer/article-split.js.map +1 -0
  124. package/dist/desktop/renderer/composer-file-references.d.ts +23 -0
  125. package/dist/desktop/renderer/composer-file-references.d.ts.map +1 -0
  126. package/dist/desktop/renderer/composer-file-references.js +51 -0
  127. package/dist/desktop/renderer/composer-file-references.js.map +1 -0
  128. package/dist/desktop/renderer/conversation.d.ts +23 -0
  129. package/dist/desktop/renderer/conversation.d.ts.map +1 -0
  130. package/dist/desktop/renderer/conversation.js +66 -0
  131. package/dist/desktop/renderer/conversation.js.map +1 -0
  132. package/dist/desktop/renderer/dom-reconcile.d.ts +15 -0
  133. package/dist/desktop/renderer/dom-reconcile.d.ts.map +1 -0
  134. package/dist/desktop/renderer/dom-reconcile.js +33 -0
  135. package/dist/desktop/renderer/dom-reconcile.js.map +1 -0
  136. package/dist/desktop/renderer/index.html +211 -34
  137. package/dist/desktop/renderer/indexing-view.d.ts +22 -0
  138. package/dist/desktop/renderer/indexing-view.d.ts.map +1 -0
  139. package/dist/desktop/renderer/indexing-view.js +115 -0
  140. package/dist/desktop/renderer/indexing-view.js.map +1 -0
  141. package/dist/desktop/renderer/markdown-view.d.ts +23 -0
  142. package/dist/desktop/renderer/markdown-view.d.ts.map +1 -0
  143. package/dist/desktop/renderer/markdown-view.js +436 -0
  144. package/dist/desktop/renderer/markdown-view.js.map +1 -0
  145. package/dist/desktop/renderer/motion.d.ts +67 -0
  146. package/dist/desktop/renderer/motion.d.ts.map +1 -0
  147. package/dist/desktop/renderer/motion.js +296 -0
  148. package/dist/desktop/renderer/motion.js.map +1 -0
  149. package/dist/desktop/renderer/renderer.js +1788 -151
  150. package/dist/desktop/renderer/renderer.js.map +1 -1
  151. package/dist/desktop/renderer/session-find.d.ts +18 -0
  152. package/dist/desktop/renderer/session-find.d.ts.map +1 -0
  153. package/dist/desktop/renderer/session-find.js +29 -0
  154. package/dist/desktop/renderer/session-find.js.map +1 -0
  155. package/dist/desktop/renderer/session-search.d.ts +30 -0
  156. package/dist/desktop/renderer/session-search.d.ts.map +1 -0
  157. package/dist/desktop/renderer/session-search.js +296 -0
  158. package/dist/desktop/renderer/session-search.js.map +1 -0
  159. package/dist/desktop/renderer/store.d.ts +33 -0
  160. package/dist/desktop/renderer/store.d.ts.map +1 -1
  161. package/dist/desktop/renderer/store.js +92 -19
  162. package/dist/desktop/renderer/store.js.map +1 -1
  163. package/dist/desktop/renderer/styles.css +910 -268
  164. package/dist/desktop/renderer/vendor/gsap.min.js +11 -0
  165. package/dist/desktop/renderer-diagnostics.d.ts +5 -0
  166. package/dist/desktop/renderer-diagnostics.d.ts.map +1 -0
  167. package/dist/desktop/renderer-diagnostics.js +47 -0
  168. package/dist/desktop/renderer-diagnostics.js.map +1 -0
  169. package/dist/desktop/security.d.ts +26 -1
  170. package/dist/desktop/security.d.ts.map +1 -1
  171. package/dist/desktop/security.js +26 -6
  172. package/dist/desktop/security.js.map +1 -1
  173. package/dist/desktop/smoke-fixture.d.ts.map +1 -1
  174. package/dist/desktop/smoke-fixture.js +77 -3
  175. package/dist/desktop/smoke-fixture.js.map +1 -1
  176. package/dist/desktop/splash/index.html +25 -0
  177. package/dist/desktop/splash/splash.d.ts +2 -0
  178. package/dist/desktop/splash/splash.d.ts.map +1 -0
  179. package/dist/desktop/splash/splash.js +8 -0
  180. package/dist/desktop/splash/splash.js.map +1 -0
  181. package/dist/desktop/splash/styles.css +24 -0
  182. package/dist/desktop/system-open.d.ts +16 -0
  183. package/dist/desktop/system-open.d.ts.map +1 -0
  184. package/dist/desktop/system-open.js +90 -0
  185. package/dist/desktop/system-open.js.map +1 -0
  186. package/dist/desktop/validation.d.ts.map +1 -1
  187. package/dist/desktop/validation.js +99 -21
  188. package/dist/desktop/validation.js.map +1 -1
  189. package/dist/desktop/window-security.d.ts +4 -0
  190. package/dist/desktop/window-security.d.ts.map +1 -0
  191. package/dist/desktop/window-security.js +26 -0
  192. package/dist/desktop/window-security.js.map +1 -0
  193. package/dist/documents/errors.d.ts +12 -0
  194. package/dist/documents/errors.d.ts.map +1 -0
  195. package/dist/documents/errors.js +12 -0
  196. package/dist/documents/errors.js.map +1 -0
  197. package/dist/documents/extract.d.ts +37 -0
  198. package/dist/documents/extract.d.ts.map +1 -0
  199. package/dist/documents/extract.js +90 -0
  200. package/dist/documents/extract.js.map +1 -0
  201. package/dist/documents/legacy-office.d.ts +23 -0
  202. package/dist/documents/legacy-office.d.ts.map +1 -0
  203. package/dist/documents/legacy-office.js +439 -0
  204. package/dist/documents/legacy-office.js.map +1 -0
  205. package/dist/documents/ole.d.ts +20 -0
  206. package/dist/documents/ole.d.ts.map +1 -0
  207. package/dist/documents/ole.js +189 -0
  208. package/dist/documents/ole.js.map +1 -0
  209. package/dist/documents/ooxml.d.ts +23 -0
  210. package/dist/documents/ooxml.d.ts.map +1 -0
  211. package/dist/documents/ooxml.js +197 -0
  212. package/dist/documents/ooxml.js.map +1 -0
  213. package/dist/documents/pdf.d.ts +20 -0
  214. package/dist/documents/pdf.d.ts.map +1 -0
  215. package/dist/documents/pdf.js +95 -0
  216. package/dist/documents/pdf.js.map +1 -0
  217. package/dist/documents/plain-text.d.ts +22 -0
  218. package/dist/documents/plain-text.d.ts.map +1 -0
  219. package/dist/documents/plain-text.js +57 -0
  220. package/dist/documents/plain-text.js.map +1 -0
  221. package/dist/documents/zip.d.ts +15 -0
  222. package/dist/documents/zip.d.ts.map +1 -0
  223. package/dist/documents/zip.js +137 -0
  224. package/dist/documents/zip.js.map +1 -0
  225. package/dist/domain/indexing.d.ts +60 -0
  226. package/dist/domain/indexing.d.ts.map +1 -0
  227. package/dist/domain/indexing.js +13 -0
  228. package/dist/domain/indexing.js.map +1 -0
  229. package/dist/domain/runtime.d.ts +133 -5
  230. package/dist/domain/runtime.d.ts.map +1 -1
  231. package/dist/gateway/local-gateway.d.ts +8 -0
  232. package/dist/gateway/local-gateway.d.ts.map +1 -1
  233. package/dist/gateway/local-gateway.js +105 -21
  234. package/dist/gateway/local-gateway.js.map +1 -1
  235. package/dist/indexing/index-service.d.ts +56 -0
  236. package/dist/indexing/index-service.d.ts.map +1 -0
  237. package/dist/indexing/index-service.js +262 -0
  238. package/dist/indexing/index-service.js.map +1 -0
  239. package/dist/kernel/loop.d.ts +10 -0
  240. package/dist/kernel/loop.d.ts.map +1 -1
  241. package/dist/kernel/loop.js +6 -2
  242. package/dist/kernel/loop.js.map +1 -1
  243. package/dist/kernel/reducer.d.ts.map +1 -1
  244. package/dist/kernel/reducer.js +17 -1
  245. package/dist/kernel/reducer.js.map +1 -1
  246. package/dist/kernel/state.d.ts +17 -4
  247. package/dist/kernel/state.d.ts.map +1 -1
  248. package/dist/kernel/state.js +2 -0
  249. package/dist/kernel/state.js.map +1 -1
  250. package/dist/pi/context-budget.d.ts +8 -0
  251. package/dist/pi/context-budget.d.ts.map +1 -0
  252. package/dist/pi/context-budget.js +62 -0
  253. package/dist/pi/context-budget.js.map +1 -0
  254. package/dist/pi/pi-runtime-adapter.d.ts +38 -3
  255. package/dist/pi/pi-runtime-adapter.d.ts.map +1 -1
  256. package/dist/pi/pi-runtime-adapter.js +551 -56
  257. package/dist/pi/pi-runtime-adapter.js.map +1 -1
  258. package/dist/plugins/bundled-skills.d.ts +56 -0
  259. package/dist/plugins/bundled-skills.d.ts.map +1 -0
  260. package/dist/plugins/bundled-skills.js +243 -0
  261. package/dist/plugins/bundled-skills.js.map +1 -0
  262. package/dist/plugins/extension-manager.d.ts +23 -1
  263. package/dist/plugins/extension-manager.d.ts.map +1 -1
  264. package/dist/plugins/extension-manager.js +117 -0
  265. package/dist/plugins/extension-manager.js.map +1 -1
  266. package/dist/plugins/types.d.ts +29 -0
  267. package/dist/plugins/types.d.ts.map +1 -1
  268. package/dist/plugins/workspace-skills.d.ts +36 -0
  269. package/dist/plugins/workspace-skills.d.ts.map +1 -0
  270. package/dist/plugins/workspace-skills.js +309 -0
  271. package/dist/plugins/workspace-skills.js.map +1 -0
  272. package/dist/policy/policy.d.ts.map +1 -1
  273. package/dist/policy/policy.js +22 -3
  274. package/dist/policy/policy.js.map +1 -1
  275. package/dist/protocol/client.d.ts +1 -0
  276. package/dist/protocol/client.d.ts.map +1 -1
  277. package/dist/protocol/client.js +49 -24
  278. package/dist/protocol/client.js.map +1 -1
  279. package/dist/protocol/limits.d.ts +69 -0
  280. package/dist/protocol/limits.d.ts.map +1 -0
  281. package/dist/protocol/limits.js +166 -0
  282. package/dist/protocol/limits.js.map +1 -0
  283. package/dist/protocol/schema.d.ts +6 -0
  284. package/dist/protocol/schema.d.ts.map +1 -1
  285. package/dist/protocol/schema.js.map +1 -1
  286. package/dist/protocol/server-frame-codec.d.ts +18 -0
  287. package/dist/protocol/server-frame-codec.d.ts.map +1 -0
  288. package/dist/protocol/server-frame-codec.js +211 -0
  289. package/dist/protocol/server-frame-codec.js.map +1 -0
  290. package/dist/service/legacy-daemon.d.ts +6 -0
  291. package/dist/service/legacy-daemon.d.ts.map +1 -0
  292. package/dist/service/legacy-daemon.js +42 -0
  293. package/dist/service/legacy-daemon.js.map +1 -0
  294. package/dist/service/service-launch.d.ts +10 -0
  295. package/dist/service/service-launch.d.ts.map +1 -0
  296. package/dist/service/service-launch.js +56 -0
  297. package/dist/service/service-launch.js.map +1 -0
  298. package/dist/service/system-service.d.ts +57 -0
  299. package/dist/service/system-service.d.ts.map +1 -0
  300. package/dist/service/system-service.js +263 -0
  301. package/dist/service/system-service.js.map +1 -0
  302. package/dist/sessions/session-catalog.d.ts +45 -0
  303. package/dist/sessions/session-catalog.d.ts.map +1 -0
  304. package/dist/sessions/session-catalog.js +270 -0
  305. package/dist/sessions/session-catalog.js.map +1 -0
  306. package/dist/sessions/session-file.d.ts +12 -0
  307. package/dist/sessions/session-file.d.ts.map +1 -0
  308. package/dist/sessions/session-file.js +47 -0
  309. package/dist/sessions/session-file.js.map +1 -0
  310. package/dist/settings/compaction-settings.d.ts +10 -0
  311. package/dist/settings/compaction-settings.d.ts.map +1 -0
  312. package/dist/settings/compaction-settings.js +8 -0
  313. package/dist/settings/compaction-settings.js.map +1 -0
  314. package/dist/settings/indexing-settings-service.d.ts +16 -0
  315. package/dist/settings/indexing-settings-service.d.ts.map +1 -0
  316. package/dist/settings/indexing-settings-service.js +57 -0
  317. package/dist/settings/indexing-settings-service.js.map +1 -0
  318. package/dist/settings/indexing-settings.d.ts +30 -0
  319. package/dist/settings/indexing-settings.d.ts.map +1 -0
  320. package/dist/settings/indexing-settings.js +58 -0
  321. package/dist/settings/indexing-settings.js.map +1 -0
  322. package/dist/settings/llm-settings-service.d.ts +6 -0
  323. package/dist/settings/llm-settings-service.d.ts.map +1 -1
  324. package/dist/settings/llm-settings-service.js +137 -12
  325. package/dist/settings/llm-settings-service.js.map +1 -1
  326. package/dist/settings/llm-settings.d.ts +68 -2
  327. package/dist/settings/llm-settings.d.ts.map +1 -1
  328. package/dist/settings/llm-settings.js +119 -17
  329. package/dist/settings/llm-settings.js.map +1 -1
  330. package/dist/settings/model-api.d.ts +10 -0
  331. package/dist/settings/model-api.d.ts.map +1 -0
  332. package/dist/settings/model-api.js +10 -0
  333. package/dist/settings/model-api.js.map +1 -0
  334. package/dist/storage/home.d.ts +1 -0
  335. package/dist/storage/home.d.ts.map +1 -1
  336. package/dist/storage/home.js +37 -2
  337. package/dist/storage/home.js.map +1 -1
  338. package/dist/tools/core-tools.d.ts +2 -0
  339. package/dist/tools/core-tools.d.ts.map +1 -1
  340. package/dist/tools/core-tools.js +91 -1
  341. package/dist/tools/core-tools.js.map +1 -1
  342. package/dist/ui/shared/activity.d.ts +2 -0
  343. package/dist/ui/shared/activity.d.ts.map +1 -1
  344. package/dist/ui/shared/activity.js +18 -4
  345. package/dist/ui/shared/activity.js.map +1 -1
  346. package/dist/ui/shared/full-message.d.ts +22 -0
  347. package/dist/ui/shared/full-message.d.ts.map +1 -0
  348. package/dist/ui/shared/full-message.js +40 -0
  349. package/dist/ui/shared/full-message.js.map +1 -0
  350. package/dist/ui/shared/local-links.d.ts +7 -0
  351. package/dist/ui/shared/local-links.d.ts.map +1 -0
  352. package/dist/ui/shared/local-links.js +24 -0
  353. package/dist/ui/shared/local-links.js.map +1 -0
  354. package/dist/ui/shared/markdown-stream.d.ts +25 -0
  355. package/dist/ui/shared/markdown-stream.d.ts.map +1 -0
  356. package/dist/ui/shared/markdown-stream.js +35 -0
  357. package/dist/ui/shared/markdown-stream.js.map +1 -0
  358. package/dist/ui/shared/markdown.d.ts +70 -0
  359. package/dist/ui/shared/markdown.d.ts.map +1 -0
  360. package/dist/ui/shared/markdown.js +434 -0
  361. package/dist/ui/shared/markdown.js.map +1 -0
  362. package/dist/ui/shared/presentation.d.ts +10 -2
  363. package/dist/ui/shared/presentation.d.ts.map +1 -1
  364. package/dist/ui/shared/presentation.js +83 -22
  365. package/dist/ui/shared/presentation.js.map +1 -1
  366. package/dist/ui/shared/protocol-state.d.ts +19 -0
  367. package/dist/ui/shared/protocol-state.d.ts.map +1 -1
  368. package/dist/ui/shared/protocol-state.js +188 -26
  369. package/dist/ui/shared/protocol-state.js.map +1 -1
  370. package/dist/ui/shared/virtual-list.d.ts +34 -0
  371. package/dist/ui/shared/virtual-list.d.ts.map +1 -0
  372. package/dist/ui/shared/virtual-list.js +143 -0
  373. package/dist/ui/shared/virtual-list.js.map +1 -0
  374. package/dist/ui/tui/commands.d.ts +1 -1
  375. package/dist/ui/tui/commands.d.ts.map +1 -1
  376. package/dist/ui/tui/commands.js +8 -3
  377. package/dist/ui/tui/commands.js.map +1 -1
  378. package/dist/ui/tui/store.d.ts +22 -1
  379. package/dist/ui/tui/store.d.ts.map +1 -1
  380. package/dist/ui/tui/store.js +50 -3
  381. package/dist/ui/tui/store.js.map +1 -1
  382. package/dist/ui/tui/tui-app.d.ts.map +1 -1
  383. package/dist/ui/tui/tui-app.js +231 -31
  384. package/dist/ui/tui/tui-app.js.map +1 -1
  385. package/dist/ui/tui/view.d.ts +7 -0
  386. package/dist/ui/tui/view.d.ts.map +1 -1
  387. package/dist/ui/tui/view.js +161 -57
  388. package/dist/ui/tui/view.js.map +1 -1
  389. package/dist/version.d.ts +1 -1
  390. package/dist/version.d.ts.map +1 -1
  391. package/dist/version.js +1 -1
  392. package/dist/version.js.map +1 -1
  393. package/package.json +13 -2
@@ -1,22 +1,106 @@
1
1
  /// <reference lib="DOM" />
2
+ import { publishedTextTruncation } from "../../protocol/limits.js";
2
3
  import { defaultModelFor } from "../../settings/llm-settings.js";
3
- import { clockTime, conversationIntent, formatBytes, formatTokens, presentSessions, selectedAgent as findSelectedAgent, shortId, terminalAgentStatus, } from "../../ui/shared/presentation.js";
4
- import { DesktopStore } from "./store.js";
4
+ import { clockTime, conversationIntent, formatBytes, formatTokens, kernelMetrics, listTime, presentSessions, selectedAgent as findSelectedAgent, shortId, terminalAgentStatus, } from "../../ui/shared/presentation.js";
5
+ import { readFullMessage } from "../../ui/shared/full-message.js";
6
+ import { isScrollNearEnd, VirtualHeightIndex } from "../../ui/shared/virtual-list.js";
7
+ import { DAEMON_NOT_CONNECTED_MESSAGE, } from "../ipc-contract.js";
8
+ import { composeMessageWithFileReferences, composerFileReference, composerFileReferenceName, mergeComposerFileReferences, } from "./composer-file-references.js";
9
+ import { parseArticleReceipt } from "../../articles/receipt.js";
10
+ import { ArticlePanel } from "./article-panel.js";
11
+ import { formatElapsed, presentIndexing } from "./indexing-view.js";
12
+ import { reconcileChildren } from "./dom-reconcile.js";
13
+ import { conversationRows, conversationRowText, isToolMessage, toolCallSummary } from "./conversation.js";
14
+ import { appendMarkdown, createMarkdownSurface } from "./markdown-view.js";
15
+ import { createSessionSearch } from "./session-search.js";
16
+ import { animateMessagesIn, animateTranscriptHero, crossfadeText, flipStatusChip, hideOverlayMotion, introChoreography, nudgeElement, popChips, pulseStatus, revealListItems, revealPanel, setReconnectingPulse, showOverlayMotion, toggleBannerMotion, tweenMetric, } from "./motion.js";
17
+ import { CONNECTION_SETTLE_MS, DesktopStore } from "./store.js";
5
18
  const store = new DesktopStore();
6
19
  const queuedFrames = [];
7
20
  let initialized = false;
8
21
  let versions;
9
22
  let jobs = [];
10
23
  let resources = [];
24
+ /** The fixed tools this build ships, as reported by the daemon. */
25
+ let builtinTools = [];
26
+ /** Every skill new sessions are offered, and the ones found but not loaded. */
27
+ let skillCatalog = [];
28
+ const SKILL_SOURCE_LABELS = {
29
+ installed: "已安装",
30
+ bundled: "内置",
31
+ workspace: "本地",
32
+ };
11
33
  let inspection;
12
34
  let activeApprovalId;
13
35
  let renderSequence = 0;
14
36
  let settingsOpen = false;
15
37
  let savingSettings = false;
16
38
  let responsiveMode;
39
+ let introPlayed = false;
40
+ let lastConnectionPhase = "";
41
+ let settleTimer;
42
+ let lastSessionSignature = "";
43
+ let lastSessionStatus = "";
44
+ let lastActivitySignature = "";
45
+ let lastReferenceCount = 0;
46
+ let indexingDirectories = [];
47
+ /** Session whose delete control is armed, so a stray click cannot destroy history. */
48
+ let armedSessionDeletion;
49
+ let armSessionDeletionTimer;
50
+ let renderFrame;
51
+ let transcriptRenderFrame;
52
+ let composerFileReferences = [];
53
+ /** Well-known directories, filled in by the Electron main process at bootstrap. */
54
+ let desktopPlaces;
55
+ /** How long a delete control stays armed before it reverts on its own. */
56
+ const SESSION_DELETE_ARM_MS = 5_000;
57
+ /**
58
+ * The run phase currently on screen and when it began.
59
+ *
60
+ * Elapsed time is measured here rather than taken from the daemon because what
61
+ * the reader is asking is "how long has this been showing me nothing", which is
62
+ * a question about their own screen.
63
+ */
64
+ let activityPhase;
65
+ let activityTicker;
66
+ let indexingTicker;
67
+ const transcriptState = {
68
+ sessionId: "",
69
+ end: -1,
70
+ loadingHistory: false,
71
+ heights: new VirtualHeightIndex(180),
72
+ nodes: new Map(),
73
+ measureFrame: undefined,
74
+ messageSource: undefined,
75
+ items: [],
76
+ running: false,
77
+ hasStreaming: false,
78
+ followTail: true,
79
+ pinnedIndex: undefined,
80
+ spacerStart: virtualSpacer("start"),
81
+ spacerEnd: virtualSpacer("end"),
82
+ stream: undefined,
83
+ };
17
84
  const THEME_STORAGE_KEY = "pspore.desktop.theme";
85
+ /** Distance from the top that triggers loading the page before the tail. */
86
+ const HISTORY_BACKFILL_TRIGGER_PX = 240;
87
+ const HISTORY_PAGE_SIZE = 200;
88
+ /** How many restored full messages are kept, so scrollback cannot grow forever. */
89
+ const FULL_TEXT_CACHE_ENTRIES = 4;
90
+ /**
91
+ * Full text for messages the daemon had to cut, keyed by session and message.
92
+ * Bounded, and dropped oldest-first: restoring a few very long messages must not
93
+ * turn the transcript into an unbounded buffer.
94
+ */
95
+ const fullMessageTexts = new Map();
96
+ const loadingFullText = new Set();
97
+ const processViews = new Map();
98
+ /** Kept separately from the DOM cache so scrolling does not reset disclosure. */
99
+ const disclosureStates = new Map();
18
100
  const elements = {
19
101
  appShell: byId("app-shell"),
102
+ topbar: document.querySelector(".topbar") ?? missingElement(".topbar"),
103
+ conversationPanel: document.querySelector(".conversation") ?? missingElement(".conversation"),
20
104
  navigationPanel: byId("navigation-panel"),
21
105
  navigationToggle: byId("navigation-toggle"),
22
106
  inspectorPanel: byId("inspector-panel"),
@@ -28,6 +112,7 @@ const elements = {
28
112
  daemonInstance: byId("daemon-instance"),
29
113
  revision: byId("revision-value"),
30
114
  usage: byId("usage-value"),
115
+ totalUsage: byId("total-usage-value"),
31
116
  cache: byId("cache-value"),
32
117
  themeToggle: byId("theme-toggle"),
33
118
  themeColor: byId("theme-color"),
@@ -35,7 +120,22 @@ const elements = {
35
120
  connectionBanner: byId("connection-banner"),
36
121
  connectionMessage: byId("connection-message"),
37
122
  sessionCount: byId("session-count"),
123
+ newSession: byId("new-session-button"),
38
124
  sessionList: byId("session-list"),
125
+ runActivity: byId("run-activity"),
126
+ runActivityMark: byId("run-activity-mark"),
127
+ runActivityLabel: byId("run-activity-label"),
128
+ runActivityElapsed: byId("run-activity-elapsed"),
129
+ indexingPanel: byId("indexing-panel"),
130
+ indexingPhase: byId("indexing-phase"),
131
+ indexingBarFill: byId("indexing-bar-fill"),
132
+ indexingCounts: byId("indexing-counts"),
133
+ indexingCurrent: byId("indexing-current"),
134
+ indexingNote: byId("indexing-note"),
135
+ railRuns: byId("rail-runs"),
136
+ railMcp: byId("rail-mcp"),
137
+ railResources: byId("rail-resources"),
138
+ railApprovals: byId("rail-approvals"),
39
139
  agentCompose: byId("agent-compose"),
40
140
  agentComposeToggle: byId("agent-compose-toggle"),
41
141
  agentComposeClose: byId("agent-compose-close"),
@@ -51,13 +151,22 @@ const elements = {
51
151
  versionLine: byId("version-line"),
52
152
  runtimeLine: byId("runtime-line"),
53
153
  sessionTitle: byId("session-title"),
154
+ sessionSearchButton: byId("session-search-button"),
155
+ sessionSearch: byId("session-search"),
156
+ sessionSearchInput: byId("session-search-input"),
157
+ sessionSearchCount: byId("session-search-count"),
158
+ sessionSearchPrevious: byId("session-search-prev"),
159
+ sessionSearchNext: byId("session-search-next"),
160
+ sessionSearchClose: byId("session-search-close"),
54
161
  sessionStatus: byId("session-status"),
55
162
  modelName: byId("model-name"),
56
163
  transcript: byId("transcript"),
57
164
  notice: byId("notice-line"),
165
+ messageQueue: byId("message-queue"),
58
166
  composerForm: byId("composer-form"),
59
167
  composerInput: byId("composer-input"),
60
- composerMode: byId("composer-mode"),
168
+ composerDropOverlay: byId("composer-drop-overlay"),
169
+ composerReferences: byId("composer-references"),
61
170
  compact: byId("compact-button"),
62
171
  abort: byId("abort-button"),
63
172
  send: byId("send-button"),
@@ -79,12 +188,41 @@ const elements = {
79
188
  settingsBaseUrl: byId("settings-base-url"),
80
189
  settingsApi: byId("settings-api"),
81
190
  settingsThinking: byId("settings-thinking"),
191
+ settingsPermissionMode: byId("settings-permission-mode"),
192
+ settingsPermissionHelp: byId("settings-permission-help"),
193
+ settingsPermissionWarning: byId("settings-permission-warning"),
194
+ settingsAutoCompaction: byId("settings-auto-compaction"),
195
+ settingsCompactionTrigger: byId("settings-compaction-trigger"),
196
+ settingsCompactionReserve: byId("settings-compaction-reserve"),
197
+ settingsCompactionKeepRecent: byId("settings-compaction-keep-recent"),
82
198
  settingsApiKey: byId("settings-api-key"),
83
199
  settingsKeyReveal: byId("settings-key-reveal"),
84
200
  settingsClearKey: byId("settings-clear-key"),
201
+ settingsCookie: byId("settings-cookie"),
202
+ settingsCookieReveal: byId("settings-cookie-reveal"),
203
+ settingsClearCookie: byId("settings-clear-cookie"),
204
+ settingsConfigPath: byId("settings-config-path"),
205
+ settingsModelsPath: byId("settings-models-path"),
206
+ settingsApiKeyPath: byId("settings-api-key-path"),
207
+ settingsApiKeyState: byId("settings-api-key-state"),
208
+ settingsKeyCopyPath: byId("settings-key-copy-path"),
209
+ settingsCookiePath: byId("settings-cookie-path"),
210
+ attachFile: byId("attach-file-button"),
211
+ attachDirectory: byId("attach-directory-button"),
212
+ settingsHeaderList: byId("settings-header-list"),
213
+ settingsHeaderAdd: byId("settings-header-add"),
214
+ settingsCookieState: byId("settings-cookie-state"),
215
+ settingsCookieCopyPath: byId("settings-cookie-copy-path"),
216
+ settingsIndexingEnabled: byId("settings-indexing-enabled"),
217
+ settingsIndexingInterval: byId("settings-indexing-interval"),
218
+ settingsIndexingExt: byId("settings-indexing-ext"),
219
+ settingsIndexingAdd: byId("settings-indexing-add"),
220
+ settingsIndexingList: byId("settings-indexing-list"),
221
+ settingsIndexingNote: byId("settings-indexing-note"),
85
222
  settingsStatus: byId("settings-status"),
86
223
  settingsSave: byId("settings-save"),
87
224
  approvalOverlay: byId("approval-overlay"),
225
+ approvalDialog: document.querySelector(".approval-dialog") ?? missingElement(".approval-dialog"),
88
226
  approvalTitle: byId("approval-title"),
89
227
  approvalSummary: byId("approval-summary"),
90
228
  approvalCapability: byId("approval-capability"),
@@ -93,14 +231,53 @@ const elements = {
93
231
  approvalCommand: byId("approval-command"),
94
232
  approvalActions: byId("approval-actions"),
95
233
  };
234
+ const sessionSearch = createSessionSearch({
235
+ bar: elements.sessionSearch,
236
+ input: elements.sessionSearchInput,
237
+ count: elements.sessionSearchCount,
238
+ button: elements.sessionSearchButton,
239
+ previous: elements.sessionSearchPrevious,
240
+ next: elements.sessionSearchNext,
241
+ close: elements.sessionSearchClose,
242
+ transcript: elements.transcript,
243
+ requestRender: () => scheduleTranscriptRender(),
244
+ getTexts: () => currentSessionTexts(store.snapshot()),
245
+ getSessionId: () => store.snapshot().selectedSessionId,
246
+ getOffset: (index) => transcriptState.heights.offsetAt(index),
247
+ setFollowTail: (value) => {
248
+ transcriptState.followTail = value;
249
+ },
250
+ setPinnedIndex: (index) => {
251
+ transcriptState.pinnedIndex = index;
252
+ },
253
+ forEachMessage: (visit) => {
254
+ for (const node of transcriptState.nodes.values())
255
+ visit(node);
256
+ if (transcriptState.stream)
257
+ visit(transcriptState.stream.node);
258
+ },
259
+ isModalOpen: () => !elements.settingsOverlay.hidden || !elements.approvalOverlay.hidden,
260
+ });
96
261
  applyTheme(initialTheme());
97
262
  syncResponsivePanels(true);
263
+ const articles = new ArticlePanel({
264
+ sessionId: () => store.snapshot().selectedSessionId,
265
+ dispatch: (command) => window.pspore.dispatch(command),
266
+ exportArticle: (request) => window.pspore.exportArticle(request),
267
+ onLayout: () => scheduleResizeReflow(),
268
+ });
98
269
  const unsubscribe = window.pspore.subscribe((frame) => {
270
+ if (frame.kind === "event")
271
+ articles.onEvent(frame.event);
99
272
  if (!initialized)
100
273
  queuedFrames.push(frame);
101
274
  else {
102
275
  store.applyFrame(frame);
103
- render();
276
+ // The selection can change without anyone clicking: a restored session
277
+ // arrives with the first snapshot, and a reconnect replaces it. The panel
278
+ // no-ops when nothing changed.
279
+ articles.onSessionChanged();
280
+ scheduleRender();
104
281
  }
105
282
  });
106
283
  window.addEventListener("beforeunload", unsubscribe, { once: true });
@@ -110,27 +287,42 @@ async function initialize() {
110
287
  try {
111
288
  const bootstrap = await window.pspore.bootstrap();
112
289
  versions = bootstrap.versions;
290
+ desktopPlaces = bootstrap.places;
113
291
  store.applyBootstrap(bootstrap);
292
+ articles.onSessionChanged();
114
293
  initialized = true;
115
294
  for (const frame of queuedFrames.splice(0))
116
295
  store.applyFrame(frame);
296
+ articles.onSessionChanged();
117
297
  render();
118
298
  await refreshSideData();
119
299
  }
120
300
  catch (error) {
121
301
  initialized = true;
122
- store.setNotice(`Desktop initialization failed: ${toError(error).message}`);
302
+ store.setNotice(`桌面初始化失败:${toError(error).message}`);
123
303
  render();
124
304
  }
125
305
  finally {
126
306
  document.documentElement.dataset["ready"] = "true";
307
+ playIntroOnce();
127
308
  }
128
309
  }
310
+ function playIntroOnce() {
311
+ if (introPlayed)
312
+ return;
313
+ introPlayed = true;
314
+ introChoreography(elements.topbar, [elements.navigationPanel, elements.conversationPanel, elements.inspectorPanel]);
315
+ }
129
316
  function bindStaticActions() {
130
317
  elements.navigationToggle.addEventListener("click", () => {
131
318
  setPanelState("navigation", document.documentElement.dataset["navigation"] !== "open");
132
319
  });
133
320
  elements.inspectorToggle.addEventListener("click", () => {
321
+ if (articles.isOpen()) {
322
+ articles.close();
323
+ setPanelState("inspector", true);
324
+ return;
325
+ }
134
326
  setPanelState("inspector", document.documentElement.dataset["inspector"] !== "open");
135
327
  });
136
328
  elements.inspectorClose.addEventListener("click", () => setPanelState("inspector", false));
@@ -140,10 +332,15 @@ function bindStaticActions() {
140
332
  elements.agentComposeToggle.addEventListener("click", () => setAgentCompose(elements.agentCompose.hidden));
141
333
  elements.agentComposeClose.addEventListener("click", () => setAgentCompose(false));
142
334
  elements.resourceShortcut.addEventListener("click", () => {
335
+ if (articles.isOpen())
336
+ articles.close();
143
337
  selectInspectorTab("resources");
144
338
  setPanelState("inspector", true);
145
339
  });
146
- window.addEventListener("resize", () => syncResponsivePanels(false));
340
+ window.addEventListener("resize", () => {
341
+ syncResponsivePanels(false);
342
+ scheduleResizeReflow();
343
+ });
147
344
  elements.themeToggle.addEventListener("click", () => {
148
345
  const theme = document.documentElement.dataset["theme"] === "light" ? "dark" : "light";
149
346
  applyTheme(theme);
@@ -155,6 +352,15 @@ function bindStaticActions() {
155
352
  }
156
353
  });
157
354
  elements.refresh.addEventListener("click", () => void refreshSideData());
355
+ elements.newSession.addEventListener("click", () => {
356
+ void perform("新建会话", { action: "session.create" }, (result) => {
357
+ if (!isSessionView(result))
358
+ throw new Error("服务返回了无效的会话");
359
+ store.selectSession(result.sessionId);
360
+ articles.onSessionChanged();
361
+ elements.composerInput.focus();
362
+ });
363
+ });
158
364
  elements.settingsButton.addEventListener("click", () => void openSettings());
159
365
  elements.settingsClose.addEventListener("click", () => closeSettings());
160
366
  elements.settingsCancel.addEventListener("click", () => closeSettings());
@@ -163,19 +369,15 @@ function bindStaticActions() {
163
369
  void saveSettings();
164
370
  });
165
371
  elements.settingsProvider.addEventListener("change", () => configureProviderFields(true));
166
- elements.settingsKeyReveal.addEventListener("click", () => {
167
- const revealing = elements.settingsApiKey.type === "password";
168
- elements.settingsApiKey.type = revealing ? "text" : "password";
169
- elements.settingsKeyReveal.textContent = revealing ? "隐藏" : "显示";
170
- });
171
- elements.settingsApiKey.addEventListener("input", () => {
172
- if (elements.settingsApiKey.value)
173
- elements.settingsClearKey.checked = false;
174
- });
175
- elements.settingsClearKey.addEventListener("change", () => {
176
- if (elements.settingsClearKey.checked)
177
- elements.settingsApiKey.value = "";
178
- });
372
+ elements.settingsPermissionMode.addEventListener("change", () => configurePermissionFields());
373
+ bindSecretField(elements.settingsApiKey, elements.settingsKeyReveal, elements.settingsClearKey);
374
+ bindSecretField(elements.settingsCookie, elements.settingsCookieReveal, elements.settingsClearCookie);
375
+ bindPathCopy(elements.settingsKeyCopyPath, elements.settingsApiKeyPath);
376
+ elements.settingsIndexingAdd.addEventListener("click", () => void addIndexingDirectory());
377
+ bindPathCopy(elements.settingsCookieCopyPath, elements.settingsCookiePath);
378
+ elements.settingsHeaderAdd.addEventListener("click", () => addCustomHeaderRow("", false));
379
+ elements.attachFile.addEventListener("click", () => void chooseAttachments("file"));
380
+ elements.attachDirectory.addEventListener("click", () => void chooseAttachments("directory"));
179
381
  elements.settingsOverlay.addEventListener("keydown", (event) => {
180
382
  if (event.key === "Escape" && !savingSettings) {
181
383
  event.preventDefault();
@@ -189,17 +391,24 @@ function bindStaticActions() {
189
391
  void submitComposer();
190
392
  });
191
393
  elements.composerInput.addEventListener("keydown", (event) => {
192
- if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
193
- event.preventDefault();
194
- elements.composerForm.requestSubmit();
195
- }
394
+ if (event.key !== "Enter" || event.isComposing)
395
+ return;
396
+ // Enter sends; Ctrl/Cmd+Enter, Shift+Enter and Alt+Enter insert a newline.
397
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey)
398
+ return;
399
+ event.preventDefault();
400
+ elements.composerForm.requestSubmit();
196
401
  });
402
+ bindComposerFileDrop();
403
+ elements.transcript.addEventListener("scroll", handleTranscriptScroll, { passive: true });
197
404
  elements.spawnForm.addEventListener("submit", (event) => {
198
405
  event.preventDefault();
199
406
  const objective = elements.spawnObjective.value.trim();
200
407
  if (!objective)
201
408
  return;
202
- void perform("Starting child agent", { action: "agent.spawn", objective }, () => {
409
+ const snapshot = store.snapshot();
410
+ const parentId = selectedAgent(snapshot)?.rootId ?? snapshot.selectedSessionId;
411
+ void perform("启动子 Agent", { action: "agent.spawn", objective, parentId }, () => {
203
412
  elements.spawnObjective.value = "";
204
413
  setAgentCompose(false);
205
414
  return refreshSideData();
@@ -208,26 +417,31 @@ function bindStaticActions() {
208
417
  elements.abort.addEventListener("click", () => {
209
418
  const snapshot = store.snapshot();
210
419
  const child = selectedAgent(snapshot);
211
- const command = child ? { action: "agent.cancel", agentId: child.id } : { action: "abort" };
212
- void perform(child ? `Cancelling ${child.id}` : "Aborting main session", command);
420
+ const command = child
421
+ ? { action: "agent.cancel", agentId: child.id }
422
+ : { action: "abort", sessionId: snapshot.selectedSessionId };
423
+ void perform(child ? `取消 ${child.id}` : "中止主会话", command);
424
+ });
425
+ elements.compact.addEventListener("click", () => {
426
+ const sessionId = store.snapshot().selectedSessionId;
427
+ void perform("压缩会话上下文", { action: "compact", sessionId });
213
428
  });
214
- elements.compact.addEventListener("click", () => void perform("Compacting main context", { action: "compact" }));
215
429
  elements.joinAgent.addEventListener("click", () => {
216
430
  const child = selectedAgent(store.snapshot());
217
431
  if (child)
218
- void perform(`Waiting for ${child.id}`, { action: "agent.join", agentIds: [child.id] });
432
+ void perform(`等待 ${child.id} 完成`, { action: "agent.join", agentIds: [child.id] });
219
433
  });
220
434
  elements.cancelAgent.addEventListener("click", () => {
221
435
  const child = selectedAgent(store.snapshot());
222
436
  if (child)
223
- void perform(`Cancelling ${child.id}`, { action: "agent.cancel", agentId: child.id });
437
+ void perform(`取消 ${child.id}`, { action: "agent.cancel", agentId: child.id });
224
438
  });
225
439
  elements.inspectForm.addEventListener("submit", (event) => {
226
440
  event.preventDefault();
227
441
  const stagingName = elements.stagingName.value.trim();
228
442
  if (!stagingName)
229
443
  return;
230
- void perform("Inspecting staged resource", { action: "resources.inspect", stagingName }, (result) => {
444
+ void perform("检查暂存资源", { action: "resources.inspect", stagingName }, (result) => {
231
445
  inspection = isInspection(result) ? result : undefined;
232
446
  renderInspection();
233
447
  });
@@ -240,13 +454,191 @@ function bindStaticActions() {
240
454
  trapDialogFocus(event, elements.approvalOverlay);
241
455
  });
242
456
  }
457
+ function bindComposerFileDrop() {
458
+ elements.composerForm.addEventListener("dragenter", (event) => {
459
+ if (!hasDraggedFiles(event.dataTransfer))
460
+ return;
461
+ event.preventDefault();
462
+ setComposerDropActive(true);
463
+ });
464
+ elements.composerForm.addEventListener("dragover", (event) => {
465
+ if (!hasDraggedFiles(event.dataTransfer))
466
+ return;
467
+ event.preventDefault();
468
+ if (event.dataTransfer)
469
+ event.dataTransfer.dropEffect = "copy";
470
+ setComposerDropActive(true);
471
+ });
472
+ elements.composerForm.addEventListener("dragleave", (event) => {
473
+ const nextTarget = event.relatedTarget;
474
+ if (nextTarget instanceof Node && elements.composerForm.contains(nextTarget))
475
+ return;
476
+ setComposerDropActive(false);
477
+ });
478
+ elements.composerForm.addEventListener("drop", (event) => {
479
+ if (!hasDraggedFiles(event.dataTransfer))
480
+ return;
481
+ event.preventDefault();
482
+ event.stopPropagation();
483
+ setComposerDropActive(false);
484
+ addComposerFileReferences(droppedComposerFileReferences(event.dataTransfer));
485
+ });
486
+ window.addEventListener("dragend", () => setComposerDropActive(false));
487
+ }
488
+ /**
489
+ * The same references a drop produces, chosen from a native dialog instead.
490
+ * Dragging is not always possible — a folder the user reaches through a file
491
+ * manager's tree, a window that cannot be arranged side by side — and the
492
+ * renderer has no path of its own to offer.
493
+ *
494
+ * Files and directories are separate buttons because they are separate dialogs:
495
+ * the GTK chooser answers a request for both with directories only.
496
+ */
497
+ async function chooseAttachments(kind) {
498
+ let chosen;
499
+ try {
500
+ chosen = await window.pspore.pickAttachments(kind);
501
+ }
502
+ catch (error) {
503
+ store.setNotice(`无法打开选择器:${toError(error).message}`);
504
+ render();
505
+ return;
506
+ }
507
+ if (chosen.length === 0)
508
+ return;
509
+ addComposerFileReferences(chosen.flatMap((entry) => {
510
+ const reference = composerFileReference(entry.path, entry.kind);
511
+ return reference ? [reference] : [];
512
+ }));
513
+ }
514
+ /** Merges references into the composer and says what happened, for drops and the chooser alike. */
515
+ function addComposerFileReferences(incoming) {
516
+ const merged = mergeComposerFileReferences(composerFileReferences, incoming);
517
+ composerFileReferences = merged.references;
518
+ renderComposerFileReferences();
519
+ if (merged.added > 0) {
520
+ const ignored = merged.skipped > 0 ? `;另有 ${merged.skipped} 个重复引用或超出上限` : "";
521
+ store.setNotice(`已添加 ${merged.added} 个文件/目录引用,仅会发送路径${ignored}`);
522
+ }
523
+ else {
524
+ store.setNotice(incoming.length > 0 ? "这些路径已经引用,未重复添加" : "未能获取本地文件或目录路径");
525
+ }
526
+ render();
527
+ elements.composerInput.focus();
528
+ }
529
+ function hasDraggedFiles(transfer) {
530
+ if (!transfer)
531
+ return false;
532
+ return Array.from(transfer.types).includes("Files") || Array.from(transfer.items).some((item) => item.kind === "file");
533
+ }
534
+ function setComposerDropActive(active) {
535
+ elements.composerForm.dataset["dropActive"] = String(active);
536
+ elements.composerDropOverlay.hidden = !active;
537
+ }
538
+ function droppedComposerFileReferences(transfer) {
539
+ if (!transfer)
540
+ return [];
541
+ const references = new Map();
542
+ const add = (file, kind) => {
543
+ if (!file)
544
+ return;
545
+ try {
546
+ const reference = composerFileReference(window.pspore.getPathForFile(file), kind);
547
+ if (reference && !references.has(reference.path))
548
+ references.set(reference.path, reference);
549
+ }
550
+ catch {
551
+ // Browser-created Files have no operating-system path and are not valid references.
552
+ }
553
+ };
554
+ for (const item of Array.from(transfer.items)) {
555
+ if (item.kind !== "file")
556
+ continue;
557
+ let kind = "file";
558
+ try {
559
+ if (item.webkitGetAsEntry()?.isDirectory)
560
+ kind = "directory";
561
+ }
562
+ catch {
563
+ // The path remains usable even when Chromium cannot expose entry metadata.
564
+ }
565
+ add(item.getAsFile(), kind);
566
+ }
567
+ for (const file of Array.from(transfer.files))
568
+ add(file, "file");
569
+ return [...references.values()];
570
+ }
571
+ function renderComposerFileReferences() {
572
+ const nodes = composerFileReferences.map((reference) => {
573
+ const item = element("div", "composer-reference");
574
+ item.setAttribute("role", "listitem");
575
+ item.setAttribute("aria-label", `${reference.kind === "directory" ? "目录" : "文件"} ${reference.path}`);
576
+ item.dataset["kind"] = reference.kind;
577
+ const kind = element("span", "composer-reference-kind");
578
+ kind.textContent = reference.kind === "directory" ? "目录" : "文件";
579
+ const path = element("span", "composer-reference-path");
580
+ path.textContent = composerFileReferenceName(reference.path);
581
+ path.title = reference.path;
582
+ const remove = element("button", "composer-reference-remove");
583
+ remove.type = "button";
584
+ remove.textContent = "×";
585
+ remove.title = "移除引用";
586
+ remove.setAttribute("aria-label", `移除引用 ${composerFileReferenceName(reference.path)}`);
587
+ remove.addEventListener("click", () => {
588
+ composerFileReferences = composerFileReferences.filter((candidate) => candidate.path !== reference.path);
589
+ renderComposerFileReferences();
590
+ elements.composerInput.focus();
591
+ });
592
+ item.append(kind, path, remove);
593
+ return item;
594
+ });
595
+ const added = nodes.length > lastReferenceCount;
596
+ lastReferenceCount = nodes.length;
597
+ elements.composerReferences.replaceChildren(...nodes);
598
+ elements.composerReferences.hidden = nodes.length === 0;
599
+ if (added)
600
+ popChips(nodes);
601
+ }
602
+ /**
603
+ * Re-lays the transcript out after the window changed size.
604
+ *
605
+ * The transcript is a variable-height virtual list, and every height in its index
606
+ * was measured at the previous width. A resize invalidates all of them at once:
607
+ * messages rewrap, so the spacers keep reserving space for a layout that no
608
+ * longer exists and the conversation stops tracking the window. Nothing else
609
+ * re-rendered on resize either — `syncResponsivePanels` returns early while the
610
+ * breakpoint is unchanged, so dragging a window within one band redrew nothing.
611
+ *
612
+ * Debounced because a drag emits a resize per frame, and re-measuring the whole
613
+ * transcript on each one would fight the drag.
614
+ */
615
+ let resizeReflowTimer;
616
+ function scheduleResizeReflow() {
617
+ if (resizeReflowTimer !== undefined)
618
+ window.clearTimeout(resizeReflowTimer);
619
+ resizeReflowTimer = window.setTimeout(() => {
620
+ resizeReflowTimer = undefined;
621
+ // Dropping the index rather than re-measuring what is on screen: the items
622
+ // outside the viewport rewrapped too, and their stale heights are what put
623
+ // the scroll extent wrong. `messageSource` is cleared with it because the
624
+ // render only re-syncs the index when the messages themselves changed, and a
625
+ // fresh index that is never synced holds no items at all.
626
+ transcriptState.heights = new VirtualHeightIndex(180);
627
+ transcriptState.messageSource = undefined;
628
+ render();
629
+ }, RESIZE_REFLOW_MS);
630
+ }
631
+ /** Long enough that a drag settles first, short enough to feel immediate. */
632
+ const RESIZE_REFLOW_MS = 120;
243
633
  function syncResponsivePanels(force) {
244
- const nextMode = window.innerWidth <= 1_160 ? "narrow" : window.innerWidth <= 1_320 ? "compact" : "wide";
634
+ // Only a full desktop viewport has room for all three columns; narrower windows keep the conversation dominant.
635
+ const width = window.innerWidth;
636
+ const nextMode = width <= 1_160 ? "narrow" : width <= 1_320 ? "compact" : width < 1_600 ? "wide" : "expansive";
245
637
  if (!force && nextMode === responsiveMode)
246
638
  return;
247
639
  responsiveMode = nextMode;
248
640
  setPanelState("navigation", nextMode !== "narrow");
249
- setPanelState("inspector", false);
641
+ setPanelState("inspector", nextMode === "expansive");
250
642
  }
251
643
  function setPanelState(panel, open) {
252
644
  document.documentElement.dataset[panel] = open ? "open" : "closed";
@@ -265,8 +657,13 @@ function selectInspectorTab(selected) {
265
657
  tab.setAttribute("aria-selected", String(active));
266
658
  tab.tabIndex = active ? 0 : -1;
267
659
  }
268
- for (const view of elements.inspectorViews)
269
- view.hidden = view.dataset["inspectorPanel"] !== selected;
660
+ for (const view of elements.inspectorViews) {
661
+ const active = view.dataset["inspectorPanel"] === selected;
662
+ const becameVisible = active && view.hidden;
663
+ view.hidden = !active;
664
+ if (becameVisible)
665
+ revealPanel(view);
666
+ }
270
667
  }
271
668
  function setAgentCompose(open) {
272
669
  elements.agentCompose.hidden = !open;
@@ -279,34 +676,179 @@ async function openSettings() {
279
676
  return;
280
677
  settingsOpen = true;
281
678
  elements.settingsOverlay.hidden = false;
679
+ showOverlayMotion(elements.settingsOverlay, elements.settingsDialog);
282
680
  elements.settingsStatus.textContent = "读取设置中…";
283
681
  elements.settingsSave.disabled = true;
284
682
  updateAppInert();
285
683
  requestAnimationFrame(() => elements.settingsClose.focus());
286
684
  try {
287
- const settings = await window.pspore.getSettings();
685
+ const [settings, indexing] = await Promise.all([
686
+ window.pspore.getSettings(),
687
+ window.pspore.getIndexing(),
688
+ ]);
288
689
  populateSettings(settings);
289
- elements.settingsStatus.textContent = settings.hasApiKey
290
- ? "已检测到私有 Key;留空会保留当前值。"
291
- : "尚未通过桌面设置保存 API Key。";
690
+ populateIndexingSettings(indexing);
691
+ elements.settingsStatus.textContent = settingsStatus(settings);
292
692
  elements.settingsSave.disabled = false;
293
693
  }
294
694
  catch (error) {
295
695
  elements.settingsStatus.textContent = `读取失败:${toError(error).message}`;
296
696
  }
297
697
  }
698
+ function populateIndexingSettings(indexing) {
699
+ elements.settingsIndexingEnabled.checked = indexing.enabled;
700
+ elements.settingsIndexingInterval.value = String(indexing.intervalMinutes);
701
+ elements.settingsIndexingExt.value = indexing.extensions.join(",");
702
+ indexingDirectories = [...indexing.directories];
703
+ renderIndexingDirectories();
704
+ elements.settingsIndexingNote.textContent = indexingSettingsNote(indexing);
705
+ }
706
+ /**
707
+ * Says in the settings panel itself why a ticked checkbox may still not index
708
+ * anything, so it agrees with what the status panel reports.
709
+ */
710
+ function indexingSettingsNote(indexing) {
711
+ const notes = [
712
+ indexing.indexerAvailable
713
+ ? `索引程序:${indexing.indexerPath}`
714
+ : `本次发行版未包含索引程序(${indexing.indexerPath}),启用后会报告不可用。`,
715
+ ];
716
+ if (indexing.enabled && indexing.directories.length === 0) {
717
+ notes.push("已启用,但还没有选择目录,索引不会运行。");
718
+ }
719
+ return notes.join(" ");
720
+ }
721
+ function renderIndexingDirectories() {
722
+ elements.settingsIndexingList.replaceChildren(...indexingDirectories.map((directory) => {
723
+ const row = element("div", "indexing-dir");
724
+ row.setAttribute("role", "listitem");
725
+ const path = element("code");
726
+ path.textContent = directory;
727
+ const remove = element("button", "quiet-text-button");
728
+ remove.type = "button";
729
+ remove.textContent = "移除";
730
+ remove.setAttribute("aria-label", `移除索引目录 ${directory}`);
731
+ remove.addEventListener("click", () => {
732
+ indexingDirectories = indexingDirectories.filter((candidate) => candidate !== directory);
733
+ renderIndexingDirectories();
734
+ });
735
+ row.append(path, remove);
736
+ return row;
737
+ }));
738
+ }
739
+ async function addIndexingDirectory() {
740
+ // The renderer has no filesystem access, so the absolute path can only come
741
+ // from the main process's native chooser.
742
+ const directory = await window.pspore.pickIndexDirectory();
743
+ if (!directory || indexingDirectories.includes(directory))
744
+ return;
745
+ indexingDirectories = [...indexingDirectories, directory].sort();
746
+ renderIndexingDirectories();
747
+ popChips(Array.from(elements.settingsIndexingList.children));
748
+ }
298
749
  function populateSettings(settings) {
299
750
  elements.settingsProvider.value = settings.provider;
300
751
  elements.settingsModel.value = settings.model;
301
752
  elements.settingsBaseUrl.value = settings.baseUrl;
302
753
  elements.settingsApi.value = settings.api;
303
754
  elements.settingsThinking.value = settings.thinking;
755
+ elements.settingsPermissionMode.value = settings.permissionMode;
756
+ elements.settingsAutoCompaction.checked = settings.autoCompactionEnabled;
757
+ elements.settingsCompactionTrigger.value = String(settings.autoCompactionTriggerTokens);
758
+ elements.settingsCompactionReserve.value = String(settings.autoCompactionReserveTokens);
759
+ elements.settingsCompactionKeepRecent.value = String(settings.autoCompactionKeepRecentTokens);
760
+ populateSettingsPaths(settings);
761
+ configurePermissionFields();
304
762
  configureProviderFields(false);
305
- elements.settingsApiKey.value = "";
306
- elements.settingsApiKey.type = "password";
307
- elements.settingsApiKey.placeholder = settings.hasApiKey ? "已配置;留空将保留" : "输入 API Key";
308
- elements.settingsKeyReveal.textContent = "显示";
309
- elements.settingsClearKey.checked = false;
763
+ resetSecretField(elements.settingsApiKey, elements.settingsKeyReveal, elements.settingsClearKey, settings.hasApiKey, "API Key");
764
+ resetSecretField(elements.settingsCookie, elements.settingsCookieReveal, elements.settingsClearCookie, settings.hasCookie, "Cookie");
765
+ renderCustomHeaders(settings.customHeaders);
766
+ }
767
+ /**
768
+ * One row per extra header. A stored value is never sent back here, so the row
769
+ * says it exists and leaves the field blank: typing replaces it, leaving it
770
+ * alone keeps it, and removing the row deletes both the header and its file.
771
+ */
772
+ function renderCustomHeaders(headers) {
773
+ elements.settingsHeaderList.replaceChildren();
774
+ for (const header of headers)
775
+ addCustomHeaderRow(header.name, header.hasValue);
776
+ }
777
+ function addCustomHeaderRow(name, hasValue) {
778
+ const row = element("div", "settings-header-row");
779
+ const nameField = document.createElement("input");
780
+ nameField.type = "text";
781
+ nameField.className = "settings-header-name";
782
+ nameField.value = name;
783
+ nameField.maxLength = 63;
784
+ nameField.placeholder = "X-Example";
785
+ nameField.spellcheck = false;
786
+ nameField.setAttribute("aria-label", "请求头名称");
787
+ const valueField = document.createElement("input");
788
+ valueField.type = "password";
789
+ valueField.className = "settings-header-value";
790
+ valueField.maxLength = 4_096;
791
+ valueField.autocomplete = "new-password";
792
+ valueField.spellcheck = false;
793
+ valueField.placeholder = hasValue ? "已配置,留空保留" : "值";
794
+ valueField.dataset["stored"] = hasValue ? "true" : "false";
795
+ valueField.setAttribute("aria-label", "请求头值");
796
+ const remove = document.createElement("button");
797
+ remove.type = "button";
798
+ remove.className = "quiet-text-button";
799
+ remove.textContent = "移除";
800
+ remove.addEventListener("click", () => row.remove());
801
+ row.append(nameField, valueField, remove);
802
+ elements.settingsHeaderList.append(row);
803
+ if (!hasValue)
804
+ nameField.focus();
805
+ }
806
+ function collectCustomHeaders() {
807
+ const headers = [];
808
+ for (const row of Array.from(elements.settingsHeaderList.querySelectorAll(".settings-header-row"))) {
809
+ const name = row.querySelector(".settings-header-name")?.value.trim() ?? "";
810
+ const field = row.querySelector(".settings-header-value");
811
+ if (!name)
812
+ continue;
813
+ const value = field?.value ?? "";
814
+ // A renamed row has no stored value under its new name, so it must carry one.
815
+ if (value)
816
+ headers.push({ name, value });
817
+ else
818
+ headers.push({ name });
819
+ }
820
+ return headers;
821
+ }
822
+ /**
823
+ * Secrets stay in files outside `config.json`, so the panel shows exactly where
824
+ * each one is read from. Only the path is ever rendered — never the value.
825
+ */
826
+ function populateSettingsPaths(settings) {
827
+ elements.settingsConfigPath.textContent = settings.configPath;
828
+ elements.settingsModelsPath.textContent = settings.modelsPath;
829
+ elements.settingsApiKeyPath.textContent = settings.apiKeyPath;
830
+ elements.settingsCookiePath.textContent = settings.cookiePath;
831
+ elements.settingsApiKeyState.textContent = settings.hasApiKey ? "已配置" : "未配置";
832
+ elements.settingsApiKeyState.dataset["state"] = settings.hasApiKey ? "present" : "absent";
833
+ elements.settingsCookieState.textContent = settings.hasCookie ? "已配置" : "未配置";
834
+ elements.settingsCookieState.dataset["state"] = settings.hasCookie ? "present" : "absent";
835
+ }
836
+ function bindPathCopy(button, source) {
837
+ button.addEventListener("click", () => {
838
+ const path = source.textContent ?? "";
839
+ if (!path || path === "—")
840
+ return;
841
+ void navigator.clipboard.writeText(path).then(() => flashButtonLabel(button, "已复制"), () => flashButtonLabel(button, "复制失败"));
842
+ });
843
+ }
844
+ function flashButtonLabel(button, label) {
845
+ const original = button.dataset["label"] ?? button.textContent ?? "";
846
+ button.dataset["label"] = original;
847
+ button.textContent = label;
848
+ nudgeElement(button);
849
+ window.setTimeout(() => {
850
+ button.textContent = button.dataset["label"] ?? original;
851
+ }, 1_400);
310
852
  }
311
853
  async function saveSettings() {
312
854
  if (savingSettings)
@@ -318,8 +860,16 @@ async function saveSettings() {
318
860
  api: elements.settingsApi.value,
319
861
  thinking: elements.settingsThinking.value,
320
862
  customModel: elements.settingsProvider.value === "custom-openai",
863
+ permissionMode: elements.settingsPermissionMode.value,
864
+ autoCompactionEnabled: elements.settingsAutoCompaction.checked,
865
+ autoCompactionTriggerTokens: elements.settingsCompactionTrigger.valueAsNumber,
866
+ autoCompactionReserveTokens: elements.settingsCompactionReserve.valueAsNumber,
867
+ autoCompactionKeepRecentTokens: elements.settingsCompactionKeepRecent.valueAsNumber,
321
868
  ...(elements.settingsApiKey.value ? { apiKey: elements.settingsApiKey.value } : {}),
869
+ ...(elements.settingsCookie.value ? { cookie: elements.settingsCookie.value } : {}),
870
+ customHeaders: collectCustomHeaders(),
322
871
  clearApiKey: elements.settingsClearKey.checked,
872
+ clearCookie: elements.settingsClearCookie.checked,
323
873
  };
324
874
  savingSettings = true;
325
875
  elements.settingsSave.disabled = true;
@@ -329,7 +879,15 @@ async function saveSettings() {
329
879
  try {
330
880
  const result = await window.pspore.saveSettings(update);
331
881
  populateSettings(result.settings);
332
- elements.settingsStatus.textContent = result.message;
882
+ // Indexing is a separate config section; saving it here keeps one button.
883
+ const indexing = await window.pspore.saveIndexing({
884
+ enabled: elements.settingsIndexingEnabled.checked,
885
+ directories: [...indexingDirectories],
886
+ intervalMinutes: elements.settingsIndexingInterval.valueAsNumber,
887
+ extensions: elements.settingsIndexingExt.value.split(",").map((value) => value.trim()).filter(Boolean),
888
+ });
889
+ populateIndexingSettings(indexing.settings);
890
+ elements.settingsStatus.textContent = `${result.message} ${indexing.message}`;
333
891
  }
334
892
  catch (error) {
335
893
  elements.settingsStatus.textContent = `保存失败:${toError(error).message}`;
@@ -341,6 +899,17 @@ async function saveSettings() {
341
899
  elements.settingsClose.disabled = false;
342
900
  }
343
901
  }
902
+ const PERMISSION_MODE_HELP = {
903
+ ask_risky: "越界操作会先弹出批准请求。",
904
+ ask_all: "所有写入、执行与外部副作用类工具都会先请求批准。",
905
+ allow_all: "所有工具立即执行,不再产生批准请求。",
906
+ deny: "所有受控工具都会被直接拒绝,只保留纯读取能力。",
907
+ };
908
+ function configurePermissionFields() {
909
+ const mode = elements.settingsPermissionMode.value;
910
+ elements.settingsPermissionWarning.hidden = mode !== "allow_all";
911
+ elements.settingsPermissionHelp.textContent = PERMISSION_MODE_HELP[mode] ?? PERMISSION_MODE_HELP["ask_risky"] ?? "";
912
+ }
344
913
  function configureProviderFields(resetModel) {
345
914
  const provider = elements.settingsProvider.value;
346
915
  const custom = provider === "custom-openai";
@@ -368,10 +937,15 @@ function closeSettings(restoreFocus = true) {
368
937
  if (!settingsOpen || savingSettings)
369
938
  return;
370
939
  settingsOpen = false;
371
- elements.settingsOverlay.hidden = true;
372
940
  elements.settingsApiKey.value = "";
373
941
  elements.settingsApiKey.type = "password";
942
+ elements.settingsCookie.value = "";
943
+ elements.settingsCookie.type = "password";
374
944
  updateAppInert();
945
+ hideOverlayMotion(elements.settingsOverlay, elements.settingsDialog, () => {
946
+ if (!settingsOpen)
947
+ elements.settingsOverlay.hidden = true;
948
+ });
375
949
  if (restoreFocus)
376
950
  requestAnimationFrame(() => elements.settingsButton.focus());
377
951
  }
@@ -401,12 +975,20 @@ function trapDialogFocus(event, container) {
401
975
  }
402
976
  }
403
977
  async function submitComposer() {
404
- const text = elements.composerInput.value.trim();
978
+ let text;
979
+ try {
980
+ text = composeMessageWithFileReferences(elements.composerInput.value, composerFileReferences);
981
+ }
982
+ catch (error) {
983
+ store.setNotice(toError(error).message);
984
+ render();
985
+ return;
986
+ }
405
987
  if (!text)
406
988
  return;
989
+ nudgeElement(elements.send);
407
990
  const snapshot = store.snapshot();
408
- const mode = elements.composerMode.value;
409
- const intent = conversationIntent(snapshot.kernel, snapshot.selectedSessionId, text, mode === "steer" ? "steer" : mode === "follow_up" ? "follow_up" : "prompt");
991
+ const intent = conversationIntent(snapshot.kernel, snapshot.selectedSessionId, text, "auto");
410
992
  let command;
411
993
  if (intent.target === "agent") {
412
994
  command = {
@@ -417,41 +999,88 @@ async function submitComposer() {
417
999
  };
418
1000
  }
419
1001
  else {
420
- command = { action: intent.mode, text: intent.text };
1002
+ command = { action: "prompt", text: intent.text, sessionId: intent.sessionId };
421
1003
  }
422
- await perform(`Sending to ${intent.target === "agent" ? intent.agentId : "main"}`, command, () => {
1004
+ await perform(`发送到 ${intent.target === "agent" ? intent.agentId : shortId(intent.sessionId)}`, command, () => {
423
1005
  elements.composerInput.value = "";
1006
+ composerFileReferences = [];
1007
+ renderComposerFileReferences();
424
1008
  elements.composerInput.focus();
425
1009
  });
426
1010
  }
427
1011
  async function perform(pendingNotice, command, onSuccess) {
428
- store.setNotice(`${pendingNotice}…`);
1012
+ store.setNotice(`正在${pendingNotice}…`);
429
1013
  render();
430
1014
  try {
431
- const result = await window.pspore.dispatch(command);
432
- store.setNotice(`${pendingNotice}: done`);
1015
+ const result = await dispatchAcrossReconnect(command);
1016
+ store.setNotice(`${pendingNotice}:完成`);
433
1017
  await onSuccess?.(result);
434
1018
  }
435
1019
  catch (error) {
436
- store.setNotice(`${pendingNotice}: ${toError(error).message}`);
1020
+ store.setNotice(`${pendingNotice}失败:${toError(error).message}`);
437
1021
  }
438
1022
  render();
439
1023
  }
1024
+ /**
1025
+ * The shell keeps its controls enabled through a short reconnect, so a command
1026
+ * issued in that window must not simply fail. Only the "not connected" error is
1027
+ * retried: the bridge raises it before writing anything to the socket, so the
1028
+ * retry cannot duplicate a command the daemon already ran.
1029
+ */
1030
+ async function dispatchAcrossReconnect(command) {
1031
+ try {
1032
+ return await window.pspore.dispatch(command);
1033
+ }
1034
+ catch (error) {
1035
+ const message = toError(error).message;
1036
+ if (!message.includes(DAEMON_NOT_CONNECTED_MESSAGE) || !store.snapshot().reconnecting)
1037
+ throw error;
1038
+ store.setNotice("守护进程正在重连,命令将在连接恢复后发送…");
1039
+ render();
1040
+ if (!(await waitForConnection()))
1041
+ throw error;
1042
+ return window.pspore.dispatch(command);
1043
+ }
1044
+ }
1045
+ function waitForConnection() {
1046
+ return new Promise((resolve) => {
1047
+ const deadline = Date.now() + CONNECTION_SETTLE_MS;
1048
+ const poll = () => {
1049
+ if (store.snapshot().connection.phase === "connected")
1050
+ resolve(true);
1051
+ else if (Date.now() >= deadline)
1052
+ resolve(false);
1053
+ else
1054
+ window.setTimeout(poll, 100);
1055
+ };
1056
+ poll();
1057
+ });
1058
+ }
440
1059
  async function refreshSideData() {
441
1060
  const snapshot = store.snapshot();
442
- if (snapshot.connection.phase !== "connected") {
443
- store.setNotice("Daemon is offline; lists will refresh after reconnect");
1061
+ if (snapshot.stableConnection.phase !== "connected") {
1062
+ store.setNotice("守护进程离线;重连后会自动刷新列表");
444
1063
  render();
445
1064
  return;
446
1065
  }
447
- store.setNotice("Refreshing jobs and trusted resources…");
1066
+ store.setNotice("正在刷新计划任务与受信资源…");
448
1067
  render();
449
1068
  const results = await Promise.allSettled([
450
1069
  window.pspore.dispatch({ action: "jobs.list" }),
451
1070
  window.pspore.dispatch({ action: "resources.list" }),
452
1071
  window.pspore.dispatch({ action: "agents.list" }),
453
1072
  window.pspore.dispatch({ action: "mcp.servers" }),
1073
+ window.pspore.dispatch({ action: "tools.list" }),
1074
+ window.pspore.dispatch({ action: "skills.catalog" }),
454
1075
  ]);
1076
+ const toolResult = results[4];
1077
+ if (toolResult?.status === "fulfilled" && Array.isArray(toolResult.value)) {
1078
+ builtinTools = toolResult.value.filter(isBuiltinTool);
1079
+ }
1080
+ const skillResult = results[5];
1081
+ if (skillResult?.status === "fulfilled" && Array.isArray(skillResult.value)) {
1082
+ skillCatalog = skillResult.value.filter(isSkillCatalogEntry);
1083
+ }
455
1084
  const jobResult = results[0];
456
1085
  const resourceResult = results[1];
457
1086
  if (jobResult?.status === "fulfilled" && Array.isArray(jobResult.value)) {
@@ -461,7 +1090,7 @@ async function refreshSideData() {
461
1090
  resources = resourceResult.value.filter(isResourceEntry);
462
1091
  }
463
1092
  const failures = results.filter((result) => result.status === "rejected");
464
- store.setNotice(failures.length ? `${failures.length} list refresh request(s) failed` : "Lists refreshed");
1093
+ store.setNotice(failures.length ? `有 ${failures.length} 项列表刷新失败` : "列表已刷新");
465
1094
  render();
466
1095
  }
467
1096
  function render() {
@@ -470,6 +1099,8 @@ function render() {
470
1099
  renderSessions(snapshot);
471
1100
  renderConversation(snapshot);
472
1101
  renderActivities(snapshot.activities);
1102
+ renderRunActivity(snapshot);
1103
+ renderIndexing(snapshot);
473
1104
  renderJobs(snapshot);
474
1105
  renderIntegrations(snapshot);
475
1106
  renderNotifications(snapshot);
@@ -480,49 +1111,247 @@ function render() {
480
1111
  document.documentElement.dataset["renderSequence"] = String(renderSequence);
481
1112
  document.documentElement.dataset["revision"] = String(snapshot.kernel.revision);
482
1113
  document.documentElement.dataset["approval"] = Object.keys(snapshot.kernel.approvals).length ? "visible" : "hidden";
483
- document.documentElement.dataset["connection"] = snapshot.connection.phase;
1114
+ document.documentElement.dataset["connection"] = snapshot.stableConnection.phase;
1115
+ document.documentElement.dataset["reconnecting"] = snapshot.reconnecting ? "true" : "false";
1116
+ }
1117
+ /**
1118
+ * The settle window is time-based, so a run that stays disconnected without
1119
+ * emitting further frames still has to surface. One timer, re-armed only while
1120
+ * a reconnect is being masked.
1121
+ */
1122
+ function scheduleConnectionSettle(snapshot) {
1123
+ if (!snapshot.reconnecting) {
1124
+ if (settleTimer !== undefined) {
1125
+ window.clearTimeout(settleTimer);
1126
+ settleTimer = undefined;
1127
+ }
1128
+ return;
1129
+ }
1130
+ if (settleTimer !== undefined)
1131
+ return;
1132
+ settleTimer = window.setTimeout(() => {
1133
+ settleTimer = undefined;
1134
+ if (store.settleConnection())
1135
+ scheduleRender();
1136
+ else
1137
+ scheduleConnectionSettle(store.snapshot());
1138
+ }, 250);
1139
+ }
1140
+ function scheduleRender() {
1141
+ if (renderFrame !== undefined)
1142
+ return;
1143
+ renderFrame = requestAnimationFrame(() => {
1144
+ renderFrame = undefined;
1145
+ render();
1146
+ });
1147
+ }
1148
+ function scheduleTranscriptRender() {
1149
+ if (transcriptRenderFrame !== undefined)
1150
+ return;
1151
+ transcriptRenderFrame = requestAnimationFrame(() => {
1152
+ transcriptRenderFrame = undefined;
1153
+ if (initialized)
1154
+ renderConversation(store.snapshot());
1155
+ });
1156
+ }
1157
+ function handleTranscriptScroll() {
1158
+ transcriptState.followTail = isScrollNearEnd(elements.transcript.scrollHeight, elements.transcript.scrollTop, elements.transcript.clientHeight);
1159
+ if (elements.transcript.scrollTop < HISTORY_BACKFILL_TRIGGER_PX)
1160
+ void loadOlderHistory();
1161
+ scheduleTranscriptRender();
1162
+ }
1163
+ /**
1164
+ * Broadcasts carry only the newest part of a conversation, so scrolling to the
1165
+ * top asks the daemon for the page before it. The viewport is pinned to the
1166
+ * message the reader was looking at rather than to a scroll offset that the
1167
+ * prepended history has just moved.
1168
+ */
1169
+ async function loadOlderHistory() {
1170
+ if (transcriptState.loadingHistory)
1171
+ return;
1172
+ const snapshot = store.snapshot();
1173
+ const sessionId = snapshot.selectedSessionId;
1174
+ const session = snapshot.kernel.sessions[sessionId];
1175
+ if (!session || session.messageOffset === 0)
1176
+ return;
1177
+ const from = Math.max(0, session.messageOffset - HISTORY_PAGE_SIZE);
1178
+ transcriptState.loadingHistory = true;
1179
+ try {
1180
+ const page = await dispatchAcrossReconnect({
1181
+ action: "session.transcript",
1182
+ sessionId,
1183
+ from,
1184
+ limit: session.messageOffset - from,
1185
+ });
1186
+ if (!isTranscriptPage(page) || page.sessionId !== store.snapshot().selectedSessionId)
1187
+ return;
1188
+ const anchor = elements.transcript.scrollHeight - elements.transcript.scrollTop;
1189
+ if (!store.applyTranscriptPage(page))
1190
+ return;
1191
+ render();
1192
+ elements.transcript.scrollTop = Math.max(0, elements.transcript.scrollHeight - anchor);
1193
+ }
1194
+ catch {
1195
+ // A refused or disconnected backfill simply leaves the history where it is.
1196
+ }
1197
+ finally {
1198
+ transcriptState.loadingHistory = false;
1199
+ }
1200
+ }
1201
+ function isTranscriptPage(value) {
1202
+ if (typeof value !== "object" || value === null)
1203
+ return false;
1204
+ const page = value;
1205
+ return typeof page["sessionId"] === "string"
1206
+ && typeof page["from"] === "number"
1207
+ && typeof page["count"] === "number"
1208
+ && Array.isArray(page["messages"]);
484
1209
  }
485
1210
  function renderHeader(snapshot) {
486
- const connection = snapshot.connection;
1211
+ // The settled state is what the header shows: a reconnect that finishes inside
1212
+ // the settle window stays "ONLINE" so a long run does not strobe the badge.
1213
+ const connection = snapshot.stableConnection;
487
1214
  elements.connectionBadge.className = `connection-badge ${connection.phase}`;
1215
+ elements.connectionBadge.dataset["reconnecting"] = snapshot.reconnecting ? "true" : "false";
488
1216
  elements.connectionBadge.replaceChildren(element("i"), text(connection.phase === "connected" ? "ONLINE" : connection.phase.toUpperCase()));
1217
+ if (lastConnectionPhase && connection.phase !== lastConnectionPhase)
1218
+ pulseStatus(elements.connectionBadge);
1219
+ lastConnectionPhase = connection.phase;
1220
+ setReconnectingPulse(elements.connectionBadge, snapshot.reconnecting);
489
1221
  elements.daemonInstance.textContent = connection.instanceId ? `daemon ${shortId(connection.instanceId)}` : "等待本地守护进程";
490
- elements.connectionBanner.classList.toggle("visible", connection.phase !== "connected");
1222
+ toggleBannerMotion(elements.connectionBanner, connection.phase !== "connected");
491
1223
  elements.connectionMessage.textContent = friendlyConnectionMessage(connection.message, connection.phase);
492
- elements.revision.textContent = String(snapshot.kernel.revision);
493
1224
  const session = snapshot.kernel.sessions[snapshot.selectedSessionId];
494
- elements.usage.textContent = formatTokens(session?.usage.total ?? 0);
495
- elements.cache.textContent = formatTokens((session?.usage.cacheRead ?? 0) + (session?.usage.cacheWrite ?? 0));
496
- elements.notice.textContent = snapshot.notice;
1225
+ tweenMetric(elements.revision, snapshot.kernel.revision, String);
1226
+ if (session?.context) {
1227
+ tweenMetric(elements.usage, session.context.tokens, (value) => `≈${formatTokens(value)}`);
1228
+ elements.usage.title = `当前上下文估算 ${session.context.tokens.toLocaleString()} / ${session.context.window.toLocaleString()} tokens${session.context.triggerTokens ? `;自动压缩阈值 ${session.context.triggerTokens.toLocaleString()}` : ""};累计用量 ${session.usage.total.toLocaleString()} tokens`;
1229
+ }
1230
+ else {
1231
+ elements.usage.textContent = "—";
1232
+ elements.usage.title = `当前上下文暂不可用;累计用量 ${(session?.usage.total ?? 0).toLocaleString()} tokens`;
1233
+ }
1234
+ tweenMetric(elements.totalUsage, session?.usage.total ?? 0, formatTokens);
1235
+ tweenMetric(elements.cache, (session?.usage.cacheRead ?? 0) + (session?.usage.cacheWrite ?? 0), formatTokens);
1236
+ crossfadeText(elements.notice, snapshot.notice);
497
1237
  elements.refresh.disabled = connection.phase !== "connected";
1238
+ scheduleConnectionSettle(snapshot);
498
1239
  }
499
1240
  function renderSessions(snapshot) {
500
1241
  const sessions = presentSessions(snapshot.kernel, snapshot.selectedSessionId);
501
- const nodes = sessions.map((session) => sessionButton(session.id, session.title, session.status, session.depth, session.selected, session.active));
1242
+ const connected = snapshot.stableConnection.phase === "connected";
1243
+ const nodes = sessions.map((session) => sessionRow(session, connected));
1244
+ const sessionSignature = sessions
1245
+ .map((session) => `${session.id}:${session.status}:${session.selected}:${session.id === armedSessionDeletion}`)
1246
+ .join("|");
502
1247
  elements.sessionList.replaceChildren(...nodes);
503
- elements.sessionCount.textContent = String(sessions.length);
1248
+ if (sessionSignature !== lastSessionSignature) {
1249
+ if (lastSessionSignature)
1250
+ revealListItems(nodes, { distance: 6 });
1251
+ lastSessionSignature = sessionSignature;
1252
+ }
1253
+ tweenMetric(elements.sessionCount, sessions.length, String);
1254
+ elements.newSession.disabled = snapshot.stableConnection.phase !== "connected";
504
1255
  const child = selectedAgent(snapshot);
505
1256
  elements.childControls.hidden = !child;
506
- elements.joinAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.connection.phase !== "connected";
507
- elements.cancelAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.connection.phase !== "connected";
1257
+ elements.joinAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.stableConnection.phase !== "connected";
1258
+ elements.cancelAgent.disabled = !child || terminalAgentStatus(child.status) || snapshot.stableConnection.phase !== "connected";
1259
+ const metrics = kernelMetrics(snapshot.kernel);
1260
+ elements.railRuns.textContent = `${metrics.activeRuns}/${metrics.runs}`;
1261
+ elements.railMcp.textContent = `${metrics.readyMcpServers}/${metrics.mcpServers}`;
1262
+ elements.railResources.textContent = `${metrics.activeResources}/${metrics.resources}`;
1263
+ elements.railApprovals.textContent = String(metrics.pendingApprovals);
1264
+ elements.railApprovals.classList.toggle("attention", metrics.pendingApprovals > 0);
1265
+ }
1266
+ /**
1267
+ * One row of the session rail: the session button plus its delete control.
1268
+ *
1269
+ * The delete control cannot live inside the session button — a button may not
1270
+ * nest another — so the row wraps both and the control is positioned over the
1271
+ * button's trailing edge.
1272
+ */
1273
+ function sessionRow(session, connected) {
1274
+ const row = element("div", `session-row${session.id === armedSessionDeletion ? " arming" : ""}`);
1275
+ row.append(sessionButton(session.id, session.title, session.status, session.depth, session.selected, session.active, session.createdAt));
1276
+ // Child agents belong to their root session and are cancelled, not deleted;
1277
+ // a placeholder row for a session the daemon does not hold has nothing to
1278
+ // delete either.
1279
+ if (session.kind === "main" && session.active) {
1280
+ row.append(sessionDeleteButton(session, connected));
1281
+ }
1282
+ return row;
1283
+ }
1284
+ /**
1285
+ * Deleting a session destroys its transcript, so the first click only arms the
1286
+ * control and the second one confirms. An inline two-step control keeps the
1287
+ * confirmation in the rail, where the row being deleted is still visible.
1288
+ */
1289
+ function sessionDeleteButton(session, connected) {
1290
+ const armed = session.id === armedSessionDeletion;
1291
+ const button = element("button", `session-delete${armed ? " armed" : ""}`);
1292
+ button.type = "button";
1293
+ button.disabled = !connected;
1294
+ button.textContent = armed ? "确认删除" : "✕";
1295
+ const label = session.id === "main" ? "main" : shortId(session.id);
1296
+ button.title = armed ? `确认删除会话 ${label}(包括聊天记录)` : `删除会话 ${label}`;
1297
+ button.setAttribute("aria-label", button.title);
1298
+ button.addEventListener("click", (event) => {
1299
+ event.stopPropagation();
1300
+ if (!armed) {
1301
+ armSessionDeletion(session.id);
1302
+ return;
1303
+ }
1304
+ disarmSessionDeletion();
1305
+ void perform(`删除会话 ${label}`, { action: "session.delete", sessionId: session.id });
1306
+ });
1307
+ return button;
508
1308
  }
509
- function sessionButton(id, title, status, depth, selected, active) {
1309
+ /** Arms one row at a time and disarms it again if the user walks away. */
1310
+ function armSessionDeletion(id) {
1311
+ if (armSessionDeletionTimer !== undefined)
1312
+ clearTimeout(armSessionDeletionTimer);
1313
+ armedSessionDeletion = id;
1314
+ armSessionDeletionTimer = window.setTimeout(() => {
1315
+ armedSessionDeletion = undefined;
1316
+ armSessionDeletionTimer = undefined;
1317
+ render();
1318
+ }, SESSION_DELETE_ARM_MS);
1319
+ render();
1320
+ }
1321
+ function disarmSessionDeletion() {
1322
+ if (armSessionDeletionTimer !== undefined)
1323
+ clearTimeout(armSessionDeletionTimer);
1324
+ armSessionDeletionTimer = undefined;
1325
+ armedSessionDeletion = undefined;
1326
+ }
1327
+ function sessionButton(id, title, status, depth, selected, active, createdAt) {
510
1328
  const button = element("button", `session-item ${status}${selected ? " selected" : ""}`);
511
1329
  button.type = "button";
512
1330
  button.disabled = !active;
513
- button.style.paddingLeft = `${10 + depth * 13}px`;
1331
+ // The rail draws the agent tree as hyphae branching off main; depth feeds both the indent and the connector.
1332
+ button.dataset["depth"] = String(depth);
1333
+ button.style.setProperty("--session-depth", String(depth));
514
1334
  const copy = element("span", "session-copy");
515
1335
  const strong = element("strong");
516
1336
  strong.textContent = id === "main" ? "main" : shortId(id);
517
1337
  const small = element("small");
518
1338
  small.textContent = title;
519
1339
  copy.append(strong, small);
520
- const depthMark = element("span", "depth-mark");
521
- depthMark.textContent = depth ? `L${depth}` : "ROOT";
522
- button.append(element("i", "node-dot"), copy, depthMark);
1340
+ // The creation time sits beside the id, which is what the list is ordered by,
1341
+ // so the ordering is legible instead of something the reader has to infer.
1342
+ if (createdAt !== undefined) {
1343
+ const when = element("time", "session-time");
1344
+ when.textContent = listTime(createdAt);
1345
+ when.setAttribute("datetime", createdAt);
1346
+ strong.append(when);
1347
+ }
1348
+ const statusMark = element("span", "session-status-mark");
1349
+ statusMark.textContent = status;
1350
+ button.append(element("i", "node-dot"), copy, statusMark);
523
1351
  button.addEventListener("click", () => {
524
1352
  try {
525
1353
  store.selectSession(id);
1354
+ articles.onSessionChanged();
526
1355
  render();
527
1356
  }
528
1357
  catch (error) {
@@ -535,91 +1364,671 @@ function sessionButton(id, title, status, depth, selected, active) {
535
1364
  function renderConversation(snapshot) {
536
1365
  const session = snapshot.kernel.sessions[snapshot.selectedSessionId];
537
1366
  const child = selectedAgent(snapshot);
538
- elements.sessionTitle.textContent = child ? `${shortId(child.id)} · ${child.objective}` : "main";
1367
+ elements.sessionTitle.textContent = child
1368
+ ? `${shortId(child.id)} · ${child.objective}`
1369
+ : session?.rootSession?.title ?? (snapshot.selectedSessionId === "main" ? "main" : shortId(snapshot.selectedSessionId));
539
1370
  const status = session?.status ?? (child?.status ?? "idle");
540
1371
  elements.sessionStatus.textContent = status.toUpperCase();
541
1372
  elements.sessionStatus.className = `status-chip ${status}`;
1373
+ if (lastSessionStatus && status !== lastSessionStatus)
1374
+ flipStatusChip(elements.sessionStatus);
1375
+ lastSessionStatus = status;
542
1376
  elements.modelName.textContent = session?.model ? `${session.model.provider}/${session.model.model}` : "未配置模型";
543
- elements.composerInput.placeholder = `向 ${child?.id ?? "main"} 发送消息…`;
544
- const connected = snapshot.connection.phase === "connected";
1377
+ elements.composerInput.placeholder = `向 ${child?.id ?? shortId(snapshot.selectedSessionId)} 发送消息…`;
1378
+ const connected = snapshot.stableConnection.phase === "connected";
545
1379
  elements.composerInput.disabled = !connected;
546
- elements.composerMode.disabled = !connected;
547
1380
  elements.send.disabled = !connected;
548
1381
  elements.abort.disabled = !connected || (!child && session?.status !== "running");
549
1382
  elements.compact.disabled = !connected || Boolean(child) || !session;
1383
+ renderMessageQueue(snapshot, child ? undefined : session);
550
1384
  const transcript = elements.transcript;
551
- const pinned = transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight < 120;
552
1385
  const messages = session?.messages ?? [];
553
- const content = messages.map((message) => renderMessage(message));
554
1386
  const streaming = snapshot.streamingText[snapshot.selectedSessionId];
555
- if (streaming)
556
- content.push(renderStreamingMessage(streaming));
557
- if (content.length === 0) {
558
- const empty = element("div", "empty-transcript");
559
- const inner = element("div");
560
- const heading = element("strong");
561
- heading.textContent = child ? "子 Agent 尚未产生消息" : "本地会话已就绪";
562
- const body = element("span");
563
- body.textContent = child ? "可发送 next、steer 或 follow-up 消息。" : "输入任务,或从左侧委派一个有界子任务。";
564
- inner.append(heading, body);
565
- empty.append(inner);
566
- content.push(empty);
567
- }
568
- transcript.replaceChildren(...content);
569
- if (pinned)
570
- requestAnimationFrame(() => transcript.scrollTo({ top: transcript.scrollHeight }));
571
- }
572
- function renderMessage(message) {
1387
+ const thinking = snapshot.streamingThinking[snapshot.selectedSessionId];
1388
+ const sessionChanged = transcriptState.sessionId !== snapshot.selectedSessionId;
1389
+ const offset = session?.messageOffset ?? 0;
1390
+ const previousEnd = transcriptState.end;
1391
+ if (sessionChanged) {
1392
+ if (transcriptState.measureFrame !== undefined)
1393
+ cancelAnimationFrame(transcriptState.measureFrame);
1394
+ transcriptState.heights = new VirtualHeightIndex(180);
1395
+ transcriptState.nodes.clear();
1396
+ processViews.clear();
1397
+ transcriptState.messageSource = undefined;
1398
+ transcriptState.items = [];
1399
+ transcriptState.hasStreaming = false;
1400
+ transcriptState.followTail = true;
1401
+ transcriptState.pinnedIndex = undefined;
1402
+ transcriptState.stream = undefined;
1403
+ }
1404
+ transcriptState.sessionId = snapshot.selectedSessionId;
1405
+ transcriptState.end = offset + messages.length;
1406
+ if (messages.length === 0 && !streaming && !thinking) {
1407
+ transcriptState.heights.sync([]);
1408
+ const empty = emptyTranscript(Boolean(child));
1409
+ transcript.replaceChildren(empty);
1410
+ if (sessionChanged)
1411
+ animateTranscriptHero(empty.querySelector(":scope > div"));
1412
+ sessionSearch.paint();
1413
+ return;
1414
+ }
1415
+ const hasStreaming = Boolean(streaming || thinking);
1416
+ const running = status === "running";
1417
+ const messagesChanged = transcriptState.messageSource !== messages || transcriptState.running !== running;
1418
+ if (messagesChanged) {
1419
+ transcriptState.messageSource = messages;
1420
+ transcriptState.running = running;
1421
+ // Keyed by absolute transcript position, so prepending older history keeps
1422
+ // the keys — and therefore the measured heights — of what is on screen.
1423
+ const resolvedMessages = messages.map((message, index) => {
1424
+ const address = message.id === undefined ? { index: offset + index } : { id: message.id };
1425
+ // A message restored in full replaces the cut copy the daemon published.
1426
+ const restored = fullMessageTexts.get(fullTextKey(snapshot.selectedSessionId, address));
1427
+ const resolved = restored === undefined ? message : { ...message, text: restored };
1428
+ return resolved;
1429
+ });
1430
+ transcriptState.items = conversationRows(resolvedMessages, offset, running);
1431
+ }
1432
+ if (messagesChanged || transcriptState.hasStreaming !== hasStreaming) {
1433
+ transcriptState.hasStreaming = hasStreaming;
1434
+ transcriptState.heights.sync([
1435
+ ...transcriptState.items.map((item) => item.key),
1436
+ ...(hasStreaming ? ["$stream"] : []),
1437
+ ]);
1438
+ }
1439
+ const itemCount = transcriptState.items.length + (hasStreaming ? 1 : 0);
1440
+ sessionSearch.prepare(sessionChanged);
1441
+ const viewportHeight = Math.max(1, transcript.clientHeight);
1442
+ // A held selection outranks the tail: scrolling under a drag would keep
1443
+ // changing what is selected, which is what made copying mid-reply impossible.
1444
+ const shouldPin = transcriptState.followTail && !hasTranscriptSelection();
1445
+ const scrollTop = shouldPin
1446
+ ? Math.max(0, transcriptState.heights.totalSize() - viewportHeight)
1447
+ : transcript.scrollTop;
1448
+ // An item holding a selection stays rendered even when the window drifts off
1449
+ // it: dropping its element is the one way left to collapse a selection the
1450
+ // reader is still dragging.
1451
+ const heldIndex = Number(selectedTranscriptNode()?.dataset["virtualIndex"] ?? Number.NaN);
1452
+ const range = transcriptState.heights.range(scrollTop, viewportHeight, undefined, Number.isInteger(heldIndex) ? heldIndex : transcriptState.pinnedIndex);
1453
+ const content = [sizeSpacer(transcriptState.spacerStart, range.paddingStart)];
1454
+ const visibleMessages = [];
1455
+ const appeared = [];
1456
+ for (let index = range.start; index < range.end; index += 1) {
1457
+ const item = transcriptState.items[index];
1458
+ const isStreaming = hasStreaming && index === transcriptState.items.length;
1459
+ if (!item && !isStreaming)
1460
+ continue;
1461
+ const key = isStreaming ? "$stream" : item?.key;
1462
+ if (!key)
1463
+ continue;
1464
+ let node;
1465
+ if (isStreaming) {
1466
+ const stream = transcriptState.stream ?? createStreamingView();
1467
+ if (transcriptState.stream !== stream) {
1468
+ transcriptState.stream = stream;
1469
+ appeared.push(stream.node);
1470
+ }
1471
+ stream.update(streaming ?? "", thinking ?? "");
1472
+ node = stream.node;
1473
+ }
1474
+ else {
1475
+ if (!item)
1476
+ continue;
1477
+ const before = item.kind === "process" ? processViews.get(key)?.node : transcriptState.nodes.get(messageVirtualKey(item.entry.message, item.entry.index));
1478
+ node = item.kind === "process" ? renderProcess(item, transcriptState.pinnedIndex === index) : cachedEntry(item.entry);
1479
+ if (before !== node)
1480
+ appeared.push(node);
1481
+ }
1482
+ node.dataset["virtualKey"] = key;
1483
+ node.dataset["virtualIndex"] = String(index);
1484
+ node.setAttribute("aria-posinset", String(index + 1));
1485
+ node.setAttribute("aria-setsize", String(itemCount));
1486
+ visibleMessages.push(node);
1487
+ content.push(node);
1488
+ }
1489
+ content.push(sizeSpacer(transcriptState.spacerEnd, range.paddingEnd));
1490
+ // The bubble is kept for the next reply, so it is emptied when this one ends
1491
+ // rather than left holding the blocks it last rendered.
1492
+ if (!hasStreaming)
1493
+ transcriptState.stream?.update("", "");
1494
+ transcript.setAttribute("aria-busy", String(hasStreaming));
1495
+ reconcileChildren(transcript, content);
1496
+ if (sessionChanged) {
1497
+ animateMessagesIn(visibleMessages.slice(-14), true);
1498
+ }
1499
+ else if (transcriptState.end > previousEnd) {
1500
+ // Compared at absolute positions: a history backfill grows the list without
1501
+ // adding anything new at the tail, and must not replay the entrance. Only
1502
+ // nodes built for this render are animated — one already on screen would be
1503
+ // faded back in from nothing every time the tail grows.
1504
+ const firstAppended = transcriptState.items.length - (transcriptState.end - previousEnd);
1505
+ animateMessagesIn(visibleMessages.filter((node) => appeared.includes(node) && Number(node.dataset["virtualIndex"]) >= firstAppended), false);
1506
+ }
1507
+ if (shouldPin)
1508
+ transcript.scrollTo({ top: transcript.scrollHeight, behavior: "auto" });
1509
+ scheduleTranscriptMeasurement();
1510
+ sessionSearch.paint();
1511
+ }
1512
+ function currentSessionTexts(snapshot) {
1513
+ const texts = transcriptState.items.map(conversationRowText);
1514
+ const streaming = snapshot.streamingText[snapshot.selectedSessionId] ?? "";
1515
+ const thinking = snapshot.streamingThinking[snapshot.selectedSessionId] ?? "";
1516
+ if (streaming || thinking)
1517
+ texts.push(`${thinking}${thinking && streaming ? "\n" : ""}${streaming}`);
1518
+ return texts;
1519
+ }
1520
+ function cachedMessage(key, message, address) {
1521
+ const cached = transcriptState.nodes.get(key);
1522
+ if (cached) {
1523
+ transcriptState.nodes.delete(key);
1524
+ transcriptState.nodes.set(key, cached);
1525
+ return cached;
1526
+ }
1527
+ const node = renderMessage(message, address);
1528
+ return cacheMessageNode(key, node);
1529
+ }
1530
+ function cacheMessageNode(key, node) {
1531
+ transcriptState.nodes.set(key, node);
1532
+ while (transcriptState.nodes.size > 128) {
1533
+ const oldest = transcriptState.nodes.keys().next().value;
1534
+ if (oldest === undefined)
1535
+ break;
1536
+ transcriptState.nodes.delete(oldest);
1537
+ }
1538
+ return node;
1539
+ }
1540
+ function scheduleTranscriptMeasurement() {
1541
+ if (transcriptState.measureFrame !== undefined)
1542
+ cancelAnimationFrame(transcriptState.measureFrame);
1543
+ transcriptState.measureFrame = requestAnimationFrame(() => {
1544
+ transcriptState.measureFrame = undefined;
1545
+ let changed = false;
1546
+ for (const node of Array.from(elements.transcript.querySelectorAll("[data-virtual-key]"))) {
1547
+ const key = node.dataset["virtualKey"];
1548
+ if (!key)
1549
+ continue;
1550
+ const margin = Number.parseFloat(getComputedStyle(node).marginBottom) || 0;
1551
+ changed = transcriptState.heights.update(key, node.offsetHeight + margin) || changed;
1552
+ }
1553
+ if (transcriptState.followTail && !hasTranscriptSelection()) {
1554
+ elements.transcript.scrollTo({ top: elements.transcript.scrollHeight, behavior: "auto" });
1555
+ }
1556
+ if (changed)
1557
+ scheduleTranscriptRender();
1558
+ });
1559
+ }
1560
+ /**
1561
+ * One padding element per edge, kept for the life of the transcript and resized
1562
+ * in place. Recreating them would make every render a structural change, which
1563
+ * is what used to detach the messages between them.
1564
+ */
1565
+ function virtualSpacer(edge) {
1566
+ const spacer = element("div", `virtual-spacer ${edge}`);
1567
+ spacer.style.height = "0px";
1568
+ spacer.setAttribute("aria-hidden", "true");
1569
+ return spacer;
1570
+ }
1571
+ function sizeSpacer(spacer, height) {
1572
+ const next = `${Math.max(0, Math.round(height))}px`;
1573
+ if (spacer.style.height !== next)
1574
+ spacer.style.height = next;
1575
+ return spacer;
1576
+ }
1577
+ /**
1578
+ * True while the reader is holding a selection inside the transcript.
1579
+ *
1580
+ * Following the tail then would drag the viewport out from under them mid-drag,
1581
+ * so the pin stands down until the selection collapses — which a click anywhere
1582
+ * does.
1583
+ */
1584
+ function hasTranscriptSelection() {
1585
+ return selectedTranscriptNode() !== undefined;
1586
+ }
1587
+ /** The rendered message the reader's selection starts in, if it is in the transcript. */
1588
+ function selectedTranscriptNode() {
1589
+ const selection = document.getSelection();
1590
+ if (!selection || selection.isCollapsed || selection.rangeCount === 0)
1591
+ return undefined;
1592
+ for (const node of [selection.anchorNode, selection.focusNode]) {
1593
+ if (!node || !elements.transcript.contains(node))
1594
+ continue;
1595
+ const element = node.nodeType === Node.ELEMENT_NODE ? node : node.parentElement;
1596
+ const held = element?.closest("[data-virtual-index]");
1597
+ if (held)
1598
+ return held;
1599
+ }
1600
+ return undefined;
1601
+ }
1602
+ function messageVirtualKey(message, index) {
1603
+ return `${index}:${textFingerprint(JSON.stringify(message))}`;
1604
+ }
1605
+ function cachedEntry(entry) {
1606
+ const { message, index } = entry;
1607
+ return cachedMessage(messageVirtualKey(message, index), message, message.id === undefined ? { index } : { id: message.id });
1608
+ }
1609
+ function rememberDisclosure(key, open) {
1610
+ disclosureStates.delete(key);
1611
+ disclosureStates.set(key, open);
1612
+ while (disclosureStates.size > 512) {
1613
+ const oldest = disclosureStates.keys().next().value;
1614
+ if (oldest === undefined)
1615
+ break;
1616
+ disclosureStates.delete(oldest);
1617
+ }
1618
+ }
1619
+ function renderProcess(row, searchHit) {
1620
+ const stateKey = `${transcriptState.sessionId}:${row.key}`;
1621
+ let view = processViews.get(row.key);
1622
+ if (!view) {
1623
+ const node = element("article", "message process-message");
1624
+ const details = document.createElement("details");
1625
+ details.className = "process-details";
1626
+ const summary = element("summary", "process-summary");
1627
+ const body = element("div", "process-body");
1628
+ details.open = disclosureStates.get(`${stateKey}:${row.active}`) ?? row.active;
1629
+ details.append(summary, body);
1630
+ node.append(details);
1631
+ view = { node, details, summary, body, active: row.active };
1632
+ const current = view;
1633
+ details.addEventListener("toggle", () => {
1634
+ rememberDisclosure(`${stateKey}:${current.active}`, details.open);
1635
+ scheduleTranscriptMeasurement();
1636
+ scheduleTranscriptRender();
1637
+ });
1638
+ processViews.set(row.key, view);
1639
+ if (processViews.size > 128) {
1640
+ const oldest = processViews.keys().next().value;
1641
+ if (oldest !== undefined)
1642
+ processViews.delete(oldest);
1643
+ }
1644
+ }
1645
+ if (view.active !== row.active) {
1646
+ view.active = row.active;
1647
+ view.details.open = disclosureStates.get(`${stateKey}:${row.active}`) ?? row.active;
1648
+ }
1649
+ const calls = row.entries.flatMap(({ message }) => message.toolCalls ?? []);
1650
+ const ids = new Set(calls.map((call) => call.id));
1651
+ const results = new Map(row.entries.flatMap((entry) => entry.message.toolCallId ? [[entry.message.toolCallId, entry]] : []));
1652
+ const orphanResults = row.entries.filter(({ message }) => isToolMessage(message) && (!message.toolCallId || !ids.has(message.toolCallId)));
1653
+ const count = calls.length + orphanResults.length;
1654
+ const names = [...new Set([...calls.map((call) => call.name), ...orphanResults.map(({ message }) => message.toolName ?? "工具")])];
1655
+ const failed = row.entries.some(({ message }) => message.isError);
1656
+ const label = `${row.active ? "思考与执行中" : "思考与执行过程"}${count ? ` · ${count} 次工具调用` : ""}${names.length ? ` · ${names.join("、")}` : ""}${failed ? " · 含失败调用" : ""}`;
1657
+ if (view.summary.textContent !== label)
1658
+ view.summary.textContent = label;
1659
+ view.summary.title = label;
1660
+ view.node.dataset["active"] = String(row.active);
1661
+ if (searchHit)
1662
+ view.details.open = true;
1663
+ // A completed run can contain hundreds of steps; mount them only on demand.
1664
+ if (!view.details.open) {
1665
+ reconcileChildren(view.body, []);
1666
+ return view.node;
1667
+ }
1668
+ const children = [];
1669
+ for (const entry of row.entries) {
1670
+ const message = entry.message;
1671
+ if (isToolMessage(message)) {
1672
+ if (!message.toolCallId || !ids.has(message.toolCallId))
1673
+ children.push(cachedEntry(entry));
1674
+ continue;
1675
+ }
1676
+ if (message.thinking || message.text.trim())
1677
+ children.push(cachedEntry({ ...entry, message: { ...message, toolCalls: [] } }));
1678
+ for (const call of message.toolCalls ?? []) {
1679
+ const result = results.get(call.id);
1680
+ const key = `call:${call.id}:${textFingerprint(call.input)}:${result ? messageVirtualKey(result.message, result.index) : row.active}`;
1681
+ let card = transcriptState.nodes.get(key);
1682
+ if (!card) {
1683
+ card = renderToolCall(call, result?.message, result ? (result.message.id ? { id: result.message.id } : { index: result.index }) : undefined, row.active);
1684
+ cacheMessageNode(key, card);
1685
+ }
1686
+ children.push(card);
1687
+ }
1688
+ }
1689
+ for (const child of children) {
1690
+ delete child.dataset["virtualKey"];
1691
+ delete child.dataset["virtualIndex"];
1692
+ child.removeAttribute("aria-posinset");
1693
+ child.removeAttribute("aria-setsize");
1694
+ }
1695
+ reconcileChildren(view.body, children);
1696
+ return view.node;
1697
+ }
1698
+ function renderToolCall(call, result, address, active) {
1699
+ const card = element("section", "tool-call");
1700
+ const details = document.createElement("details");
1701
+ const stateKey = `${transcriptState.sessionId}:tool:${call.id}`;
1702
+ details.open = disclosureStates.get(stateKey) ?? !result;
1703
+ const summary = element("summary", "tool-call-summary");
1704
+ const name = element("strong", "tool-call-name");
1705
+ name.textContent = call.name;
1706
+ const target = element("span", "tool-call-target");
1707
+ target.textContent = toolCallSummary(call);
1708
+ target.title = target.textContent;
1709
+ const status = element("span", `tool-call-status${result?.isError ? " error" : ""}`);
1710
+ status.textContent = result ? (result.isError ? "失败" : "完成") : active ? "执行中" : "未完成";
1711
+ summary.append(name, target, status);
1712
+ const inputLabel = element("div", "tool-section-label");
1713
+ inputLabel.textContent = "输入";
1714
+ const input = element("pre", "tool-call-input");
1715
+ input.textContent = call.input;
1716
+ details.append(summary, inputLabel, input);
1717
+ if (result) {
1718
+ const label = element("div", "tool-section-label");
1719
+ label.textContent = "输出";
1720
+ const output = element("pre", "tool-call-output");
1721
+ output.textContent = result.text || "(无输出)";
1722
+ details.append(label, output);
1723
+ const truncation = publishedTextTruncation(result.text);
1724
+ if (truncation && address)
1725
+ details.append(truncationNotice(address, truncation));
1726
+ }
1727
+ details.addEventListener("toggle", () => {
1728
+ rememberDisclosure(stateKey, details.open);
1729
+ scheduleTranscriptMeasurement();
1730
+ });
1731
+ card.append(details);
1732
+ // Preserve the document action even when the raw tool input/output is closed.
1733
+ if (result && parseArticleReceipt(result.text))
1734
+ card.append(renderMessage(result, address ?? { index: 0 }));
1735
+ return card;
1736
+ }
1737
+ function textFingerprint(value) {
1738
+ let hash = 2_166_136_261;
1739
+ for (let index = 0; index < value.length; index += 1) {
1740
+ hash ^= value.charCodeAt(index);
1741
+ hash = Math.imul(hash, 16_777_619);
1742
+ }
1743
+ return (hash >>> 0).toString(36);
1744
+ }
1745
+ function renderMessageQueue(snapshot, session) {
1746
+ const direct = session?.queue.steering ?? [];
1747
+ const queued = session?.queue.followUp ?? [];
1748
+ elements.messageQueue.hidden = direct.length + queued.length === 0;
1749
+ if (direct.length + queued.length === 0) {
1750
+ elements.messageQueue.replaceChildren();
1751
+ return;
1752
+ }
1753
+ const directNodes = direct.map((message) => {
1754
+ const row = element("article", "queue-message direct");
1755
+ row.dataset["queueMode"] = "direct";
1756
+ const state = element("span");
1757
+ state.textContent = "直接发送消息";
1758
+ state.title = "等待当前工具步骤结束后发送";
1759
+ const copy = element("p");
1760
+ copy.textContent = message;
1761
+ copy.title = message;
1762
+ row.append(state, copy);
1763
+ return row;
1764
+ });
1765
+ const queuedNodes = queued.map((message, index) => {
1766
+ const row = element("article", "queue-message");
1767
+ row.dataset["queueMode"] = "follow-up";
1768
+ const state = element("span");
1769
+ state.textContent = "继续排队";
1770
+ const copy = element("p");
1771
+ copy.textContent = message;
1772
+ copy.title = message;
1773
+ const sendNow = actionButton("直接发送消息", "ghost-button", () => {
1774
+ void perform("直接发送消息", {
1775
+ action: "queue.send-now",
1776
+ sessionId: snapshot.selectedSessionId,
1777
+ index,
1778
+ });
1779
+ });
1780
+ sendNow.disabled = snapshot.stableConnection.phase !== "connected";
1781
+ row.append(state, copy, sendNow);
1782
+ return row;
1783
+ });
1784
+ elements.messageQueue.replaceChildren(...directNodes, ...queuedNodes);
1785
+ }
1786
+ /**
1787
+ * What a first conversation should look like: each one runs real tools, comes
1788
+ * back in seconds, and needs no approval under the default policy. The first
1789
+ * ends in the document panel, because a written deliverable is the part of this
1790
+ * app nothing else on the screen explains.
1791
+ */
1792
+ const MAIN_SESSION_EXAMPLES = [
1793
+ {
1794
+ label: "把这台机器的 CPU、内存、磁盘和系统版本整理成一份可导出的说明文档",
1795
+ prompt: "把这台机器的 CPU、内存、磁盘和系统版本整理成一份可导出的说明文档",
1796
+ },
1797
+ {
1798
+ label: "看看现在最占内存的 10 个进程,说说哪些可以关掉",
1799
+ prompt: "看看现在最占内存的 10 个进程,说说哪些可以关掉",
1800
+ },
1801
+ {
1802
+ // A template rather than a prompt. Clicking it also attaches the desktop,
1803
+ // so the reference a dropped folder produces is already on screen: the way
1804
+ // to point this agent at a directory is visible before it is explained.
1805
+ label: "分析一个目录里都有什么 —— 已附上桌面,也可以拖入别的文件夹",
1806
+ prompt: "分析这个目录里都有什么、哪些可以清理,把结论整理成文档:",
1807
+ attach: "desktop",
1808
+ },
1809
+ ];
1810
+ function emptyTranscript(isChild) {
1811
+ const empty = element("div", "empty-transcript");
1812
+ const inner = element("div");
1813
+ const heading = element("strong");
1814
+ heading.textContent = isChild ? "子 Agent 尚未产生消息" : "在这台机器上开始一件事";
1815
+ const body = element("span");
1816
+ body.textContent = isChild
1817
+ ? "可以继续发送消息推进它;运行中发送的内容会自动排队。"
1818
+ : "pSpore 在本地运行:读写文件、执行命令、安排定时任务;越界操作会先向你请求批准。";
1819
+ inner.append(heading, body);
1820
+ if (!isChild) {
1821
+ const suggestions = element("div", "transcript-suggestions");
1822
+ for (const example of MAIN_SESSION_EXAMPLES) {
1823
+ suggestions.append(actionButton(example.label, "", () => {
1824
+ elements.composerInput.value = example.prompt;
1825
+ if (example.attach === "desktop" && desktopPlaces?.desktop) {
1826
+ const reference = composerFileReference(desktopPlaces.desktop, "directory");
1827
+ if (reference)
1828
+ addComposerFileReferences([reference]);
1829
+ }
1830
+ elements.composerInput.focus();
1831
+ // Leaves the caret at the end, where a dropped path is appended.
1832
+ elements.composerInput.setSelectionRange(example.prompt.length, example.prompt.length);
1833
+ }));
1834
+ }
1835
+ inner.append(suggestions);
1836
+ }
1837
+ empty.append(inner);
1838
+ return empty;
1839
+ }
1840
+ function renderMessage(message, address) {
573
1841
  const role = normalizedRole(message.role);
574
1842
  const article = element("article", `message ${role}`);
575
1843
  const meta = element("div", "message-meta");
576
1844
  const label = element("span");
577
1845
  label.textContent = role === "assistant" ? "pSpore" : role;
578
1846
  meta.append(element("i", "role-dot"), label);
1847
+ if (message.thinking) {
1848
+ const thought = element("section", "thinking-stream settled-thinking");
1849
+ const heading = element("header");
1850
+ const title = element("strong");
1851
+ title.textContent = "思考";
1852
+ heading.append(title);
1853
+ const body = element("div", "thinking-stream-body");
1854
+ body.textContent = message.thinking;
1855
+ thought.append(heading, body);
1856
+ article.append(thought);
1857
+ }
579
1858
  if (role === "tool") {
1859
+ const receipt = parseArticleReceipt(message.text);
1860
+ if (receipt) {
1861
+ const card = element("div", "article-receipt");
1862
+ const heading = element("strong");
1863
+ heading.textContent = receipt.title ? `文稿 · ${receipt.title}` : "文稿";
1864
+ const version = element("span");
1865
+ version.textContent = `第 ${receipt.version} 版`;
1866
+ const open = document.createElement("button");
1867
+ open.className = "ghost-button";
1868
+ open.type = "button";
1869
+ open.textContent = "打开文稿";
1870
+ open.addEventListener("click", () => {
1871
+ void articles.open(receipt.id, receipt.version);
1872
+ });
1873
+ card.append(heading, version, open);
1874
+ article.append(meta, card);
1875
+ return article;
1876
+ }
580
1877
  const details = element("details");
581
1878
  const summary = element("summary");
582
- summary.textContent = `Tool output · ${formatBytes(new TextEncoder().encode(message.text).byteLength)} · click to expand`;
1879
+ summary.textContent = `${message.toolName ?? "工具输出"}${message.isError ? " · 失败" : ""} · ${formatBytes(new TextEncoder().encode(message.text).byteLength)} · 点击展开`;
583
1880
  const pre = element("pre");
584
- pre.textContent = message.text || "(empty output)";
1881
+ pre.textContent = message.text || "(无输出)";
585
1882
  details.append(summary, pre);
1883
+ details.addEventListener("toggle", scheduleTranscriptMeasurement);
586
1884
  article.append(meta, details);
587
1885
  }
1886
+ else if (role === "assistant" && message.text.trim() === "") {
1887
+ // A turn that only called a tool has no words of its own. The tool card
1888
+ // below already says what happened, so the empty shell is collapsed rather
1889
+ // than announced — with the article tool this is every writing turn.
1890
+ if (!message.thinking)
1891
+ article.classList.add("message-silent");
1892
+ }
588
1893
  else {
589
- const body = element("div", "message-body");
590
- appendRichText(body, message.text || "(empty message)");
1894
+ const body = element("div", "message-body markdown");
1895
+ appendMarkdown(body, message.text || "(空消息)");
591
1896
  article.append(meta, body);
592
1897
  }
1898
+ const truncation = publishedTextTruncation(message.text);
1899
+ if (truncation)
1900
+ article.append(truncationNotice(address, truncation));
593
1901
  return article;
594
1902
  }
595
- function renderStreamingMessage(value) {
1903
+ /**
1904
+ * Offers the rest of a message the daemon had to cut. The publishing ceiling
1905
+ * keeps one enormous message from crowding out everything else on the wire, so
1906
+ * the full text is something a reader asks for rather than something every
1907
+ * broadcast carries.
1908
+ */
1909
+ function truncationNotice(address, truncation) {
1910
+ const notice = element("div", "message-truncated");
1911
+ const summary = element("span");
1912
+ summary.textContent = `仅显示前 ${truncation.kept.toLocaleString("zh-CN")} / ${truncation.total.toLocaleString("zh-CN")} 个字符`;
1913
+ const button = element("button", "ghost-button");
1914
+ button.type = "button";
1915
+ button.textContent = "载入全文";
1916
+ button.addEventListener("click", () => {
1917
+ button.disabled = true;
1918
+ button.textContent = "载入中…";
1919
+ void loadFullMessage(store.snapshot().selectedSessionId, address).then((loaded) => {
1920
+ // A successful load re-renders the message without this notice; a failed
1921
+ // one leaves it on screen, so the button has to become usable again.
1922
+ if (loaded)
1923
+ return;
1924
+ button.disabled = false;
1925
+ button.textContent = "重试载入全文";
1926
+ });
1927
+ });
1928
+ notice.append(summary, button);
1929
+ return notice;
1930
+ }
1931
+ /**
1932
+ * Pulls one message's untruncated text in slices no larger than the daemon
1933
+ * publishes, then re-renders with it in place. Slices rather than one frame,
1934
+ * because raising the ceiling for a single message raises it for every one.
1935
+ */
1936
+ async function loadFullMessage(sessionId, address) {
1937
+ const key = fullTextKey(sessionId, address);
1938
+ if (loadingFullText.has(key) || fullMessageTexts.has(key))
1939
+ return false;
1940
+ loadingFullText.add(key);
1941
+ try {
1942
+ const restored = await readFullMessage((offset, limit) => dispatchAcrossReconnect({
1943
+ action: "session.message",
1944
+ sessionId,
1945
+ ...address,
1946
+ offset,
1947
+ limit,
1948
+ }));
1949
+ // Slices that do not line up would splice two versions of one message
1950
+ // together, so the restore is abandoned rather than shown.
1951
+ if (restored === undefined)
1952
+ throw new Error("消息内容已变化");
1953
+ rememberFullText(key, restored);
1954
+ // The message array is unchanged, so the cached items have to be rebuilt
1955
+ // for the restored text to reach the screen.
1956
+ transcriptState.messageSource = undefined;
1957
+ store.setNotice("载入全文:完成");
1958
+ return true;
1959
+ }
1960
+ catch (error) {
1961
+ store.setNotice(`载入全文失败:${toError(error).message}`);
1962
+ return false;
1963
+ }
1964
+ finally {
1965
+ loadingFullText.delete(key);
1966
+ render();
1967
+ }
1968
+ }
1969
+ function rememberFullText(key, value) {
1970
+ fullMessageTexts.set(key, value);
1971
+ while (fullMessageTexts.size > FULL_TEXT_CACHE_ENTRIES) {
1972
+ const oldest = fullMessageTexts.keys().next().value;
1973
+ if (oldest === undefined)
1974
+ break;
1975
+ fullMessageTexts.delete(oldest);
1976
+ }
1977
+ }
1978
+ function fullTextKey(sessionId, address) {
1979
+ return "id" in address ? `${sessionId}\u0000id:${address.id}` : `${sessionId}\u0000at:${String(address.index)}`;
1980
+ }
1981
+ function createStreamingView() {
596
1982
  const article = element("article", "message assistant streaming");
597
1983
  const meta = element("div", "message-meta");
598
1984
  const label = element("span");
599
- label.textContent = "pSpore · streaming";
600
1985
  meta.append(element("i", "role-dot"), label);
601
- const body = element("div", "message-body");
602
- body.append(text(value), element("i", "stream-cursor"));
603
- article.append(meta, body);
604
- return article;
605
- }
606
- function appendRichText(container, value) {
607
- const expression = /```(?:[^\n]*)\n?([\s\S]*?)```/g;
608
- let cursor = 0;
609
- for (const match of value.matchAll(expression)) {
610
- const index = match.index;
611
- if (index > cursor)
612
- container.append(text(value.slice(cursor, index)));
613
- const pre = element("pre");
614
- pre.textContent = match[1] ?? "";
615
- container.append(pre);
616
- cursor = index + match[0].length;
617
- }
618
- if (cursor < value.length)
619
- container.append(text(value.slice(cursor)));
1986
+ const reasoning = element("section", "thinking-stream");
1987
+ reasoning.setAttribute("aria-label", "思考过程");
1988
+ const heading = element("header");
1989
+ const title = element("strong");
1990
+ title.textContent = "思考中";
1991
+ const hint = element("span");
1992
+ hint.textContent = "回复完成后自动隐藏";
1993
+ heading.append(title, hint);
1994
+ const thinkingBody = element("div", "thinking-stream-body");
1995
+ const thinkingText = document.createTextNode("");
1996
+ thinkingBody.append(thinkingText);
1997
+ reasoning.append(heading, thinkingBody);
1998
+ const body = element("div", "message-body markdown");
1999
+ const surface = createMarkdownSurface(body);
2000
+ const cursor = element("i", "stream-cursor");
2001
+ article.append(meta, reasoning, body);
2002
+ let labelText = "";
2003
+ return {
2004
+ node: article,
2005
+ update(value, thinking) {
2006
+ const nextLabel = value ? "pSpore · streaming" : "pSpore · thinking";
2007
+ if (labelText !== nextLabel) {
2008
+ label.textContent = nextLabel;
2009
+ labelText = nextLabel;
2010
+ }
2011
+ reasoning.hidden = thinking === "";
2012
+ // Written into the same Text node: replacing it would move the selection
2013
+ // out from under a reader holding one, and the value only ever grows.
2014
+ if (thinkingText.nodeValue !== thinking)
2015
+ thinkingText.nodeValue = thinking;
2016
+ body.hidden = value === "";
2017
+ if (value) {
2018
+ surface.update(value, cursor);
2019
+ }
2020
+ else {
2021
+ surface.update("");
2022
+ if (thinking)
2023
+ thinkingBody.append(cursor);
2024
+ else
2025
+ cursor.remove();
2026
+ }
2027
+ },
2028
+ };
620
2029
  }
621
2030
  function renderActivities(activities) {
622
- elements.activityCount.textContent = String(activities.length);
2031
+ tweenMetric(elements.activityCount, activities.length, String);
623
2032
  const cards = activities.slice(-12).reverse().map((activity) => {
624
2033
  const card = element("article", "activity-card");
625
2034
  card.dataset["status"] = activity.status;
@@ -627,14 +2036,14 @@ function renderActivities(activities) {
627
2036
  const title = element("b");
628
2037
  const state = element("span", `state-dot ${activity.status}`);
629
2038
  state.setAttribute("aria-label", activity.status);
630
- title.append(state, text(activity.label));
2039
+ title.append(state, text(activity.type.startsWith("tool.") && activity.toolName ? `${activity.toolName} · ${activity.label}` : activity.label));
631
2040
  const time = element("time");
632
2041
  time.textContent = clockTime(activity.ts);
633
2042
  time.dateTime = activity.ts;
634
2043
  header.append(title, time);
635
2044
  card.append(header);
636
2045
  const summary = element("p");
637
- summary.textContent = `${activity.summary}${activity.resultSummary ? ` · ${activity.resultSummary}` : ""}${activity.sessionId ? ` · session ${shortId(activity.sessionId)}` : ""}`;
2046
+ summary.textContent = `${activity.summary}${activity.resultSummary ? ` · ${activity.resultSummary}` : ""}${activity.sessionId ? ` · 会话 ${shortId(activity.sessionId)}` : ""}`;
638
2047
  card.append(summary);
639
2048
  if (activity.detail) {
640
2049
  const details = element("details");
@@ -648,13 +2057,137 @@ function renderActivities(activities) {
648
2057
  return card;
649
2058
  });
650
2059
  elements.activityList.replaceChildren(...(cards.length ? cards : [emptyList("暂无活动事件")]));
2060
+ // The inspector re-renders on every frame; only animate when the set changed,
2061
+ // otherwise a streaming run would restart the stagger dozens of times a second.
2062
+ const signature = activities.slice(-12).map((activity) => `${activity.id}:${activity.status}`).join("|");
2063
+ if (signature !== lastActivitySignature) {
2064
+ if (lastActivitySignature && cards.length)
2065
+ revealListItems(cards, { distance: 6, maxStagger: 8 });
2066
+ lastActivitySignature = signature;
2067
+ }
2068
+ }
2069
+ /**
2070
+ * What the agent is doing right now, so a run that produces no text for a while
2071
+ * is visibly working rather than indistinguishable from a hang.
2072
+ *
2073
+ * The phases are separated because they fail differently: a tool that never
2074
+ * returns is a stuck command, a model that never answers is a stalled request,
2075
+ * and an unanswered approval is waiting on the reader. Each gets its own mark
2076
+ * and its own elapsed clock.
2077
+ */
2078
+ function renderRunActivity(snapshot) {
2079
+ const phase = currentRunPhase(snapshot);
2080
+ if (!phase) {
2081
+ activityPhase = undefined;
2082
+ stopActivityTicker();
2083
+ elements.runActivity.hidden = true;
2084
+ return;
2085
+ }
2086
+ if (activityPhase?.key !== phase.key)
2087
+ activityPhase = { key: phase.key, since: Date.now() };
2088
+ elements.runActivity.hidden = false;
2089
+ elements.runActivity.dataset["phase"] = phase.kind;
2090
+ elements.runActivityMark.dataset["phase"] = phase.kind;
2091
+ elements.runActivityLabel.textContent = phase.label;
2092
+ const seconds = Math.floor((Date.now() - (activityPhase?.since ?? Date.now())) / 1000);
2093
+ // Silence for a second or two is normal; a count that only appears after that
2094
+ // keeps the line calm while still answering "how long" when it matters.
2095
+ elements.runActivityElapsed.textContent = seconds >= 2 ? `已 ${formatElapsed(seconds)}` : "";
2096
+ startActivityTicker();
2097
+ }
2098
+ function currentRunPhase(snapshot) {
2099
+ const sessionId = snapshot.selectedSessionId;
2100
+ // An approval blocks the run until the reader acts, so it outranks everything
2101
+ // else: nothing will move until it is answered.
2102
+ const approval = Object.values(snapshot.kernel.approvals)
2103
+ .find((request) => request.sessionId === sessionId);
2104
+ if (approval) {
2105
+ return { key: `approval:${approval.id}`, kind: "approval", label: `等待你批准 ${approval.capability}` };
2106
+ }
2107
+ const session = snapshot.kernel.sessions[sessionId];
2108
+ const child = selectedAgent(snapshot);
2109
+ const running = (session?.status ?? child?.status) === "running";
2110
+ if (!running)
2111
+ return undefined;
2112
+ const tool = snapshot.activities
2113
+ .filter((activity) => activity.status === "running" && (activity.sessionId ?? sessionId) === sessionId)
2114
+ .at(-1);
2115
+ if (tool) {
2116
+ const label = tool.toolName === "article" ? "撰写文稿" : tool.toolName === "compaction" ? "正在压缩上下文,完成后继续" : `执行 ${tool.toolName ?? tool.label}`;
2117
+ return { key: `tool:${tool.id}`, kind: "tool", label };
2118
+ }
2119
+ if (articles.isWriting())
2120
+ return { key: `article:${articles.writingKey()}`, kind: "tool", label: "撰写文稿" };
2121
+ if (snapshot.streamingText[sessionId])
2122
+ return { key: `stream:${sessionId}`, kind: "streaming", label: "生成回复中" };
2123
+ if (snapshot.streamingThinking[sessionId])
2124
+ return { key: `think:${sessionId}`, kind: "thinking", label: "思考中" };
2125
+ return { key: `wait:${sessionId}`, kind: "waiting", label: "等待模型响应" };
2126
+ }
2127
+ /** The elapsed count has to advance without new frames, so it drives itself. */
2128
+ function startActivityTicker() {
2129
+ if (activityTicker !== undefined)
2130
+ return;
2131
+ activityTicker = window.setInterval(() => {
2132
+ if (elements.runActivity.hidden || !activityPhase) {
2133
+ stopActivityTicker();
2134
+ return;
2135
+ }
2136
+ const seconds = Math.floor((Date.now() - activityPhase.since) / 1000);
2137
+ elements.runActivityElapsed.textContent = seconds >= 2 ? `已 ${formatElapsed(seconds)}` : "";
2138
+ }, 1_000);
2139
+ }
2140
+ function stopActivityTicker() {
2141
+ if (activityTicker === undefined)
2142
+ return;
2143
+ window.clearInterval(activityTicker);
2144
+ activityTicker = undefined;
2145
+ }
2146
+ /**
2147
+ * Continuous indexing status. The daemon owns the work; this is a read-only
2148
+ * view so the user can tell whether a search is running against a current index.
2149
+ */
2150
+ function renderIndexing(snapshot) {
2151
+ const view = presentIndexing(snapshot.kernel.indexing, Date.now());
2152
+ elements.indexingPanel.dataset["phase"] = view.phase;
2153
+ elements.indexingPanel.dataset["progress"] = view.percent === undefined ? "indeterminate" : "determinate";
2154
+ elements.indexingPhase.textContent = view.phaseLabel;
2155
+ const bar = elements.indexingBarFill.parentElement;
2156
+ if (view.percent === undefined) {
2157
+ // An indeterminate bar has no value; the stylesheet animates the fill instead.
2158
+ elements.indexingBarFill.style.width = "";
2159
+ bar?.removeAttribute("aria-valuenow");
2160
+ }
2161
+ else {
2162
+ elements.indexingBarFill.style.width = `${view.percent}%`;
2163
+ bar?.setAttribute("aria-valuenow", String(view.percent));
2164
+ }
2165
+ elements.indexingCounts.textContent = view.counts;
2166
+ elements.indexingCurrent.textContent = view.current;
2167
+ elements.indexingNote.textContent = view.note;
2168
+ if (view.ticking)
2169
+ startIndexingTicker();
2170
+ else
2171
+ stopIndexingTicker();
2172
+ }
2173
+ /** A walk that reports nothing still has to show the clock moving. */
2174
+ function startIndexingTicker() {
2175
+ if (indexingTicker !== undefined)
2176
+ return;
2177
+ indexingTicker = window.setInterval(() => renderIndexing(store.snapshot()), 1_000);
2178
+ }
2179
+ function stopIndexingTicker() {
2180
+ if (indexingTicker === undefined)
2181
+ return;
2182
+ window.clearInterval(indexingTicker);
2183
+ indexingTicker = undefined;
651
2184
  }
652
2185
  function renderJobs(snapshot) {
653
- const connected = snapshot.connection.phase === "connected";
2186
+ const connected = snapshot.stableConnection.phase === "connected";
654
2187
  const jobNodes = jobs.slice(0, 4).map((job) => {
655
2188
  const item = denseItem(job.id, job.enabled ? `${job.schedule.cron} · ${job.schedule.timezone}` : "disabled", job.enabled ? "ready" : "disabled");
656
2189
  const actions = element("div", "item-actions");
657
- const run = actionButton("立即运行", "ghost-button", () => void perform(`Running ${job.id}`, { action: "job.run", jobId: job.id }));
2190
+ const run = actionButton("立即运行", "ghost-button", () => void perform(`运行 ${job.id}`, { action: "job.run", jobId: job.id }));
658
2191
  run.disabled = !connected || !job.enabled;
659
2192
  actions.append(run);
660
2193
  item.append(actions);
@@ -668,7 +2201,7 @@ function renderJobs(snapshot) {
668
2201
  const item = denseItem(run.jobId ?? shortId(run.id), `${run.status} · ${clockTime(run.receivedAt)}`, run.status);
669
2202
  if (run.status === "queued" || run.status === "running") {
670
2203
  const actions = element("div", "item-actions");
671
- const cancel = actionButton("取消运行", "danger-ghost-button", () => void perform(`Cancelling ${run.id}`, { action: "run.cancel", runId: run.id }));
2204
+ const cancel = actionButton("取消运行", "danger-ghost-button", () => void perform(`取消运行 ${run.id}`, { action: "run.cancel", runId: run.id }));
672
2205
  cancel.disabled = !connected;
673
2206
  actions.append(cancel);
674
2207
  item.append(actions);
@@ -677,14 +2210,61 @@ function renderJobs(snapshot) {
677
2210
  });
678
2211
  elements.runList.replaceChildren(...(runNodes.length ? runNodes : [emptyList("暂无运行记录")]));
679
2212
  }
2213
+ function isBuiltinTool(value) {
2214
+ return typeof value === "object" && value !== null && typeof value.name === "string";
2215
+ }
680
2216
  function renderIntegrations(snapshot) {
681
- const connected = snapshot.connection.phase === "connected";
2217
+ const connected = snapshot.stableConnection.phase === "connected";
682
2218
  const nodes = [];
2219
+ // The fixed tools come first: they are what the agent can always do, and
2220
+ // without them the panel only ever answered "what has been added".
2221
+ if (builtinTools.length > 0) {
2222
+ const tools = element("div", "tool-chips");
2223
+ tools.setAttribute("role", "list");
2224
+ for (const tool of builtinTools) {
2225
+ const chip = element("span", "tool-chip");
2226
+ chip.setAttribute("role", "listitem");
2227
+ chip.textContent = tool.name;
2228
+ if (tool.description)
2229
+ chip.title = `${tool.label ?? tool.name} — ${tool.description}`;
2230
+ tools.append(chip);
2231
+ }
2232
+ const item = denseItem(`内置工具 · ${builtinTools.length}`, "每个会话都具备", "ready");
2233
+ item.append(tools);
2234
+ nodes.push(item);
2235
+ }
2236
+ // Skills next, from every source a session loads them from — not only the
2237
+ // trusted installs below — so a skill the user dropped into the workspace is
2238
+ // either visible here or listed with the reason it was not loaded.
2239
+ const activeSkills = skillCatalog.filter((skill) => skill.status === "active");
2240
+ if (activeSkills.length > 0) {
2241
+ const chips = element("div", "tool-chips skill-chips");
2242
+ chips.setAttribute("role", "list");
2243
+ for (const skill of activeSkills) {
2244
+ const chip = element("span", "tool-chip");
2245
+ chip.setAttribute("role", "listitem");
2246
+ chip.textContent = skill.name;
2247
+ chip.title = [`${skill.name} · ${SKILL_SOURCE_LABELS[skill.source]}`, skill.description, skill.path, skill.message]
2248
+ .filter(Boolean)
2249
+ .join("\n");
2250
+ chips.append(chip);
2251
+ }
2252
+ const item = denseItem(`Skills · ${activeSkills.length}`, "新会话可用", "ready");
2253
+ item.append(chips);
2254
+ nodes.push(item);
2255
+ }
2256
+ for (const skill of skillCatalog.filter((candidate) => candidate.status !== "active")) {
2257
+ const item = denseItem(`未加载 Skill · ${skill.name}`, `${SKILL_SOURCE_LABELS[skill.source]} · ${skill.status === "shadowed" ? "同名被覆盖" : "已跳过"}`, "degraded");
2258
+ const reason = element("p");
2259
+ reason.textContent = skill.message ? `${skill.message}(${skill.path})` : skill.path;
2260
+ item.append(reason);
2261
+ nodes.push(item);
2262
+ }
683
2263
  for (const health of Object.values(snapshot.kernel.mcpServers)) {
684
2264
  const item = denseItem(`MCP · ${health.serverId}`, `${health.status} · ${health.toolCount} tools`, health.status);
685
2265
  const actions = element("div", "item-actions");
686
- const discover = actionButton("发现工具", "ghost-button", () => void perform(`Discovering ${health.serverId}`, { action: "mcp.discover", serverId: health.serverId }));
687
- const ping = actionButton("Ping", "ghost-button", () => void perform(`Pinging ${health.serverId}`, { action: "mcp.ping", serverId: health.serverId }));
2266
+ const discover = actionButton("发现工具", "ghost-button", () => void perform(`发现 ${health.serverId} 工具`, { action: "mcp.discover", serverId: health.serverId }));
2267
+ const ping = actionButton("Ping", "ghost-button", () => void perform(`Ping ${health.serverId}`, { action: "mcp.ping", serverId: health.serverId }));
688
2268
  discover.disabled = !connected || health.status === "disabled" || health.status === "untrusted";
689
2269
  ping.disabled = discover.disabled;
690
2270
  actions.append(discover, ping);
@@ -697,7 +2277,7 @@ function renderIntegrations(snapshot) {
697
2277
  const catalog = catalogByKey.get(`${health.kind}:${health.name}`);
698
2278
  if (catalog?.record.trusted) {
699
2279
  const actions = element("div", "item-actions");
700
- const disable = actionButton("撤销信任", "danger-ghost-button", () => void perform(`Disabling ${health.name}`, {
2280
+ const disable = actionButton("撤销信任", "danger-ghost-button", () => void perform(`撤销 ${health.name} 信任`, {
701
2281
  action: "resources.disable",
702
2282
  kind: health.kind,
703
2283
  name: health.name,
@@ -725,15 +2305,26 @@ function renderApproval(snapshot) {
725
2305
  const request = Object.values(snapshot.kernel.approvals).sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))[0];
726
2306
  if (!request) {
727
2307
  const previousApproval = activeApprovalId;
728
- elements.approvalOverlay.hidden = true;
729
- updateAppInert();
730
2308
  activeApprovalId = undefined;
731
- if (previousApproval)
2309
+ if (elements.approvalOverlay.hidden)
2310
+ return;
2311
+ updateAppInert();
2312
+ const finishHide = () => {
2313
+ if (activeApprovalId !== undefined)
2314
+ return;
2315
+ elements.approvalOverlay.hidden = true;
2316
+ updateAppInert();
732
2317
  requestAnimationFrame(() => elements.composerInput.focus());
2318
+ };
2319
+ if (previousApproval)
2320
+ hideOverlayMotion(elements.approvalOverlay, elements.approvalDialog, finishHide);
2321
+ else
2322
+ elements.approvalOverlay.hidden = true;
733
2323
  return;
734
2324
  }
735
2325
  if (settingsOpen && !savingSettings)
736
2326
  closeSettings(false);
2327
+ const firstPaint = elements.approvalOverlay.hidden || activeApprovalId !== request.id;
737
2328
  elements.approvalOverlay.hidden = false;
738
2329
  updateAppInert();
739
2330
  elements.approvalTitle.textContent = request.display.summary;
@@ -743,21 +2334,26 @@ function renderApproval(snapshot) {
743
2334
  elements.approvalReasons.textContent = request.riskReasons.join(";") || "策略要求确认";
744
2335
  elements.approvalCommand.hidden = !request.display.command;
745
2336
  elements.approvalCommand.textContent = request.display.command ?? "";
746
- const actions = [];
747
- const denialScope = request.allowedScopes[0] ?? "once";
748
- actions.push(approvalButton("拒绝", "danger-ghost-button", request, false, denialScope));
749
- for (const scope of request.allowedScopes) {
750
- const label = scope === "once" ? "仅此一次" : scope === "session" ? "本会话允许" : "此任务允许";
751
- actions.push(approvalButton(label, scope === "once" ? "primary-button" : "secondary-button", request, true, scope));
752
- }
753
- elements.approvalActions.replaceChildren(...actions);
2337
+ // The buttons are rebuilt only for a different request. Replacing them on every
2338
+ // render destroys whichever one holds focus, which silently moved focus back to
2339
+ // the document whenever anything else re-rendered while the modal was open.
754
2340
  if (activeApprovalId !== request.id) {
2341
+ const actions = [];
2342
+ const denialScope = request.allowedScopes[0] ?? "once";
2343
+ actions.push(approvalButton("拒绝", "danger-ghost-button", request, false, denialScope));
2344
+ for (const scope of request.allowedScopes) {
2345
+ const label = scope === "once" ? "仅此一次" : scope === "session" ? "本会话允许" : "此任务允许";
2346
+ actions.push(approvalButton(label, scope === "once" ? "primary-button" : "secondary-button", request, true, scope));
2347
+ }
2348
+ elements.approvalActions.replaceChildren(...actions);
755
2349
  activeApprovalId = request.id;
2350
+ if (firstPaint)
2351
+ showOverlayMotion(elements.approvalOverlay, elements.approvalDialog);
756
2352
  requestAnimationFrame(() => actions[0]?.focus());
757
2353
  }
758
2354
  }
759
2355
  function approvalButton(label, className, request, approved, scope) {
760
- return actionButton(label, className, () => void perform(`${approved ? "Approving" : "Denying"} ${request.id}`, {
2356
+ return actionButton(label, className, () => void perform(`${approved ? "批准" : "拒绝"} ${request.id}`, {
761
2357
  action: "approval.resolve",
762
2358
  approvalId: request.id,
763
2359
  approved,
@@ -776,16 +2372,16 @@ function renderInspection() {
776
2372
  const digest = element("code");
777
2373
  digest.textContent = result.digest;
778
2374
  const summary = element("p");
779
- summary.textContent = `${result.files.length} files · ${formatBytes(result.totalBytes)}${result.diagnostics.length ? ` · ${result.diagnostics.join("; ")}` : ""}`;
2375
+ summary.textContent = `${result.files.length} 个文件 · ${formatBytes(result.totalBytes)}${result.diagnostics.length ? ` · ${result.diagnostics.join(";")}` : ""}`;
780
2376
  const install = actionButton(result.installable ? "信任并安装精确内容" : "不可安装", "secondary-button", () => {
781
2377
  const stagingName = elements.stagingName.value.trim();
782
2378
  if (stagingName)
783
- void perform(`Installing ${result.name}`, { action: "resources.install", stagingName, expectedDigest: result.digest }, async () => {
2379
+ void perform(`安装 ${result.name}`, { action: "resources.install", stagingName, expectedDigest: result.digest }, async () => {
784
2380
  inspection = undefined;
785
2381
  await refreshSideData();
786
2382
  });
787
2383
  });
788
- install.disabled = !result.installable || store.snapshot().connection.phase !== "connected";
2384
+ install.disabled = !result.installable || store.snapshot().stableConnection.phase !== "connected";
789
2385
  elements.inspectionResult.replaceChildren(title, digest, summary, install);
790
2386
  }
791
2387
  function renderVersions() {
@@ -836,6 +2432,33 @@ function byId(id) {
836
2432
  throw new Error(`Missing desktop element #${id}`);
837
2433
  return value;
838
2434
  }
2435
+ function bindSecretField(input, reveal, clear) {
2436
+ reveal.addEventListener("click", () => {
2437
+ const revealing = input.type === "password";
2438
+ input.type = revealing ? "text" : "password";
2439
+ reveal.textContent = revealing ? "隐藏" : "显示";
2440
+ });
2441
+ input.addEventListener("input", () => {
2442
+ if (input.value)
2443
+ clear.checked = false;
2444
+ });
2445
+ clear.addEventListener("change", () => {
2446
+ if (clear.checked)
2447
+ input.value = "";
2448
+ });
2449
+ }
2450
+ function resetSecretField(input, reveal, clear, configured, label) {
2451
+ input.value = "";
2452
+ input.type = "password";
2453
+ input.placeholder = configured ? "已配置;留空将保留" : `输入 ${label}`;
2454
+ reveal.textContent = "显示";
2455
+ clear.checked = false;
2456
+ }
2457
+ function settingsStatus(settings) {
2458
+ const key = settings.hasApiKey ? "已检测到私有 Key" : "尚未保存 API Key";
2459
+ const cookie = settings.hasCookie ? "已检测到 Cookie" : "尚未保存 Cookie";
2460
+ return `${key};${cookie}。留空会保留当前值。`;
2461
+ }
839
2462
  function missingElement(selector) {
840
2463
  throw new Error(`Missing desktop element ${selector}`);
841
2464
  }
@@ -856,16 +2479,17 @@ function initialTheme() {
856
2479
  return stored;
857
2480
  }
858
2481
  catch {
859
- // Fall through to the operating-system preference.
2482
+ // Fall through to the daylight default.
860
2483
  }
861
- return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
2484
+ // The product default is the daylight (white & cobalt) theme.
2485
+ return "light";
862
2486
  }
863
2487
  function applyTheme(theme) {
864
2488
  document.documentElement.dataset["theme"] = theme;
865
2489
  const nextLabel = theme === "dark" ? "切换到浅色模式" : "切换到深色模式";
866
2490
  elements.themeToggle.setAttribute("aria-label", nextLabel);
867
2491
  elements.themeToggle.title = nextLabel;
868
- elements.themeColor.content = theme === "dark" ? "#0d100e" : "#f2f2ec";
2492
+ elements.themeColor.content = theme === "dark" ? "#0c121b" : "#f4f7fb";
869
2493
  }
870
2494
  function friendlyConnectionMessage(message, phase) {
871
2495
  if (message?.includes("ENOENT") || message?.includes("daemon.sock")) {
@@ -884,9 +2508,22 @@ function isResourceEntry(value) {
884
2508
  return false;
885
2509
  return typeof value["record"]["name"] === "string" && typeof value["record"]["digest"] === "string";
886
2510
  }
2511
+ function isSkillCatalogEntry(value) {
2512
+ return isRecord(value)
2513
+ && typeof value["name"] === "string"
2514
+ && typeof value["path"] === "string"
2515
+ && (value["source"] === "installed" || value["source"] === "bundled" || value["source"] === "workspace")
2516
+ && (value["status"] === "active" || value["status"] === "skipped" || value["status"] === "shadowed");
2517
+ }
887
2518
  function isInspection(value) {
888
2519
  return isRecord(value) && value["version"] === 1 && typeof value["name"] === "string" && typeof value["digest"] === "string" && Array.isArray(value["files"]);
889
2520
  }
2521
+ function isSessionView(value) {
2522
+ return isRecord(value)
2523
+ && typeof value["sessionId"] === "string"
2524
+ && typeof value["upstreamSessionId"] === "string"
2525
+ && Array.isArray(value["messages"]);
2526
+ }
890
2527
  function isRecord(value) {
891
2528
  return typeof value === "object" && value !== null && !Array.isArray(value);
892
2529
  }