@neurocode-ai/app 1.18.8

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 (577) hide show
  1. package/AGENTS.md +35 -0
  2. package/README.md +50 -0
  3. package/V1_API_MIGRATION.md +220 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/performance/AGENTS.md +13 -0
  7. package/e2e/performance/README.md +79 -0
  8. package/e2e/performance/benchmark.ts +144 -0
  9. package/e2e/performance/chrome-trace.ts +95 -0
  10. package/e2e/performance/playwright.config.ts +20 -0
  11. package/e2e/performance/playwright.uncapped.config.ts +13 -0
  12. package/e2e/performance/timeline/first-navigation-benchmark.spec.ts +87 -0
  13. package/e2e/performance/timeline/first-navigation-metrics.ts +32 -0
  14. package/e2e/performance/timeline/first-navigation-probe.ts +86 -0
  15. package/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts +114 -0
  16. package/e2e/performance/timeline/navigation-milestones.ts +128 -0
  17. package/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts +312 -0
  18. package/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +172 -0
  19. package/e2e/performance/timeline/session-tab-flash.spec.ts +67 -0
  20. package/e2e/performance/timeline/session-tab-repaint-probe.ts +251 -0
  21. package/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +144 -0
  22. package/e2e/performance/timeline/session-tab-switch-metrics.ts +59 -0
  23. package/e2e/performance/timeline/session-tab-switch-probe.ts +226 -0
  24. package/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +504 -0
  25. package/e2e/performance/timeline/session-timeline-benchmark.spec.ts +306 -0
  26. package/e2e/performance/timeline/session-timeline-profile.ts +40 -0
  27. package/e2e/performance/timeline/session-timeline-stream-probe.ts +547 -0
  28. package/e2e/performance/timeline/session-timeline-stress.fixture.ts +369 -0
  29. package/e2e/performance/timeline/timeline-test-helpers.ts +134 -0
  30. package/e2e/performance/timeline-stability/README.md +61 -0
  31. package/e2e/performance/timeline-stability/adverse.spec.ts +250 -0
  32. package/e2e/performance/timeline-stability/context-matrix.spec.ts +192 -0
  33. package/e2e/performance/timeline-stability/environment-matrix.spec.ts +113 -0
  34. package/e2e/performance/timeline-stability/file-matrix.spec.ts +121 -0
  35. package/e2e/performance/timeline-stability/file-mutation.spec.ts +113 -0
  36. package/e2e/performance/timeline-stability/fixture.test.ts +68 -0
  37. package/e2e/performance/timeline-stability/fixture.ts +566 -0
  38. package/e2e/performance/timeline-stability/interaction.spec.ts +242 -0
  39. package/e2e/performance/timeline-stability/lifecycle.spec.ts +157 -0
  40. package/e2e/performance/timeline-stability/oracle-browser.spec.ts +75 -0
  41. package/e2e/performance/timeline-stability/playwright.config.ts +17 -0
  42. package/e2e/performance/timeline-stability/scroll-interaction.spec.ts +353 -0
  43. package/e2e/performance/timeline-stability/shell-matrix.spec.ts +255 -0
  44. package/e2e/performance/timeline-stability/tool-mutation.spec.ts +106 -0
  45. package/e2e/performance/timeline-stability/tools.spec.ts +198 -0
  46. package/e2e/performance/timeline-stability/transition-matrix.spec.ts +272 -0
  47. package/e2e/performance/unit/chrome-trace-write.test.ts +15 -0
  48. package/e2e/performance/unit/first-navigation-metrics.test.ts +32 -0
  49. package/e2e/performance/unit/mock-server.test.ts +46 -0
  50. package/e2e/performance/unit/navigation-milestones.test.ts +34 -0
  51. package/e2e/performance/unit/session-tab-repaint-probe.test.ts +42 -0
  52. package/e2e/performance/unit/session-tab-switch-metrics.test.ts +86 -0
  53. package/e2e/performance/unit/session-tab-switch-probe.test.ts +55 -0
  54. package/e2e/performance/unit/session-timeline-stream-probe.test.ts +14 -0
  55. package/e2e/performance/unit/session-timeline-visual-tracking.test.ts +16 -0
  56. package/e2e/performance/unit/timeline-test-helpers.test.ts +10 -0
  57. package/e2e/performance/unit/visual-stability.test.ts +392 -0
  58. package/e2e/regression/cross-server-tab-close.spec.ts +153 -0
  59. package/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +150 -0
  60. package/e2e/regression/legacy-new-session.spec.ts +41 -0
  61. package/e2e/regression/new-session-panel-corner.spec.ts +83 -0
  62. package/e2e/regression/prompt-input-v2-command-draft.spec.ts +50 -0
  63. package/e2e/regression/prompt-thinking-level.spec.ts +84 -0
  64. package/e2e/regression/remote-session-settings.spec.ts +302 -0
  65. package/e2e/regression/remote-tab-busy.spec.ts +131 -0
  66. package/e2e/regression/review-image-flash.spec.ts +202 -0
  67. package/e2e/regression/review-line-comment.spec.ts +158 -0
  68. package/e2e/regression/review-open-file.spec.ts +163 -0
  69. package/e2e/regression/review-state-persistence.spec.ts +155 -0
  70. package/e2e/regression/review-tab-switch.spec.ts +147 -0
  71. package/e2e/regression/review-terminal-stacked.spec.ts +308 -0
  72. package/e2e/regression/session-list-path-loading.spec.ts +41 -0
  73. package/e2e/regression/session-request-docks.spec.ts +221 -0
  74. package/e2e/regression/session-timeline-accessibility.spec.ts +22 -0
  75. package/e2e/regression/session-timeline-collapse-state.spec.ts +439 -0
  76. package/e2e/regression/session-timeline-context-resize.spec.ts +374 -0
  77. package/e2e/regression/session-timeline-context-state.spec.ts +31 -0
  78. package/e2e/regression/session-timeline-file-projection.spec.ts +52 -0
  79. package/e2e/regression/session-timeline-file-state.spec.ts +94 -0
  80. package/e2e/regression/session-timeline-history-root.spec.ts +242 -0
  81. package/e2e/regression/session-timeline-lifecycle-state.spec.ts +111 -0
  82. package/e2e/regression/session-timeline-locale-projection.spec.ts +25 -0
  83. package/e2e/regression/session-timeline-projection.spec.ts +287 -0
  84. package/e2e/regression/session-timeline-reasoning-projection.spec.ts +93 -0
  85. package/e2e/regression/session-timeline-reducer-projection.spec.ts +43 -0
  86. package/e2e/regression/session-timeline-shell-outline.spec.ts +228 -0
  87. package/e2e/regression/session-timeline-tool-projection.spec.ts +99 -0
  88. package/e2e/regression/session-timeline-tool-state.spec.ts +77 -0
  89. package/e2e/regression/session-timeline-transport.spec.ts +116 -0
  90. package/e2e/regression/session-todo-dock-navigation.spec.ts +190 -0
  91. package/e2e/regression/subagent-child-navigation.spec.ts +203 -0
  92. package/e2e/regression/tab-navigate-mousedown.spec.ts +127 -0
  93. package/e2e/regression/terminal-composer-focus.spec.ts +227 -0
  94. package/e2e/regression/terminal-hidden.spec.ts +117 -0
  95. package/e2e/regression/terminal-tab-switch.spec.ts +165 -0
  96. package/e2e/reproduction/timeline-suspense/index.html +34 -0
  97. package/e2e/reproduction/timeline-suspense/main.tsx +315 -0
  98. package/e2e/reproduction/timeline-suspense/playwright.config.ts +34 -0
  99. package/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +179 -0
  100. package/e2e/reproduction/timeline-suspense/vite.config.ts +7 -0
  101. package/e2e/smoke/session-timeline.fixture.ts +315 -0
  102. package/e2e/smoke/session-timeline.spec.ts +740 -0
  103. package/e2e/tsconfig.json +20 -0
  104. package/e2e/utils/errors.ts +18 -0
  105. package/e2e/utils/mock-server.ts +443 -0
  106. package/e2e/utils/sse-transport.ts +312 -0
  107. package/e2e/utils/visual-stability/analyzer.ts +209 -0
  108. package/e2e/utils/visual-stability/capture.ts +51 -0
  109. package/e2e/utils/visual-stability/index.ts +8 -0
  110. package/e2e/utils/visual-stability/invariant.ts +112 -0
  111. package/e2e/utils/visual-stability/model.ts +47 -0
  112. package/e2e/utils/visual-stability/probe.ts +226 -0
  113. package/e2e/utils/visual-stability/regions.ts +18 -0
  114. package/e2e/utils/visual-stability/reporter.ts +63 -0
  115. package/e2e/utils/visual-stability/scenario.ts +20 -0
  116. package/e2e/utils/visual-stability.ts +54 -0
  117. package/e2e/utils/waits.ts +11 -0
  118. package/happydom.ts +75 -0
  119. package/index.html +28 -0
  120. package/package.json +96 -0
  121. package/playwright.config.ts +45 -0
  122. package/public/_headers +17 -0
  123. package/public/apple-touch-icon-v3.png +1 -0
  124. package/public/apple-touch-icon.png +1 -0
  125. package/public/assets/Inter.ttf +0 -0
  126. package/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  127. package/public/favicon-96x96-v3.png +1 -0
  128. package/public/favicon-96x96.png +1 -0
  129. package/public/favicon-v3.ico +1 -0
  130. package/public/favicon-v3.svg +1 -0
  131. package/public/favicon.ico +1 -0
  132. package/public/favicon.svg +1 -0
  133. package/public/oc-theme-preload.js +40 -0
  134. package/public/site.webmanifest +1 -0
  135. package/public/social-share-zen.png +1 -0
  136. package/public/social-share.png +1 -0
  137. package/public/web-app-manifest-192x192.png +1 -0
  138. package/public/web-app-manifest-512x512.png +1 -0
  139. package/src/addons/serialize.test.ts +327 -0
  140. package/src/addons/serialize.ts +642 -0
  141. package/src/app.tsx +659 -0
  142. package/src/assets/help/home.png +0 -0
  143. package/src/assets/help/introducing-tabs.mp4 +0 -0
  144. package/src/assets/help/placeholder.png +0 -0
  145. package/src/assets/help/tabs.png +0 -0
  146. package/src/components/command-palette.ts +281 -0
  147. package/src/components/command-tooltip-keybind.test.ts +22 -0
  148. package/src/components/command-tooltip-keybind.ts +11 -0
  149. package/src/components/debug-bar.tsx +550 -0
  150. package/src/components/dialog-command-palette-v2.css +220 -0
  151. package/src/components/dialog-command-palette-v2.tsx +342 -0
  152. package/src/components/dialog-connect-provider.stories.tsx +73 -0
  153. package/src/components/dialog-connect-provider.tsx +1178 -0
  154. package/src/components/dialog-custom-provider-form.ts +158 -0
  155. package/src/components/dialog-custom-provider.test.ts +80 -0
  156. package/src/components/dialog-custom-provider.tsx +330 -0
  157. package/src/components/dialog-edit-project-v2.tsx +156 -0
  158. package/src/components/dialog-edit-project.tsx +170 -0
  159. package/src/components/dialog-fork.tsx +104 -0
  160. package/src/components/dialog-manage-models.tsx +265 -0
  161. package/src/components/dialog-release-notes.tsx +144 -0
  162. package/src/components/dialog-select-directory-v2.css +107 -0
  163. package/src/components/dialog-select-directory-v2.tsx +378 -0
  164. package/src/components/dialog-select-directory.tsx +191 -0
  165. package/src/components/dialog-select-file.tsx +189 -0
  166. package/src/components/dialog-select-mcp.tsx +92 -0
  167. package/src/components/dialog-select-model-search.test.ts +19 -0
  168. package/src/components/dialog-select-model-search.ts +18 -0
  169. package/src/components/dialog-select-model-unpaid-v2.stories.tsx +55 -0
  170. package/src/components/dialog-select-model-unpaid-v2.tsx +175 -0
  171. package/src/components/dialog-select-model-unpaid.tsx +147 -0
  172. package/src/components/dialog-select-model.tsx +549 -0
  173. package/src/components/dialog-select-server.tsx +722 -0
  174. package/src/components/dialog-settings.tsx +94 -0
  175. package/src/components/dialog-usage-exceeded.tsx +44 -0
  176. package/src/components/directory-picker-domain.test.ts +248 -0
  177. package/src/components/directory-picker-domain.ts +407 -0
  178. package/src/components/directory-picker-policy.ts +7 -0
  179. package/src/components/directory-picker.test.ts +21 -0
  180. package/src/components/directory-picker.tsx +45 -0
  181. package/src/components/edit-project.ts +122 -0
  182. package/src/components/file-tree-v2-model.test.ts +74 -0
  183. package/src/components/file-tree-v2-model.ts +107 -0
  184. package/src/components/file-tree-v2.tsx +299 -0
  185. package/src/components/file-tree.test.ts +80 -0
  186. package/src/components/file-tree.tsx +509 -0
  187. package/src/components/help-button.tsx +148 -0
  188. package/src/components/link.tsx +26 -0
  189. package/src/components/model-tooltip.tsx +124 -0
  190. package/src/components/pierre-tree.test.ts +23 -0
  191. package/src/components/prompt-input/attachments.test.ts +108 -0
  192. package/src/components/prompt-input/attachments.ts +228 -0
  193. package/src/components/prompt-input/build-request-parts.test.ts +396 -0
  194. package/src/components/prompt-input/build-request-parts.ts +213 -0
  195. package/src/components/prompt-input/context-items.tsx +98 -0
  196. package/src/components/prompt-input/contracts.ts +57 -0
  197. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  198. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  199. package/src/components/prompt-input/editor-dom.ts +148 -0
  200. package/src/components/prompt-input/files.ts +98 -0
  201. package/src/components/prompt-input/history-store.ts +47 -0
  202. package/src/components/prompt-input/history.test.ts +153 -0
  203. package/src/components/prompt-input/history.ts +256 -0
  204. package/src/components/prompt-input/image-attachments.css +43 -0
  205. package/src/components/prompt-input/image-attachments.tsx +168 -0
  206. package/src/components/prompt-input/paste.ts +24 -0
  207. package/src/components/prompt-input/placeholder.test.ts +48 -0
  208. package/src/components/prompt-input/placeholder.ts +20 -0
  209. package/src/components/prompt-input/slash-popover.tsx +376 -0
  210. package/src/components/prompt-input/submission-state.ts +33 -0
  211. package/src/components/prompt-input/submit.test.ts +598 -0
  212. package/src/components/prompt-input/submit.ts +634 -0
  213. package/src/components/prompt-input/transient-state.ts +46 -0
  214. package/src/components/prompt-input-v2.tsx +592 -0
  215. package/src/components/prompt-input.stories.tsx +223 -0
  216. package/src/components/prompt-input.tsx +1790 -0
  217. package/src/components/prompt-project-selector.tsx +593 -0
  218. package/src/components/prompt-workspace-selector.tsx +128 -0
  219. package/src/components/server/server-row-menu.tsx +96 -0
  220. package/src/components/server/server-row.tsx +127 -0
  221. package/src/components/session/index.ts +7 -0
  222. package/src/components/session/open-in-app-v2.tsx +98 -0
  223. package/src/components/session/open-in-app.tsx +229 -0
  224. package/src/components/session/session-context-breakdown.test.ts +61 -0
  225. package/src/components/session/session-context-breakdown.ts +132 -0
  226. package/src/components/session/session-context-format.ts +20 -0
  227. package/src/components/session/session-context-metrics.test.ts +99 -0
  228. package/src/components/session/session-context-metrics.ts +65 -0
  229. package/src/components/session/session-context-tab.tsx +343 -0
  230. package/src/components/session/session-header.tsx +568 -0
  231. package/src/components/session/session-new-design-view.tsx +16 -0
  232. package/src/components/session/session-new-view.tsx +91 -0
  233. package/src/components/session/session-sortable-tab-v2.tsx +74 -0
  234. package/src/components/session/session-sortable-tab.tsx +78 -0
  235. package/src/components/session/session-sortable-terminal-tab-v2.tsx +284 -0
  236. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  237. package/src/components/session-context-usage.tsx +169 -0
  238. package/src/components/settings-dialog.tsx +43 -0
  239. package/src/components/settings-general.tsx +794 -0
  240. package/src/components/settings-keybinds.tsx +553 -0
  241. package/src/components/settings-list.tsx +5 -0
  242. package/src/components/settings-models.tsx +149 -0
  243. package/src/components/settings-providers.tsx +264 -0
  244. package/src/components/settings-server-picker.tsx +106 -0
  245. package/src/components/settings-servers.tsx +33 -0
  246. package/src/components/settings-v2/dialog-server-v2.tsx +134 -0
  247. package/src/components/settings-v2/dialog-settings-v2.tsx +98 -0
  248. package/src/components/settings-v2/general.tsx +722 -0
  249. package/src/components/settings-v2/index.tsx +1 -0
  250. package/src/components/settings-v2/interface-transition.stories.tsx +76 -0
  251. package/src/components/settings-v2/interface-transition.tsx +54 -0
  252. package/src/components/settings-v2/models.tsx +138 -0
  253. package/src/components/settings-v2/parts/list.tsx +6 -0
  254. package/src/components/settings-v2/parts/row.tsx +20 -0
  255. package/src/components/settings-v2/providers.tsx +264 -0
  256. package/src/components/settings-v2/servers.tsx +139 -0
  257. package/src/components/settings-v2/settings-v2.css +675 -0
  258. package/src/components/status-popover-body.tsx +514 -0
  259. package/src/components/status-popover-indicator.test.ts +36 -0
  260. package/src/components/status-popover-indicator.ts +20 -0
  261. package/src/components/status-popover.tsx +190 -0
  262. package/src/components/terminal.tsx +759 -0
  263. package/src/components/titlebar-history.test.ts +63 -0
  264. package/src/components/titlebar-history.ts +57 -0
  265. package/src/components/titlebar-session-events.test.ts +32 -0
  266. package/src/components/titlebar-session-events.ts +35 -0
  267. package/src/components/titlebar-tab-gesture.test.ts +33 -0
  268. package/src/components/titlebar-tab-gesture.ts +17 -0
  269. package/src/components/titlebar-tab-nav.css +100 -0
  270. package/src/components/titlebar-tab-nav.tsx +429 -0
  271. package/src/components/titlebar-tab-popover.css +127 -0
  272. package/src/components/titlebar-tab-popover.tsx +92 -0
  273. package/src/components/titlebar-tab-strip.tsx +333 -0
  274. package/src/components/titlebar.css +77 -0
  275. package/src/components/titlebar.tsx +776 -0
  276. package/src/components/ui/drawer.tsx +119 -0
  277. package/src/components/updater-action.test.ts +26 -0
  278. package/src/components/updater-action.ts +51 -0
  279. package/src/components/virtual-scroll-element.test.ts +18 -0
  280. package/src/components/virtual-scroll-element.ts +4 -0
  281. package/src/components/windows-app-menu.tsx +131 -0
  282. package/src/constants/file-picker.ts +89 -0
  283. package/src/context/closed-tabs.ts +40 -0
  284. package/src/context/command-keybind.test.ts +69 -0
  285. package/src/context/command.test.ts +55 -0
  286. package/src/context/command.tsx +467 -0
  287. package/src/context/comments.test.ts +188 -0
  288. package/src/context/comments.tsx +261 -0
  289. package/src/context/directory-sync.ts +155 -0
  290. package/src/context/file/content-cache.ts +88 -0
  291. package/src/context/file/path.test.ts +360 -0
  292. package/src/context/file/path.ts +151 -0
  293. package/src/context/file/tree-store.ts +174 -0
  294. package/src/context/file/types.ts +41 -0
  295. package/src/context/file/view-cache.ts +147 -0
  296. package/src/context/file/watcher.test.ts +149 -0
  297. package/src/context/file/watcher.ts +53 -0
  298. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  299. package/src/context/file.tsx +303 -0
  300. package/src/context/global-sync/bootstrap.test.ts +231 -0
  301. package/src/context/global-sync/bootstrap.ts +536 -0
  302. package/src/context/global-sync/child-store.test.ts +276 -0
  303. package/src/context/global-sync/child-store.ts +396 -0
  304. package/src/context/global-sync/event-reducer.test.ts +616 -0
  305. package/src/context/global-sync/event-reducer.ts +478 -0
  306. package/src/context/global-sync/eviction.ts +28 -0
  307. package/src/context/global-sync/home-session-index.test.ts +154 -0
  308. package/src/context/global-sync/home-session-index.ts +174 -0
  309. package/src/context/global-sync/mcp.test.ts +54 -0
  310. package/src/context/global-sync/mcp.ts +19 -0
  311. package/src/context/global-sync/queue.test.ts +46 -0
  312. package/src/context/global-sync/queue.ts +87 -0
  313. package/src/context/global-sync/session-cache.test.ts +104 -0
  314. package/src/context/global-sync/session-cache.ts +62 -0
  315. package/src/context/global-sync/session-load.ts +33 -0
  316. package/src/context/global-sync/session-trim.test.ts +59 -0
  317. package/src/context/global-sync/session-trim.ts +57 -0
  318. package/src/context/global-sync/types.ts +135 -0
  319. package/src/context/global-sync/utils.test.ts +133 -0
  320. package/src/context/global-sync/utils.ts +171 -0
  321. package/src/context/global.tsx +161 -0
  322. package/src/context/highlights.tsx +233 -0
  323. package/src/context/language.tsx +243 -0
  324. package/src/context/layout-helpers.ts +38 -0
  325. package/src/context/layout-scroll.test.ts +64 -0
  326. package/src/context/layout-scroll.ts +126 -0
  327. package/src/context/layout-tabs.test.ts +82 -0
  328. package/src/context/layout-tabs.ts +103 -0
  329. package/src/context/layout.test.ts +69 -0
  330. package/src/context/layout.tsx +1075 -0
  331. package/src/context/local-agent.test.ts +29 -0
  332. package/src/context/local-agent.ts +7 -0
  333. package/src/context/local.tsx +417 -0
  334. package/src/context/mcp.ts +19 -0
  335. package/src/context/model-variant.test.ts +86 -0
  336. package/src/context/model-variant.ts +52 -0
  337. package/src/context/models.tsx +173 -0
  338. package/src/context/notification.tsx +478 -0
  339. package/src/context/permission-auto-respond.test.ts +125 -0
  340. package/src/context/permission-auto-respond.ts +60 -0
  341. package/src/context/permission.tsx +484 -0
  342. package/src/context/platform.tsx +142 -0
  343. package/src/context/prompt-state.test.ts +29 -0
  344. package/src/context/prompt-state.ts +266 -0
  345. package/src/context/prompt.tsx +170 -0
  346. package/src/context/sdk.tsx +17 -0
  347. package/src/context/server-sdk.test.ts +196 -0
  348. package/src/context/server-sdk.tsx +444 -0
  349. package/src/context/server-session-v2-reducer.test.ts +156 -0
  350. package/src/context/server-session-v2-reducer.ts +502 -0
  351. package/src/context/server-session.test.ts +1638 -0
  352. package/src/context/server-session.ts +1425 -0
  353. package/src/context/server-sync.test.ts +231 -0
  354. package/src/context/server-sync.tsx +753 -0
  355. package/src/context/server.test.ts +245 -0
  356. package/src/context/server.tsx +360 -0
  357. package/src/context/settings.test.ts +79 -0
  358. package/src/context/settings.tsx +531 -0
  359. package/src/context/sync-optimistic.test.ts +123 -0
  360. package/src/context/sync.tsx +119 -0
  361. package/src/context/tab-memory.ts +36 -0
  362. package/src/context/tabs.test.ts +107 -0
  363. package/src/context/tabs.tsx +387 -0
  364. package/src/context/terminal-title.ts +24 -0
  365. package/src/context/terminal.test.ts +91 -0
  366. package/src/context/terminal.tsx +546 -0
  367. package/src/custom-elements.d.ts +1 -0
  368. package/src/desktop-menu.test.ts +13 -0
  369. package/src/desktop-menu.ts +223 -0
  370. package/src/entry.tsx +182 -0
  371. package/src/env.d.ts +31 -0
  372. package/src/hooks/provider-catalog.test.ts +59 -0
  373. package/src/hooks/provider-catalog.ts +27 -0
  374. package/src/hooks/use-providers.ts +71 -0
  375. package/src/i18n/ar.ts +1008 -0
  376. package/src/i18n/br.ts +1024 -0
  377. package/src/i18n/bs.ts +1100 -0
  378. package/src/i18n/da.ts +1092 -0
  379. package/src/i18n/de.ts +1037 -0
  380. package/src/i18n/en.ts +1102 -0
  381. package/src/i18n/es.ts +1108 -0
  382. package/src/i18n/fr.ts +1037 -0
  383. package/src/i18n/ja.ts +1016 -0
  384. package/src/i18n/ko.ts +1015 -0
  385. package/src/i18n/no.ts +1111 -0
  386. package/src/i18n/parity.test.ts +118 -0
  387. package/src/i18n/pl.ts +1024 -0
  388. package/src/i18n/ru.ts +1103 -0
  389. package/src/i18n/th.ts +1087 -0
  390. package/src/i18n/tr.ts +1106 -0
  391. package/src/i18n/uk.ts +1105 -0
  392. package/src/i18n/zh.ts +1078 -0
  393. package/src/i18n/zht.ts +1074 -0
  394. package/src/index.css +330 -0
  395. package/src/index.ts +30 -0
  396. package/src/pages/directory-layout.tsx +122 -0
  397. package/src/pages/error-description.test.ts +17 -0
  398. package/src/pages/error-description.ts +11 -0
  399. package/src/pages/error.tsx +372 -0
  400. package/src/pages/home/home-controller.ts +108 -0
  401. package/src/pages/home/home-projects-controller.tsx +128 -0
  402. package/src/pages/home/home-projects-view.tsx +615 -0
  403. package/src/pages/home/home-projects.tsx +40 -0
  404. package/src/pages/home/home-scroll-controller.ts +145 -0
  405. package/src/pages/home/home-session-search-controller.ts +114 -0
  406. package/src/pages/home/home-sessions-controller.tsx +314 -0
  407. package/src/pages/home/home-sessions-view.tsx +550 -0
  408. package/src/pages/home/home-sessions.tsx +48 -0
  409. package/src/pages/home/legacy-home.tsx +142 -0
  410. package/src/pages/home-session-archive.test.ts +51 -0
  411. package/src/pages/home-session-archive.ts +27 -0
  412. package/src/pages/home-session-open.test.ts +31 -0
  413. package/src/pages/home-session-open.ts +14 -0
  414. package/src/pages/home.tsx +50 -0
  415. package/src/pages/layout/deep-links.ts +50 -0
  416. package/src/pages/layout/helpers.test.ts +321 -0
  417. package/src/pages/layout/helpers.ts +150 -0
  418. package/src/pages/layout/inline-editor.tsx +126 -0
  419. package/src/pages/layout/project-avatar-state.ts +49 -0
  420. package/src/pages/layout/session-tab-avatar.tsx +60 -0
  421. package/src/pages/layout/sidebar-items.tsx +335 -0
  422. package/src/pages/layout/sidebar-project.tsx +377 -0
  423. package/src/pages/layout/sidebar-shell.tsx +125 -0
  424. package/src/pages/layout/sidebar-workspace.tsx +488 -0
  425. package/src/pages/layout-new.tsx +53 -0
  426. package/src/pages/layout.tsx +2441 -0
  427. package/src/pages/new-session.tsx +282 -0
  428. package/src/pages/session/composer/index.ts +4 -0
  429. package/src/pages/session/composer/prompt-model-selection.ts +133 -0
  430. package/src/pages/session/composer/session-composer-controls.ts +129 -0
  431. package/src/pages/session/composer/session-composer-region-controller.ts +145 -0
  432. package/src/pages/session/composer/session-composer-region.tsx +168 -0
  433. package/src/pages/session/composer/session-composer-state.test.ts +138 -0
  434. package/src/pages/session/composer/session-composer-state.ts +204 -0
  435. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  436. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  437. package/src/pages/session/composer/session-question-dock.tsx +640 -0
  438. package/src/pages/session/composer/session-request-tree.ts +52 -0
  439. package/src/pages/session/composer/session-revert-dock.stories.tsx +124 -0
  440. package/src/pages/session/composer/session-revert-dock.tsx +188 -0
  441. package/src/pages/session/composer/session-todo-dock.tsx +277 -0
  442. package/src/pages/session/composer/todo-panel-motion.stories.tsx +622 -0
  443. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  444. package/src/pages/session/file-tab-scroll.ts +67 -0
  445. package/src/pages/session/file-tabs.tsx +800 -0
  446. package/src/pages/session/handoff.ts +36 -0
  447. package/src/pages/session/helpers.test.ts +214 -0
  448. package/src/pages/session/helpers.ts +209 -0
  449. package/src/pages/session/message-gesture.test.ts +62 -0
  450. package/src/pages/session/message-gesture.ts +21 -0
  451. package/src/pages/session/message-id-from-hash.ts +6 -0
  452. package/src/pages/session/new-session-layout.ts +2 -0
  453. package/src/pages/session/review-tab.tsx +172 -0
  454. package/src/pages/session/session-layout.ts +30 -0
  455. package/src/pages/session/session-lineage.ts +69 -0
  456. package/src/pages/session/session-model-helpers.test.ts +151 -0
  457. package/src/pages/session/session-model-helpers.ts +63 -0
  458. package/src/pages/session/session-ownership.ts +37 -0
  459. package/src/pages/session/session-panel-layout.test.ts +19 -0
  460. package/src/pages/session/session-panel-layout.ts +6 -0
  461. package/src/pages/session/session-panel-width.test.ts +52 -0
  462. package/src/pages/session/session-panel-width.ts +19 -0
  463. package/src/pages/session/session-side-panel.tsx +865 -0
  464. package/src/pages/session/terminal-label.ts +16 -0
  465. package/src/pages/session/terminal-panel-v2.tsx +352 -0
  466. package/src/pages/session/terminal-panel.test.ts +25 -0
  467. package/src/pages/session/terminal-panel.tsx +343 -0
  468. package/src/pages/session/timeline/measure.test.ts +30 -0
  469. package/src/pages/session/timeline/measure.ts +5 -0
  470. package/src/pages/session/timeline/message-timeline.tsx +1864 -0
  471. package/src/pages/session/timeline/model.test.ts +95 -0
  472. package/src/pages/session/timeline/model.ts +128 -0
  473. package/src/pages/session/timeline/observe-element-offset.test.ts +199 -0
  474. package/src/pages/session/timeline/observe-element-offset.ts +73 -0
  475. package/src/pages/session/timeline/projection.test.ts +96 -0
  476. package/src/pages/session/timeline/projection.ts +81 -0
  477. package/src/pages/session/timeline/row-reconciliation.ts +56 -0
  478. package/src/pages/session/timeline/rows-current.test.ts +170 -0
  479. package/src/pages/session/timeline/rows.ts +344 -0
  480. package/src/pages/session/timeline/summary-diffs.test.ts +42 -0
  481. package/src/pages/session/timeline/summary-diffs.ts +20 -0
  482. package/src/pages/session/timeline/timeline-row.ts +80 -0
  483. package/src/pages/session/timeline/virtual-items.ts +3 -0
  484. package/src/pages/session/usage-exceeded-dialogs.tsx +104 -0
  485. package/src/pages/session/use-composer-commands.tsx +83 -0
  486. package/src/pages/session/use-session-commands.tsx +606 -0
  487. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  488. package/src/pages/session/use-session-hash-scroll.ts +202 -0
  489. package/src/pages/session/v2/review-diff-kinds.test.ts +70 -0
  490. package/src/pages/session/v2/review-diff-kinds.ts +62 -0
  491. package/src/pages/session/v2/review-panel-v2-state.ts +41 -0
  492. package/src/pages/session/v2/review-panel-v2.tsx +255 -0
  493. package/src/pages/session/v2/session-file-browser-tab.tsx +181 -0
  494. package/src/pages/session/v2/session-file-list-v2.tsx +166 -0
  495. package/src/pages/session.tsx +2385 -0
  496. package/src/sst-env.d.ts +12 -0
  497. package/src/theme-preload.test.ts +46 -0
  498. package/src/updater.ts +17 -0
  499. package/src/utils/agent.ts +44 -0
  500. package/src/utils/aim.ts +138 -0
  501. package/src/utils/base64.ts +10 -0
  502. package/src/utils/comment-note.ts +88 -0
  503. package/src/utils/diffs.test.ts +75 -0
  504. package/src/utils/diffs.ts +50 -0
  505. package/src/utils/file-manager.ts +24 -0
  506. package/src/utils/id.ts +93 -0
  507. package/src/utils/notification-click.test.ts +27 -0
  508. package/src/utils/notification-click.ts +13 -0
  509. package/src/utils/path-key.ts +24 -0
  510. package/src/utils/persist.test.ts +211 -0
  511. package/src/utils/persist.ts +661 -0
  512. package/src/utils/prompt.test.ts +44 -0
  513. package/src/utils/prompt.ts +203 -0
  514. package/src/utils/refcount.test.ts +49 -0
  515. package/src/utils/refcount.ts +32 -0
  516. package/src/utils/runtime-adapters.test.ts +64 -0
  517. package/src/utils/runtime-adapters.ts +39 -0
  518. package/src/utils/same.ts +6 -0
  519. package/src/utils/scoped-cache.test.ts +69 -0
  520. package/src/utils/scoped-cache.ts +104 -0
  521. package/src/utils/search-keydown.ts +116 -0
  522. package/src/utils/server-compat.test.ts +193 -0
  523. package/src/utils/server-compat.ts +512 -0
  524. package/src/utils/server-errors.test.ts +175 -0
  525. package/src/utils/server-errors.ts +109 -0
  526. package/src/utils/server-health.test.ts +178 -0
  527. package/src/utils/server-health.ts +172 -0
  528. package/src/utils/server-protocol.test.ts +40 -0
  529. package/src/utils/server-protocol.ts +35 -0
  530. package/src/utils/server-scope.test.ts +61 -0
  531. package/src/utils/server-scope.ts +73 -0
  532. package/src/utils/server.test.ts +23 -0
  533. package/src/utils/server.ts +62 -0
  534. package/src/utils/session-message.test.ts +214 -0
  535. package/src/utils/session-message.ts +355 -0
  536. package/src/utils/session-route.test.ts +71 -0
  537. package/src/utils/session-route.ts +39 -0
  538. package/src/utils/session-title.ts +7 -0
  539. package/src/utils/session.test.ts +94 -0
  540. package/src/utils/session.ts +37 -0
  541. package/src/utils/solid-dnd.tsx +49 -0
  542. package/src/utils/sound.ts +102 -0
  543. package/src/utils/terminal-websocket-url.test.ts +80 -0
  544. package/src/utils/terminal-websocket-url.ts +35 -0
  545. package/src/utils/terminal-writer.test.ts +64 -0
  546. package/src/utils/terminal-writer.ts +65 -0
  547. package/src/utils/time.ts +22 -0
  548. package/src/utils/toast.tsx +34 -0
  549. package/src/utils/uuid.test.ts +78 -0
  550. package/src/utils/uuid.ts +12 -0
  551. package/src/utils/worktree.test.ts +58 -0
  552. package/src/utils/worktree.ts +76 -0
  553. package/src/wsl/add-server-probes.ts +57 -0
  554. package/src/wsl/context.tsx +36 -0
  555. package/src/wsl/dialog-add-server.tsx +463 -0
  556. package/src/wsl/dialog-add-wsl-server.css +392 -0
  557. package/src/wsl/settings-model.test.ts +231 -0
  558. package/src/wsl/settings-model.ts +341 -0
  559. package/src/wsl/settings.tsx +173 -0
  560. package/src/wsl/types.ts +85 -0
  561. package/sst-env.d.ts +10 -0
  562. package/test-browser/command-palette.test.ts +78 -0
  563. package/test-browser/motion-spring.test.ts +20 -0
  564. package/test-browser/prompt-attachments.test.ts +90 -0
  565. package/test-browser/prompt-persistence.test.ts +71 -0
  566. package/test-browser/prompt-scope.test.ts +14 -0
  567. package/test-browser/prompt-submission-state.test.ts +73 -0
  568. package/test-browser/prompt-transient-state.test.ts +38 -0
  569. package/test-browser/review-panel-v2-state.test.ts +65 -0
  570. package/test-browser/session-lineage.test.ts +231 -0
  571. package/test-browser/session-ownership.test.ts +45 -0
  572. package/test-browser/solid-virtual.test.ts +112 -0
  573. package/test-browser/toast-owner.test.ts +26 -0
  574. package/tsconfig.json +26 -0
  575. package/vendor/opencode-ai-client-1.17.13.tgz +0 -0
  576. package/vite.config.ts +33 -0
  577. package/vite.js +48 -0
@@ -0,0 +1,2385 @@
1
+ import type { FilePart, Project, UserMessage, VcsFileDiff } from "@neurocode-ai/sdk/v2"
2
+ import { getFilename } from "@neurocode-ai/core/util/path"
3
+ import { useDialog } from "@neurocode-ai/ui/context/dialog"
4
+ import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query"
5
+ import {
6
+ batch,
7
+ ErrorBoundary,
8
+ onCleanup,
9
+ Show,
10
+ Match,
11
+ Switch,
12
+ createMemo,
13
+ createEffect,
14
+ createComputed,
15
+ createSignal,
16
+ on,
17
+ onMount,
18
+ type ParentProps,
19
+ untrack,
20
+ } from "solid-js"
21
+ import { makeEventListener } from "@solid-primitives/event-listener"
22
+ import { createMediaQuery } from "@solid-primitives/media"
23
+ import { createResizeObserver } from "@solid-primitives/resize-observer"
24
+ import { debounce } from "@solid-primitives/scheduled"
25
+ import { useLocal } from "@/context/local"
26
+ import { FileProvider, selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
27
+ import { createStore } from "solid-js/store"
28
+ import type { SessionReviewLineComment } from "@neurocode-ai/session-ui/session-review"
29
+ import { ResizeHandle } from "@neurocode-ai/ui/resize-handle"
30
+ import { Select } from "@neurocode-ai/ui/select"
31
+ import { SelectV2 } from "@neurocode-ai/ui/v2/select-v2"
32
+ import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@neurocode-ai/ui/scroll-view"
33
+ import { Tabs } from "@neurocode-ai/ui/tabs"
34
+ import { ButtonV2 } from "@neurocode-ai/ui/v2/button-v2"
35
+ import { createAutoScroll } from "@neurocode-ai/ui/hooks"
36
+ import { previewSelectedLines } from "@neurocode-ai/session-ui/pierre/selection-bridge"
37
+ import { Button } from "@neurocode-ai/ui/button"
38
+ import { showToast } from "@/utils/toast"
39
+ import { base64Encode, checksum } from "@neurocode-ai/core/util/encode"
40
+ import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
41
+ import { NewSessionView, SessionHeader } from "@/components/session"
42
+ import { ErrorPage } from "@/pages/error"
43
+ import { CommentsProvider, useComments } from "@/context/comments"
44
+ import { useCommand } from "@/context/command"
45
+ import { DirectoryDataProvider } from "@/pages/directory-layout"
46
+ import { useServerSync } from "@/context/server-sync"
47
+ import { useLanguage } from "@/context/language"
48
+ import { useLayout } from "@/context/layout"
49
+ import { ModelsProvider } from "@/context/models"
50
+ import { useNotification } from "@/context/notification"
51
+ import { PromptProvider, usePrompt } from "@/context/prompt"
52
+ import { usePlatform } from "@/context/platform"
53
+ import { SDKProvider, useSDK } from "@/context/sdk"
54
+ import { useServerSDK } from "@/context/server-sdk"
55
+ import { ServerConnection, serverName, useServer } from "@/context/server"
56
+ import { useSettings } from "@/context/settings"
57
+ import { useSync } from "@/context/sync"
58
+ import { useTabs } from "@/context/tabs"
59
+ import { TerminalProvider, useTerminal } from "@/context/terminal"
60
+ import { PromptInput } from "@/components/prompt-input"
61
+ import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2"
62
+ import { useSettingsCommand } from "@/components/settings-dialog"
63
+ import { setCursorPosition } from "@/components/prompt-input/editor-dom"
64
+ import { promptLength } from "@/components/prompt-input/history"
65
+ import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
66
+ import {
67
+ createPromptInputController,
68
+ createSessionComposerController,
69
+ createSessionComposerRegionController,
70
+ SessionComposerRegion,
71
+ } from "@/pages/session/composer"
72
+ import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
73
+ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
74
+ import { createTimelineModel } from "@/pages/session/timeline/model"
75
+ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
76
+ import { useSessionLayout } from "@/pages/session/session-layout"
77
+ import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
78
+ import {
79
+ clampSessionPanelWidth,
80
+ SESSION_PANEL_WIDTH_MIN,
81
+ sessionPanelWidthMax,
82
+ } from "@/pages/session/session-panel-width"
83
+ import { SessionSidePanel } from "@/pages/session/session-side-panel"
84
+ import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
85
+ import { SessionReviewEmptyChangesV2 } from "@neurocode-ai/session-ui/v2/session-review-empty-changes-v2"
86
+ import { SessionReviewEmptyNoGitV2 } from "@neurocode-ai/session-ui/v2/session-review-empty-no-git-v2"
87
+ import { SessionReviewV2SidebarToggle } from "@neurocode-ai/session-ui/v2/session-review-v2"
88
+ import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
89
+ import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
90
+ import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds"
91
+ import { TerminalPanel } from "@/pages/session/terminal-panel"
92
+ import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
93
+ import { useComposerCommands } from "@/pages/session/use-composer-commands"
94
+ import { useSessionCommands } from "@/pages/session/use-session-commands"
95
+ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
96
+ import { Identifier } from "@/utils/id"
97
+ import { diffs as list } from "@/utils/diffs"
98
+ import { Persist, persisted } from "@/utils/persist"
99
+ import { extractPromptFromParts } from "@/utils/prompt"
100
+ import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
101
+ import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
102
+ import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
103
+ import { createSessionOwnership } from "./session/session-ownership"
104
+ import { createSessionLineage } from "./session/session-lineage"
105
+
106
+ type FollowupItem = FollowupDraft & { id: string }
107
+ type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
108
+ const emptyFollowups: FollowupItem[] = []
109
+
110
+ type ChangeMode = "git" | "branch" | "turn"
111
+ type VcsMode = "git" | "branch"
112
+
113
+ const sessionViewState = () => ({
114
+ messageId: undefined as string | undefined,
115
+ mobileTab: "session" as "session" | "changes",
116
+ })
117
+
118
+ function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undefined) {
119
+ if (!sessionID) return false
120
+ return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
121
+ }
122
+
123
+ async function runPromptRollbackMutation<T, R>(input: {
124
+ capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
125
+ optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
126
+ request: () => Promise<R>
127
+ complete: (result: R) => void
128
+ rollback: () => void
129
+ fail: (error: unknown) => void
130
+ }) {
131
+ const prompt = input.capturePrompt()
132
+ const previous = prompt.current().slice()
133
+ batch(() => input.optimistic(prompt))
134
+ await input
135
+ .request()
136
+ .then(input.complete)
137
+ .catch((error) => {
138
+ batch(() => {
139
+ input.rollback()
140
+ prompt.set(previous)
141
+ })
142
+ input.fail(error)
143
+ })
144
+ }
145
+
146
+ export function SessionPage() {
147
+ return (
148
+ <SessionProviders>
149
+ <Page />
150
+ </SessionProviders>
151
+ )
152
+ }
153
+
154
+ // Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
155
+ // remount around the server-scoped providers. Nothing here may key on the
156
+ // session ID: session tabs on the same server share this route instance, and
157
+ // workspace-scoped state (terminal, directory providers) lives below.
158
+ export function TargetSessionRouteContent() {
159
+ const params = useParams<{ serverKey: string; id: string }>()
160
+ const serverSync = useServerSync()
161
+ const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.directory)
162
+ return (
163
+ // Settings must keep the target-server SDK, sync, and models context and remain registered
164
+ // when session content falls back to the route error boundary.
165
+ <TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
166
+ <TargetSessionSettingsCommand />
167
+ <SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
168
+ <ResolvedTargetSessionRoute />
169
+ </SessionRouteErrorBoundary>
170
+ </TargetServerScopedProviders>
171
+ )
172
+ }
173
+
174
+ function TargetSessionSettingsCommand() {
175
+ useSettingsCommand()
176
+ return null
177
+ }
178
+
179
+ export function SessionRouteErrorBoundary(
180
+ props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
181
+ ) {
182
+ const settings = useSettings()
183
+ return (
184
+ <ErrorBoundary
185
+ fallback={(error) =>
186
+ settings.general.newLayoutDesigns() ? (
187
+ <SessionRouteFrame padded={props.padded}>
188
+ <SessionPanelFrame newLayout raised={!!props.sessionID}>
189
+ <SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
190
+ </SessionPanelFrame>
191
+ </SessionRouteFrame>
192
+ ) : (
193
+ <ErrorPage error={error} />
194
+ )
195
+ }
196
+ >
197
+ {props.children}
198
+ </ErrorBoundary>
199
+ )
200
+ }
201
+
202
+ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
203
+ const language = useLanguage()
204
+ const server = useServer()
205
+ const tabs = useTabs()
206
+ const displayServer = createMemo(() => {
207
+ const key = props.serverKey ?? server.key
208
+ const conn = server.list.find((item) => ServerConnection.key(item) === key)
209
+ return conn ? serverName(conn) : key
210
+ })
211
+ const closeTab = () => {
212
+ if (!props.sessionID) return
213
+ tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
214
+ }
215
+ if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
216
+ return (
217
+ <div class="flex-1 min-h-0 overflow-hidden">
218
+ <div class="h-full px-6 pb-42 -mt-4 flex flex-col items-center justify-center text-center gap-4">
219
+ <div class="flex flex-col items-center gap-2">
220
+ <div class="text-16-medium text-text max-w-md">{language.t("session.error.notFound")}</div>
221
+ <div class="text-13-regular text-text-weak max-w-md">
222
+ {language.t("session.error.notFound.description")}
223
+ </div>
224
+ </div>
225
+ <Show when={props.sessionID}>
226
+ {(sessionID) => (
227
+ <div class="max-w-full flex flex-col items-center gap-1">
228
+ <div class="max-w-full text-11-regular text-text-faint break-all">{displayServer()}</div>
229
+ <code class="max-w-full rounded-[4px] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-text-base break-all bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)]">
230
+ {sessionID()}
231
+ </code>
232
+ </div>
233
+ )}
234
+ </Show>
235
+ <ButtonV2 variant="neutral" size="normal" icon="xmark-small" onClick={closeTab}>
236
+ {language.t("session.error.notFound.closeTab")}
237
+ </ButtonV2>
238
+ </div>
239
+ </div>
240
+ )
241
+ }
242
+ return <ErrorPage error={props.error} />
243
+ }
244
+
245
+ function ResolvedTargetSessionRoute() {
246
+ const params = useParams<{ serverKey: string; id: string }>()
247
+ const tabs = useTabs()
248
+ const sync = useServerSync()
249
+ const serverKey = createMemo(() => requireServerKey(params.serverKey))
250
+ const current = createSessionLineage(
251
+ () => params.id,
252
+ () => sync().session.lineage,
253
+ )
254
+ const directory = createMemo(() => current()?.session.directory)
255
+ const targetDirectory = () => directory()!
256
+
257
+ createEffect(() => {
258
+ const session = current()
259
+ if (!session) return
260
+ tabs.addSessionTab({
261
+ server: serverKey(),
262
+ sessionId: session.root.id,
263
+ })
264
+ })
265
+
266
+ return (
267
+ // Non-keyed: closes only while the target's directory is unknown (uncached
268
+ // lineage mid-resolution), which tears down the workspace subtree including
269
+ // the terminal. Same-workspace tab switches keep it open because warm
270
+ // targets resolve synchronously from the sync cache.
271
+ <Show when={directory()}>
272
+ <SDKProvider directory={targetDirectory}>
273
+ <DirectoryDataProvider directory={targetDirectory} server={serverKey}>
274
+ <TargetSessionPage />
275
+ </DirectoryDataProvider>
276
+ </SDKProvider>
277
+ </Show>
278
+ )
279
+ }
280
+
281
+ // Owns the workspace-identity remount. Must not include the session ID in the
282
+ // key: SessionPage handles session changes reactively, and remounting here
283
+ // destroys workspace-scoped state (terminal PTYs, file/prompt providers).
284
+ function TargetSessionPage() {
285
+ const sdk = useSDK()
286
+ const serverSDK = useServerSDK()
287
+ return (
288
+ <Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
289
+ <SessionPage />
290
+ </Show>
291
+ )
292
+ }
293
+
294
+ function TargetServerScopedProviders(
295
+ props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
296
+ ) {
297
+ return (
298
+ <>
299
+ <MarkSessionNotificationsViewed sessionID={props.sessionID} />
300
+ <ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
301
+ </>
302
+ )
303
+ }
304
+
305
+ function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
306
+ const notification = useNotification()
307
+ createEffect(() => {
308
+ const sessionID = props.sessionID?.()
309
+ if (!notification.ready() || !sessionID) return
310
+ if (notification.session.unseenCount(sessionID) === 0) return
311
+ notification.session.markViewed(sessionID)
312
+ })
313
+ return null
314
+ }
315
+
316
+ function SessionProviders(props: ParentProps) {
317
+ return (
318
+ <TerminalProvider>
319
+ <FileProvider>
320
+ <PromptProvider>
321
+ <CommentsProvider>{props.children}</CommentsProvider>
322
+ </PromptProvider>
323
+ </FileProvider>
324
+ </TerminalProvider>
325
+ )
326
+ }
327
+
328
+ function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
329
+ return (
330
+ <div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
331
+ {props.children}
332
+ </div>
333
+ )
334
+ }
335
+
336
+ function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boolean }>) {
337
+ return (
338
+ <div
339
+ classList={{
340
+ "flex-1 min-h-0 flex flex-col": true,
341
+ "bg-v2-background-bg-base": props.newLayout,
342
+ "bg-background-stronger": !props.newLayout,
343
+ "rounded-[10px] overflow-hidden": props.newLayout,
344
+ "shadow-[var(--v2-elevation-raised)]": props.newLayout && props.raised,
345
+ }}
346
+ >
347
+ {props.children}
348
+ </div>
349
+ )
350
+ }
351
+
352
+ export default function Page() {
353
+ const serverSync = useServerSync()
354
+ const layout = useLayout()
355
+ const local = useLocal()
356
+ const file = useFile()
357
+ const sync = useSync()
358
+ const queryClient = useQueryClient()
359
+ const dialog = useDialog()
360
+ const language = useLanguage()
361
+ const sdk = useSDK()
362
+ const serverSDK = useServerSDK()
363
+ const settings = useSettings()
364
+ const platform = usePlatform()
365
+ const prompt = usePrompt()
366
+ const comments = useComments()
367
+ const command = useCommand()
368
+ const terminal = useTerminal()
369
+ const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
370
+ const location = useLocation()
371
+ const navigate = useNavigate()
372
+ const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
373
+ const reviewMode = () => view().review.mode() ?? "git"
374
+ const reviewFile = () => view().review.file()
375
+ const sessionOwnership = createSessionOwnership(sessionKey)
376
+ const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
377
+
378
+ createEffect(() => {
379
+ if (!prompt.ready()) return
380
+ untrack(() => {
381
+ if (params.id) return
382
+ const text = searchParams.prompt
383
+ if (!text) return
384
+ prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
385
+ setSearchParams({ ...searchParams, prompt: undefined })
386
+ })
387
+ })
388
+
389
+ const [ui, setUi] = createStore({
390
+ pendingMessage: undefined as string | undefined,
391
+ reviewSnap: false,
392
+ scrollGesture: 0,
393
+ scroll: {
394
+ overflow: false,
395
+ bottom: true,
396
+ jump: false,
397
+ },
398
+ })
399
+
400
+ const composer = createSessionComposerController()
401
+ const inputController = createPromptInputController({
402
+ sessionKey,
403
+ sessionID: () => params.id,
404
+ queryOptions: serverSync().queryOptions,
405
+ })
406
+
407
+ const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
408
+ const sessionPanelKey = createMemo(() => (params.id ? `${serverSDK().scope}\0${params.id}` : undefined))
409
+
410
+ createEffect(
411
+ on(
412
+ () => params.id,
413
+ (id, prev) => {
414
+ if (!id) return
415
+ if (prev) return
416
+
417
+ const pending = layout.handoff.tabs()
418
+ if (!pending) return
419
+ if (Date.now() - pending.at > 60_000) {
420
+ layout.handoff.clearTabs()
421
+ return
422
+ }
423
+ if (pending.scope !== serverSDK().scope) return
424
+
425
+ if (pending.id !== id) return
426
+ layout.handoff.clearTabs()
427
+ if (pending.dir !== base64Encode(sdk().directory)) return
428
+
429
+ const from = workspaceTabs().tabs()
430
+ if (from.all.length === 0 && !from.active) return
431
+
432
+ const current = tabs().tabs()
433
+ if (current.all.length > 0 || current.active) return
434
+
435
+ const all = normalizeTabs(from.all)
436
+ const active = from.active ? normalizeTab(from.active) : undefined
437
+ tabs().setAll(all)
438
+ tabs().setActive(active && all.includes(active) ? active : all[0])
439
+
440
+ workspaceTabs().setAll([])
441
+ workspaceTabs().setActive(undefined)
442
+ },
443
+ { defer: true },
444
+ ),
445
+ )
446
+
447
+ const isDesktop = createMediaQuery("(min-width: 768px)")
448
+ const size = createSizing()
449
+ const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
450
+ const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
451
+ const terminalOpen = createMemo(() => view().terminal.opened())
452
+ const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
453
+ const desktopInlineTerminalOnlyOpen = createMemo(
454
+ () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
455
+ )
456
+ const desktopFileTreeOpen = createMemo(
457
+ () =>
458
+ isDesktop() &&
459
+ shouldShowFileTree({
460
+ visible: settings.visibility.fileTree(),
461
+ opened: layout.fileTree.opened(),
462
+ }),
463
+ )
464
+ const desktopSessionResizeOpen = createMemo(() =>
465
+ newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(),
466
+ )
467
+ const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
468
+ let panelRow: HTMLDivElement | undefined
469
+ const [panelRowWidth, setPanelRowWidth] = createSignal<number>()
470
+ createResizeObserver(
471
+ () => panelRow,
472
+ ({ width }) => setPanelRowWidth(width),
473
+ )
474
+ const splitReview = createMemo(
475
+ () => (newSessionDesign() ? desktopV2ReviewOpen() : desktopReviewOpen()) && layout.review.diffStyle() === "split",
476
+ )
477
+ // The observer reports the content-box width, which already excludes the row
478
+ // padding; only the flex gap between the panels remains to subtract.
479
+ const sessionPanelAvailable = createMemo(() => {
480
+ const width = panelRowWidth()
481
+ if (width === undefined) return undefined
482
+ return width - (settings.general.newLayoutDesigns() ? 8 : 0)
483
+ })
484
+ const sessionPanelMax = createMemo(() => {
485
+ const available = sessionPanelAvailable()
486
+ if (available === undefined) return 1000
487
+ return sessionPanelWidthMax({ available, split: splitReview() })
488
+ })
489
+ // Clamp at render time so window or sidebar resizes squeeze the chat panel
490
+ // instead of the review pane, without overwriting the persisted width.
491
+ const sessionPanelResizedWidth = createMemo(() =>
492
+ clampSessionPanelWidth({
493
+ width: layout.session.width(),
494
+ available: sessionPanelAvailable(),
495
+ split: splitReview(),
496
+ }),
497
+ )
498
+ const sessionPanelWidth = createMemo(() => {
499
+ if (!desktopSidePanelOpen()) return "100%"
500
+ if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px`
501
+ return `calc(100% - ${layout.fileTree.width()}px)`
502
+ })
503
+ const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen()))
504
+ const desktopV2PanelLayout = createMemo(() =>
505
+ sessionPanelLayout({
506
+ review: desktopV2ReviewOpen(),
507
+ terminal: desktopTerminalOpen(),
508
+ files: desktopFileTreeOpen(),
509
+ }),
510
+ )
511
+
512
+ function normalizeTab(tab: string) {
513
+ if (!tab.startsWith("file://")) return tab
514
+ return file.tab(tab)
515
+ }
516
+
517
+ function normalizeTabs(list: string[]) {
518
+ const seen = new Set<string>()
519
+ const next: string[] = []
520
+ for (const item of list) {
521
+ const value = normalizeTab(item)
522
+ if (seen.has(value)) continue
523
+ seen.add(value)
524
+ next.push(value)
525
+ }
526
+ return next
527
+ }
528
+
529
+ const openReviewPanel = () => {
530
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
531
+ }
532
+
533
+ const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
534
+ const isChildSession = createMemo(() => !!info()?.parentID)
535
+ const canReview = createMemo(() => !!sync().project)
536
+ const reviewTab = createMemo(() => isDesktop())
537
+ const tabState = createSessionTabs({
538
+ tabs,
539
+ pathFromTab: file.pathFromTab,
540
+ normalizeTab,
541
+ review: reviewTab,
542
+ hasReview: canReview,
543
+ })
544
+ const activeTab = tabState.activeTab
545
+ const activeFileTab = tabState.activeFileTab
546
+ const revertMessageID = createMemo(() => info()?.revert?.messageID)
547
+ const timeline = createTimelineModel({ sessionID: () => params.id, revertMessageID })
548
+ const historyLoading = timeline.history.loading
549
+ const historyMore = timeline.history.more
550
+ const lastUserMessage = timeline.lastUserMessage
551
+ const messages = timeline.messages
552
+ const messagesReady = timeline.ready
553
+ const sessionSync = timeline.resource
554
+ const userMessages = timeline.userMessages
555
+ const visibleUserMessages = timeline.visibleUserMessages
556
+
557
+ createEffect(() => {
558
+ const tab = activeFileTab()
559
+ if (!tab) return
560
+
561
+ const path = file.pathFromTab(tab)
562
+ if (path) void file.load(path)
563
+ })
564
+
565
+ createEffect(
566
+ on(
567
+ () => lastUserMessage()?.id,
568
+ () => {
569
+ const msg = lastUserMessage()
570
+ if (!msg) return
571
+ syncSessionModel(local, msg)
572
+ },
573
+ ),
574
+ )
575
+
576
+ let restoredModelSession: string | undefined
577
+ createEffect(() => {
578
+ const id = params.id
579
+ if (!id || !prompt.ready() || !local.session.ready()) return
580
+ if (restoredModelSession !== id) {
581
+ restoredModelSession = id
582
+ if (restorePromptModel(local, prompt)) return
583
+ }
584
+ syncPromptModel(local, prompt)
585
+ })
586
+
587
+ createEffect(
588
+ on(
589
+ () => ({ dir: sdk().directory, id: params.id }),
590
+ (next, prev) => {
591
+ if (!prev) return
592
+ if (next.dir === prev.dir && next.id === prev.id) return
593
+ if (prev.id && !next.id) local.session.reset()
594
+ },
595
+ { defer: true },
596
+ ),
597
+ )
598
+
599
+ const [store, setStore] = createStore({
600
+ ...sessionViewState(),
601
+ newSessionWorktree: "main",
602
+ deferRender: false,
603
+ })
604
+
605
+ const [followup, setFollowup] = persisted(
606
+ Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
607
+ createStore<{
608
+ items: Record<string, FollowupItem[] | undefined>
609
+ failed: Record<string, string | undefined>
610
+ paused: Record<string, boolean | undefined>
611
+ edit: Record<string, FollowupEdit | undefined>
612
+ }>({
613
+ items: {},
614
+ failed: {},
615
+ paused: {},
616
+ edit: {},
617
+ }),
618
+ )
619
+
620
+ createComputed((prev) => {
621
+ const key = sessionKey()
622
+ if (key !== prev) {
623
+ setStore("deferRender", true)
624
+ const owner = sessionOwnership.capture()
625
+ requestAnimationFrame(() => {
626
+ setTimeout(() => owner.run(() => setStore("deferRender", false)), 0)
627
+ })
628
+ }
629
+ return key
630
+ })
631
+
632
+ let reviewFrame: number | undefined
633
+ let todoFrame: number | undefined
634
+ let todoTimer: number | undefined
635
+ let diffFrame: number | undefined
636
+ let diffTimer: number | undefined
637
+
638
+ createComputed((prev) => {
639
+ const open = desktopReviewOpen()
640
+ if (prev === undefined || prev === open) return open
641
+
642
+ if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
643
+ setUi("reviewSnap", true)
644
+ reviewFrame = requestAnimationFrame(() => {
645
+ reviewFrame = undefined
646
+ setUi("reviewSnap", false)
647
+ })
648
+ return open
649
+ }, desktopReviewOpen())
650
+
651
+ const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
652
+ const nogit = createMemo(() => {
653
+ const project = sync().project
654
+ return !!project && project.vcs !== "git"
655
+ })
656
+ const changesOptions = createMemo<ChangeMode[]>(() => {
657
+ const list: ChangeMode[] = []
658
+ const project = sync().project
659
+ const vcs = sync().data.vcs
660
+ if (project?.vcs === "git") list.push("git")
661
+ if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
662
+ list.push("branch")
663
+ }
664
+ list.push("turn")
665
+ return list
666
+ })
667
+ const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes")
668
+ const wantsReview = createMemo(() =>
669
+ isDesktop()
670
+ ? desktopFileTreeOpen() ||
671
+ (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab())))
672
+ : store.mobileTab === "changes",
673
+ )
674
+ const vcsMode = createMemo<VcsMode | undefined>(() => {
675
+ const mode = reviewMode()
676
+ if (mode === "git" || mode === "branch") return mode
677
+ })
678
+ const vcsKey = createMemo(
679
+ () =>
680
+ ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const,
681
+ )
682
+ const vcsQuery = createQuery(() => {
683
+ const mode = vcsMode()
684
+ const enabled = wantsReview() && sync().project?.vcs === "git"
685
+
686
+ return {
687
+ queryKey: [...vcsKey(), mode] as const,
688
+ enabled,
689
+ queryFn: mode
690
+ ? () =>
691
+ sdk()
692
+ .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode })
693
+ .then((result) => result.data)
694
+ .catch((error) => {
695
+ console.debug("[session-review] failed to load vcs diff", { mode, error })
696
+ return []
697
+ })
698
+ : skipToken,
699
+ }
700
+ })
701
+ const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100)
702
+ const reviewDiffs = () => {
703
+ if (reviewMode() === "git" || reviewMode() === "branch")
704
+ // avoids suspense
705
+ return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
706
+ return turnDiffs()
707
+ }
708
+ const activeReviewFile = () => {
709
+ const diffs = reviewDiffs()
710
+ const selected = reviewFile()
711
+ if (selected && diffs.some((diff) => diff.file === selected)) return selected
712
+ return diffs[0]?.file
713
+ }
714
+ const reviewCount = () => reviewDiffs().length
715
+ const hasReview = () => reviewCount() > 0
716
+ const reviewReady = () => {
717
+ if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending
718
+ return true
719
+ }
720
+ const loadReviewDiff = async (file: string, version?: number): Promise<VcsFileDiff | undefined> => {
721
+ const mode = vcsMode()
722
+ if (!mode) return
723
+ const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
724
+ const directory = reviewDiffDirectory(root, file)
725
+ const source = reviewDiffs().find((diff) => diff.file === file)
726
+ const valid = (diff: VcsFileDiff | undefined) => {
727
+ if (!diff || !source) return
728
+ if (diff.additions !== source.additions || diff.deletions !== source.deletions) return
729
+ if (reviewDiffNeedsLoad(diff)) return
730
+ return diff
731
+ }
732
+ const request = (scope: string, context?: number) =>
733
+ queryClient
734
+ .fetchQuery({
735
+ queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
736
+ staleTime: Number.POSITIVE_INFINITY,
737
+ retry: 2,
738
+ queryFn: () =>
739
+ sdk()
740
+ .api.vcs.diff({
741
+ location: { directory: scope },
742
+ mode: mode === "git" ? "working" : mode,
743
+ context,
744
+ })
745
+ .then((result) => result.data),
746
+ })
747
+ .then((diffs) => diffs.find((diff) => diff.file === file))
748
+
749
+ if (directory !== root) {
750
+ try {
751
+ const scoped = valid(await request(directory))
752
+ if (scoped) return scoped
753
+ } catch (error) {
754
+ console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error })
755
+ }
756
+ }
757
+ try {
758
+ const bounded = valid(await request(root, 3))
759
+ if (bounded) return bounded
760
+ } catch (error) {
761
+ console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error })
762
+ }
763
+ }
764
+
765
+ const newSessionWorktree = createMemo(() => {
766
+ if (store.newSessionWorktree === "create") return "create"
767
+ const project = sync().project
768
+ if (project && sdk().directory !== project.worktree) return sdk().directory
769
+ return "main"
770
+ })
771
+
772
+ const setActiveMessage = (message: UserMessage | undefined) => {
773
+ messageMark = scrollMark
774
+ setStore("messageId", message?.id)
775
+ }
776
+
777
+ const anchor = (id: string) => `message-${id}`
778
+
779
+ const cursor = () => {
780
+ const root = scroller
781
+ if (!root) return store.messageId
782
+
783
+ const box = root.getBoundingClientRect()
784
+ const line = box.top + 100
785
+ const list = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
786
+ .map((el) => {
787
+ const id = el.dataset.messageId
788
+ if (!id) return
789
+
790
+ const rect = el.getBoundingClientRect()
791
+ return { id, top: rect.top, bottom: rect.bottom }
792
+ })
793
+ .filter((item): item is { id: string; top: number; bottom: number } => !!item)
794
+
795
+ const shown = list.filter((item) => item.bottom > box.top && item.top < box.bottom)
796
+ const hit = shown.find((item) => item.top <= line && item.bottom >= line)
797
+ if (hit) return hit.id
798
+
799
+ const near = [...shown].sort((a, b) => {
800
+ const da = Math.abs(a.top - line)
801
+ const db = Math.abs(b.top - line)
802
+ if (da !== db) return da - db
803
+ return a.top - b.top
804
+ })[0]
805
+ if (near) return near.id
806
+
807
+ return list.filter((item) => item.top <= line).at(-1)?.id ?? list[0]?.id ?? store.messageId
808
+ }
809
+
810
+ function navigateMessageByOffset(offset: number) {
811
+ const msgs = visibleUserMessages()
812
+ if (msgs.length === 0) return
813
+
814
+ const current = store.messageId && messageMark === scrollMark ? store.messageId : cursor()
815
+ const base = current ? msgs.findIndex((m) => m.id === current) : msgs.length
816
+ const currentIndex = base === -1 ? msgs.length : base
817
+ const targetIndex = currentIndex + offset
818
+ if (targetIndex < 0 || targetIndex > msgs.length) return
819
+
820
+ if (targetIndex === msgs.length) {
821
+ resumeScroll()
822
+ return
823
+ }
824
+
825
+ autoScroll.pause()
826
+ scrollToMessage(msgs[targetIndex], "auto")
827
+ }
828
+
829
+ function upsert(next: Project) {
830
+ const list = serverSync().data.project
831
+ sync().set("project", next.id)
832
+ const idx = list.findIndex((item) => item.id === next.id)
833
+ if (idx >= 0) {
834
+ serverSync().set(
835
+ "project",
836
+ list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
837
+ )
838
+ return
839
+ }
840
+ const at = list.findIndex((item) => item.id > next.id)
841
+ if (at >= 0) {
842
+ serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)])
843
+ return
844
+ }
845
+ serverSync().set("project", [...list, next])
846
+ }
847
+
848
+ const gitMutation = useMutation(() => ({
849
+ mutationFn: () => sdk().client.project.initGit(),
850
+ onSuccess: (x) => {
851
+ if (!x.data) return
852
+ upsert(x.data)
853
+ },
854
+ onError: (err) => {
855
+ showToast({
856
+ variant: "error",
857
+ title: language.t("common.requestFailed"),
858
+ description: formatServerError(err, language.t),
859
+ })
860
+ },
861
+ }))
862
+
863
+ function initGit() {
864
+ if (gitMutation.isPending) return
865
+ gitMutation.mutate()
866
+ }
867
+
868
+ let inputRef!: HTMLDivElement
869
+ let promptDock: HTMLDivElement | undefined
870
+ let dockHeight = 0
871
+ let scroller: HTMLDivElement | undefined
872
+ let content: HTMLDivElement | undefined
873
+ let revealMessage = (_id: string) => {}
874
+ let scrollToEnd = () => {}
875
+ let scrollMark = 0
876
+ let messageMark = 0
877
+
878
+ const scrollGestureWindowMs = 250
879
+
880
+ const markScrollGesture = (target?: EventTarget | null) => {
881
+ const root = scroller
882
+ if (!root) return
883
+
884
+ const el = target instanceof Element ? target : undefined
885
+ const nested = el?.closest("[data-scrollable]")
886
+ if (nested && nested !== root) return
887
+
888
+ setUi("scrollGesture", Date.now())
889
+ }
890
+
891
+ const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
892
+
893
+ createEffect(
894
+ on(
895
+ () => {
896
+ const id = params.id
897
+ return [
898
+ sdk().directory,
899
+ id,
900
+ id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
901
+ id ? composer.blocked() : false,
902
+ ] as const
903
+ },
904
+ ([dir, id, status, blocked]) => {
905
+ if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
906
+ if (todoTimer !== undefined) window.clearTimeout(todoTimer)
907
+ todoFrame = undefined
908
+ todoTimer = undefined
909
+ if (!id) return
910
+ if (status === "idle" && !blocked) return
911
+ const cached = untrack(() => sync().data.todo[id] !== undefined)
912
+
913
+ todoFrame = requestAnimationFrame(() => {
914
+ todoFrame = undefined
915
+ todoTimer = window.setTimeout(() => {
916
+ todoTimer = undefined
917
+ if (sdk().directory !== dir || params.id !== id) return
918
+ untrack(() => {
919
+ void sync().session.todo(id, cached ? { force: true } : undefined)
920
+ })
921
+ }, 0)
922
+ })
923
+ },
924
+ { defer: true },
925
+ ),
926
+ )
927
+
928
+ createEffect(
929
+ on(
930
+ () => visibleUserMessages().at(-1)?.id,
931
+ (lastId, prevLastId) => {
932
+ if (lastId && prevLastId && lastId > prevLastId) {
933
+ setStore("messageId", undefined)
934
+ }
935
+ },
936
+ { defer: true },
937
+ ),
938
+ )
939
+
940
+ createEffect(
941
+ on(
942
+ sessionKey,
943
+ () => {
944
+ setStore(sessionViewState())
945
+ setUi("pendingMessage", undefined)
946
+ },
947
+ { defer: true },
948
+ ),
949
+ )
950
+
951
+ const stopVcs = sdk().event.listen((evt) => {
952
+ const details = evt.details as { type: string; properties?: unknown }
953
+ if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return
954
+ const props =
955
+ typeof details.properties === "object" && details.properties
956
+ ? (details.properties as Record<string, unknown>)
957
+ : undefined
958
+ const file = typeof props?.file === "string" ? props.file : undefined
959
+ if (!file || file.startsWith(".git/")) return
960
+ refreshVcs()
961
+ })
962
+ onCleanup(stopVcs)
963
+
964
+ createEffect(
965
+ on(
966
+ () => sdk().directory,
967
+ (dir) => {
968
+ if (!dir) return
969
+ setStore("newSessionWorktree", "main")
970
+ },
971
+ { defer: true },
972
+ ),
973
+ )
974
+
975
+ const selectionPreview = (path: string, selection: FileSelection) => {
976
+ const content = file.get(path)?.content?.content
977
+ if (!content) return undefined
978
+ return previewSelectedLines(content, { start: selection.startLine, end: selection.endLine })
979
+ }
980
+
981
+ const addCommentToContext = (input: {
982
+ file: string
983
+ selection: SelectedLineRange
984
+ comment: string
985
+ preview?: string
986
+ origin?: "review" | "file"
987
+ }) => {
988
+ const selection = selectionFromLines(input.selection)
989
+ const preview = input.preview ?? selectionPreview(input.file, selection)
990
+ const saved = comments.add({
991
+ file: input.file,
992
+ selection: input.selection,
993
+ comment: input.comment,
994
+ })
995
+ prompt.context.add({
996
+ type: "file",
997
+ path: input.file,
998
+ selection,
999
+ comment: input.comment,
1000
+ commentID: saved.id,
1001
+ commentOrigin: input.origin,
1002
+ preview,
1003
+ })
1004
+ }
1005
+
1006
+ const updateCommentInContext = (input: {
1007
+ id: string
1008
+ file: string
1009
+ selection: SelectedLineRange
1010
+ comment: string
1011
+ preview?: string
1012
+ }) => {
1013
+ comments.update(input.file, input.id, input.comment)
1014
+ prompt.context.updateComment(input.file, input.id, {
1015
+ comment: input.comment,
1016
+ ...(input.preview ? { preview: input.preview } : {}),
1017
+ })
1018
+ }
1019
+
1020
+ const removeCommentFromContext = (input: { id: string; file: string }) => {
1021
+ comments.remove(input.file, input.id)
1022
+ prompt.context.removeComment(input.file, input.id)
1023
+ }
1024
+
1025
+ const reviewCommentActions = createMemo(() => ({
1026
+ moreLabel: language.t("common.moreOptions"),
1027
+ editLabel: language.t("common.edit"),
1028
+ deleteLabel: language.t("common.delete"),
1029
+ saveLabel: language.t("common.save"),
1030
+ }))
1031
+
1032
+ const isEditableTarget = (target: EventTarget | null | undefined) => {
1033
+ if (!(target instanceof HTMLElement)) return false
1034
+ return /^(INPUT|TEXTAREA|SELECT|BUTTON)$/.test(target.tagName) || target.isContentEditable
1035
+ }
1036
+
1037
+ const deepActiveElement = () => {
1038
+ let current: Element | null = document.activeElement
1039
+ while (current instanceof HTMLElement && current.shadowRoot?.activeElement) {
1040
+ current = current.shadowRoot.activeElement
1041
+ }
1042
+ return current instanceof HTMLElement ? current : undefined
1043
+ }
1044
+
1045
+ const handleKeyDown = (event: KeyboardEvent) => {
1046
+ const path = event.composedPath()
1047
+ const target = path.find((item): item is HTMLElement => item instanceof HTMLElement)
1048
+ const activeElement = deepActiveElement()
1049
+
1050
+ const protectedTarget = path.some(
1051
+ (item) => item instanceof HTMLElement && item.closest("[data-prevent-autofocus]") !== null,
1052
+ )
1053
+ if (protectedTarget || isEditableTarget(target)) return
1054
+
1055
+ if (activeElement) {
1056
+ const isProtected = activeElement.closest("[data-prevent-autofocus]")
1057
+ const isInput = isEditableTarget(activeElement)
1058
+ if (isProtected || isInput) return
1059
+ }
1060
+ if (dialog.active) return
1061
+
1062
+ if (activeElement === inputRef) {
1063
+ if (event.key === "Escape") inputRef?.blur()
1064
+ return
1065
+ }
1066
+
1067
+ const key = scrollKey(event)
1068
+ if (key) {
1069
+ if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
1070
+ if (scrollKeyOwner(scroller, target ?? null, key) !== scroller) return
1071
+ markScrollGesture(scroller)
1072
+ return
1073
+ }
1074
+
1075
+ if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
1076
+ if (composer.blocked() || isChildSession()) return
1077
+ const input = inputRef
1078
+ if (!input) return
1079
+ input.focus()
1080
+ setCursorPosition(input, prompt.cursor() ?? promptLength(prompt.current()))
1081
+ }
1082
+ }
1083
+
1084
+ createEffect(() => {
1085
+ if (!layout.ready()) return
1086
+ if (sync().status !== "complete") return
1087
+ if (!sync().project) return
1088
+ const list = changesOptions()
1089
+ const mode = reviewMode()
1090
+ if (list.includes(mode)) return
1091
+ const next = list[0]
1092
+ if (!next) return
1093
+ view().review.setMode(next)
1094
+ })
1095
+
1096
+ createEffect(
1097
+ on(
1098
+ () => sync().data.session_status[params.id ?? ""]?.type,
1099
+ (next, prev) => {
1100
+ if (next !== "idle" || prev === undefined || prev === "idle") return
1101
+ refreshVcs()
1102
+ },
1103
+ { defer: true },
1104
+ ),
1105
+ )
1106
+
1107
+ const fileTreeTab = () => layout.fileTree.tab()
1108
+ const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value)
1109
+
1110
+ const [tree, setTree] = createStore({
1111
+ reviewScroll: undefined as HTMLDivElement | undefined,
1112
+ pendingDiff: undefined as string | undefined,
1113
+ })
1114
+
1115
+ createEffect(
1116
+ on(
1117
+ sessionKey,
1118
+ () => {
1119
+ setTree({
1120
+ reviewScroll: undefined,
1121
+ pendingDiff: undefined,
1122
+ })
1123
+ },
1124
+ { defer: true },
1125
+ ),
1126
+ )
1127
+
1128
+ const showAllFiles = () => {
1129
+ if (fileTreeTab() !== "changes") return
1130
+ setFileTreeTab("all")
1131
+ }
1132
+
1133
+ const focusInput = () => {
1134
+ if (isChildSession()) return
1135
+ inputRef?.focus()
1136
+ }
1137
+
1138
+ useComposerCommands()
1139
+ useSessionCommands({
1140
+ navigateMessageByOffset,
1141
+ setActiveMessage,
1142
+ focusInput,
1143
+ review: reviewTab,
1144
+ fileBrowser: () => newSessionDesign() && isDesktop() && !!params.id,
1145
+ })
1146
+ command.register("session-palette", () => [
1147
+ {
1148
+ id: "command.palette",
1149
+ title: language.t("command.palette"),
1150
+ hidden: true,
1151
+ onSelect: () => command.trigger("file.open", "palette"),
1152
+ },
1153
+ ])
1154
+
1155
+ const openReviewFile = createOpenReviewFile({
1156
+ showAllFiles,
1157
+ tabForPath: file.tab,
1158
+ openTab: tabs().open,
1159
+ setActive: tabs().setActive,
1160
+ loadFile: file.load,
1161
+ })
1162
+
1163
+ const changesLabel = (option: ChangeMode) => {
1164
+ if (option === "git") return language.t("ui.sessionReview.title.git")
1165
+ if (option === "branch") return language.t("ui.sessionReview.title.branch")
1166
+ return language.t("ui.sessionReview.title.lastTurn")
1167
+ }
1168
+
1169
+ const changesTitle = () => {
1170
+ if (!canReview()) {
1171
+ return null
1172
+ }
1173
+
1174
+ return (
1175
+ <Select
1176
+ options={changesOptions()}
1177
+ current={reviewMode()}
1178
+ label={changesLabel}
1179
+ onSelect={(option) => option && view().review.setMode(option)}
1180
+ variant="ghost"
1181
+ size="small"
1182
+ valueClass="text-14-medium"
1183
+ />
1184
+ )
1185
+ }
1186
+
1187
+ const changesTitleV2 = () => {
1188
+ if (!canReview()) {
1189
+ return null
1190
+ }
1191
+
1192
+ return (
1193
+ <SelectV2
1194
+ appearance="inline"
1195
+ options={changesOptions()}
1196
+ current={reviewMode()}
1197
+ label={changesLabel}
1198
+ placement="bottom-start"
1199
+ gutter={6}
1200
+ onSelect={(option) => option && view().review.setMode(option)}
1201
+ />
1202
+ )
1203
+ }
1204
+
1205
+ const empty = (text: string) => (
1206
+ <div class="h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6">
1207
+ <div class="text-14-regular text-text-weak max-w-56">{text}</div>
1208
+ </div>
1209
+ )
1210
+
1211
+ const createGit = (input: { emptyClass: string }) => (
1212
+ <div class={input.emptyClass}>
1213
+ <div class="flex flex-col gap-3">
1214
+ <div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
1215
+ <div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
1216
+ {language.t("session.review.noVcs.createGit.description")}
1217
+ </div>
1218
+ </div>
1219
+ <Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
1220
+ {gitMutation.isPending
1221
+ ? language.t("session.review.noVcs.createGit.actionLoading")
1222
+ : language.t("session.review.noVcs.createGit.action")}
1223
+ </Button>
1224
+ </div>
1225
+ )
1226
+
1227
+ const reviewEmptyText = createMemo(() => {
1228
+ if (reviewMode() === "git") return language.t("session.review.noUncommittedChanges")
1229
+ if (reviewMode() === "branch") return language.t("session.review.noBranchChanges")
1230
+ return language.t("session.review.noChanges")
1231
+ })
1232
+
1233
+ const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
1234
+ if (reviewMode() === "git" || reviewMode() === "branch") {
1235
+ if (!reviewReady()) return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
1236
+ return empty(reviewEmptyText())
1237
+ }
1238
+
1239
+ if (reviewMode() === "turn") {
1240
+ if (nogit()) return createGit(input)
1241
+ return empty(reviewEmptyText())
1242
+ }
1243
+
1244
+ return (
1245
+ <div class={input.emptyClass}>
1246
+ <div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>
1247
+ </div>
1248
+ )
1249
+ }
1250
+
1251
+ const reviewEmptyV2 = () => {
1252
+ if ((reviewMode() === "git" || reviewMode() === "branch") && !reviewReady()) {
1253
+ return <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
1254
+ }
1255
+ if (reviewMode() === "turn" && nogit()) {
1256
+ return <SessionReviewEmptyNoGitV2 pending={gitMutation.isPending} onInitGit={initGit} />
1257
+ }
1258
+ return <SessionReviewEmptyChangesV2 />
1259
+ }
1260
+
1261
+ const reviewContent = (input: {
1262
+ diffStyle: DiffStyle
1263
+ onDiffStyleChange?: (style: DiffStyle) => void
1264
+ classes?: SessionReviewTabProps["classes"]
1265
+ loadingClass: string
1266
+ emptyClass: string
1267
+ }) => (
1268
+ <Show when={!store.deferRender}>
1269
+ <SessionReviewTab
1270
+ title={changesTitle()}
1271
+ empty={reviewEmpty(input)}
1272
+ diffs={reviewDiffs}
1273
+ view={view}
1274
+ diffStyle={input.diffStyle}
1275
+ onDiffStyleChange={input.onDiffStyleChange}
1276
+ onScrollRef={(el) => setTree("reviewScroll", el)}
1277
+ focusedFile={activeReviewFile()}
1278
+ onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
1279
+ onLineCommentUpdate={updateCommentInContext}
1280
+ onLineCommentDelete={removeCommentFromContext}
1281
+ lineCommentActions={reviewCommentActions()}
1282
+ commentMentions={{
1283
+ items: file.searchFilesAndDirectories,
1284
+ }}
1285
+ comments={comments.all()}
1286
+ focusedComment={comments.focus()}
1287
+ onFocusedCommentChange={comments.setFocus}
1288
+ onViewFile={openReviewFile}
1289
+ classes={input.classes}
1290
+ />
1291
+ </Show>
1292
+ )
1293
+
1294
+ const reviewV2State = createReviewPanelV2State()
1295
+
1296
+ // Getters defer reactive reads to the consuming scope. Eager reads here ran inside
1297
+ // the side panel's Show children and remounted the whole review panel on unrelated
1298
+ // updates such as session switches.
1299
+ const reviewPanelV2Props = () => ({
1300
+ get title() {
1301
+ return changesTitleV2()
1302
+ },
1303
+ get empty() {
1304
+ return reviewEmptyV2()
1305
+ },
1306
+ diffs: reviewDiffs,
1307
+ diffsReady: reviewReady,
1308
+ get diffVersion() {
1309
+ return vcsQuery.dataUpdatedAt
1310
+ },
1311
+ loadDiff: loadReviewDiff,
1312
+ get activeFile() {
1313
+ return activeReviewFile()
1314
+ },
1315
+ onSelectFile: focusReviewDiff,
1316
+ get diffStyle() {
1317
+ return layout.review.diffStyle()
1318
+ },
1319
+ onDiffStyleChange: layout.review.setDiffStyle,
1320
+ state: reviewV2State,
1321
+ onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }),
1322
+ onLineCommentUpdate: updateCommentInContext,
1323
+ onLineCommentDelete: removeCommentFromContext,
1324
+ get lineCommentActions() {
1325
+ return reviewCommentActions()
1326
+ },
1327
+ get comments() {
1328
+ return comments.all()
1329
+ },
1330
+ get focusedComment() {
1331
+ return comments.focus()
1332
+ },
1333
+ onFocusedCommentChange: (focus: { file: string; id: string } | null) => {
1334
+ // The preview clears the focus once it has opened the comment; persist the
1335
+ // focused file as the active selection so the preview stays on it. Skip
1336
+ // files outside the current diff set (their focus is cleared unhandled).
1337
+ if (!focus) {
1338
+ const current = comments.focus()
1339
+ if (current && reviewDiffs().some((diff) => diff.file === current.file)) focusReviewDiff(current.file)
1340
+ }
1341
+ comments.setFocus(focus)
1342
+ },
1343
+ })
1344
+
1345
+ // Latch: defer only the first diff render off the mount critical path. This Page
1346
+ // stays mounted across same-workspace session tab switches, so gating on every
1347
+ // deferRender flip tore down and remounted the whole review pane on tab switch.
1348
+ const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
1349
+
1350
+ const reviewPanelV2 = () => (
1351
+ <div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
1352
+ <Show when={reviewPanelV2Rendered()}>
1353
+ <ReviewPanelV2 {...reviewPanelV2Props()} />
1354
+ </Show>
1355
+ </div>
1356
+ )
1357
+
1358
+ const reviewPanel = () => (
1359
+ <div
1360
+ classList={{
1361
+ "flex flex-col h-full overflow-hidden contain-strict": true,
1362
+ "bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
1363
+ "bg-background-stronger": !settings.general.newLayoutDesigns(),
1364
+ }}
1365
+ >
1366
+ <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
1367
+ {reviewContent({
1368
+ diffStyle: layout.review.diffStyle(),
1369
+ onDiffStyleChange: layout.review.setDiffStyle,
1370
+ loadingClass: "px-6 py-4 text-text-weak",
1371
+ emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
1372
+ })}
1373
+ </div>
1374
+ </div>
1375
+ )
1376
+
1377
+ createEffect(
1378
+ on(
1379
+ activeFileTab,
1380
+ (active) => {
1381
+ if (!active) return
1382
+ if (fileTreeTab() !== "changes") return
1383
+ showAllFiles()
1384
+ },
1385
+ { defer: true },
1386
+ ),
1387
+ )
1388
+
1389
+ const reviewDiffId = (path: string) => {
1390
+ const sum = checksum(path)
1391
+ if (!sum) return
1392
+ return `session-review-diff-${sum}`
1393
+ }
1394
+
1395
+ const reviewDiffTop = (path: string) => {
1396
+ const root = tree.reviewScroll
1397
+ if (!root) return
1398
+
1399
+ const id = reviewDiffId(path)
1400
+ if (!id) return
1401
+
1402
+ const el = document.getElementById(id)
1403
+ if (!(el instanceof HTMLElement)) return
1404
+ if (!root.contains(el)) return
1405
+
1406
+ const a = el.getBoundingClientRect()
1407
+ const b = root.getBoundingClientRect()
1408
+ return a.top - b.top + root.scrollTop
1409
+ }
1410
+
1411
+ const scrollToReviewDiff = (path: string) => {
1412
+ const root = tree.reviewScroll
1413
+ if (!root) return false
1414
+
1415
+ const top = reviewDiffTop(path)
1416
+ if (top === undefined) return false
1417
+
1418
+ view().setScroll("review", { x: root.scrollLeft, y: top })
1419
+ root.scrollTo({ top, behavior: "auto" })
1420
+ return true
1421
+ }
1422
+
1423
+ const focusReviewDiff = (path: string) => {
1424
+ openReviewPanel()
1425
+ view().review.openPath(path)
1426
+ view().review.setFile(path)
1427
+ setTree("pendingDiff", path)
1428
+ }
1429
+
1430
+ createEffect(() => {
1431
+ const pending = tree.pendingDiff
1432
+ if (!pending) return
1433
+ if (!tree.reviewScroll) return
1434
+ if (!reviewReady()) return
1435
+
1436
+ const attempt = (count: number) => {
1437
+ if (tree.pendingDiff !== pending) return
1438
+ if (count > 60) {
1439
+ setTree("pendingDiff", undefined)
1440
+ return
1441
+ }
1442
+
1443
+ const root = tree.reviewScroll
1444
+ if (!root) {
1445
+ requestAnimationFrame(() => attempt(count + 1))
1446
+ return
1447
+ }
1448
+
1449
+ if (!scrollToReviewDiff(pending)) {
1450
+ requestAnimationFrame(() => attempt(count + 1))
1451
+ return
1452
+ }
1453
+
1454
+ const top = reviewDiffTop(pending)
1455
+ if (top === undefined) {
1456
+ requestAnimationFrame(() => attempt(count + 1))
1457
+ return
1458
+ }
1459
+
1460
+ if (Math.abs(root.scrollTop - top) <= 1) {
1461
+ setTree("pendingDiff", undefined)
1462
+ return
1463
+ }
1464
+
1465
+ requestAnimationFrame(() => attempt(count + 1))
1466
+ }
1467
+
1468
+ requestAnimationFrame(() => attempt(0))
1469
+ })
1470
+
1471
+ let treeDir: string | undefined
1472
+ createEffect(() => {
1473
+ const dir = sdk().directory
1474
+ if (!isDesktop()) return
1475
+ if (!layout.fileTree.opened()) return
1476
+ if (sync().status === "loading") return
1477
+
1478
+ fileTreeTab()
1479
+ const refresh = treeDir !== dir
1480
+ treeDir = dir
1481
+ void (refresh ? file.tree.refresh("") : file.tree.list(""))
1482
+ })
1483
+
1484
+ createEffect(
1485
+ on(
1486
+ () => sdk().directory,
1487
+ () => {
1488
+ const tab = activeFileTab()
1489
+ if (!tab) return
1490
+ const path = file.pathFromTab(tab)
1491
+ if (!path) return
1492
+ void file.load(path, { force: true })
1493
+ },
1494
+ { defer: true },
1495
+ ),
1496
+ )
1497
+
1498
+ const autoScroll = createAutoScroll({
1499
+ working: () => true,
1500
+ overflowAnchor: "none",
1501
+ })
1502
+ createEffect(
1503
+ on(
1504
+ () => params.id,
1505
+ (id, previous) => {
1506
+ if (!id || !previous || id === previous) return
1507
+ if (location.hash || store.messageId || ui.pendingMessage) return
1508
+ autoScroll.resume()
1509
+ },
1510
+ ),
1511
+ )
1512
+
1513
+ let scrollStateFrame: number | undefined
1514
+ let scrollStateTarget: HTMLDivElement | undefined
1515
+ let fillFrame: number | undefined
1516
+
1517
+ const jumpThreshold = (el: HTMLDivElement) => Math.max(400, el.clientHeight)
1518
+
1519
+ const updateScrollState = (el: HTMLDivElement) => {
1520
+ const max = el.scrollHeight - el.clientHeight
1521
+ const distance = max - el.scrollTop
1522
+ const overflow = max > 1
1523
+ const bottom = !overflow || distance <= 2
1524
+ const jump = overflow && distance > jumpThreshold(el)
1525
+
1526
+ if (ui.scroll.overflow === overflow && ui.scroll.bottom === bottom && ui.scroll.jump === jump) return
1527
+ setUi("scroll", { overflow, bottom, jump })
1528
+ }
1529
+
1530
+ const scheduleScrollState = (el: HTMLDivElement) => {
1531
+ scrollStateTarget = el
1532
+ if (scrollStateFrame !== undefined) return
1533
+
1534
+ scrollStateFrame = requestAnimationFrame(() => {
1535
+ scrollStateFrame = undefined
1536
+
1537
+ const target = scrollStateTarget
1538
+ scrollStateTarget = undefined
1539
+ if (!target) return
1540
+
1541
+ updateScrollState(target)
1542
+ })
1543
+ }
1544
+
1545
+ const resumeScroll = () => {
1546
+ setStore("messageId", undefined)
1547
+ autoScroll.resume()
1548
+ scrollToEnd()
1549
+ clearMessageHash()
1550
+
1551
+ const el = scroller
1552
+ if (el) scheduleScrollState(el)
1553
+ }
1554
+
1555
+ // When the user returns to the bottom, treat the active message as "latest".
1556
+ createEffect(
1557
+ on(
1558
+ autoScroll.userScrolled,
1559
+ (scrolled) => {
1560
+ if (scrolled) return
1561
+ setStore("messageId", undefined)
1562
+ clearMessageHash()
1563
+ },
1564
+ { defer: true },
1565
+ ),
1566
+ )
1567
+
1568
+ let fill = () => {}
1569
+
1570
+ const setScrollRef = (el: HTMLDivElement | undefined) => {
1571
+ scroller = el
1572
+ autoScroll.scrollRef(el)
1573
+ if (!el) return
1574
+ scheduleScrollState(el)
1575
+ fill()
1576
+ }
1577
+
1578
+ const markUserScroll = () => {
1579
+ scrollMark += 1
1580
+ }
1581
+
1582
+ createResizeObserver(
1583
+ () => content,
1584
+ () => {
1585
+ const el = scroller
1586
+ if (el) scheduleScrollState(el)
1587
+ fill()
1588
+ },
1589
+ )
1590
+
1591
+ let captureHistoryAnchor = () => {}
1592
+ let restoreHistoryAnchor = (_done: boolean) => {}
1593
+ const historyRequests = new Set<string>()
1594
+ let historyContinuationFrame: number | undefined
1595
+ const loadOlder = async () => {
1596
+ const owner = sessionOwnership.capture()
1597
+ if (historyLoading() || historyRequests.has(owner.key)) return
1598
+ historyRequests.add(owner.key)
1599
+ const before = timeline.messages().length
1600
+ try {
1601
+ await timeline.history.loadOlder({
1602
+ before: () => owner.run(captureHistoryAnchor),
1603
+ after: (done) => owner.run(() => restoreHistoryAnchor(done)),
1604
+ })
1605
+ } finally {
1606
+ historyRequests.delete(owner.key)
1607
+ }
1608
+ if (!owner.current() || timeline.messages().length <= before) return
1609
+ if (!autoScroll.userScrolled() || !scroller || scroller.scrollTop >= 200 || !historyMore()) return
1610
+ if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
1611
+ historyContinuationFrame = requestAnimationFrame(() => {
1612
+ historyContinuationFrame = undefined
1613
+ owner.run(onHistoryScroll)
1614
+ })
1615
+ }
1616
+ const onHistoryScroll = () => {
1617
+ if (
1618
+ historyRequests.has(sessionOwnership.key()) ||
1619
+ historyLoading() ||
1620
+ !autoScroll.userScrolled() ||
1621
+ !scroller ||
1622
+ scroller.scrollTop >= 200
1623
+ )
1624
+ return
1625
+ void loadOlder()
1626
+ }
1627
+
1628
+ onCleanup(() => {
1629
+ if (historyContinuationFrame !== undefined) cancelAnimationFrame(historyContinuationFrame)
1630
+ })
1631
+
1632
+ fill = () => {
1633
+ if (fillFrame !== undefined) return
1634
+
1635
+ fillFrame = requestAnimationFrame(() => {
1636
+ fillFrame = undefined
1637
+
1638
+ if (!params.id || !messagesReady()) return
1639
+ if (autoScroll.userScrolled() || historyLoading()) return
1640
+
1641
+ const el = scroller
1642
+ if (!el) return
1643
+ if (el.scrollHeight > el.clientHeight + 1) return
1644
+ if (!historyMore()) return
1645
+
1646
+ void loadOlder()
1647
+ })
1648
+ }
1649
+
1650
+ createEffect(
1651
+ on(
1652
+ () =>
1653
+ [
1654
+ params.id,
1655
+ messagesReady(),
1656
+ historyMore(),
1657
+ historyLoading(),
1658
+ autoScroll.userScrolled(),
1659
+ visibleUserMessages().length,
1660
+ ] as const,
1661
+ ([id, ready, more, loading, scrolled]) => {
1662
+ if (!id || !ready || loading || scrolled) return
1663
+ if (!more) return
1664
+ fill()
1665
+ },
1666
+ { defer: true },
1667
+ ),
1668
+ )
1669
+
1670
+ const draft = (id: string) =>
1671
+ extractPromptFromParts(sync().data.part[id] ?? [], {
1672
+ directory: sdk().directory,
1673
+ attachmentName: language.t("common.attachment"),
1674
+ })
1675
+
1676
+ const line = (id: string) => {
1677
+ const text = draft(id)
1678
+ .map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
1679
+ .join("")
1680
+ .replace(/\s+/g, " ")
1681
+ .trim()
1682
+ if (text) return text
1683
+ return `[${language.t("common.attachment")}]`
1684
+ }
1685
+
1686
+ const fail = (err: unknown) => {
1687
+ showToast({
1688
+ variant: "error",
1689
+ title: language.t("common.requestFailed"),
1690
+ description: formatServerError(err, language.t),
1691
+ })
1692
+ }
1693
+
1694
+ const merge = (next: NonNullable<ReturnType<typeof info>>, target = sync()) => target.session.remember(next)
1695
+
1696
+ const roll = (sessionID: string, next: NonNullable<ReturnType<typeof info>>["revert"], target = sync()) => {
1697
+ const session = target.session.get(sessionID)
1698
+ if (!session) return
1699
+ target.session.remember({ ...session, revert: next })
1700
+ }
1701
+
1702
+ const busy = (sessionID: string) => sync().data.session_working(sessionID)
1703
+
1704
+ const queuedFollowups = createMemo(() => {
1705
+ const id = params.id
1706
+ if (!id) return emptyFollowups
1707
+ return followup.items[id] ?? emptyFollowups
1708
+ })
1709
+
1710
+ const editingFollowup = createMemo(() => {
1711
+ const id = params.id
1712
+ if (!id) return
1713
+ return followup.edit[id]
1714
+ })
1715
+
1716
+ const followupMutation = useMutation(() => ({
1717
+ mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
1718
+ const owner = sessionOwnership.capture()
1719
+ const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
1720
+ if (!item) return
1721
+
1722
+ if (input.manual) setFollowup("paused", input.sessionID, undefined)
1723
+ setFollowup("failed", input.sessionID, undefined)
1724
+
1725
+ const ok = await sendFollowupDraft({
1726
+ api: sdk().api.session,
1727
+ sync: sync(),
1728
+ serverSync: serverSync(),
1729
+ draft: item,
1730
+ optimisticBusy: item.sessionDirectory === sdk().directory,
1731
+ }).catch((err) => {
1732
+ setFollowup("failed", input.sessionID, input.id)
1733
+ fail(err)
1734
+ return false
1735
+ })
1736
+ if (!ok) return
1737
+
1738
+ setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
1739
+ if (input.manual) owner.run(resumeScroll)
1740
+ },
1741
+ }))
1742
+
1743
+ const followupBusy = (sessionID: string) =>
1744
+ followupMutation.isPending && followupMutation.variables?.sessionID === sessionID
1745
+
1746
+ const sendingFollowup = createMemo(() => {
1747
+ const id = params.id
1748
+ if (!id) return
1749
+ if (!followupBusy(id)) return
1750
+ return followupMutation.variables?.id
1751
+ })
1752
+
1753
+ const queueEnabled = createMemo(() => {
1754
+ const id = params.id
1755
+ if (!id) return false
1756
+ return settings.general.followup() === "queue" && busy(id) && !composer.blocked() && !isChildSession()
1757
+ })
1758
+
1759
+ const followupText = (item: FollowupDraft) => {
1760
+ const text = item.prompt
1761
+ .map((part) => {
1762
+ if (part.type === "image") return `[image:${part.filename}]`
1763
+ if (part.type === "file") return `[file:${part.path}]`
1764
+ if (part.type === "agent") return `@${part.name}`
1765
+ return part.content
1766
+ })
1767
+ .join("")
1768
+ .split(/\r?\n/)
1769
+ .map((line) => line.trim())
1770
+ .find((line) => !!line)
1771
+
1772
+ if (text) return text
1773
+ return `[${language.t("common.attachment")}]`
1774
+ }
1775
+
1776
+ const queueFollowup = (draft: FollowupDraft) => {
1777
+ setFollowup("items", draft.sessionID, (items) => [
1778
+ ...(items ?? []),
1779
+ { id: Identifier.ascending("message"), ...draft },
1780
+ ])
1781
+ setFollowup("failed", draft.sessionID, undefined)
1782
+ setFollowup("paused", draft.sessionID, undefined)
1783
+ }
1784
+
1785
+ const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
1786
+
1787
+ const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
1788
+ if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
1789
+ const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
1790
+ if (!item) return Promise.resolve()
1791
+ if (followupBusy(sessionID)) return Promise.resolve()
1792
+
1793
+ return followupMutation.mutateAsync({ sessionID, id, manual: opts?.manual })
1794
+ }
1795
+
1796
+ const editFollowup = (id: string) => {
1797
+ const sessionID = params.id
1798
+ if (!sessionID) return
1799
+ if (followupBusy(sessionID)) return
1800
+
1801
+ const item = queuedFollowups().find((entry) => entry.id === id)
1802
+ if (!item) return
1803
+
1804
+ setFollowup("items", sessionID, (items) => (items ?? []).filter((entry) => entry.id !== id))
1805
+ setFollowup("failed", sessionID, (value) => (value === id ? undefined : value))
1806
+ setFollowup("edit", sessionID, {
1807
+ id: item.id,
1808
+ prompt: item.prompt,
1809
+ context: item.context,
1810
+ })
1811
+ }
1812
+
1813
+ const clearFollowupEdit = () => {
1814
+ const id = params.id
1815
+ if (!id) return
1816
+ setFollowup("edit", id, undefined)
1817
+ }
1818
+
1819
+ const halt = (sessionID: string) =>
1820
+ busy(sessionID)
1821
+ ? sdk()
1822
+ .api.session.interrupt({ sessionID })
1823
+ .catch(() => {})
1824
+ : Promise.resolve()
1825
+
1826
+ const revertMutation = useMutation(() => ({
1827
+ mutationFn: async (input: { sessionID: string; messageID: string }) => {
1828
+ const session = sdk().api.session
1829
+ const target = sync()
1830
+ const last = target.session.get(input.sessionID)?.revert
1831
+ const value = draft(input.messageID)
1832
+ await runPromptRollbackMutation({
1833
+ capturePrompt: prompt.capture,
1834
+ optimistic: (prompt) => {
1835
+ roll(input.sessionID, { messageID: input.messageID }, target)
1836
+ prompt.set(value)
1837
+ },
1838
+ request: () => halt(input.sessionID).then(() => session.revert.stage(input)),
1839
+ complete: () => undefined,
1840
+ rollback: () => roll(input.sessionID, last, target),
1841
+ fail,
1842
+ })
1843
+ },
1844
+ }))
1845
+
1846
+ const restoreMutation = useMutation(() => ({
1847
+ mutationFn: async (id: string) => {
1848
+ const sessionID = params.id
1849
+ if (!sessionID) return
1850
+
1851
+ const session = sdk().api.session
1852
+ const target = sync()
1853
+ const next = userMessages().find((item) => item.id > id)
1854
+ const last = target.session.get(sessionID)?.revert
1855
+
1856
+ await runPromptRollbackMutation({
1857
+ capturePrompt: prompt.capture,
1858
+ optimistic: (promptSession) => {
1859
+ roll(sessionID, next ? { messageID: next.id } : undefined, target)
1860
+ if (next) {
1861
+ promptSession.set(draft(next.id))
1862
+ return
1863
+ }
1864
+ promptSession.reset()
1865
+ },
1866
+ request: () =>
1867
+ !next
1868
+ ? halt(sessionID).then(() => session.revert.clear({ sessionID }))
1869
+ : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
1870
+ complete: () => undefined,
1871
+ rollback: () => roll(sessionID, last, target),
1872
+ fail,
1873
+ })
1874
+ },
1875
+ }))
1876
+
1877
+ const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
1878
+ const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
1879
+
1880
+ const revert = (input: { sessionID: string; messageID: string }) => {
1881
+ if (reverting()) return
1882
+ return revertMutation.mutateAsync(input)
1883
+ }
1884
+
1885
+ const restore = (id: string) => {
1886
+ if (!params.id || reverting()) return
1887
+ return restoreMutation.mutateAsync(id)
1888
+ }
1889
+
1890
+ const rolled = createMemo(() => {
1891
+ const id = revertMessageID()
1892
+ if (!id) return []
1893
+ return userMessages()
1894
+ .filter((item) => item.id >= id)
1895
+ .map((item) => ({ id: item.id, text: line(item.id) }))
1896
+ })
1897
+
1898
+ // attachment bytes are embedded as a data URL, so downloading always works;
1899
+ // revealing requires the on-disk path captured by the client that attached the file
1900
+ const openAttachment = (file: FilePart) => {
1901
+ const download = () => {
1902
+ const anchor = document.createElement("a")
1903
+ anchor.href = file.url
1904
+ anchor.download = getFilename(file.filename) || "attachment"
1905
+ anchor.click()
1906
+ }
1907
+ const path = file.filename ?? ""
1908
+ const absolute = path.startsWith("/") || path.startsWith("\\\\") || /^[a-zA-Z]:[\\/]/.test(path)
1909
+ if (platform.revealPath && absolute) {
1910
+ void platform.revealPath(path).then(
1911
+ (revealed) => {
1912
+ if (!revealed) download()
1913
+ },
1914
+ () => download(),
1915
+ )
1916
+ return
1917
+ }
1918
+ download()
1919
+ }
1920
+
1921
+ const actions = { revert, openAttachment }
1922
+
1923
+ createEffect(() => {
1924
+ const sessionID = params.id
1925
+ if (!sessionID) return
1926
+
1927
+ const item = queuedFollowups()[0]
1928
+ if (!item) return
1929
+ if (followupBusy(sessionID)) return
1930
+ if (followup.failed[sessionID] === item.id) return
1931
+ if (followup.paused[sessionID]) return
1932
+ if (isChildSession()) return
1933
+ if (composer.blocked()) return
1934
+ if (busy(sessionID)) return
1935
+
1936
+ void sendFollowup(sessionID, item.id)
1937
+ })
1938
+
1939
+ createResizeObserver(
1940
+ () => promptDock,
1941
+ ({ height }) => {
1942
+ const next = Math.ceil(height)
1943
+
1944
+ if (next === dockHeight) return
1945
+
1946
+ const el = scroller
1947
+ const delta = next - dockHeight
1948
+ const stick = el
1949
+ ? !autoScroll.userScrolled() || el.scrollHeight - el.clientHeight - el.scrollTop < 10 + Math.max(0, delta)
1950
+ : false
1951
+
1952
+ dockHeight = next
1953
+
1954
+ if (stick) scrollToEnd()
1955
+
1956
+ if (el) scheduleScrollState(el)
1957
+ fill()
1958
+ },
1959
+ )
1960
+
1961
+ const { clearMessageHash, scrollToMessage } = useSessionHashScroll({
1962
+ sessionKey,
1963
+ sessionID: () => params.id,
1964
+ messagesReady,
1965
+ visibleUserMessages,
1966
+ historyMore,
1967
+ historyLoading,
1968
+ loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
1969
+ currentMessageId: () => store.messageId,
1970
+ pendingMessage: () => ui.pendingMessage,
1971
+ setPendingMessage: (value) => setUi("pendingMessage", value),
1972
+ setActiveMessage,
1973
+ autoScroll: {
1974
+ pause: autoScroll.pause,
1975
+ forceScrollToBottom: () => {
1976
+ autoScroll.resume()
1977
+ scrollToEnd()
1978
+ },
1979
+ },
1980
+ scroller: () => scroller,
1981
+ anchor,
1982
+ revealMessage: (id) => revealMessage(id),
1983
+ scheduleScrollState,
1984
+ consumePendingMessage: layout.pendingMessage.consume,
1985
+ })
1986
+
1987
+ createEffect(
1988
+ on(
1989
+ () => params.id,
1990
+ (id) => {
1991
+ if (!id) requestAnimationFrame(() => inputRef?.focus())
1992
+ },
1993
+ ),
1994
+ )
1995
+
1996
+ onMount(() => {
1997
+ makeEventListener(document, "keydown", handleKeyDown)
1998
+ })
1999
+
2000
+ onCleanup(() => {
2001
+ if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
2002
+ if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
2003
+ if (todoTimer !== undefined) window.clearTimeout(todoTimer)
2004
+ if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
2005
+ if (diffTimer !== undefined) window.clearTimeout(diffTimer)
2006
+ if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
2007
+ if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
2008
+ })
2009
+
2010
+ useUsageExceededDialogs()
2011
+
2012
+ const mobileTabs = (compact = false, bottom = false) => (
2013
+ <Tabs value={store.mobileTab} class="h-auto">
2014
+ <Tabs.List
2015
+ classList={{
2016
+ "!h-9": compact,
2017
+ "[&::after]:!border-b-0 [&::after]:!border-t [&::after]:!border-border-weak-base": bottom,
2018
+ }}
2019
+ >
2020
+ <Tabs.Trigger
2021
+ value="session"
2022
+ classList={{
2023
+ "!w-1/2 !max-w-none": true,
2024
+ "!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
2025
+ }}
2026
+ classes={{ button: compact ? "w-full !py-2" : "w-full" }}
2027
+ onClick={() => setStore("mobileTab", "session")}
2028
+ >
2029
+ {language.t("session.tab.session")}
2030
+ </Tabs.Trigger>
2031
+ <Tabs.Trigger
2032
+ value="changes"
2033
+ classList={{
2034
+ "!w-1/2 !max-w-none !border-r-0": true,
2035
+ "!border-b-0 !border-t !border-border-weak-base [&:has([data-selected])]:!border-t-transparent": bottom,
2036
+ }}
2037
+ classes={{ button: compact ? "w-full !py-2" : "w-full" }}
2038
+ onClick={() => setStore("mobileTab", "changes")}
2039
+ >
2040
+ {hasReview()
2041
+ ? language.t("session.review.filesChanged", { count: reviewCount() })
2042
+ : language.t("session.review.change.other")}
2043
+ </Tabs.Trigger>
2044
+ </Tabs.List>
2045
+ </Tabs>
2046
+ )
2047
+ const mobileTabsBottom = createMemo(
2048
+ () => !isDesktop() && settings.general.newLayoutDesigns() && settings.general.mobileTitlebarPosition() === "bottom",
2049
+ )
2050
+
2051
+ const sessionErrorFallback = (error: unknown, reset: () => void) => {
2052
+ createEffect(on(sessionKey, reset, { defer: true }))
2053
+ return <SessionErrorFallback error={error} sessionID={params.id} />
2054
+ }
2055
+
2056
+ const sessionPanelContent = () => (
2057
+ <>
2058
+ {sessionSync() ?? ""}
2059
+ <Show when={!isDesktop() && !!params.id && settings.general.newLayoutDesigns() && !mobileTabsBottom()}>
2060
+ {mobileTabs(true)}
2061
+ </Show>
2062
+ <div class="flex-1 min-h-0 overflow-hidden">
2063
+ <Switch>
2064
+ <Match when={params.id && mobileChanges()}>
2065
+ <div class="relative h-full overflow-hidden">
2066
+ {reviewContent({
2067
+ diffStyle: "unified",
2068
+ classes: {
2069
+ root: "pb-8 [&_[data-slot=session-review-list]]:pb-0",
2070
+ header: "px-4 !h-16 !pb-4",
2071
+ container: "px-4",
2072
+ },
2073
+ loadingClass: "px-4 py-4 text-text-weak",
2074
+ emptyClass: "h-full pb-64 -mt-4 flex flex-col items-center justify-center text-center gap-6",
2075
+ })}
2076
+ </div>
2077
+ </Match>
2078
+ <Match when={params.id}>
2079
+ <Show when={messagesReady() ? params.id : undefined} keyed>
2080
+ {(_id) => (
2081
+ <MessageTimeline
2082
+ actions={actions}
2083
+ scroll={ui.scroll}
2084
+ onResumeScroll={resumeScroll}
2085
+ setScrollRef={setScrollRef}
2086
+ onScheduleScrollState={scheduleScrollState}
2087
+ onAutoScrollHandleScroll={autoScroll.handleScroll}
2088
+ onMarkScrollGesture={markScrollGesture}
2089
+ hasScrollGesture={hasScrollGesture}
2090
+ onUserScroll={markUserScroll}
2091
+ onHistoryScroll={onHistoryScroll}
2092
+ onAutoScrollInteraction={autoScroll.handleInteraction}
2093
+ shouldAnchorBottom={() =>
2094
+ !location.hash && !store.messageId && !ui.pendingMessage && !autoScroll.userScrolled()
2095
+ }
2096
+ centered={centered()}
2097
+ setContentRef={(el) => {
2098
+ content = el
2099
+ autoScroll.contentRef(el)
2100
+
2101
+ const root = scroller
2102
+ if (root) scheduleScrollState(root)
2103
+ }}
2104
+ userMessages={visibleUserMessages()}
2105
+ setHistoryAnchor={(handlers) => {
2106
+ captureHistoryAnchor = handlers.capture
2107
+ restoreHistoryAnchor = handlers.restore
2108
+ }}
2109
+ anchor={anchor}
2110
+ setRevealMessage={(fn) => {
2111
+ revealMessage = fn
2112
+ }}
2113
+ setScrollToEnd={(fn) => {
2114
+ scrollToEnd = fn
2115
+ }}
2116
+ />
2117
+ )}
2118
+ </Show>
2119
+ </Match>
2120
+ <Match when={true}>
2121
+ <NewSessionView worktree={newSessionWorktree()} />
2122
+ </Match>
2123
+ </Switch>
2124
+ </div>
2125
+
2126
+ <Show when={(params.id || !newSessionDesign()) && !mobileChanges()}>
2127
+ {(_) => {
2128
+ const controller = createSessionComposerRegionController({
2129
+ state: composer,
2130
+ sessionKey,
2131
+ sessionID: () => params.id,
2132
+ prompt,
2133
+ ready: () => !store.deferRender && messagesReady(),
2134
+ centered,
2135
+ todo: {
2136
+ collapsed: () => view().todoCollapsed.get(),
2137
+ onToggle: () => view().todoCollapsed.set(!view().todoCollapsed.get()),
2138
+ },
2139
+ followup: () =>
2140
+ params.id && !isChildSession()
2141
+ ? {
2142
+ items: followupDock(),
2143
+ sending: sendingFollowup(),
2144
+ onSend: (id) => void sendFollowup(params.id!, id, { manual: true }),
2145
+ onEdit: editFollowup,
2146
+ }
2147
+ : undefined,
2148
+ revert: () =>
2149
+ rolled().length > 0
2150
+ ? {
2151
+ items: rolled(),
2152
+ restoring: restoring(),
2153
+ disabled: reverting(),
2154
+ onRestore: restore,
2155
+ }
2156
+ : undefined,
2157
+ onResponseSubmit: resumeScroll,
2158
+ openParent: () => {
2159
+ const id = info()?.parentID
2160
+ if (!id) return
2161
+ navigate(
2162
+ params.serverKey
2163
+ ? sessionHref(requireServerKey(params.serverKey), id)
2164
+ : legacySessionHref(sdk().directory, id),
2165
+ )
2166
+ },
2167
+ setPromptRef: (el) => {
2168
+ inputRef = el
2169
+ },
2170
+ setDockRef: (el) => {
2171
+ promptDock = el
2172
+ },
2173
+ })
2174
+ return (
2175
+ <SessionComposerRegion
2176
+ controller={controller}
2177
+ promptInput={
2178
+ <Show
2179
+ when={newSessionDesign()}
2180
+ fallback={
2181
+ <PromptInput
2182
+ controls={inputController()}
2183
+ ref={(el) => {
2184
+ inputRef = el
2185
+ }}
2186
+ newSessionWorktree={newSessionWorktree()}
2187
+ onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
2188
+ onSubmit={() => {
2189
+ comments.clear()
2190
+ resumeScroll()
2191
+ }}
2192
+ edit={editingFollowup()}
2193
+ onEditLoaded={clearFollowupEdit}
2194
+ shouldQueue={queueEnabled}
2195
+ onQueue={queueFollowup}
2196
+ onAbort={() => {
2197
+ const id = params.id
2198
+ if (!id) return
2199
+ setFollowup("paused", id, true)
2200
+ }}
2201
+ />
2202
+ }
2203
+ >
2204
+ {(_) => {
2205
+ const controller = usePromptInputV2Controller({
2206
+ get controls() {
2207
+ return inputController()
2208
+ },
2209
+ ref: (el) => {
2210
+ inputRef = el
2211
+ },
2212
+ get newSessionWorktree() {
2213
+ return newSessionWorktree()
2214
+ },
2215
+ onNewSessionWorktreeReset: () => setStore("newSessionWorktree", "main"),
2216
+ onSubmit: () => {
2217
+ comments.clear()
2218
+ resumeScroll()
2219
+ },
2220
+ shouldQueue: queueEnabled,
2221
+ onQueue: queueFollowup,
2222
+ onAbort: () => {
2223
+ const id = params.id
2224
+ if (!id) return
2225
+ setFollowup("paused", id, true)
2226
+ },
2227
+ })
2228
+ return (
2229
+ <PromptInputV2Composer
2230
+ controller={controller}
2231
+ borderUnderlay
2232
+ edit={editingFollowup()}
2233
+ onEditLoaded={clearFollowupEdit}
2234
+ />
2235
+ )
2236
+ }}
2237
+ </Show>
2238
+ }
2239
+ />
2240
+ )
2241
+ }}
2242
+ </Show>
2243
+ <Show when={!!params.id && mobileTabsBottom()}>{mobileTabs(true, true)}</Show>
2244
+ </>
2245
+ )
2246
+
2247
+ return (
2248
+ <SessionRouteFrame>
2249
+ <SessionHeader />
2250
+ <div
2251
+ ref={panelRow}
2252
+ class="flex-1 min-h-0 flex flex-col md:flex-row"
2253
+ classList={{
2254
+ "gap-2 p-2": settings.general.newLayoutDesigns(),
2255
+ }}
2256
+ >
2257
+ <Show when={!isDesktop() && !!params.id && !settings.general.newLayoutDesigns()}>{mobileTabs()}</Show>
2258
+
2259
+ <div
2260
+ classList={{
2261
+ "@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
2262
+ "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
2263
+ !size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(),
2264
+ }}
2265
+ style={{
2266
+ width: sessionPanelWidth(),
2267
+ }}
2268
+ >
2269
+ {settings.general.newLayoutDesigns() ? (
2270
+ <Show when={sessionPanelKey()} keyed>
2271
+ {(_) => (
2272
+ <SessionPanelFrame newLayout raised={!!params.id}>
2273
+ <ErrorBoundary fallback={sessionErrorFallback}>{sessionPanelContent()}</ErrorBoundary>
2274
+ </SessionPanelFrame>
2275
+ )}
2276
+ </Show>
2277
+ ) : (
2278
+ <SessionPanelFrame newLayout={false} raised={!!params.id}>
2279
+ {sessionPanelContent()}
2280
+ </SessionPanelFrame>
2281
+ )}
2282
+
2283
+ <Show when={desktopSessionResizeOpen()}>
2284
+ <div onPointerDown={() => size.start()}>
2285
+ <ResizeHandle
2286
+ classList={{
2287
+ "-right-1": settings.general.newLayoutDesigns(),
2288
+ }}
2289
+ direction="horizontal"
2290
+ size={sessionPanelResizedWidth()}
2291
+ min={SESSION_PANEL_WIDTH_MIN}
2292
+ max={sessionPanelMax()}
2293
+ onResize={(width) => {
2294
+ size.touch()
2295
+ layout.session.resize(width)
2296
+ }}
2297
+ />
2298
+ </div>
2299
+ </Show>
2300
+ </div>
2301
+
2302
+ <Show when={!newSessionDesign() && desktopSidePanelOpen()}>
2303
+ <SessionSidePanel
2304
+ canReview={canReview}
2305
+ diffs={reviewDiffs}
2306
+ diffsReady={reviewReady}
2307
+ empty={reviewEmptyText}
2308
+ hasReview={hasReview}
2309
+ reviewHasFocusableContent={hasReview}
2310
+ reviewCount={reviewCount}
2311
+ reviewPanel={reviewPanel}
2312
+ activeDiff={activeReviewFile()}
2313
+ focusReviewDiff={focusReviewDiff}
2314
+ reviewSnap={ui.reviewSnap}
2315
+ size={size}
2316
+ />
2317
+ </Show>
2318
+ <Show when={newSessionDesign()}>
2319
+ <Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
2320
+ <div class="min-w-0 h-full flex flex-1 flex-col">
2321
+ <Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
2322
+ <div class="min-h-0 flex-1">
2323
+ <SessionSidePanel
2324
+ canReview={canReview}
2325
+ diffs={reviewDiffs}
2326
+ diffsReady={reviewReady}
2327
+ empty={reviewEmptyText}
2328
+ hasReview={hasReview}
2329
+ reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
2330
+ reviewCount={reviewCount}
2331
+ reviewPanel={reviewPanelV2}
2332
+ reviewSidebarToggle={(disabled) => (
2333
+ <SessionReviewV2SidebarToggle
2334
+ opened={reviewV2State.sidebarOpened()}
2335
+ disabled={disabled}
2336
+ onToggle={reviewV2State.toggleSidebar}
2337
+ />
2338
+ )}
2339
+ fileBrowserState={reviewV2State}
2340
+ activeDiff={activeReviewFile()}
2341
+ focusReviewDiff={focusReviewDiff}
2342
+ reviewSnap={ui.reviewSnap}
2343
+ size={size}
2344
+ stacked={desktopV2PanelLayout().stacked}
2345
+ />
2346
+ </div>
2347
+ </Show>
2348
+ <Show when={desktopV2PanelLayout().stacked}>
2349
+ <div class="relative h-2 shrink-0" onPointerDown={() => size.start()}>
2350
+ <ResizeHandle
2351
+ class="!relative !inset-auto !h-full !w-full !transform-none"
2352
+ direction="vertical"
2353
+ size={layout.terminal.height()}
2354
+ min={100}
2355
+ max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
2356
+ collapseThreshold={50}
2357
+ onResize={(height) => {
2358
+ size.touch()
2359
+ layout.terminal.resize(height)
2360
+ }}
2361
+ onCollapse={() => view().terminal.close()}
2362
+ />
2363
+ </div>
2364
+ </Show>
2365
+ <Show when={terminalOpen()}>
2366
+ <div
2367
+ classList={{
2368
+ "min-h-0 shrink-0": desktopV2PanelLayout().stacked,
2369
+ "min-h-0 flex-1": !desktopV2PanelLayout().stacked,
2370
+ }}
2371
+ >
2372
+ <TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} />
2373
+ </div>
2374
+ </Show>
2375
+ </div>
2376
+ </Show>
2377
+ </Show>
2378
+ </div>
2379
+
2380
+ <Show when={!newSessionDesign()}>
2381
+ <TerminalPanel />
2382
+ </Show>
2383
+ </SessionRouteFrame>
2384
+ )
2385
+ }