nastechai-desktop 18.1.0

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 (546) hide show
  1. package/.prettierrc +11 -0
  2. package/DESIGN.md +167 -0
  3. package/README.md +141 -0
  4. package/assets/icon.icns +0 -0
  5. package/assets/icon.ico +0 -0
  6. package/assets/icon.png +0 -0
  7. package/components.json +21 -0
  8. package/electron/backend-env.cjs +112 -0
  9. package/electron/backend-env.test.cjs +111 -0
  10. package/electron/backend-probes.cjs +106 -0
  11. package/electron/backend-probes.test.cjs +82 -0
  12. package/electron/backend-ready.cjs +66 -0
  13. package/electron/bootstrap-platform.cjs +91 -0
  14. package/electron/bootstrap-platform.test.cjs +111 -0
  15. package/electron/bootstrap-runner.cjs +720 -0
  16. package/electron/bootstrap-runner.test.cjs +138 -0
  17. package/electron/connection-config.cjs +254 -0
  18. package/electron/connection-config.test.cjs +329 -0
  19. package/electron/dashboard-token.cjs +99 -0
  20. package/electron/dashboard-token.test.cjs +142 -0
  21. package/electron/desktop-uninstall.cjs +232 -0
  22. package/electron/desktop-uninstall.test.cjs +246 -0
  23. package/electron/entitlements.mac.inherit.plist +14 -0
  24. package/electron/entitlements.mac.plist +14 -0
  25. package/electron/fs-read-dir.cjs +109 -0
  26. package/electron/fs-read-dir.test.cjs +364 -0
  27. package/electron/gateway-ws-probe.cjs +188 -0
  28. package/electron/gateway-ws-probe.test.cjs +122 -0
  29. package/electron/git-root.cjs +54 -0
  30. package/electron/git-root.test.cjs +40 -0
  31. package/electron/git-worktrees.cjs +174 -0
  32. package/electron/hardening.cjs +184 -0
  33. package/electron/hardening.test.cjs +116 -0
  34. package/electron/main.cjs +5762 -0
  35. package/electron/oauth-net-request.cjs +20 -0
  36. package/electron/oauth-net-request.test.cjs +34 -0
  37. package/electron/preload.cjs +135 -0
  38. package/electron/session-windows.cjs +99 -0
  39. package/electron/session-windows.test.cjs +177 -0
  40. package/electron/update-remote.cjs +56 -0
  41. package/electron/update-remote.test.cjs +78 -0
  42. package/electron/vscode-marketplace.cjs +331 -0
  43. package/electron/vscode-marketplace.test.cjs +113 -0
  44. package/electron/windows-child-process.test.cjs +57 -0
  45. package/electron/windows-user-env.cjs +76 -0
  46. package/electron/windows-user-env.test.cjs +90 -0
  47. package/electron/workspace-cwd.cjs +38 -0
  48. package/electron/workspace-cwd.test.cjs +45 -0
  49. package/eslint.config.mjs +122 -0
  50. package/index.html +17 -0
  51. package/package.json +254 -0
  52. package/pr-assets/session-source-folders.png +0 -0
  53. package/preview-demo.html +65 -0
  54. package/public/apple-touch-icon.png +0 -0
  55. package/public/ds-assets/filler-bg0.jpg +0 -0
  56. package/public/nastech-frames/nastech-frame-0.png +0 -0
  57. package/public/nastech-frames/nastech-frame-1.png +0 -0
  58. package/public/nastech-frames/nastech-frame-2.png +0 -0
  59. package/public/nastech-frames/nastech-frame-3.png +0 -0
  60. package/public/nastech-frames/nastech-frame-4.png +0 -0
  61. package/public/nastech-frames/nastech-frame-5.png +0 -0
  62. package/public/nastech-frames/nastech-frame-6.png +0 -0
  63. package/public/nastech-frames/nastech-frame-7.png +0 -0
  64. package/public/nastech-girl.jpg +0 -0
  65. package/public/nastech-sprite.png +0 -0
  66. package/public/nastech.png +0 -0
  67. package/scripts/after-pack.cjs +41 -0
  68. package/scripts/assert-dist-built.cjs +70 -0
  69. package/scripts/assert-dist-built.test.cjs +84 -0
  70. package/scripts/assert-root-install.cjs +13 -0
  71. package/scripts/before-build.cjs +11 -0
  72. package/scripts/before-pack.cjs +78 -0
  73. package/scripts/before-pack.test.cjs +53 -0
  74. package/scripts/click-session.mjs +51 -0
  75. package/scripts/dev-no-hmr.mjs +22 -0
  76. package/scripts/diag-jump.mjs +115 -0
  77. package/scripts/diag-scroll-reset.mjs +229 -0
  78. package/scripts/eval.mjs +21 -0
  79. package/scripts/leak-typing.mjs +222 -0
  80. package/scripts/measure-jump.mjs +108 -0
  81. package/scripts/measure-latency.mjs +184 -0
  82. package/scripts/measure-real-stream.mjs +252 -0
  83. package/scripts/measure-submit.mjs +179 -0
  84. package/scripts/measure-synthetic-stream.mjs +322 -0
  85. package/scripts/notarize-artifact.cjs +77 -0
  86. package/scripts/notarize.cjs +100 -0
  87. package/scripts/patch-electron-builder-mac-binary.cjs +59 -0
  88. package/scripts/probe-renderer.mjs +38 -0
  89. package/scripts/probe-thread.mjs +40 -0
  90. package/scripts/profile-long-stream.mjs +191 -0
  91. package/scripts/profile-real-stream.mjs +137 -0
  92. package/scripts/profile-synth-stream.mjs +103 -0
  93. package/scripts/profile-typing-lag.md +381 -0
  94. package/scripts/profile-typing.mjs +260 -0
  95. package/scripts/reload-renderer.mjs +25 -0
  96. package/scripts/reload.mjs +36 -0
  97. package/scripts/set-exe-identity.cjs +94 -0
  98. package/scripts/stage-native-deps.cjs +159 -0
  99. package/scripts/test-desktop.mjs +425 -0
  100. package/scripts/write-build-stamp.cjs +126 -0
  101. package/src/app/agents/index.tsx +398 -0
  102. package/src/app/artifacts/index.test.ts +62 -0
  103. package/src/app/artifacts/index.tsx +906 -0
  104. package/src/app/chat/chat-drop-overlay.tsx +48 -0
  105. package/src/app/chat/chat-swap-overlay.tsx +47 -0
  106. package/src/app/chat/composer/attachments.tsx +114 -0
  107. package/src/app/chat/composer/completion-drawer.tsx +63 -0
  108. package/src/app/chat/composer/context-menu.tsx +172 -0
  109. package/src/app/chat/composer/controls.tsx +289 -0
  110. package/src/app/chat/composer/drop-affordance.ts +2 -0
  111. package/src/app/chat/composer/enter-submit-dom-race.test.tsx +218 -0
  112. package/src/app/chat/composer/focus.ts +134 -0
  113. package/src/app/chat/composer/help-hint.tsx +59 -0
  114. package/src/app/chat/composer/hooks/use-at-completions.ts +141 -0
  115. package/src/app/chat/composer/hooks/use-live-completion-adapter.ts +119 -0
  116. package/src/app/chat/composer/hooks/use-mic-recorder.ts +291 -0
  117. package/src/app/chat/composer/hooks/use-slash-completions.ts +114 -0
  118. package/src/app/chat/composer/hooks/use-voice-conversation.ts +390 -0
  119. package/src/app/chat/composer/hooks/use-voice-recorder.ts +116 -0
  120. package/src/app/chat/composer/ime-composition-dom-repro.test.tsx +108 -0
  121. package/src/app/chat/composer/index.tsx +1611 -0
  122. package/src/app/chat/composer/inline-refs.ts +138 -0
  123. package/src/app/chat/composer/model-pill.tsx +86 -0
  124. package/src/app/chat/composer/queue-panel.tsx +130 -0
  125. package/src/app/chat/composer/rich-editor.test.ts +18 -0
  126. package/src/app/chat/composer/rich-editor.ts +165 -0
  127. package/src/app/chat/composer/skin-slash-popover.tsx +61 -0
  128. package/src/app/chat/composer/slash-nav-dom-repro.test.tsx +186 -0
  129. package/src/app/chat/composer/status-stack/index.tsx +202 -0
  130. package/src/app/chat/composer/status-stack/status-row.tsx +155 -0
  131. package/src/app/chat/composer/text-utils.test.ts +77 -0
  132. package/src/app/chat/composer/text-utils.ts +107 -0
  133. package/src/app/chat/composer/trigger-popover.test.tsx +42 -0
  134. package/src/app/chat/composer/trigger-popover.tsx +116 -0
  135. package/src/app/chat/composer/types.ts +64 -0
  136. package/src/app/chat/composer/url-dialog.tsx +82 -0
  137. package/src/app/chat/composer/voice-activity.tsx +252 -0
  138. package/src/app/chat/hooks/use-composer-actions.test.ts +57 -0
  139. package/src/app/chat/hooks/use-composer-actions.ts +525 -0
  140. package/src/app/chat/hooks/use-file-drop-zone.ts +118 -0
  141. package/src/app/chat/index.tsx +390 -0
  142. package/src/app/chat/perf-probe.tsx +269 -0
  143. package/src/app/chat/right-rail/index.ts +1 -0
  144. package/src/app/chat/right-rail/preview-console-state.ts +82 -0
  145. package/src/app/chat/right-rail/preview-console.tsx +290 -0
  146. package/src/app/chat/right-rail/preview-file.tsx +559 -0
  147. package/src/app/chat/right-rail/preview-pane.test.tsx +43 -0
  148. package/src/app/chat/right-rail/preview-pane.tsx +657 -0
  149. package/src/app/chat/right-rail/preview.tsx +171 -0
  150. package/src/app/chat/scroll-to-bottom-button.test.tsx +67 -0
  151. package/src/app/chat/scroll-to-bottom-button.tsx +74 -0
  152. package/src/app/chat/sidebar/cron-jobs-section.tsx +325 -0
  153. package/src/app/chat/sidebar/index.tsx +1219 -0
  154. package/src/app/chat/sidebar/load-more-row.tsx +30 -0
  155. package/src/app/chat/sidebar/order.test.ts +21 -0
  156. package/src/app/chat/sidebar/order.ts +17 -0
  157. package/src/app/chat/sidebar/profile-switcher.tsx +516 -0
  158. package/src/app/chat/sidebar/session-actions-menu.tsx +264 -0
  159. package/src/app/chat/sidebar/session-row.tsx +257 -0
  160. package/src/app/chat/sidebar/virtual-session-list.tsx +154 -0
  161. package/src/app/chat/sidebar/workspace-groups.test.ts +149 -0
  162. package/src/app/chat/sidebar/workspace-groups.ts +326 -0
  163. package/src/app/chat/thread-loading.test.ts +34 -0
  164. package/src/app/chat/thread-loading.ts +26 -0
  165. package/src/app/command-center/index.tsx +654 -0
  166. package/src/app/command-palette/index.tsx +513 -0
  167. package/src/app/command-palette/marketplace-theme-page.tsx +157 -0
  168. package/src/app/cron/index.tsx +942 -0
  169. package/src/app/cron/job-state.ts +29 -0
  170. package/src/app/desktop-controller.tsx +938 -0
  171. package/src/app/floating-hud.ts +22 -0
  172. package/src/app/gateway/hooks/use-gateway-boot.test.tsx +265 -0
  173. package/src/app/gateway/hooks/use-gateway-boot.ts +387 -0
  174. package/src/app/gateway/hooks/use-gateway-request.ts +138 -0
  175. package/src/app/hooks/use-keybinds.ts +186 -0
  176. package/src/app/hooks/use-refresh-hotkey.ts +45 -0
  177. package/src/app/hooks/use-route-enum-param.ts +38 -0
  178. package/src/app/index.tsx +1 -0
  179. package/src/app/layout-constants.ts +13 -0
  180. package/src/app/messaging/index.tsx +648 -0
  181. package/src/app/messaging/platform-icon.tsx +93 -0
  182. package/src/app/model-picker-overlay.tsx +42 -0
  183. package/src/app/model-visibility-overlay.tsx +31 -0
  184. package/src/app/overlays/overlay-chrome.tsx +66 -0
  185. package/src/app/overlays/overlay-search-input.tsx +33 -0
  186. package/src/app/overlays/overlay-split-layout.tsx +130 -0
  187. package/src/app/overlays/overlay-view.tsx +91 -0
  188. package/src/app/page-search-shell.tsx +75 -0
  189. package/src/app/profiles/create-profile-dialog.tsx +154 -0
  190. package/src/app/profiles/delete-profile-dialog.tsx +65 -0
  191. package/src/app/profiles/index.tsx +671 -0
  192. package/src/app/profiles/rename-profile-dialog.tsx +125 -0
  193. package/src/app/right-sidebar/files/dnd-manager.ts +27 -0
  194. package/src/app/right-sidebar/files/ipc.test.ts +100 -0
  195. package/src/app/right-sidebar/files/ipc.ts +161 -0
  196. package/src/app/right-sidebar/files/remote-picker.tsx +177 -0
  197. package/src/app/right-sidebar/files/tree.tsx +224 -0
  198. package/src/app/right-sidebar/files/use-project-tree.test.ts +190 -0
  199. package/src/app/right-sidebar/files/use-project-tree.ts +268 -0
  200. package/src/app/right-sidebar/index.test.tsx +75 -0
  201. package/src/app/right-sidebar/index.tsx +395 -0
  202. package/src/app/right-sidebar/store.ts +15 -0
  203. package/src/app/right-sidebar/terminal/buffer.ts +65 -0
  204. package/src/app/right-sidebar/terminal/index.tsx +98 -0
  205. package/src/app/right-sidebar/terminal/persistent.tsx +122 -0
  206. package/src/app/right-sidebar/terminal/selection.ts +75 -0
  207. package/src/app/right-sidebar/terminal/use-terminal-session.ts +504 -0
  208. package/src/app/routes.ts +88 -0
  209. package/src/app/session/hooks/use-context-suggestions.ts +58 -0
  210. package/src/app/session/hooks/use-cwd-actions.ts +109 -0
  211. package/src/app/session/hooks/use-message-stream.ts +957 -0
  212. package/src/app/session/hooks/use-model-controls.test.tsx +198 -0
  213. package/src/app/session/hooks/use-model-controls.ts +106 -0
  214. package/src/app/session/hooks/use-nastech-config.ts +74 -0
  215. package/src/app/session/hooks/use-preview-routing.test.tsx +168 -0
  216. package/src/app/session/hooks/use-preview-routing.ts +223 -0
  217. package/src/app/session/hooks/use-prompt-actions.test.tsx +316 -0
  218. package/src/app/session/hooks/use-prompt-actions.ts +1030 -0
  219. package/src/app/session/hooks/use-route-resume.test.tsx +136 -0
  220. package/src/app/session/hooks/use-route-resume.ts +115 -0
  221. package/src/app/session/hooks/use-session-actions.test.tsx +119 -0
  222. package/src/app/session/hooks/use-session-actions.ts +885 -0
  223. package/src/app/session/hooks/use-session-state-cache.test.tsx +118 -0
  224. package/src/app/session/hooks/use-session-state-cache.ts +191 -0
  225. package/src/app/session-picker-overlay.tsx +32 -0
  226. package/src/app/session-switcher.tsx +107 -0
  227. package/src/app/settings/about-settings.tsx +173 -0
  228. package/src/app/settings/appearance-settings.tsx +162 -0
  229. package/src/app/settings/config-settings.tsx +384 -0
  230. package/src/app/settings/constants.ts +545 -0
  231. package/src/app/settings/credential-key-ui.tsx +373 -0
  232. package/src/app/settings/env-credentials.tsx +198 -0
  233. package/src/app/settings/env-var-actions-menu.tsx +136 -0
  234. package/src/app/settings/field-copy.ts +56 -0
  235. package/src/app/settings/gateway-settings.tsx +620 -0
  236. package/src/app/settings/helpers.test.ts +138 -0
  237. package/src/app/settings/helpers.ts +151 -0
  238. package/src/app/settings/index.tsx +237 -0
  239. package/src/app/settings/keys-settings.tsx +96 -0
  240. package/src/app/settings/mcp-settings.tsx +271 -0
  241. package/src/app/settings/model-settings.test.tsx +157 -0
  242. package/src/app/settings/model-settings.tsx +559 -0
  243. package/src/app/settings/notifications-settings.tsx +150 -0
  244. package/src/app/settings/primitives.tsx +115 -0
  245. package/src/app/settings/providers-settings.test.tsx +100 -0
  246. package/src/app/settings/providers-settings.tsx +258 -0
  247. package/src/app/settings/sessions-settings.tsx +276 -0
  248. package/src/app/settings/toolset-config-panel.test.tsx +289 -0
  249. package/src/app/settings/toolset-config-panel.tsx +449 -0
  250. package/src/app/settings/types.ts +42 -0
  251. package/src/app/settings/uninstall-section.tsx +185 -0
  252. package/src/app/settings/use-deep-link-highlight.ts +60 -0
  253. package/src/app/shell/app-shell.tsx +167 -0
  254. package/src/app/shell/gateway-menu-panel.tsx +150 -0
  255. package/src/app/shell/hooks/use-overlay-routing.ts +71 -0
  256. package/src/app/shell/hooks/use-status-snapshot.ts +57 -0
  257. package/src/app/shell/hooks/use-statusbar-items.tsx +403 -0
  258. package/src/app/shell/keybind-panel.tsx +220 -0
  259. package/src/app/shell/model-edit-submenu.test.tsx +84 -0
  260. package/src/app/shell/model-edit-submenu.tsx +245 -0
  261. package/src/app/shell/model-menu-panel.tsx +295 -0
  262. package/src/app/shell/sidebar-label.tsx +22 -0
  263. package/src/app/shell/statusbar-controls.tsx +185 -0
  264. package/src/app/shell/titlebar-controls.tsx +244 -0
  265. package/src/app/shell/titlebar.test.ts +26 -0
  266. package/src/app/shell/titlebar.ts +45 -0
  267. package/src/app/shell/use-group-registry.ts +39 -0
  268. package/src/app/skills/index.test.tsx +103 -0
  269. package/src/app/skills/index.tsx +371 -0
  270. package/src/app/types.ts +99 -0
  271. package/src/app/updates-overlay.tsx +369 -0
  272. package/src/components/Backdrop.tsx +114 -0
  273. package/src/components/assistant-ui/ansi-text.tsx +34 -0
  274. package/src/components/assistant-ui/clarify-tool.tsx +281 -0
  275. package/src/components/assistant-ui/directive-text.test.ts +39 -0
  276. package/src/components/assistant-ui/directive-text.tsx +389 -0
  277. package/src/components/assistant-ui/markdown-text.test.ts +204 -0
  278. package/src/components/assistant-ui/markdown-text.tsx +497 -0
  279. package/src/components/assistant-ui/message-render-boundary.test.tsx +80 -0
  280. package/src/components/assistant-ui/message-render-boundary.tsx +48 -0
  281. package/src/components/assistant-ui/streaming.test.tsx +739 -0
  282. package/src/components/assistant-ui/thread-list.tsx +307 -0
  283. package/src/components/assistant-ui/thread-virtualizer.tsx +512 -0
  284. package/src/components/assistant-ui/thread.tsx +1474 -0
  285. package/src/components/assistant-ui/todo-tool.tsx +109 -0
  286. package/src/components/assistant-ui/tool-approval-group.test.tsx +158 -0
  287. package/src/components/assistant-ui/tool-approval.test.tsx +81 -0
  288. package/src/components/assistant-ui/tool-approval.tsx +209 -0
  289. package/src/components/assistant-ui/tool-fallback-model.test.ts +66 -0
  290. package/src/components/assistant-ui/tool-fallback-model.ts +1368 -0
  291. package/src/components/assistant-ui/tool-fallback.tsx +466 -0
  292. package/src/components/assistant-ui/tooltip-icon-button.tsx +33 -0
  293. package/src/components/assistant-ui/user-message-edit.test.tsx +141 -0
  294. package/src/components/assistant-ui/user-message-text.tsx +150 -0
  295. package/src/components/boot-failure-overlay.tsx +246 -0
  296. package/src/components/boot-failure-reauth.test.ts +100 -0
  297. package/src/components/boot-failure-reauth.ts +81 -0
  298. package/src/components/brand-mark.tsx +19 -0
  299. package/src/components/chat/activity-timer-text.tsx +24 -0
  300. package/src/components/chat/activity-timer.test.tsx +43 -0
  301. package/src/components/chat/activity-timer.ts +64 -0
  302. package/src/components/chat/code-card.tsx +78 -0
  303. package/src/components/chat/compact-markdown.tsx +113 -0
  304. package/src/components/chat/composer-dock.ts +31 -0
  305. package/src/components/chat/diff-lines.tsx +54 -0
  306. package/src/components/chat/disclosure-row.tsx +63 -0
  307. package/src/components/chat/generated-image-context.tsx +19 -0
  308. package/src/components/chat/generated-image-result.tsx +174 -0
  309. package/src/components/chat/image-generation-placeholder.tsx +279 -0
  310. package/src/components/chat/intro-copy.jsonl +75 -0
  311. package/src/components/chat/intro.tsx +182 -0
  312. package/src/components/chat/preview-attachment.tsx +125 -0
  313. package/src/components/chat/shiki-highlighter.tsx +107 -0
  314. package/src/components/chat/status-row.tsx +70 -0
  315. package/src/components/chat/status-section.tsx +42 -0
  316. package/src/components/chat/terminal-output.tsx +50 -0
  317. package/src/components/chat/zoomable-image.tsx +177 -0
  318. package/src/components/desktop-install-overlay.tsx +595 -0
  319. package/src/components/desktop-onboarding-overlay.test.tsx +100 -0
  320. package/src/components/desktop-onboarding-overlay.tsx +1286 -0
  321. package/src/components/error-boundary.tsx +77 -0
  322. package/src/components/gateway-connecting-overlay.test.tsx +143 -0
  323. package/src/components/gateway-connecting-overlay.tsx +183 -0
  324. package/src/components/haptics-provider.tsx +19 -0
  325. package/src/components/language-switcher.test.tsx +53 -0
  326. package/src/components/language-switcher.tsx +175 -0
  327. package/src/components/model-picker.tsx +340 -0
  328. package/src/components/model-visibility-dialog.tsx +155 -0
  329. package/src/components/notifications.tsx +196 -0
  330. package/src/components/page-loader.tsx +34 -0
  331. package/src/components/pane-shell/context.ts +14 -0
  332. package/src/components/pane-shell/index.ts +4 -0
  333. package/src/components/pane-shell/pane-shell.test.tsx +333 -0
  334. package/src/components/pane-shell/pane-shell.tsx +330 -0
  335. package/src/components/prompt-overlays.tsx +234 -0
  336. package/src/components/session-picker.tsx +108 -0
  337. package/src/components/status-dot.tsx +26 -0
  338. package/src/components/ui/action-status.tsx +25 -0
  339. package/src/components/ui/alert.tsx +53 -0
  340. package/src/components/ui/badge.tsx +35 -0
  341. package/src/components/ui/braille-spinner.tsx +61 -0
  342. package/src/components/ui/button.tsx +81 -0
  343. package/src/components/ui/checkbox.tsx +27 -0
  344. package/src/components/ui/codicon.tsx +20 -0
  345. package/src/components/ui/command.tsx +111 -0
  346. package/src/components/ui/confirm-dialog.tsx +109 -0
  347. package/src/components/ui/context-menu.tsx +141 -0
  348. package/src/components/ui/control.ts +25 -0
  349. package/src/components/ui/copy-button.test.tsx +36 -0
  350. package/src/components/ui/copy-button.tsx +229 -0
  351. package/src/components/ui/dialog.tsx +152 -0
  352. package/src/components/ui/disclosure-caret.tsx +20 -0
  353. package/src/components/ui/dropdown-menu.tsx +291 -0
  354. package/src/components/ui/error-state.tsx +50 -0
  355. package/src/components/ui/fade-text.tsx +110 -0
  356. package/src/components/ui/glyph-spinner.tsx +63 -0
  357. package/src/components/ui/input.tsx +22 -0
  358. package/src/components/ui/kbd.tsx +37 -0
  359. package/src/components/ui/loader.tsx +558 -0
  360. package/src/components/ui/log-view.tsx +17 -0
  361. package/src/components/ui/pagination.tsx +114 -0
  362. package/src/components/ui/popover.tsx +44 -0
  363. package/src/components/ui/scroll-area.tsx +43 -0
  364. package/src/components/ui/search-field.tsx +80 -0
  365. package/src/components/ui/segmented-control.tsx +51 -0
  366. package/src/components/ui/select.tsx +92 -0
  367. package/src/components/ui/separator.tsx +26 -0
  368. package/src/components/ui/sheet.tsx +116 -0
  369. package/src/components/ui/sidebar.tsx +674 -0
  370. package/src/components/ui/skeleton.tsx +7 -0
  371. package/src/components/ui/switch.tsx +49 -0
  372. package/src/components/ui/tabs.tsx +36 -0
  373. package/src/components/ui/text-tab.tsx +43 -0
  374. package/src/components/ui/textarea.tsx +11 -0
  375. package/src/components/ui/tool-icon.tsx +65 -0
  376. package/src/components/ui/tooltip.tsx +69 -0
  377. package/src/fonts/JetBrainsMono-Bold.woff2 +0 -0
  378. package/src/fonts/JetBrainsMono-Italic.woff2 +0 -0
  379. package/src/fonts/JetBrainsMono-Regular.woff2 +0 -0
  380. package/src/global.d.ts +457 -0
  381. package/src/hooks/use-image-download.ts +85 -0
  382. package/src/hooks/use-media-query.ts +24 -0
  383. package/src/hooks/use-mobile.ts +3 -0
  384. package/src/hooks/use-resize-observer.ts +38 -0
  385. package/src/hooks/use-worktree-info.ts +68 -0
  386. package/src/i18n/catalog.ts +12 -0
  387. package/src/i18n/context.test.tsx +232 -0
  388. package/src/i18n/context.tsx +183 -0
  389. package/src/i18n/define-locale.ts +41 -0
  390. package/src/i18n/en.ts +1779 -0
  391. package/src/i18n/index.ts +20 -0
  392. package/src/i18n/ja.ts +1890 -0
  393. package/src/i18n/languages.test.ts +43 -0
  394. package/src/i18n/languages.ts +86 -0
  395. package/src/i18n/runtime.test.ts +75 -0
  396. package/src/i18n/runtime.ts +53 -0
  397. package/src/i18n/types.ts +1452 -0
  398. package/src/i18n/zh-hant.ts +1849 -0
  399. package/src/i18n/zh.ts +1923 -0
  400. package/src/lib/ansi.test.ts +123 -0
  401. package/src/lib/ansi.ts +175 -0
  402. package/src/lib/chat-messages.test.ts +708 -0
  403. package/src/lib/chat-messages.ts +885 -0
  404. package/src/lib/chat-runtime.test.ts +18 -0
  405. package/src/lib/chat-runtime.ts +335 -0
  406. package/src/lib/clipboard.ts +28 -0
  407. package/src/lib/commit-changelog.test.ts +114 -0
  408. package/src/lib/commit-changelog.ts +177 -0
  409. package/src/lib/completion-sound.ts +519 -0
  410. package/src/lib/desktop-fs.test.ts +116 -0
  411. package/src/lib/desktop-fs.ts +113 -0
  412. package/src/lib/desktop-slash-commands.test.ts +126 -0
  413. package/src/lib/desktop-slash-commands.ts +286 -0
  414. package/src/lib/embedded-images.test.ts +35 -0
  415. package/src/lib/embedded-images.ts +60 -0
  416. package/src/lib/external-link.test.tsx +168 -0
  417. package/src/lib/external-link.tsx +303 -0
  418. package/src/lib/gateway-events.test.ts +27 -0
  419. package/src/lib/gateway-events.ts +49 -0
  420. package/src/lib/gateway-ws-url.test.ts +78 -0
  421. package/src/lib/gateway-ws-url.ts +91 -0
  422. package/src/lib/generated-images.test.ts +97 -0
  423. package/src/lib/generated-images.ts +116 -0
  424. package/src/lib/haptics.ts +129 -0
  425. package/src/lib/icons.ts +203 -0
  426. package/src/lib/incremental-external-store-runtime.ts +188 -0
  427. package/src/lib/katex-memo.ts +260 -0
  428. package/src/lib/keybinds/actions.ts +125 -0
  429. package/src/lib/keybinds/combo.test.ts +86 -0
  430. package/src/lib/keybinds/combo.ts +169 -0
  431. package/src/lib/local-preview.ts +126 -0
  432. package/src/lib/markdown-code.test.ts +23 -0
  433. package/src/lib/markdown-code.ts +195 -0
  434. package/src/lib/markdown-preprocess.ts +386 -0
  435. package/src/lib/media.remote.test.ts +58 -0
  436. package/src/lib/media.ts +111 -0
  437. package/src/lib/model-status-label.test.ts +31 -0
  438. package/src/lib/model-status-label.ts +103 -0
  439. package/src/lib/mutable-ref.ts +6 -0
  440. package/src/lib/preview-targets.test.ts +27 -0
  441. package/src/lib/preview-targets.ts +63 -0
  442. package/src/lib/profile-color.ts +58 -0
  443. package/src/lib/provider-setup-errors.test.ts +26 -0
  444. package/src/lib/provider-setup-errors.ts +12 -0
  445. package/src/lib/query-client.ts +13 -0
  446. package/src/lib/remend-tail.test.ts +105 -0
  447. package/src/lib/remend-tail.ts +108 -0
  448. package/src/lib/runtime-readiness.test.ts +65 -0
  449. package/src/lib/runtime-readiness.ts +147 -0
  450. package/src/lib/session-export.ts +57 -0
  451. package/src/lib/session-search.test.ts +58 -0
  452. package/src/lib/session-search.ts +19 -0
  453. package/src/lib/session-source.ts +62 -0
  454. package/src/lib/speech-text.ts +35 -0
  455. package/src/lib/statusbar.ts +91 -0
  456. package/src/lib/storage.test.ts +25 -0
  457. package/src/lib/storage.ts +107 -0
  458. package/src/lib/todos.test.ts +35 -0
  459. package/src/lib/todos.ts +51 -0
  460. package/src/lib/tool-result-summary.test.ts +106 -0
  461. package/src/lib/tool-result-summary.ts +467 -0
  462. package/src/lib/update-copy.test.ts +38 -0
  463. package/src/lib/update-copy.ts +44 -0
  464. package/src/lib/use-enter-animation.ts +100 -0
  465. package/src/lib/utils.ts +6 -0
  466. package/src/lib/voice-playback.ts +128 -0
  467. package/src/lib/yolo-session.ts +26 -0
  468. package/src/main.tsx +43 -0
  469. package/src/nastech.test.ts +49 -0
  470. package/src/nastech.ts +718 -0
  471. package/src/store/activity.ts +100 -0
  472. package/src/store/boot.ts +91 -0
  473. package/src/store/clarify.test.ts +81 -0
  474. package/src/store/clarify.ts +69 -0
  475. package/src/store/command-palette.ts +20 -0
  476. package/src/store/compaction.test.ts +53 -0
  477. package/src/store/compaction.ts +38 -0
  478. package/src/store/completion-sound.ts +32 -0
  479. package/src/store/composer-input-history.test.ts +147 -0
  480. package/src/store/composer-input-history.ts +158 -0
  481. package/src/store/composer-queue.test.ts +148 -0
  482. package/src/store/composer-queue.ts +239 -0
  483. package/src/store/composer-status.test.ts +99 -0
  484. package/src/store/composer-status.ts +277 -0
  485. package/src/store/composer.test.ts +106 -0
  486. package/src/store/composer.ts +184 -0
  487. package/src/store/cron.ts +19 -0
  488. package/src/store/gateway.ts +290 -0
  489. package/src/store/haptics.ts +17 -0
  490. package/src/store/keybinds.ts +139 -0
  491. package/src/store/layout.ts +176 -0
  492. package/src/store/model-presets.test.ts +51 -0
  493. package/src/store/model-presets.ts +86 -0
  494. package/src/store/model-visibility.test.ts +37 -0
  495. package/src/store/model-visibility.ts +108 -0
  496. package/src/store/native-notifications.test.ts +192 -0
  497. package/src/store/native-notifications.ts +203 -0
  498. package/src/store/notifications.ts +165 -0
  499. package/src/store/onboarding.test.ts +372 -0
  500. package/src/store/onboarding.ts +866 -0
  501. package/src/store/panes.test.ts +146 -0
  502. package/src/store/panes.ts +145 -0
  503. package/src/store/preview.test.ts +135 -0
  504. package/src/store/preview.ts +466 -0
  505. package/src/store/profile.test.ts +89 -0
  506. package/src/store/profile.ts +365 -0
  507. package/src/store/prompts.test.ts +121 -0
  508. package/src/store/prompts.ts +115 -0
  509. package/src/store/session-switcher.test.ts +115 -0
  510. package/src/store/session-switcher.ts +128 -0
  511. package/src/store/session-sync.ts +25 -0
  512. package/src/store/session.test.ts +131 -0
  513. package/src/store/session.ts +255 -0
  514. package/src/store/subagents.test.ts +111 -0
  515. package/src/store/subagents.ts +260 -0
  516. package/src/store/thread-scroll.ts +46 -0
  517. package/src/store/todos.test.ts +47 -0
  518. package/src/store/todos.ts +64 -0
  519. package/src/store/tool-diffs.ts +23 -0
  520. package/src/store/tool-dismiss.ts +45 -0
  521. package/src/store/tool-view.ts +91 -0
  522. package/src/store/translucency.ts +38 -0
  523. package/src/store/updates.test.ts +77 -0
  524. package/src/store/updates.ts +315 -0
  525. package/src/store/voice-playback.ts +24 -0
  526. package/src/store/windows.test.ts +143 -0
  527. package/src/store/windows.ts +77 -0
  528. package/src/styles.css +1235 -0
  529. package/src/themes/color.ts +142 -0
  530. package/src/themes/context.tsx +339 -0
  531. package/src/themes/index.ts +3 -0
  532. package/src/themes/install.test.ts +119 -0
  533. package/src/themes/install.ts +95 -0
  534. package/src/themes/presets.test.ts +33 -0
  535. package/src/themes/presets.ts +293 -0
  536. package/src/themes/profile-theme.test.ts +41 -0
  537. package/src/themes/types.ts +66 -0
  538. package/src/themes/use-skin-command.ts +60 -0
  539. package/src/themes/user-themes.test.ts +63 -0
  540. package/src/themes/user-themes.ts +122 -0
  541. package/src/themes/vscode.test.ts +171 -0
  542. package/src/themes/vscode.ts +343 -0
  543. package/src/types/nastech.ts +646 -0
  544. package/src/vite-env.d.ts +1 -0
  545. package/tsconfig.json +25 -0
  546. package/vite.config.ts +56 -0
@@ -0,0 +1,1219 @@
1
+ import {
2
+ closestCenter,
3
+ DndContext,
4
+ type DragEndEvent,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors
9
+ } from '@dnd-kit/core'
10
+ import {
11
+ arrayMove,
12
+ SortableContext,
13
+ sortableKeyboardCoordinates,
14
+ useSortable,
15
+ verticalListSortingStrategy
16
+ } from '@dnd-kit/sortable'
17
+ import { CSS } from '@dnd-kit/utilities'
18
+ import { useStore } from '@nanostores/react'
19
+ import type * as React from 'react'
20
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
21
+
22
+ import { Button } from '@/components/ui/button'
23
+ import { Codicon } from '@/components/ui/codicon'
24
+ import { DisclosureCaret } from '@/components/ui/disclosure-caret'
25
+ import { KbdGroup } from '@/components/ui/kbd'
26
+ import { SearchField } from '@/components/ui/search-field'
27
+ import {
28
+ Sidebar,
29
+ SidebarContent,
30
+ SidebarGroup,
31
+ SidebarGroupContent,
32
+ SidebarMenu,
33
+ SidebarMenuButton,
34
+ SidebarMenuItem
35
+ } from '@/components/ui/sidebar'
36
+ import { Skeleton } from '@/components/ui/skeleton'
37
+ import { Tip } from '@/components/ui/tooltip'
38
+ import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/nastech'
39
+ import { useI18n } from '@/i18n'
40
+ import { profileColor } from '@/lib/profile-color'
41
+ import { sessionMatchesSearch } from '@/lib/session-search'
42
+ import { cn } from '@/lib/utils'
43
+ import { $cronJobs } from '@/store/cron'
44
+ import {
45
+ $panesFlipped,
46
+ $pinnedSessionIds,
47
+ $sidebarAgentsGrouped,
48
+ $sidebarCronOpen,
49
+ $sidebarOpen,
50
+ $sidebarPinsOpen,
51
+ $sidebarRecentsOpen,
52
+ pinSession,
53
+ reorderPinnedSession,
54
+ SESSION_SEARCH_FOCUS_EVENT,
55
+ setSidebarAgentsGrouped,
56
+ setSidebarCronOpen,
57
+ setSidebarPinsOpen,
58
+ setSidebarRecentsOpen,
59
+ SIDEBAR_SESSIONS_PAGE_SIZE,
60
+ unpinSession
61
+ } from '@/store/layout'
62
+ import {
63
+ $newChatProfile,
64
+ $profiles,
65
+ $profileScope,
66
+ ALL_PROFILES,
67
+ newSessionInProfile,
68
+ normalizeProfileKey
69
+ } from '@/store/profile'
70
+ import {
71
+ $cronSessions,
72
+ $selectedStoredSessionId,
73
+ $sessionProfileTotals,
74
+ $sessions,
75
+ $sessionsLoading,
76
+ $sessionsTotal,
77
+ $workingSessionIds,
78
+ sessionPinId
79
+ } from '@/store/session'
80
+
81
+ import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
82
+ import { SidebarPanelLabel } from '../../shell/sidebar-label'
83
+ import type { SidebarNavItem } from '../../types'
84
+
85
+ import { SidebarCronJobsSection } from './cron-jobs-section'
86
+ import { ProfileRail } from './profile-switcher'
87
+ import { SidebarSessionRow } from './session-row'
88
+ import { VirtualSessionList } from './virtual-session-list'
89
+
90
+ const VIRTUALIZE_THRESHOLD = 25
91
+
92
+ // Render the modifier key the user actually presses on this platform. The
93
+ // global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
94
+ // else) in desktop-controller.tsx, but the hint should match muscle memory.
95
+ const NEW_SESSION_KBD: readonly string[] =
96
+ typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
97
+
98
+ const SIDEBAR_NAV: SidebarNavItem[] = [
99
+ {
100
+ id: 'new-session',
101
+ label: '',
102
+ icon: props => <Codicon name="robot" {...props} />,
103
+ action: 'new-session'
104
+ },
105
+ {
106
+ id: 'skills',
107
+ label: '',
108
+ icon: props => <Codicon name="symbol-misc" {...props} />,
109
+ route: SKILLS_ROUTE
110
+ },
111
+ { id: 'messaging', label: '', icon: props => <Codicon name="comment" {...props} />, route: MESSAGING_ROUTE },
112
+ { id: 'artifacts', label: '', icon: props => <Codicon name="files" {...props} />, route: ARTIFACTS_ROUTE }
113
+ ]
114
+
115
+ const WORKSPACE_PAGE = 5
116
+ // ALL-profiles view: show only the latest N per profile up front to keep the
117
+ // unified list scannable, then reveal/fetch more in N-sized steps on demand.
118
+ const PROFILE_INITIAL_PAGE = 5
119
+ const WS_ID_PREFIX = 'workspace:'
120
+
121
+ const wsId = (id: string) => `${WS_ID_PREFIX}${id}`
122
+ const parseWsId = (id: string) => (id.startsWith(WS_ID_PREFIX) ? id.slice(WS_ID_PREFIX.length) : null)
123
+ const countLabel = (loaded: number, total: number) => (total > loaded ? `${loaded}/${total}` : String(loaded))
124
+ const sessionTime = (s: SessionInfo) => s.last_active || s.started_at || 0
125
+
126
+ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[]): T[] {
127
+ if (!orderIds.length) {
128
+ return items
129
+ }
130
+
131
+ const byId = new Map(items.map(item => [getId(item), item]))
132
+ const seen = new Set<string>()
133
+ const out: T[] = []
134
+
135
+ for (const id of orderIds) {
136
+ const item = byId.get(id)
137
+
138
+ if (item) {
139
+ out.push(item)
140
+ seen.add(id)
141
+ }
142
+ }
143
+
144
+ for (const item of items) {
145
+ if (!seen.has(getId(item))) {
146
+ out.push(item)
147
+ }
148
+ }
149
+
150
+ return out
151
+ }
152
+
153
+ const baseName = (path: string) =>
154
+ path
155
+ .replace(/[/\\]+$/, '')
156
+ .split(/[/\\]/)
157
+ .filter(Boolean)
158
+ .pop()
159
+
160
+ // FTS results cover sessions that aren't in the loaded page; synthesize a
161
+ // minimal SessionInfo so they render in the same row component (resume works
162
+ // by id; the snippet stands in for the preview).
163
+ function searchResultToSession(result: SessionSearchResult): SessionInfo {
164
+ const ts = result.session_started ?? Date.now() / 1000
165
+
166
+ return {
167
+ archived: false,
168
+ cwd: null,
169
+ ended_at: null,
170
+ id: result.session_id,
171
+ _lineage_root_id: result.lineage_root ?? null,
172
+ input_tokens: 0,
173
+ is_active: false,
174
+ last_active: ts,
175
+ message_count: 0,
176
+ model: result.model ?? null,
177
+ output_tokens: 0,
178
+ preview: result.snippet?.trim() || null,
179
+ source: result.source ?? null,
180
+ started_at: ts,
181
+ title: null,
182
+ tool_call_count: 0
183
+ }
184
+ }
185
+
186
+ function workspaceGroupsFor(sessions: SessionInfo[], noWorkspaceLabel: string): SidebarSessionGroup[] {
187
+ const groups = new Map<string, SidebarSessionGroup>()
188
+
189
+ for (const session of sessions) {
190
+ const path = session.cwd?.trim() || ''
191
+ const id = path || '__no_workspace__'
192
+ const label = baseName(path) || path || noWorkspaceLabel
193
+
194
+ const group = groups.get(id) ?? { id, label, path: path || null, sessions: [] }
195
+ group.sessions.push(session)
196
+ groups.set(id, group)
197
+ }
198
+
199
+ // Groups keep recency order (Map insertion = first-seen in the recency-sorted
200
+ // input, so an active project floats up), but rows *within* a group sort by
201
+ // creation time so they don't reshuffle every time a message lands — keeps
202
+ // muscle memory intact.
203
+ for (const group of groups.values()) {
204
+ group.sessions.sort((a, b) => b.started_at - a.started_at)
205
+ }
206
+
207
+ return [...groups.values()]
208
+ }
209
+
210
+ function useSortableBindings(id: string) {
211
+ const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
212
+
213
+ return {
214
+ dragging: isDragging,
215
+ dragHandleProps: { ...attributes, ...listeners },
216
+ ref: setNodeRef,
217
+ reorderable: true as const,
218
+ style: { transform: CSS.Transform.toString(transform), transition }
219
+ }
220
+ }
221
+
222
+ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
223
+ currentView: AppView
224
+ onNavigate: (item: SidebarNavItem) => void
225
+ onLoadMoreSessions: () => void
226
+ onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
227
+ onResumeSession: (sessionId: string) => void
228
+ onDeleteSession: (sessionId: string) => void
229
+ onArchiveSession: (sessionId: string) => void
230
+ onNewSessionInWorkspace: (path: null | string) => void
231
+ onManageCronJob: (jobId: string) => void
232
+ onTriggerCronJob: (jobId: string) => void
233
+ }
234
+
235
+ export function ChatSidebar({
236
+ currentView,
237
+ onNavigate,
238
+ onLoadMoreSessions,
239
+ onLoadMoreProfileSessions,
240
+ onResumeSession,
241
+ onDeleteSession,
242
+ onArchiveSession,
243
+ onNewSessionInWorkspace,
244
+ onManageCronJob,
245
+ onTriggerCronJob
246
+ }: ChatSidebarProps) {
247
+ const { t } = useI18n()
248
+ const s = t.sidebar
249
+ const sidebarOpen = useStore($sidebarOpen)
250
+ const panesFlipped = useStore($panesFlipped)
251
+ const agentsGrouped = useStore($sidebarAgentsGrouped)
252
+ const pinnedSessionIds = useStore($pinnedSessionIds)
253
+ const pinsOpen = useStore($sidebarPinsOpen)
254
+ const agentsOpen = useStore($sidebarRecentsOpen)
255
+ const cronOpen = useStore($sidebarCronOpen)
256
+ const selectedSessionId = useStore($selectedStoredSessionId)
257
+ const sessions = useStore($sessions)
258
+ const cronSessions = useStore($cronSessions)
259
+ const cronJobs = useStore($cronJobs)
260
+ const sessionsLoading = useStore($sessionsLoading)
261
+ const sessionsTotal = useStore($sessionsTotal)
262
+ const sessionProfileTotals = useStore($sessionProfileTotals)
263
+ const workingSessionIds = useStore($workingSessionIds)
264
+ const profiles = useStore($profiles)
265
+ const profileScope = useStore($profileScope)
266
+ // Only surface the profile switcher when more than one profile exists, so
267
+ // single-profile users see the unchanged sidebar.
268
+ const multiProfile = profiles.length > 1
269
+ // Gate ALL-profiles grouping on multiProfile too: if a user drops back to one
270
+ // profile while scope is still ALL (persisted), the rail is hidden and they'd
271
+ // otherwise be stuck in the grouped view with no way out.
272
+ const showAllProfiles = multiProfile && profileScope === ALL_PROFILES
273
+ const [agentOrderIds, setAgentOrderIds] = useState<string[]>([])
274
+ const [workspaceOrderIds, setWorkspaceOrderIds] = useState<string[]>([])
275
+ const [searchQuery, setSearchQuery] = useState('')
276
+ const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
277
+ const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
278
+ const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
279
+ const searchInputRef = useRef<HTMLInputElement>(null)
280
+ const trimmedQuery = searchQuery.trim()
281
+
282
+ // Hotkey (session.focusSearch) → focus the field once it's mounted.
283
+ useEffect(() => {
284
+ const onFocus = () => searchInputRef.current?.focus({ preventScroll: true })
285
+
286
+ window.addEventListener(SESSION_SEARCH_FOCUS_EVENT, onFocus)
287
+
288
+ return () => window.removeEventListener(SESSION_SEARCH_FOCUS_EVENT, onFocus)
289
+ }, [])
290
+
291
+ // Flash the ⌘N hint full-opacity (no transition) for the press, so hitting
292
+ // the shortcut visibly pings its affordance in the sidebar.
293
+ useEffect(() => {
294
+ let timeout: ReturnType<typeof setTimeout> | undefined
295
+
296
+ const onShortcut = () => {
297
+ setNewSessionKbdFlash(true)
298
+ clearTimeout(timeout)
299
+ timeout = setTimeout(() => setNewSessionKbdFlash(false), 140)
300
+ }
301
+
302
+ window.addEventListener('NASTECH:new-session-shortcut', onShortcut)
303
+
304
+ return () => {
305
+ window.removeEventListener('NASTECH:new-session-shortcut', onShortcut)
306
+ clearTimeout(timeout)
307
+ }
308
+ }, [])
309
+
310
+ const activeSidebarSessionId = currentView === 'chat' ? selectedSessionId : null
311
+
312
+ const dndSensors = useSensors(
313
+ useSensor(PointerSensor, { activationConstraint: { distance: 6 } }),
314
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
315
+ )
316
+
317
+ // Profile scope = the "workspace switcher" context. Concrete scope shows only
318
+ // that profile's sessions (clean rows, no per-row tags); ALL fans every
319
+ // profile in, grouped by profile below. Single-profile users land here with
320
+ // scope === their only profile, so nothing is filtered out.
321
+ const visibleSessions = useMemo(
322
+ () => (showAllProfiles ? sessions : sessions.filter(s => normalizeProfileKey(s.profile) === profileScope)),
323
+ [sessions, showAllProfiles, profileScope]
324
+ )
325
+
326
+ const sortedSessions = useMemo(
327
+ () => [...visibleSessions].sort((a, b) => sessionTime(b) - sessionTime(a)),
328
+ [visibleSessions]
329
+ )
330
+
331
+ const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds])
332
+
333
+ // Index sessions by both their live id and their lineage-root id so a pin
334
+ // stored as the pre-compression root resolves to the live continuation tip.
335
+ const sessionByAnyId = useMemo(() => {
336
+ const map = new Map<string, SessionInfo>()
337
+
338
+ // Cron sessions are listed separately but can still be pinned, so index
339
+ // them too — otherwise a pinned cron job can't resolve into the Pinned
340
+ // section. Recents take precedence on id collisions (set last).
341
+ for (const s of [...cronSessions, ...visibleSessions]) {
342
+ map.set(s.id, s)
343
+
344
+ if (s._lineage_root_id && !map.has(s._lineage_root_id)) {
345
+ map.set(s._lineage_root_id, s)
346
+ }
347
+ }
348
+
349
+ return map
350
+ }, [visibleSessions, cronSessions])
351
+
352
+ const pinnedSessions = useMemo(() => {
353
+ const seen = new Set<string>()
354
+ const out: SessionInfo[] = []
355
+
356
+ for (const pinId of pinnedSessionIds) {
357
+ const session = sessionByAnyId.get(pinId)
358
+
359
+ if (session && !seen.has(session.id)) {
360
+ seen.add(session.id)
361
+ out.push(session)
362
+ }
363
+ }
364
+
365
+ return out
366
+ }, [pinnedSessionIds, sessionByAnyId])
367
+
368
+ const pinnedRealIdSet = useMemo(() => new Set(pinnedSessions.map(s => s.id)), [pinnedSessions])
369
+
370
+ // Full-text search across *all* sessions (not just the loaded page) so 699
371
+ // sessions stay findable. Debounced; loaded sessions are matched instantly
372
+ // client-side and merged ahead of the server hits.
373
+ useEffect(() => {
374
+ if (!trimmedQuery) {
375
+ setServerMatches([])
376
+
377
+ return
378
+ }
379
+
380
+ let cancelled = false
381
+
382
+ const id = window.setTimeout(() => {
383
+ void searchSessions(trimmedQuery)
384
+ .then(res => {
385
+ if (!cancelled) {
386
+ setServerMatches(res.results)
387
+ }
388
+ })
389
+ .catch(() => undefined)
390
+ }, 200)
391
+
392
+ return () => {
393
+ cancelled = true
394
+ window.clearTimeout(id)
395
+ }
396
+ }, [trimmedQuery])
397
+
398
+ const searchResults = useMemo(() => {
399
+ if (!trimmedQuery) {
400
+ return []
401
+ }
402
+
403
+ const out = new Map<string, SessionInfo>()
404
+
405
+ for (const s of sortedSessions) {
406
+ if (sessionMatchesSearch(s, trimmedQuery)) {
407
+ out.set(s.id, s)
408
+ }
409
+ }
410
+
411
+ for (const match of serverMatches) {
412
+ if (out.has(match.session_id)) {
413
+ continue
414
+ }
415
+
416
+ const loaded = sessionByAnyId.get(match.session_id)
417
+ out.set(match.session_id, loaded ?? searchResultToSession(match))
418
+ }
419
+
420
+ return [...out.values()]
421
+ }, [trimmedQuery, sortedSessions, serverMatches, sessionByAnyId])
422
+
423
+ const unpinnedAgentSessions = useMemo(
424
+ () => sortedSessions.filter(s => !pinnedRealIdSet.has(s.id)),
425
+ [sortedSessions, pinnedRealIdSet]
426
+ )
427
+
428
+ const agentSessions = useMemo(
429
+ () => orderByIds(unpinnedAgentSessions, s => s.id, agentOrderIds),
430
+ [unpinnedAgentSessions, agentOrderIds]
431
+ )
432
+
433
+ const agentGroups = useMemo(
434
+ () => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
435
+ [agentSessions, s.noWorkspace, workspaceOrderIds]
436
+ )
437
+
438
+ const loadMoreForProfileGroup = useCallback(
439
+ (profile: string) => {
440
+ if (!onLoadMoreProfileSessions) {
441
+ return
442
+ }
443
+
444
+ setProfileLoadMorePending(prev => ({ ...prev, [profile]: true }))
445
+
446
+ void Promise.resolve(onLoadMoreProfileSessions(profile))
447
+ .catch(() => undefined)
448
+ .finally(() =>
449
+ setProfileLoadMorePending(({ [profile]: _done, ...rest }) => rest)
450
+ )
451
+ },
452
+ [onLoadMoreProfileSessions]
453
+ )
454
+
455
+ // ALL-profiles view: one collapsible group per profile, color on the header
456
+ // (not on every row). Default profile floats to the top, the rest alpha.
457
+ const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
458
+ if (!showAllProfiles) {
459
+ return undefined
460
+ }
461
+
462
+ const groups = new Map<string, SidebarSessionGroup>()
463
+
464
+ for (const session of agentSessions) {
465
+ const key = normalizeProfileKey(session.profile)
466
+
467
+ const group = groups.get(key) ?? {
468
+ color: profileColor(key),
469
+ id: key,
470
+ label: key,
471
+ mode: 'profile',
472
+ path: null,
473
+ sessions: []
474
+ }
475
+
476
+ group.sessions.push(session)
477
+
478
+ groups.set(key, group)
479
+ }
480
+
481
+ return [...groups.values()]
482
+ .map(group => ({
483
+ ...group,
484
+ loadingMore: Boolean(profileLoadMorePending[group.id]),
485
+ onLoadMore: onLoadMoreProfileSessions ? () => loadMoreForProfileGroup(group.id) : undefined,
486
+ totalCount: Math.max(group.sessions.length, sessionProfileTotals[group.id] ?? 0)
487
+ }))
488
+ // default (root) first, then the rest alphabetically.
489
+ .sort((a, b) => (a.id === 'default' ? -1 : b.id === 'default' ? 1 : a.label.localeCompare(b.label)))
490
+ }, [
491
+ showAllProfiles,
492
+ agentSessions,
493
+ loadMoreForProfileGroup,
494
+ onLoadMoreProfileSessions,
495
+ profileLoadMorePending,
496
+ sessionProfileTotals
497
+ ])
498
+
499
+ const showSessionSkeletons = sessionsLoading && sortedSessions.length === 0
500
+
501
+ const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
502
+
503
+ // Pagination is scope-aware. In "All profiles" mode it tracks the global
504
+ // unified set. When scoped to one profile it must compare that profile's own
505
+ // loaded rows against that profile's total — otherwise a huge default profile
506
+ // keeps "Load more" stuck on while you browse a small one (the aggregator's
507
+ // total sums every profile). Per-profile totals come from the aggregator
508
+ // (children excluded); fall back to the global total / loaded count.
509
+ const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
510
+ const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
511
+
512
+ const knownSessionTotal = Math.max(
513
+ showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
514
+ loadedSessionCount
515
+ )
516
+
517
+ const hasMoreSessions = knownSessionTotal > loadedSessionCount
518
+ const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
519
+
520
+ const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
521
+
522
+ const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
523
+ if (!over || active.id === over.id) {
524
+ return
525
+ }
526
+
527
+ const newIndex = pinnedSessions.findIndex(s => s.id === String(over.id))
528
+
529
+ if (newIndex < 0) {
530
+ return
531
+ }
532
+
533
+ // Sortable ids are live session ids; the pinned store is keyed by durable
534
+ // (lineage-root) ids, so translate before reordering.
535
+ const dragged = sessionByAnyId.get(String(active.id))
536
+ reorderPinnedSession(dragged ? sessionPinId(dragged) : String(active.id), newIndex)
537
+ }
538
+
539
+ const handleAgentDragEnd = ({ active, over }: DragEndEvent) => {
540
+ if (!over || active.id === over.id) {
541
+ return
542
+ }
543
+
544
+ const activeId = String(active.id)
545
+ const overId = String(over.id)
546
+ const activeWs = parseWsId(activeId)
547
+ const overWs = parseWsId(overId)
548
+
549
+ if (activeWs && overWs) {
550
+ const oldIdx = agentGroups.findIndex(g => g.id === activeWs)
551
+ const newIdx = agentGroups.findIndex(g => g.id === overWs)
552
+
553
+ if (oldIdx < 0 || newIdx < 0) {
554
+ return
555
+ }
556
+
557
+ setWorkspaceOrderIds(arrayMove(agentGroups, oldIdx, newIdx).map(g => g.id))
558
+
559
+ return
560
+ }
561
+
562
+ if (activeWs || overWs) {
563
+ return
564
+ }
565
+
566
+ const oldIdx = agentSessions.findIndex(s => s.id === activeId)
567
+ const newIdx = agentSessions.findIndex(s => s.id === overId)
568
+
569
+ if (oldIdx < 0 || newIdx < 0) {
570
+ return
571
+ }
572
+
573
+ setAgentOrderIds(arrayMove(agentSessions, oldIdx, newIdx).map(s => s.id))
574
+ }
575
+
576
+ return (
577
+ <Sidebar
578
+ className={cn(
579
+ 'relative h-full min-w-0 overflow-hidden border-t-0 border-b-0 text-foreground transition-none',
580
+ panesFlipped ? 'border-l border-r-0' : 'border-r border-l-0',
581
+ sidebarOpen
582
+ ? 'border-(--sidebar-edge-border) bg-(--ui-sidebar-surface-background) opacity-100'
583
+ : 'pointer-events-none border-transparent bg-transparent opacity-0'
584
+ )}
585
+ collapsible="none"
586
+ >
587
+ <SidebarContent className="gap-0 overflow-hidden bg-transparent px-2.5">
588
+ <SidebarGroup className="shrink-0 p-0 pb-2 pt-[calc(var(--titlebar-height)+0.375rem)]">
589
+ <SidebarGroupContent>
590
+ <SidebarMenu className="gap-px">
591
+ {SIDEBAR_NAV.map(item => {
592
+ const isInteractive = Boolean(item.action) || Boolean(item.route)
593
+
594
+ const active =
595
+ (item.id === 'skills' && currentView === 'skills') ||
596
+ (item.id === 'messaging' && currentView === 'messaging') ||
597
+ (item.id === 'artifacts' && currentView === 'artifacts')
598
+
599
+ const isNewSession = item.id === 'new-session'
600
+
601
+ return (
602
+ <SidebarMenuItem key={item.id}>
603
+ <SidebarMenuButton
604
+ aria-disabled={!isInteractive}
605
+ className={cn(
606
+ 'flex h-7 w-full justify-start gap-2 rounded-md border border-transparent px-2 text-left text-[0.8125rem] font-medium text-(--ui-text-secondary) transition-colors duration-100 ease-out hover:bg-(--ui-control-hover-background) hover:text-foreground hover:transition-none',
607
+ active &&
608
+ 'border-(--ui-stroke-tertiary) bg-(--ui-control-active-background) text-foreground shadow-none hover:border-(--ui-stroke-tertiary)!',
609
+ !isInteractive &&
610
+ 'cursor-default hover:border-transparent hover:bg-transparent hover:text-inherit'
611
+ )}
612
+ onClick={() => {
613
+ // A plain new session lands in whatever profile the live
614
+ // gateway is on (= the active switcher context). null →
615
+ // no swap. The switcher header is the single place to
616
+ // change which profile that is.
617
+ if (isNewSession) {
618
+ $newChatProfile.set(null)
619
+ }
620
+
621
+ onNavigate(item)
622
+ }}
623
+ tooltip={s.nav[item.id] ?? item.label}
624
+ type="button"
625
+ >
626
+ <item.icon className="size-4 shrink-0 text-[color-mix(in_srgb,currentColor_72%,transparent)]" />
627
+ {sidebarOpen && (
628
+ <>
629
+ <span className="min-w-0 flex-1 truncate max-[46.25rem]:hidden">
630
+ {s.nav[item.id] ?? item.label}
631
+ </span>
632
+ {isNewSession && (
633
+ <KbdGroup
634
+ className={cn('ml-auto max-[46.25rem]:hidden', newSessionKbdFlash && 'opacity-100!')}
635
+ keys={[...NEW_SESSION_KBD]}
636
+ />
637
+ )}
638
+ </>
639
+ )}
640
+ </SidebarMenuButton>
641
+ </SidebarMenuItem>
642
+ )
643
+ })}
644
+ </SidebarMenu>
645
+ </SidebarGroupContent>
646
+ </SidebarGroup>
647
+
648
+ {sidebarOpen && showSessionSections && (
649
+ <div className="shrink-0 px-2 pb-1 pt-1">
650
+ <SearchField
651
+ aria-label={s.searchAria}
652
+ inputRef={searchInputRef}
653
+ onChange={setSearchQuery}
654
+ placeholder={s.searchPlaceholder}
655
+ value={searchQuery}
656
+ />
657
+ </div>
658
+ )}
659
+
660
+ {sidebarOpen && showSessionSections && trimmedQuery && (
661
+ <SidebarSessionsSection
662
+ activeSessionId={activeSidebarSessionId}
663
+ contentClassName="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
664
+ emptyState={
665
+ <div className="grid min-h-24 place-items-center rounded-lg px-2 text-center text-xs text-(--ui-text-tertiary)">
666
+ {s.noMatch(trimmedQuery)}
667
+ </div>
668
+ }
669
+ label={s.results}
670
+ labelMeta={String(searchResults.length)}
671
+ onArchiveSession={onArchiveSession}
672
+ onDeleteSession={onDeleteSession}
673
+ onResumeSession={onResumeSession}
674
+ onToggle={() => undefined}
675
+ onTogglePin={pinSession}
676
+ open
677
+ pinned={false}
678
+ rootClassName="min-h-0 flex-1 p-0"
679
+ sessions={searchResults}
680
+ workingSessionIdSet={workingSessionIdSet}
681
+ />
682
+ )}
683
+
684
+ {sidebarOpen && showSessionSections && !trimmedQuery && (
685
+ <SidebarSessionsSection
686
+ activeSessionId={activeSidebarSessionId}
687
+ contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
688
+ dndSensors={dndSensors}
689
+ emptyState={<SidebarPinnedEmptyState />}
690
+ label={s.pinned}
691
+ onArchiveSession={onArchiveSession}
692
+ onDeleteSession={onDeleteSession}
693
+ onReorder={handlePinnedDragEnd}
694
+ onResumeSession={onResumeSession}
695
+ onToggle={() => setSidebarPinsOpen(!pinsOpen)}
696
+ onTogglePin={unpinSession}
697
+ open={pinsOpen}
698
+ pinned
699
+ rootClassName="shrink-0 p-0 pb-1"
700
+ sessions={pinnedSessions}
701
+ sortable={pinnedSessions.length > 1}
702
+ workingSessionIdSet={workingSessionIdSet}
703
+ />
704
+ )}
705
+
706
+ {sidebarOpen && showSessionSections && !trimmedQuery && (
707
+ <SidebarSessionsSection
708
+ activeSessionId={activeSidebarSessionId}
709
+ contentClassName={cn(
710
+ 'flex min-h-0 flex-1 flex-col overflow-y-auto overscroll-contain pb-1.75',
711
+ // Separate profile sections clearly in the ALL view; rows inside
712
+ // each group keep their own tight gap-px rhythm.
713
+ showAllProfiles ? 'gap-3' : 'gap-px'
714
+ )}
715
+ dndSensors={dndSensors}
716
+ emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
717
+ footer={
718
+ // Hide "load more" only when workspace-grouped (those groups page
719
+ // themselves). ALL-profiles now pages per-profile from each profile
720
+ // header; the global footer only applies to non-ALL views.
721
+ !showAllProfiles && !agentsGrouped && !showSessionSkeletons && hasMoreSessions ? (
722
+ <SidebarLoadMoreRow
723
+ loading={sessionsLoading}
724
+ onClick={onLoadMoreSessions}
725
+ step={Math.min(SIDEBAR_SESSIONS_PAGE_SIZE, remainingSessionCount)}
726
+ />
727
+ ) : null
728
+ }
729
+ forceEmptyState={showSessionSkeletons}
730
+ groups={showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined}
731
+ headerAction={
732
+ // Always reserve the icon-xs (size-6) slot so the header keeps the
733
+ // same height whether or not the toggle renders — otherwise the
734
+ // "Sessions" label jumps when switching to the ALL-profiles view.
735
+ // Grouping operates on unpinned recents; if everything is pinned
736
+ // the toggle does nothing, and it's irrelevant in the ALL-profiles
737
+ // view (always grouped by profile), so hide the button (not the slot).
738
+ <div className="grid size-6 shrink-0 place-items-center">
739
+ {!showAllProfiles && agentSessions.length > 0 ? (
740
+ <Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
741
+ <Button
742
+ aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
743
+ className={cn(
744
+ 'text-(--ui-text-tertiary) opacity-70 hover:bg-(--ui-control-hover-background) hover:text-foreground hover:opacity-100 focus-visible:opacity-100',
745
+ agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
746
+ )}
747
+ onClick={event => {
748
+ event.stopPropagation()
749
+ setSidebarRecentsOpen(true)
750
+ setSidebarAgentsGrouped(!agentsGrouped)
751
+ }}
752
+ size="icon-xs"
753
+ variant="ghost"
754
+ >
755
+ <Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
756
+ </Button>
757
+ </Tip>
758
+ ) : null}
759
+ </div>
760
+ }
761
+ label={s.sessions}
762
+ labelMeta={recentsMeta}
763
+ onArchiveSession={onArchiveSession}
764
+ onDeleteSession={onDeleteSession}
765
+ onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
766
+ onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
767
+ onResumeSession={onResumeSession}
768
+ onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
769
+ onTogglePin={pinSession}
770
+ open={agentsOpen}
771
+ pinned={false}
772
+ rootClassName="min-h-0 flex-1 p-0"
773
+ sessions={agentSessions}
774
+ sortable={!showAllProfiles && agentSessions.length > 1}
775
+ workingSessionIdSet={workingSessionIdSet}
776
+ />
777
+ )}
778
+
779
+ {sidebarOpen && !trimmedQuery && cronJobs.length > 0 && (
780
+ <SidebarCronJobsSection
781
+ jobs={cronJobs}
782
+ label={s.cronJobs}
783
+ onManageJob={onManageCronJob}
784
+ onOpenRun={onResumeSession}
785
+ onToggle={() => setSidebarCronOpen(!cronOpen)}
786
+ onTriggerJob={onTriggerCronJob}
787
+ open={cronOpen}
788
+ />
789
+ )}
790
+
791
+ {sidebarOpen && !showSessionSections && <div className="min-h-0 flex-1" />}
792
+
793
+ {sidebarOpen && (
794
+ <div className="shrink-0 px-0.5 pb-1 pt-0.5">
795
+ <ProfileRail />
796
+ </div>
797
+ )}
798
+ </SidebarContent>
799
+ </Sidebar>
800
+ )
801
+ }
802
+
803
+ interface SidebarSectionHeaderProps {
804
+ label: string
805
+ open: boolean
806
+ onToggle: () => void
807
+ action?: React.ReactNode
808
+ meta?: React.ReactNode
809
+ }
810
+
811
+ function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
812
+ return (
813
+ <div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
814
+ <button
815
+ className="group/section-label flex w-fit items-center gap-1 bg-transparent text-left leading-none"
816
+ onClick={onToggle}
817
+ type="button"
818
+ >
819
+ <SidebarPanelLabel>{label}</SidebarPanelLabel>
820
+ {meta && <SidebarCount>{meta}</SidebarCount>}
821
+ <DisclosureCaret
822
+ className="text-(--ui-text-tertiary) opacity-0 transition group-hover/section-label:opacity-100"
823
+ open={open}
824
+ />
825
+ </button>
826
+ {action}
827
+ </div>
828
+ )
829
+ }
830
+
831
+ function SidebarSessionSkeletons() {
832
+ return (
833
+ <div aria-hidden="true" className="grid gap-px">
834
+ {['w-32', 'w-40', 'w-28', 'w-36', 'w-24'].map((width, i) => (
835
+ <div className="grid min-h-7 grid-cols-[minmax(0,1fr)_1.5rem] items-center rounded-lg" key={`${width}-${i}`}>
836
+ <Skeleton className={cn('h-3.5 rounded-full', width)} />
837
+ <Skeleton className="mx-auto size-4 rounded-md opacity-60" />
838
+ </div>
839
+ ))}
840
+ </div>
841
+ )
842
+ }
843
+
844
+ function SidebarAllPinnedState() {
845
+ const { t } = useI18n()
846
+
847
+ return (
848
+ <div className="grid min-h-24 place-items-center rounded-lg text-center text-xs text-(--ui-text-tertiary)">
849
+ {t.sidebar.allPinned}
850
+ </div>
851
+ )
852
+ }
853
+
854
+ function SidebarPinnedEmptyState() {
855
+ const { t } = useI18n()
856
+
857
+ return (
858
+ <div className="flex min-h-7 items-center gap-1.5 rounded-lg pl-2 text-[0.75rem] text-(--ui-text-tertiary)">
859
+ <span className="grid w-3.5 shrink-0 place-items-center text-(--ui-text-quaternary)">
860
+ <Codicon name="pin" size="0.75rem" />
861
+ </span>
862
+ <span>{t.sidebar.shiftClickHint}</span>
863
+ </div>
864
+ )
865
+ }
866
+
867
+ interface SidebarSessionGroup {
868
+ id: string
869
+ label: string
870
+ path: null | string
871
+ sessions: SessionInfo[]
872
+ // Profile color for the ALL-profiles view; absent for workspace groups.
873
+ color?: null | string
874
+ loadingMore?: boolean
875
+ mode?: 'profile' | 'workspace'
876
+ onLoadMore?: () => void
877
+ totalCount?: number
878
+ }
879
+
880
+ interface SidebarSessionsSectionProps {
881
+ label: string
882
+ open: boolean
883
+ onToggle: () => void
884
+ sessions: SessionInfo[]
885
+ activeSessionId: null | string
886
+ workingSessionIdSet: Set<string>
887
+ onResumeSession: (sessionId: string) => void
888
+ onDeleteSession: (sessionId: string) => void
889
+ onArchiveSession: (sessionId: string) => void
890
+ onTogglePin: (sessionId: string) => void
891
+ onNewSessionInWorkspace?: (path: null | string) => void
892
+ pinned: boolean
893
+ rootClassName?: string
894
+ contentClassName?: string
895
+ emptyState: React.ReactNode
896
+ forceEmptyState?: boolean
897
+ headerAction?: React.ReactNode
898
+ footer?: React.ReactNode
899
+ groups?: SidebarSessionGroup[]
900
+ labelMeta?: React.ReactNode
901
+ sortable?: boolean
902
+ onReorder?: (event: DragEndEvent) => void
903
+ dndSensors?: ReturnType<typeof useSensors>
904
+ }
905
+
906
+ function SidebarSessionsSection({
907
+ label,
908
+ open,
909
+ onToggle,
910
+ sessions,
911
+ activeSessionId,
912
+ workingSessionIdSet,
913
+ onResumeSession,
914
+ onDeleteSession,
915
+ onArchiveSession,
916
+ onTogglePin,
917
+ onNewSessionInWorkspace,
918
+ pinned,
919
+ rootClassName,
920
+ contentClassName,
921
+ emptyState,
922
+ forceEmptyState = false,
923
+ headerAction,
924
+ footer,
925
+ groups,
926
+ labelMeta,
927
+ sortable = false,
928
+ onReorder,
929
+ dndSensors
930
+ }: SidebarSessionsSectionProps) {
931
+ const showEmptyState = forceEmptyState || sessions.length === 0
932
+ const dndActive = sortable && !!onReorder
933
+
934
+ const renderRow = (session: SessionInfo) => {
935
+ const rowProps = {
936
+ isPinned: pinned,
937
+ isSelected: session.id === activeSessionId,
938
+ isWorking: workingSessionIdSet.has(session.id),
939
+ onArchive: () => onArchiveSession(session.id),
940
+ onDelete: () => onDeleteSession(session.id),
941
+ onPin: () => onTogglePin(sessionPinId(session)),
942
+ onResume: () => onResumeSession(session.id),
943
+ session
944
+ }
945
+
946
+ return sortable ? (
947
+ <SortableSidebarSessionRow key={session.id} {...rowProps} />
948
+ ) : (
949
+ <SidebarSessionRow key={session.id} {...rowProps} />
950
+ )
951
+ }
952
+
953
+ const renderRows = (items: SessionInfo[]) => items.map(renderRow)
954
+
955
+ const renderSessionList = (items: SessionInfo[]) =>
956
+ dndActive ? (
957
+ <SortableContext items={items.map(s => s.id)} strategy={verticalListSortingStrategy}>
958
+ {renderRows(items)}
959
+ </SortableContext>
960
+ ) : (
961
+ renderRows(items)
962
+ )
963
+
964
+ const flatVirtualized = !showEmptyState && !groups?.length && sessions.length >= VIRTUALIZE_THRESHOLD
965
+
966
+ let inner: React.ReactNode
967
+
968
+ if (showEmptyState) {
969
+ inner = emptyState
970
+ } else if (groups?.length) {
971
+ const groupNodes = groups.map(group =>
972
+ dndActive ? (
973
+ <SortableSidebarWorkspaceGroup
974
+ group={group}
975
+ key={group.id}
976
+ onNewSession={onNewSessionInWorkspace}
977
+ renderRows={renderSessionList}
978
+ />
979
+ ) : (
980
+ <SidebarWorkspaceGroup
981
+ group={group}
982
+ key={group.id}
983
+ onNewSession={onNewSessionInWorkspace}
984
+ renderRows={renderSessionList}
985
+ />
986
+ )
987
+ )
988
+
989
+ inner = dndActive ? (
990
+ <SortableContext items={groups.map(g => wsId(g.id))} strategy={verticalListSortingStrategy}>
991
+ {groupNodes}
992
+ </SortableContext>
993
+ ) : (
994
+ groupNodes
995
+ )
996
+ } else if (flatVirtualized) {
997
+ inner = (
998
+ <VirtualSessionList
999
+ activeSessionId={activeSessionId}
1000
+ onArchiveSession={onArchiveSession}
1001
+ onDeleteSession={onDeleteSession}
1002
+ onResumeSession={onResumeSession}
1003
+ onTogglePin={onTogglePin}
1004
+ pinned={pinned}
1005
+ sessions={sessions}
1006
+ sortable={sortable}
1007
+ workingSessionIdSet={workingSessionIdSet}
1008
+ />
1009
+ )
1010
+ } else {
1011
+ inner = renderSessionList(sessions)
1012
+ }
1013
+
1014
+ const body =
1015
+ dndActive && !showEmptyState ? (
1016
+ <DndContext collisionDetection={closestCenter} onDragEnd={onReorder} sensors={dndSensors}>
1017
+ {inner}
1018
+ </DndContext>
1019
+ ) : (
1020
+ inner
1021
+ )
1022
+
1023
+ // The virtualizer owns its own scroller, so suppress the wrapper's overflow
1024
+ // to avoid a double scroll container.
1025
+ const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible')
1026
+
1027
+ return (
1028
+ <SidebarGroup className={rootClassName}>
1029
+ <SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
1030
+ {open && (
1031
+ <SidebarGroupContent className={resolvedContentClassName}>
1032
+ {body}
1033
+ {footer}
1034
+ </SidebarGroupContent>
1035
+ )}
1036
+ </SidebarGroup>
1037
+ )
1038
+ }
1039
+
1040
+ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
1041
+ group: SidebarSessionGroup
1042
+ renderRows: (sessions: SessionInfo[]) => React.ReactNode
1043
+ onNewSession?: (path: null | string) => void
1044
+ reorderable?: boolean
1045
+ dragging?: boolean
1046
+ dragHandleProps?: React.HTMLAttributes<HTMLElement>
1047
+ }
1048
+
1049
+ function SidebarWorkspaceGroup({
1050
+ group,
1051
+ renderRows,
1052
+ onNewSession,
1053
+ reorderable = false,
1054
+ dragging = false,
1055
+ dragHandleProps,
1056
+ className,
1057
+ style,
1058
+ ref,
1059
+ ...rest
1060
+ }: SidebarWorkspaceGroupProps) {
1061
+ const { t } = useI18n()
1062
+ const s = t.sidebar
1063
+ const isProfileGroup = group.mode === 'profile'
1064
+ const pageStep = isProfileGroup ? PROFILE_INITIAL_PAGE : WORKSPACE_PAGE
1065
+ const [open, setOpen] = useState(true)
1066
+ const [visibleCount, setVisibleCount] = useState(pageStep)
1067
+
1068
+ const loadedCount = group.sessions.length
1069
+ // Profile groups know their on-disk total (children excluded); workspace
1070
+ // groups only ever page within what's already loaded.
1071
+ const totalCount = isProfileGroup ? Math.max(group.totalCount ?? loadedCount, loadedCount) : loadedCount
1072
+ const visibleSessions = group.sessions.slice(0, visibleCount)
1073
+ const hiddenCount = Math.max(0, totalCount - visibleSessions.length)
1074
+ const nextCount = Math.min(pageStep, hiddenCount)
1075
+
1076
+ // Reveal already-loaded rows first; only hit the backend when the next page
1077
+ // crosses what's been fetched for this profile.
1078
+ const handleProfileLoadMore = () => {
1079
+ const target = visibleCount + pageStep
1080
+
1081
+ setVisibleCount(target)
1082
+
1083
+ if (target > loadedCount && loadedCount < totalCount) {
1084
+ group.onLoadMore?.()
1085
+ }
1086
+ }
1087
+
1088
+ return (
1089
+ <div className={cn('grid gap-px', dragging && 'z-10 opacity-60', className)} ref={ref} style={style} {...rest}>
1090
+ <div className="group/workspace flex min-h-6 items-center gap-1 px-2 pt-1 text-[0.6875rem] font-medium text-(--ui-text-tertiary)">
1091
+ <button
1092
+ className="flex min-w-0 items-center gap-1.5 bg-transparent text-left hover:text-(--ui-text-secondary)"
1093
+ onClick={() => setOpen(value => !value)}
1094
+ type="button"
1095
+ >
1096
+ {group.color ? (
1097
+ <span aria-hidden="true" className="size-2 shrink-0 rounded-full" style={{ backgroundColor: group.color }} />
1098
+ ) : null}
1099
+ <span className="truncate">{group.label}</span>
1100
+ <SidebarCount>
1101
+ {isProfileGroup ? countLabel(visibleSessions.length, totalCount) : group.sessions.length}
1102
+ </SidebarCount>
1103
+ <DisclosureCaret
1104
+ className="text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
1105
+ open={open}
1106
+ />
1107
+ </button>
1108
+ {(onNewSession || isProfileGroup) && (
1109
+ <Tip label={s.newSessionIn(group.label)}>
1110
+ <button
1111
+ aria-label={s.newSessionIn(group.label)}
1112
+ className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
1113
+ // Profile groups start a fresh session in that profile but keep the
1114
+ // all-profiles browse view (newSessionInProfile leaves the scope
1115
+ // alone); workspace groups seed the new session's cwd from the path.
1116
+ onClick={() => (isProfileGroup ? newSessionInProfile(group.id) : onNewSession?.(group.path))}
1117
+ type="button"
1118
+ >
1119
+ <Codicon name="add" size="0.75rem" />
1120
+ </button>
1121
+ </Tip>
1122
+ )}
1123
+ {reorderable && (
1124
+ <span
1125
+ {...dragHandleProps}
1126
+ aria-label={s.reorderWorkspace(group.label)}
1127
+ className="ml-auto -my-0.5 grid w-4 shrink-0 cursor-grab touch-none place-items-center self-stretch overflow-hidden active:cursor-grabbing"
1128
+ onClick={event => event.stopPropagation()}
1129
+ >
1130
+ <Codicon
1131
+ className={cn(
1132
+ 'text-(--ui-text-quaternary) opacity-0 transition-opacity group-hover/workspace:opacity-80 hover:text-(--ui-text-secondary)',
1133
+ dragging && 'text-(--ui-text-secondary) opacity-100'
1134
+ )}
1135
+ name="grabber"
1136
+ size="0.75rem"
1137
+ />
1138
+ </span>
1139
+ )}
1140
+ </div>
1141
+ {open && (
1142
+ <>
1143
+ {renderRows(visibleSessions)}
1144
+ {hiddenCount > 0 &&
1145
+ (isProfileGroup ? (
1146
+ <SidebarLoadMoreRow loading={Boolean(group.loadingMore)} onClick={handleProfileLoadMore} step={nextCount} />
1147
+ ) : (
1148
+ <Tip label={s.showMoreIn(nextCount, group.label)}>
1149
+ <button
1150
+ aria-label={s.showMoreIn(nextCount, group.label)}
1151
+ className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
1152
+ onClick={() => setVisibleCount(count => count + WORKSPACE_PAGE)}
1153
+ type="button"
1154
+ >
1155
+ <Codicon name="ellipsis" size="0.75rem" />
1156
+ </button>
1157
+ </Tip>
1158
+ ))}
1159
+ </>
1160
+ )}
1161
+ </div>
1162
+ )
1163
+ }
1164
+
1165
+ interface SortableWorkspaceProps {
1166
+ group: SidebarSessionGroup
1167
+ renderRows: (sessions: SessionInfo[]) => React.ReactNode
1168
+ onNewSession?: (path: null | string) => void
1169
+ }
1170
+
1171
+ function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
1172
+ return <SidebarWorkspaceGroup {...props} {...useSortableBindings(wsId(props.group.id))} />
1173
+ }
1174
+
1175
+ function SidebarCount({ children }: { children: React.ReactNode }) {
1176
+ return <span className="text-[0.6875rem] font-medium text-(--ui-text-quaternary)">{children}</span>
1177
+ }
1178
+
1179
+ interface SortableSessionRowProps {
1180
+ session: SessionInfo
1181
+ isPinned: boolean
1182
+ isSelected: boolean
1183
+ isWorking: boolean
1184
+ onArchive: () => void
1185
+ onDelete: () => void
1186
+ onPin: () => void
1187
+ onResume: () => void
1188
+ }
1189
+
1190
+ function SortableSidebarSessionRow(props: SortableSessionRowProps) {
1191
+ return <SidebarSessionRow {...props} {...useSortableBindings(props.session.id)} />
1192
+ }
1193
+
1194
+ interface SidebarLoadMoreRowProps {
1195
+ loading: boolean
1196
+ onClick: () => void
1197
+ step: number
1198
+ }
1199
+
1200
+ function SidebarLoadMoreRow({ loading, onClick, step }: SidebarLoadMoreRowProps) {
1201
+ const { t } = useI18n()
1202
+ const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
1203
+
1204
+ return (
1205
+ <button
1206
+ className="flex min-h-5 items-center gap-1.5 self-start bg-transparent pl-2 text-left text-[0.6875rem] text-(--ui-text-tertiary) transition-colors duration-100 ease-out hover:text-foreground hover:transition-none disabled:cursor-default disabled:opacity-60 disabled:hover:text-(--ui-text-tertiary)"
1207
+ disabled={loading}
1208
+ onClick={onClick}
1209
+ type="button"
1210
+ >
1211
+ {/* Seat the icon in the same w-3.5 column session rows use for their dot
1212
+ so the chevron + label line up with the rows above. */}
1213
+ <span className="grid w-3.5 shrink-0 place-items-center">
1214
+ <Codicon className="opacity-70" name={loading ? 'loading' : 'chevron-down'} size="0.75rem" spinning={loading} />
1215
+ </span>
1216
+ <span>{label}</span>
1217
+ </button>
1218
+ )
1219
+ }