reigncode-app 1.3.2

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 (300) hide show
  1. package/AGENTS.md +30 -0
  2. package/Dockerfile +21 -0
  3. package/README.md +51 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/AGENTS.md +226 -0
  7. package/e2e/actions.ts +1018 -0
  8. package/e2e/app/home.spec.ts +24 -0
  9. package/e2e/app/navigation.spec.ts +10 -0
  10. package/e2e/app/palette.spec.ts +20 -0
  11. package/e2e/app/server-default.spec.ts +58 -0
  12. package/e2e/app/session.spec.ts +16 -0
  13. package/e2e/app/titlebar-history.spec.ts +120 -0
  14. package/e2e/commands/input-focus.spec.ts +15 -0
  15. package/e2e/commands/panels.spec.ts +33 -0
  16. package/e2e/commands/tab-close.spec.ts +32 -0
  17. package/e2e/files/file-open.spec.ts +31 -0
  18. package/e2e/files/file-tree.spec.ts +56 -0
  19. package/e2e/files/file-viewer.spec.ts +156 -0
  20. package/e2e/fixtures.ts +154 -0
  21. package/e2e/models/model-picker.spec.ts +48 -0
  22. package/e2e/models/models-visibility.spec.ts +61 -0
  23. package/e2e/projects/project-edit.spec.ts +43 -0
  24. package/e2e/projects/projects-close.spec.ts +54 -0
  25. package/e2e/projects/projects-switch.spec.ts +116 -0
  26. package/e2e/projects/workspace-new-session.spec.ts +94 -0
  27. package/e2e/projects/workspaces.spec.ts +375 -0
  28. package/e2e/prompt/context.spec.ts +95 -0
  29. package/e2e/prompt/prompt-async.spec.ts +76 -0
  30. package/e2e/prompt/prompt-drop-file-uri.spec.ts +22 -0
  31. package/e2e/prompt/prompt-drop-file.spec.ts +30 -0
  32. package/e2e/prompt/prompt-history.spec.ts +184 -0
  33. package/e2e/prompt/prompt-mention.spec.ts +26 -0
  34. package/e2e/prompt/prompt-multiline.spec.ts +24 -0
  35. package/e2e/prompt/prompt-shell.spec.ts +62 -0
  36. package/e2e/prompt/prompt-slash-open.spec.ts +22 -0
  37. package/e2e/prompt/prompt-slash-share.spec.ts +64 -0
  38. package/e2e/prompt/prompt-slash-terminal.spec.ts +18 -0
  39. package/e2e/prompt/prompt.spec.ts +55 -0
  40. package/e2e/selectors.ts +75 -0
  41. package/e2e/session/session-child-navigation.spec.ts +37 -0
  42. package/e2e/session/session-composer-dock.spec.ts +530 -0
  43. package/e2e/session/session-model-persistence.spec.ts +359 -0
  44. package/e2e/session/session-review.spec.ts +426 -0
  45. package/e2e/session/session-undo-redo.spec.ts +233 -0
  46. package/e2e/session/session.spec.ts +174 -0
  47. package/e2e/settings/settings-keybinds.spec.ts +389 -0
  48. package/e2e/settings/settings-models.spec.ts +122 -0
  49. package/e2e/settings/settings-providers.spec.ts +136 -0
  50. package/e2e/settings/settings.spec.ts +519 -0
  51. package/e2e/sidebar/sidebar-popover-actions.spec.ts +118 -0
  52. package/e2e/sidebar/sidebar-session-links.spec.ts +30 -0
  53. package/e2e/sidebar/sidebar.spec.ts +40 -0
  54. package/e2e/status/status-popover.spec.ts +94 -0
  55. package/e2e/terminal/terminal-init.spec.ts +28 -0
  56. package/e2e/terminal/terminal-reconnect.spec.ts +46 -0
  57. package/e2e/terminal/terminal-tabs.spec.ts +168 -0
  58. package/e2e/terminal/terminal.spec.ts +18 -0
  59. package/e2e/thinking-level.spec.ts +25 -0
  60. package/e2e/tsconfig.json +9 -0
  61. package/e2e/utils.ts +63 -0
  62. package/happydom.ts +75 -0
  63. package/index.html +23 -0
  64. package/package.json +77 -0
  65. package/playwright.config.ts +45 -0
  66. package/public/_headers +17 -0
  67. package/public/oc-theme-preload.js +35 -0
  68. package/script/e2e-local.ts +180 -0
  69. package/src/addons/serialize.test.ts +319 -0
  70. package/src/addons/serialize.ts +634 -0
  71. package/src/app.tsx +308 -0
  72. package/src/components/debug-bar.tsx +443 -0
  73. package/src/components/dialog-connect-provider.tsx +617 -0
  74. package/src/components/dialog-custom-provider-form.ts +158 -0
  75. package/src/components/dialog-custom-provider.test.ts +80 -0
  76. package/src/components/dialog-custom-provider.tsx +329 -0
  77. package/src/components/dialog-edit-project.tsx +255 -0
  78. package/src/components/dialog-fork.tsx +108 -0
  79. package/src/components/dialog-manage-models.tsx +101 -0
  80. package/src/components/dialog-release-notes.tsx +144 -0
  81. package/src/components/dialog-select-directory.tsx +392 -0
  82. package/src/components/dialog-select-file.tsx +466 -0
  83. package/src/components/dialog-select-mcp.tsx +107 -0
  84. package/src/components/dialog-select-model-unpaid.tsx +137 -0
  85. package/src/components/dialog-select-model.tsx +220 -0
  86. package/src/components/dialog-select-provider.tsx +86 -0
  87. package/src/components/dialog-select-server.tsx +649 -0
  88. package/src/components/dialog-settings.tsx +73 -0
  89. package/src/components/file-tree.test.ts +78 -0
  90. package/src/components/file-tree.tsx +507 -0
  91. package/src/components/link.tsx +26 -0
  92. package/src/components/model-tooltip.tsx +91 -0
  93. package/src/components/prompt-input/attachments.test.ts +44 -0
  94. package/src/components/prompt-input/attachments.ts +201 -0
  95. package/src/components/prompt-input/build-request-parts.test.ts +312 -0
  96. package/src/components/prompt-input/build-request-parts.ts +175 -0
  97. package/src/components/prompt-input/context-items.tsx +88 -0
  98. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  99. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  100. package/src/components/prompt-input/editor-dom.ts +148 -0
  101. package/src/components/prompt-input/files.ts +66 -0
  102. package/src/components/prompt-input/history.test.ts +153 -0
  103. package/src/components/prompt-input/history.ts +256 -0
  104. package/src/components/prompt-input/image-attachments.tsx +58 -0
  105. package/src/components/prompt-input/paste.ts +24 -0
  106. package/src/components/prompt-input/placeholder.test.ts +48 -0
  107. package/src/components/prompt-input/placeholder.ts +15 -0
  108. package/src/components/prompt-input/slash-popover.tsx +141 -0
  109. package/src/components/prompt-input/submit.test.ts +346 -0
  110. package/src/components/prompt-input/submit.ts +579 -0
  111. package/src/components/prompt-input.tsx +1595 -0
  112. package/src/components/server/server-row.tsx +130 -0
  113. package/src/components/session/index.ts +5 -0
  114. package/src/components/session/session-context-breakdown.test.ts +61 -0
  115. package/src/components/session/session-context-breakdown.ts +132 -0
  116. package/src/components/session/session-context-format.ts +20 -0
  117. package/src/components/session/session-context-metrics.test.ts +101 -0
  118. package/src/components/session/session-context-metrics.ts +82 -0
  119. package/src/components/session/session-context-tab.tsx +339 -0
  120. package/src/components/session/session-header.tsx +486 -0
  121. package/src/components/session/session-new-view.tsx +91 -0
  122. package/src/components/session/session-sortable-tab.tsx +70 -0
  123. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  124. package/src/components/session-context-usage.tsx +122 -0
  125. package/src/components/settings-general.tsx +585 -0
  126. package/src/components/settings-keybinds.tsx +453 -0
  127. package/src/components/settings-list.tsx +5 -0
  128. package/src/components/settings-models.tsx +137 -0
  129. package/src/components/settings-providers.tsx +251 -0
  130. package/src/components/status-popover.tsx +419 -0
  131. package/src/components/terminal.tsx +653 -0
  132. package/src/components/titlebar-history.test.ts +63 -0
  133. package/src/components/titlebar-history.ts +57 -0
  134. package/src/components/titlebar.tsx +312 -0
  135. package/src/constants/file-picker.ts +89 -0
  136. package/src/context/command-keybind.test.ts +69 -0
  137. package/src/context/command.test.ts +25 -0
  138. package/src/context/command.tsx +437 -0
  139. package/src/context/comments.test.ts +186 -0
  140. package/src/context/comments.tsx +243 -0
  141. package/src/context/file/content-cache.ts +88 -0
  142. package/src/context/file/path.test.ts +360 -0
  143. package/src/context/file/path.ts +151 -0
  144. package/src/context/file/tree-store.ts +170 -0
  145. package/src/context/file/types.ts +41 -0
  146. package/src/context/file/view-cache.ts +146 -0
  147. package/src/context/file/watcher.test.ts +149 -0
  148. package/src/context/file/watcher.ts +53 -0
  149. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  150. package/src/context/file.tsx +280 -0
  151. package/src/context/global-sdk.tsx +232 -0
  152. package/src/context/global-sync/bootstrap.ts +206 -0
  153. package/src/context/global-sync/child-store.test.ts +38 -0
  154. package/src/context/global-sync/child-store.ts +281 -0
  155. package/src/context/global-sync/event-reducer.test.ts +552 -0
  156. package/src/context/global-sync/event-reducer.ts +359 -0
  157. package/src/context/global-sync/eviction.ts +28 -0
  158. package/src/context/global-sync/queue.ts +83 -0
  159. package/src/context/global-sync/session-cache.test.ts +102 -0
  160. package/src/context/global-sync/session-cache.ts +62 -0
  161. package/src/context/global-sync/session-load.ts +25 -0
  162. package/src/context/global-sync/session-prefetch.test.ts +96 -0
  163. package/src/context/global-sync/session-prefetch.ts +100 -0
  164. package/src/context/global-sync/session-trim.test.ts +59 -0
  165. package/src/context/global-sync/session-trim.ts +56 -0
  166. package/src/context/global-sync/types.ts +133 -0
  167. package/src/context/global-sync/utils.ts +25 -0
  168. package/src/context/global-sync.test.ts +122 -0
  169. package/src/context/global-sync.tsx +408 -0
  170. package/src/context/highlights.tsx +233 -0
  171. package/src/context/language.tsx +248 -0
  172. package/src/context/layout-scroll.test.ts +64 -0
  173. package/src/context/layout-scroll.ts +126 -0
  174. package/src/context/layout.test.ts +69 -0
  175. package/src/context/layout.tsx +937 -0
  176. package/src/context/local.tsx +422 -0
  177. package/src/context/model-variant.test.ts +86 -0
  178. package/src/context/model-variant.ts +52 -0
  179. package/src/context/models.tsx +163 -0
  180. package/src/context/notification.tsx +373 -0
  181. package/src/context/permission-auto-respond.test.ts +102 -0
  182. package/src/context/permission-auto-respond.ts +51 -0
  183. package/src/context/permission.tsx +277 -0
  184. package/src/context/platform.tsx +99 -0
  185. package/src/context/prompt.tsx +297 -0
  186. package/src/context/sdk.tsx +49 -0
  187. package/src/context/server.tsx +295 -0
  188. package/src/context/settings.tsx +241 -0
  189. package/src/context/sync-optimistic.test.ts +123 -0
  190. package/src/context/sync.tsx +618 -0
  191. package/src/context/terminal-title.ts +51 -0
  192. package/src/context/terminal.test.ts +82 -0
  193. package/src/context/terminal.tsx +437 -0
  194. package/src/entry.tsx +144 -0
  195. package/src/env.d.ts +18 -0
  196. package/src/hooks/use-providers.ts +44 -0
  197. package/src/i18n/ar.ts +855 -0
  198. package/src/i18n/br.ts +867 -0
  199. package/src/i18n/bs.ts +943 -0
  200. package/src/i18n/da.ts +937 -0
  201. package/src/i18n/de.ts +879 -0
  202. package/src/i18n/en.ts +948 -0
  203. package/src/i18n/es.ts +950 -0
  204. package/src/i18n/fr.ts +878 -0
  205. package/src/i18n/ja.ts +861 -0
  206. package/src/i18n/ko.ts +860 -0
  207. package/src/i18n/no.ts +944 -0
  208. package/src/i18n/parity.test.ts +32 -0
  209. package/src/i18n/pl.ts +865 -0
  210. package/src/i18n/ru.ts +946 -0
  211. package/src/i18n/th.ts +933 -0
  212. package/src/i18n/tr.ts +952 -0
  213. package/src/i18n/zh.ts +930 -0
  214. package/src/i18n/zht.ts +925 -0
  215. package/src/index.css +29 -0
  216. package/src/index.ts +6 -0
  217. package/src/pages/directory-layout.tsx +88 -0
  218. package/src/pages/error.tsx +327 -0
  219. package/src/pages/home.tsx +131 -0
  220. package/src/pages/layout/deep-links.ts +50 -0
  221. package/src/pages/layout/helpers.test.ts +211 -0
  222. package/src/pages/layout/helpers.ts +98 -0
  223. package/src/pages/layout/inline-editor.tsx +126 -0
  224. package/src/pages/layout/sidebar-items.tsx +437 -0
  225. package/src/pages/layout/sidebar-project.tsx +384 -0
  226. package/src/pages/layout/sidebar-shell.tsx +125 -0
  227. package/src/pages/layout/sidebar-workspace.tsx +504 -0
  228. package/src/pages/layout.tsx +2509 -0
  229. package/src/pages/session/composer/index.ts +2 -0
  230. package/src/pages/session/composer/session-composer-region.tsx +255 -0
  231. package/src/pages/session/composer/session-composer-state.test.ts +128 -0
  232. package/src/pages/session/composer/session-composer-state.ts +249 -0
  233. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  234. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  235. package/src/pages/session/composer/session-question-dock.tsx +449 -0
  236. package/src/pages/session/composer/session-request-tree.ts +52 -0
  237. package/src/pages/session/composer/session-revert-dock.tsx +99 -0
  238. package/src/pages/session/composer/session-todo-dock.tsx +330 -0
  239. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  240. package/src/pages/session/file-tab-scroll.ts +67 -0
  241. package/src/pages/session/file-tabs.tsx +456 -0
  242. package/src/pages/session/handoff.ts +36 -0
  243. package/src/pages/session/helpers.test.ts +181 -0
  244. package/src/pages/session/helpers.ts +198 -0
  245. package/src/pages/session/message-gesture.test.ts +62 -0
  246. package/src/pages/session/message-gesture.ts +21 -0
  247. package/src/pages/session/message-id-from-hash.ts +6 -0
  248. package/src/pages/session/message-timeline.tsx +1013 -0
  249. package/src/pages/session/review-tab.tsx +170 -0
  250. package/src/pages/session/session-layout.ts +20 -0
  251. package/src/pages/session/session-model-helpers.test.ts +51 -0
  252. package/src/pages/session/session-model-helpers.ts +16 -0
  253. package/src/pages/session/session-side-panel.tsx +453 -0
  254. package/src/pages/session/terminal-label.ts +16 -0
  255. package/src/pages/session/terminal-panel.test.ts +25 -0
  256. package/src/pages/session/terminal-panel.tsx +326 -0
  257. package/src/pages/session/use-session-commands.tsx +495 -0
  258. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  259. package/src/pages/session/use-session-hash-scroll.ts +197 -0
  260. package/src/pages/session.tsx +1841 -0
  261. package/src/sst-env.d.ts +12 -0
  262. package/src/testing/model-selection.ts +80 -0
  263. package/src/testing/prompt.ts +56 -0
  264. package/src/testing/session-composer.ts +84 -0
  265. package/src/testing/terminal.ts +118 -0
  266. package/src/theme-preload.test.ts +46 -0
  267. package/src/utils/agent.ts +23 -0
  268. package/src/utils/aim.ts +138 -0
  269. package/src/utils/base64.ts +10 -0
  270. package/src/utils/comment-note.ts +88 -0
  271. package/src/utils/id.ts +99 -0
  272. package/src/utils/notification-click.test.ts +27 -0
  273. package/src/utils/notification-click.ts +13 -0
  274. package/src/utils/persist.test.ts +115 -0
  275. package/src/utils/persist.ts +476 -0
  276. package/src/utils/prompt.test.ts +44 -0
  277. package/src/utils/prompt.ts +203 -0
  278. package/src/utils/runtime-adapters.test.ts +62 -0
  279. package/src/utils/runtime-adapters.ts +39 -0
  280. package/src/utils/same.ts +6 -0
  281. package/src/utils/scoped-cache.test.ts +69 -0
  282. package/src/utils/scoped-cache.ts +104 -0
  283. package/src/utils/server-errors.test.ts +131 -0
  284. package/src/utils/server-errors.ts +80 -0
  285. package/src/utils/server-health.test.ts +123 -0
  286. package/src/utils/server-health.ts +91 -0
  287. package/src/utils/server.ts +22 -0
  288. package/src/utils/solid-dnd.tsx +49 -0
  289. package/src/utils/sound.ts +117 -0
  290. package/src/utils/terminal-writer.test.ts +64 -0
  291. package/src/utils/terminal-writer.ts +65 -0
  292. package/src/utils/time.ts +22 -0
  293. package/src/utils/uuid.test.ts +78 -0
  294. package/src/utils/uuid.ts +12 -0
  295. package/src/utils/worktree.test.ts +46 -0
  296. package/src/utils/worktree.ts +73 -0
  297. package/sst-env.d.ts +10 -0
  298. package/tsconfig.json +26 -0
  299. package/vite.config.ts +15 -0
  300. package/vite.js +26 -0
@@ -0,0 +1,2509 @@
1
+ import {
2
+ batch,
3
+ createEffect,
4
+ createMemo,
5
+ createResource,
6
+ For,
7
+ on,
8
+ onCleanup,
9
+ onMount,
10
+ ParentProps,
11
+ Show,
12
+ untrack,
13
+ type Accessor,
14
+ } from "solid-js"
15
+ import { useNavigate, useParams } from "@solidjs/router"
16
+ import { useLayout, LocalProject } from "@/context/layout"
17
+ import { useGlobalSync } from "@/context/global-sync"
18
+ import { Persist, persisted } from "@/utils/persist"
19
+ import { base64Encode } from "@reign-labs/util/encode"
20
+ import { decode64 } from "@/utils/base64"
21
+ import { ResizeHandle } from "@reign-labs/ui/resize-handle"
22
+ import { Button } from "@reign-labs/ui/button"
23
+ import { IconButton } from "@reign-labs/ui/icon-button"
24
+ import { Tooltip } from "@reign-labs/ui/tooltip"
25
+ import { DropdownMenu } from "@reign-labs/ui/dropdown-menu"
26
+ import { Dialog } from "@reign-labs/ui/dialog"
27
+ import { getFilename } from "@reign-labs/util/path"
28
+ import { Session, type Message } from "@reign-labs/sdk/v2/client"
29
+ import { usePlatform } from "@/context/platform"
30
+ import { useSettings } from "@/context/settings"
31
+ import { createStore, produce, reconcile } from "solid-js/store"
32
+ import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
33
+ import type { DragEvent } from "@thisbeyond/solid-dnd"
34
+ import { useProviders } from "@/hooks/use-providers"
35
+ import { showToast, Toast, toaster } from "@reign-labs/ui/toast"
36
+ import { useGlobalSDK } from "@/context/global-sdk"
37
+ import { clearWorkspaceTerminals } from "@/context/terminal"
38
+ import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
39
+ import {
40
+ clearSessionPrefetchInflight,
41
+ clearSessionPrefetch,
42
+ getSessionPrefetch,
43
+ isSessionPrefetchCurrent,
44
+ runSessionPrefetch,
45
+ setSessionPrefetch,
46
+ shouldSkipSessionPrefetch,
47
+ } from "@/context/global-sync/session-prefetch"
48
+ import { useNotification } from "@/context/notification"
49
+ import { usePermission } from "@/context/permission"
50
+ import { Binary } from "@reign-labs/util/binary"
51
+ import { retry } from "@reign-labs/util/retry"
52
+ import { playSound, soundSrc } from "@/utils/sound"
53
+ import { createAim } from "@/utils/aim"
54
+ import { setNavigate } from "@/utils/notification-click"
55
+ import { Worktree as WorktreeState } from "@/utils/worktree"
56
+ import { setSessionHandoff } from "@/pages/session/handoff"
57
+
58
+ import { useDialog } from "@reign-labs/ui/context/dialog"
59
+ import { useTheme, type ColorScheme } from "@reign-labs/ui/theme"
60
+ import { DialogSelectProvider } from "@/components/dialog-select-provider"
61
+ import { DialogSelectServer } from "@/components/dialog-select-server"
62
+ import { DialogSettings } from "@/components/dialog-settings"
63
+ import { useCommand, type CommandOption } from "@/context/command"
64
+ import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
65
+ import { DialogSelectDirectory } from "@/components/dialog-select-directory"
66
+ import { DialogEditProject } from "@/components/dialog-edit-project"
67
+ import { DebugBar } from "@/components/debug-bar"
68
+ import { Titlebar } from "@/components/titlebar"
69
+ import { useServer } from "@/context/server"
70
+ import { useLanguage, type Locale } from "@/context/language"
71
+ import {
72
+ displayName,
73
+ effectiveWorkspaceOrder,
74
+ errorMessage,
75
+ latestRootSession,
76
+ sortedRootSessions,
77
+ workspaceKey,
78
+ } from "./layout/helpers"
79
+ import {
80
+ collectNewSessionDeepLinks,
81
+ collectOpenProjectDeepLinks,
82
+ deepLinkEvent,
83
+ drainPendingDeepLinks,
84
+ } from "./layout/deep-links"
85
+ import { createInlineEditorController } from "./layout/inline-editor"
86
+ import {
87
+ LocalWorkspace,
88
+ SortableWorkspace,
89
+ WorkspaceDragOverlay,
90
+ type WorkspaceSidebarContext,
91
+ } from "./layout/sidebar-workspace"
92
+ import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
93
+ import { SidebarContent } from "./layout/sidebar-shell"
94
+
95
+ export default function Layout(props: ParentProps) {
96
+ const [store, setStore, , ready] = persisted(
97
+ Persist.global("layout.page", ["layout.page.v1"]),
98
+ createStore({
99
+ lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
100
+ activeProject: undefined as string | undefined,
101
+ activeWorkspace: undefined as string | undefined,
102
+ workspaceOrder: {} as Record<string, string[]>,
103
+ workspaceName: {} as Record<string, string>,
104
+ workspaceBranchName: {} as Record<string, Record<string, string>>,
105
+ workspaceExpanded: {} as Record<string, boolean>,
106
+ gettingStartedDismissed: false,
107
+ }),
108
+ )
109
+
110
+ const pageReady = createMemo(() => ready())
111
+
112
+ let scrollContainerRef: HTMLDivElement | undefined
113
+
114
+ const params = useParams()
115
+ const globalSDK = useGlobalSDK()
116
+ const globalSync = useGlobalSync()
117
+ const layout = useLayout()
118
+ const layoutReady = createMemo(() => layout.ready())
119
+ const platform = usePlatform()
120
+ const settings = useSettings()
121
+ const server = useServer()
122
+ const notification = useNotification()
123
+ const permission = usePermission()
124
+ const navigate = useNavigate()
125
+ setNavigate(navigate)
126
+ const providers = useProviders()
127
+ const dialog = useDialog()
128
+ const command = useCommand()
129
+ const theme = useTheme()
130
+ const language = useLanguage()
131
+ const initialDirectory = decode64(params.dir)
132
+ const route = createMemo(() => {
133
+ const slug = params.dir
134
+ if (!slug) return { slug, dir: "" }
135
+ const dir = decode64(slug)
136
+ if (!dir) return { slug, dir: "" }
137
+ return {
138
+ slug,
139
+ dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
140
+ }
141
+ })
142
+ const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
143
+ const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
144
+ const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
145
+ system: "theme.scheme.system",
146
+ light: "theme.scheme.light",
147
+ dark: "theme.scheme.dark",
148
+ }
149
+ const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
150
+ const currentDir = createMemo(() => route().dir)
151
+
152
+ const [state, setState] = createStore({
153
+ autoselect: !initialDirectory,
154
+ busyWorkspaces: {} as Record<string, boolean>,
155
+ hoverSession: undefined as string | undefined,
156
+ hoverProject: undefined as string | undefined,
157
+ scrollSessionKey: undefined as string | undefined,
158
+ nav: undefined as HTMLElement | undefined,
159
+ sortNow: Date.now(),
160
+ sizing: false,
161
+ peek: undefined as string | undefined,
162
+ peeked: false,
163
+ })
164
+
165
+ const editor = createInlineEditorController()
166
+ const setBusy = (directory: string, value: boolean) => {
167
+ const key = workspaceKey(directory)
168
+ if (value) {
169
+ setState("busyWorkspaces", key, true)
170
+ return
171
+ }
172
+ setState(
173
+ "busyWorkspaces",
174
+ produce((draft) => {
175
+ delete draft[key]
176
+ }),
177
+ )
178
+ }
179
+ const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
180
+ const navLeave = { current: undefined as number | undefined }
181
+ const sortNow = () => state.sortNow
182
+ let sizet: number | undefined
183
+ let sortNowInterval: ReturnType<typeof setInterval> | undefined
184
+ const sortNowTimeout = setTimeout(
185
+ () => {
186
+ setState("sortNow", Date.now())
187
+ sortNowInterval = setInterval(() => setState("sortNow", Date.now()), 60_000)
188
+ },
189
+ 60_000 - (Date.now() % 60_000),
190
+ )
191
+
192
+ const aim = createAim({
193
+ enabled: () => !layout.sidebar.opened(),
194
+ active: () => state.hoverProject,
195
+ el: () => state.nav?.querySelector<HTMLElement>("[data-component='sidebar-rail']") ?? state.nav,
196
+ onActivate: (directory) => {
197
+ globalSync.child(directory)
198
+ setState("hoverProject", directory)
199
+ setState("hoverSession", undefined)
200
+ },
201
+ })
202
+
203
+ onCleanup(() => {
204
+ if (navLeave.current !== undefined) clearTimeout(navLeave.current)
205
+ clearTimeout(sortNowTimeout)
206
+ if (sortNowInterval) clearInterval(sortNowInterval)
207
+ if (sizet !== undefined) clearTimeout(sizet)
208
+ if (peekt !== undefined) clearTimeout(peekt)
209
+ aim.reset()
210
+ })
211
+
212
+ onMount(() => {
213
+ const stop = () => setState("sizing", false)
214
+ const blur = () => reset()
215
+ const hide = () => {
216
+ if (document.visibilityState !== "hidden") return
217
+ reset()
218
+ }
219
+ window.addEventListener("pointerup", stop)
220
+ window.addEventListener("pointercancel", stop)
221
+ window.addEventListener("blur", stop)
222
+ window.addEventListener("blur", blur)
223
+ document.addEventListener("visibilitychange", hide)
224
+ onCleanup(() => {
225
+ window.removeEventListener("pointerup", stop)
226
+ window.removeEventListener("pointercancel", stop)
227
+ window.removeEventListener("blur", stop)
228
+ window.removeEventListener("blur", blur)
229
+ document.removeEventListener("visibilitychange", hide)
230
+ })
231
+ })
232
+
233
+ const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
234
+ const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering())
235
+ const setHoverProject = (value: string | undefined) => {
236
+ setState("hoverProject", value)
237
+ if (value !== undefined) return
238
+ aim.reset()
239
+ }
240
+ const clearHoverProjectSoon = () => queueMicrotask(() => setHoverProject(undefined))
241
+ const setHoverSession = (id: string | undefined) => setState("hoverSession", id)
242
+
243
+ const disarm = () => {
244
+ if (navLeave.current === undefined) return
245
+ clearTimeout(navLeave.current)
246
+ navLeave.current = undefined
247
+ }
248
+
249
+ const reset = () => {
250
+ disarm()
251
+ setState("hoverSession", undefined)
252
+ setHoverProject(undefined)
253
+ }
254
+
255
+ const arm = () => {
256
+ if (layout.sidebar.opened()) return
257
+ if (state.hoverProject === undefined) return
258
+ disarm()
259
+ navLeave.current = window.setTimeout(() => {
260
+ navLeave.current = undefined
261
+ setHoverProject(undefined)
262
+ setState("hoverSession", undefined)
263
+ }, 300)
264
+ }
265
+
266
+ let peekt: number | undefined
267
+
268
+ const hoverProjectData = createMemo(() => {
269
+ const id = state.hoverProject
270
+ if (!id) return
271
+ return layout.projects.list().find((project) => project.worktree === id)
272
+ })
273
+
274
+ const peekProject = createMemo(() => {
275
+ const id = state.peek
276
+ if (!id) return
277
+ return layout.projects.list().find((project) => project.worktree === id)
278
+ })
279
+
280
+ createEffect(() => {
281
+ const p = hoverProjectData()
282
+ if (p) {
283
+ if (peekt !== undefined) {
284
+ clearTimeout(peekt)
285
+ peekt = undefined
286
+ }
287
+ setState("peek", p.worktree)
288
+ setState("peeked", true)
289
+ return
290
+ }
291
+
292
+ setState("peeked", false)
293
+ if (state.peek === undefined) return
294
+ if (peekt !== undefined) clearTimeout(peekt)
295
+ peekt = window.setTimeout(() => {
296
+ peekt = undefined
297
+ setState("peek", undefined)
298
+ }, 180)
299
+ })
300
+
301
+ createEffect(() => {
302
+ if (!layout.sidebar.opened()) return
303
+ setHoverProject(undefined)
304
+ })
305
+
306
+ createEffect(() => {
307
+ if (!state.autoselect) return
308
+ const dir = params.dir
309
+ if (!dir) return
310
+ const directory = decode64(dir)
311
+ if (!directory) return
312
+ setState("autoselect", false)
313
+ })
314
+
315
+ const editorOpen = editor.editorOpen
316
+ const openEditor = editor.openEditor
317
+ const closeEditor = editor.closeEditor
318
+ const setEditor = editor.setEditor
319
+ const InlineEditor = editor.InlineEditor
320
+
321
+ const clearSidebarHoverState = () => {
322
+ if (layout.sidebar.opened()) return
323
+ reset()
324
+ }
325
+
326
+ const navigateWithSidebarReset = (href: string) => {
327
+ clearSidebarHoverState()
328
+ navigate(href)
329
+ layout.mobileSidebar.hide()
330
+ }
331
+
332
+ function cycleTheme(direction = 1) {
333
+ const ids = availableThemeEntries().map(([id]) => id)
334
+ if (ids.length === 0) return
335
+ const currentIndex = ids.indexOf(theme.themeId())
336
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
337
+ const nextThemeId = ids[nextIndex]
338
+ theme.setTheme(nextThemeId)
339
+ const nextTheme = theme.themes()[nextThemeId]
340
+ showToast({
341
+ title: language.t("toast.theme.title"),
342
+ description: nextTheme?.name ?? nextThemeId,
343
+ })
344
+ }
345
+
346
+ function cycleColorScheme(direction = 1) {
347
+ const current = theme.colorScheme()
348
+ const currentIndex = colorSchemeOrder.indexOf(current)
349
+ const nextIndex =
350
+ currentIndex === -1 ? 0 : (currentIndex + direction + colorSchemeOrder.length) % colorSchemeOrder.length
351
+ const next = colorSchemeOrder[nextIndex]
352
+ theme.setColorScheme(next)
353
+ showToast({
354
+ title: language.t("toast.scheme.title"),
355
+ description: colorSchemeLabel(next),
356
+ })
357
+ }
358
+
359
+ function setLocale(next: Locale) {
360
+ if (next === language.locale()) return
361
+ language.setLocale(next)
362
+ showToast({
363
+ title: language.t("toast.language.title"),
364
+ description: language.t("toast.language.description", { language: language.label(next) }),
365
+ })
366
+ }
367
+
368
+ function cycleLanguage(direction = 1) {
369
+ const locales = language.locales
370
+ const currentIndex = locales.indexOf(language.locale())
371
+ const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + locales.length) % locales.length
372
+ const next = locales[nextIndex]
373
+ if (!next) return
374
+ setLocale(next)
375
+ }
376
+
377
+ const useUpdatePolling = () =>
378
+ onMount(() => {
379
+ if (!platform.checkUpdate || !platform.update || !platform.restart) return
380
+
381
+ let toastId: number | undefined
382
+ let interval: ReturnType<typeof setInterval> | undefined
383
+
384
+ const pollUpdate = () =>
385
+ platform.checkUpdate!().then(({ updateAvailable, version }) => {
386
+ if (!updateAvailable) return
387
+ if (toastId !== undefined) return
388
+ toastId = showToast({
389
+ persistent: true,
390
+ icon: "download",
391
+ title: language.t("toast.update.title"),
392
+ description: language.t("toast.update.description", { version: version ?? "" }),
393
+ actions: [
394
+ {
395
+ label: language.t("toast.update.action.installRestart"),
396
+ onClick: async () => {
397
+ await platform.update!()
398
+ await platform.restart!()
399
+ },
400
+ },
401
+ {
402
+ label: language.t("toast.update.action.notYet"),
403
+ onClick: "dismiss",
404
+ },
405
+ ],
406
+ })
407
+ })
408
+
409
+ createEffect(() => {
410
+ if (!settings.ready()) return
411
+
412
+ if (!settings.updates.startup()) {
413
+ if (interval === undefined) return
414
+ clearInterval(interval)
415
+ interval = undefined
416
+ return
417
+ }
418
+
419
+ if (interval !== undefined) return
420
+ void pollUpdate()
421
+ interval = setInterval(pollUpdate, 10 * 60 * 1000)
422
+ })
423
+
424
+ onCleanup(() => {
425
+ if (interval === undefined) return
426
+ clearInterval(interval)
427
+ })
428
+ })
429
+
430
+ const useSDKNotificationToasts = () =>
431
+ onMount(() => {
432
+ const toastBySession = new Map<string, number>()
433
+ const alertedAtBySession = new Map<string, number>()
434
+ const cooldownMs = 5000
435
+
436
+ const dismissSessionAlert = (sessionKey: string) => {
437
+ const toastId = toastBySession.get(sessionKey)
438
+ if (toastId === undefined) return
439
+ toaster.dismiss(toastId)
440
+ toastBySession.delete(sessionKey)
441
+ alertedAtBySession.delete(sessionKey)
442
+ }
443
+
444
+ const unsub = globalSDK.event.listen((e) => {
445
+ if (e.details?.type === "worktree.ready") {
446
+ setBusy(e.name, false)
447
+ WorktreeState.ready(e.name)
448
+ return
449
+ }
450
+
451
+ if (e.details?.type === "worktree.failed") {
452
+ setBusy(e.name, false)
453
+ WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
454
+ return
455
+ }
456
+
457
+ if (
458
+ e.details?.type === "question.replied" ||
459
+ e.details?.type === "question.rejected" ||
460
+ e.details?.type === "permission.replied"
461
+ ) {
462
+ const props = e.details.properties as { sessionID: string }
463
+ const sessionKey = `${e.name}:${props.sessionID}`
464
+ dismissSessionAlert(sessionKey)
465
+ return
466
+ }
467
+
468
+ if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
469
+ const title =
470
+ e.details.type === "permission.asked"
471
+ ? language.t("notification.permission.title")
472
+ : language.t("notification.question.title")
473
+ const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
474
+ const directory = e.name
475
+ const props = e.details.properties
476
+ if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
477
+
478
+ const [store] = globalSync.child(directory, { bootstrap: false })
479
+ const session = store.session.find((s) => s.id === props.sessionID)
480
+ const sessionKey = `${directory}:${props.sessionID}`
481
+
482
+ const sessionTitle = session?.title ?? language.t("command.session.new")
483
+ const projectName = getFilename(directory)
484
+ const description =
485
+ e.details.type === "permission.asked"
486
+ ? language.t("notification.permission.description", { sessionTitle, projectName })
487
+ : language.t("notification.question.description", { sessionTitle, projectName })
488
+ const href = `/${base64Encode(directory)}/session/${props.sessionID}`
489
+
490
+ const now = Date.now()
491
+ const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
492
+ if (now - lastAlerted < cooldownMs) return
493
+ alertedAtBySession.set(sessionKey, now)
494
+
495
+ if (e.details.type === "permission.asked") {
496
+ if (settings.sounds.permissionsEnabled()) {
497
+ playSound(soundSrc(settings.sounds.permissions()))
498
+ }
499
+ if (settings.notifications.permissions()) {
500
+ void platform.notify(title, description, href)
501
+ }
502
+ }
503
+
504
+ if (e.details.type === "question.asked") {
505
+ if (settings.notifications.agent()) {
506
+ void platform.notify(title, description, href)
507
+ }
508
+ }
509
+
510
+ const currentSession = params.id
511
+ if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
512
+ if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
513
+
514
+ dismissSessionAlert(sessionKey)
515
+
516
+ const toastId = showToast({
517
+ persistent: true,
518
+ icon,
519
+ title,
520
+ description,
521
+ actions: [
522
+ {
523
+ label: language.t("notification.action.goToSession"),
524
+ onClick: () => navigate(href),
525
+ },
526
+ {
527
+ label: language.t("common.dismiss"),
528
+ onClick: "dismiss",
529
+ },
530
+ ],
531
+ })
532
+ toastBySession.set(sessionKey, toastId)
533
+ })
534
+ onCleanup(unsub)
535
+
536
+ createEffect(() => {
537
+ const currentSession = params.id
538
+ if (!currentDir() || !currentSession) return
539
+ const sessionKey = `${currentDir()}:${currentSession}`
540
+ dismissSessionAlert(sessionKey)
541
+ const [store] = globalSync.child(currentDir(), { bootstrap: false })
542
+ const childSessions = store.session.filter((s) => s.parentID === currentSession)
543
+ for (const child of childSessions) {
544
+ dismissSessionAlert(`${currentDir()}:${child.id}`)
545
+ }
546
+ })
547
+ })
548
+
549
+ useUpdatePolling()
550
+ useSDKNotificationToasts()
551
+
552
+ function scrollToSession(sessionId: string, sessionKey: string) {
553
+ if (!scrollContainerRef) return
554
+ if (state.scrollSessionKey === sessionKey) return
555
+ const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
556
+ if (!element) return
557
+ const containerRect = scrollContainerRef.getBoundingClientRect()
558
+ const elementRect = element.getBoundingClientRect()
559
+ if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) {
560
+ setState("scrollSessionKey", sessionKey)
561
+ return
562
+ }
563
+ setState("scrollSessionKey", sessionKey)
564
+ element.scrollIntoView({ block: "nearest", behavior: "smooth" })
565
+ }
566
+
567
+ const currentProject = createMemo(() => {
568
+ const directory = currentDir()
569
+ if (!directory) return
570
+ const key = workspaceKey(directory)
571
+
572
+ const projects = layout.projects.list()
573
+
574
+ const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
575
+ if (sandbox) return sandbox
576
+
577
+ const direct = projects.find((p) => workspaceKey(p.worktree) === key)
578
+ if (direct) return direct
579
+
580
+ const [child] = globalSync.child(directory, { bootstrap: false })
581
+ const id = child.project
582
+ if (!id) return
583
+
584
+ const meta = globalSync.data.project.find((p) => p.id === id)
585
+ const root = meta?.worktree
586
+ if (!root) return
587
+
588
+ return projects.find((p) => p.worktree === root)
589
+ })
590
+
591
+ const [autoselecting] = createResource(async () => {
592
+ await ready.promise
593
+ await layout.ready.promise
594
+ if (!untrack(() => state.autoselect)) return
595
+
596
+ const list = layout.projects.list()
597
+ const last = server.projects.last()
598
+
599
+ if (list.length === 0) {
600
+ if (!last) return
601
+ await openProject(last, true)
602
+ } else {
603
+ const next = list.find((project) => project.worktree === last) ?? list[0]
604
+ if (!next) return
605
+ await openProject(next.worktree, true)
606
+ }
607
+ })
608
+
609
+ const workspaceName = (directory: string, projectId?: string, branch?: string) => {
610
+ const key = workspaceKey(directory)
611
+ const direct = store.workspaceName[key] ?? store.workspaceName[directory]
612
+ if (direct) return direct
613
+ if (!projectId) return
614
+ if (!branch) return
615
+ return store.workspaceBranchName[projectId]?.[branch]
616
+ }
617
+
618
+ const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
619
+ const key = workspaceKey(directory)
620
+ setStore("workspaceName", key, next)
621
+ if (!projectId) return
622
+ if (!branch) return
623
+ if (!store.workspaceBranchName[projectId]) {
624
+ setStore("workspaceBranchName", projectId, {})
625
+ }
626
+ setStore("workspaceBranchName", projectId, branch, next)
627
+ }
628
+
629
+ const workspaceLabel = (directory: string, branch?: string, projectId?: string) =>
630
+ workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
631
+
632
+ const workspaceSetting = createMemo(() => {
633
+ const project = currentProject()
634
+ if (!project) return false
635
+ if (project.vcs !== "git") return false
636
+ return layout.sidebar.workspaces(project.worktree)()
637
+ })
638
+
639
+ const visibleSessionDirs = createMemo(() => {
640
+ const project = currentProject()
641
+ if (!project) return [] as string[]
642
+ if (!workspaceSetting()) return [project.worktree]
643
+
644
+ const activeDir = currentDir()
645
+ return workspaceIds(project).filter((directory) => {
646
+ const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
647
+ const active = workspaceKey(directory) === workspaceKey(activeDir)
648
+ return expanded || active
649
+ })
650
+ })
651
+
652
+ createEffect(() => {
653
+ if (!pageReady()) return
654
+ if (!layoutReady()) return
655
+ const projects = layout.projects.list()
656
+ for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
657
+ if (!expanded) continue
658
+ const key = workspaceKey(directory)
659
+ const project = projects.find(
660
+ (item) =>
661
+ workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
662
+ )
663
+ if (!project) continue
664
+ if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
665
+ setStore("workspaceExpanded", directory, false)
666
+ }
667
+ })
668
+
669
+ const currentSessions = createMemo(() => {
670
+ const now = Date.now()
671
+ const dirs = visibleSessionDirs()
672
+ if (dirs.length === 0) return [] as Session[]
673
+
674
+ const result: Session[] = []
675
+ for (const dir of dirs) {
676
+ const [dirStore] = globalSync.child(dir, { bootstrap: true })
677
+ const dirSessions = sortedRootSessions(dirStore, now)
678
+ result.push(...dirSessions)
679
+ }
680
+ return result
681
+ })
682
+
683
+ type PrefetchQueue = {
684
+ inflight: Set<string>
685
+ pending: string[]
686
+ pendingSet: Set<string>
687
+ running: number
688
+ }
689
+
690
+ const prefetchChunk = 200
691
+ const prefetchConcurrency = 2
692
+ const prefetchPendingLimit = 10
693
+ const span = 4
694
+ const prefetchToken = { value: 0 }
695
+ const prefetchQueues = new Map<string, PrefetchQueue>()
696
+
697
+ const PREFETCH_MAX_SESSIONS_PER_DIR = 10
698
+ const prefetchedByDir = new Map<string, Set<string>>()
699
+
700
+ const lruFor = (directory: string) => {
701
+ const existing = prefetchedByDir.get(directory)
702
+ if (existing) return existing
703
+ const created = new Set<string>()
704
+ prefetchedByDir.set(directory, created)
705
+ return created
706
+ }
707
+
708
+ const markPrefetched = (directory: string, sessionID: string) => {
709
+ const lru = lruFor(directory)
710
+ return pickSessionCacheEvictions({
711
+ seen: lru,
712
+ keep: sessionID,
713
+ limit: PREFETCH_MAX_SESSIONS_PER_DIR,
714
+ preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
715
+ })
716
+ }
717
+
718
+ createEffect(() => {
719
+ const active = new Set(visibleSessionDirs())
720
+ for (const directory of [...prefetchedByDir.keys()]) {
721
+ if (active.has(directory)) continue
722
+ prefetchedByDir.delete(directory)
723
+ }
724
+ })
725
+
726
+ createEffect(() => {
727
+ route()
728
+ globalSDK.url
729
+
730
+ prefetchToken.value += 1
731
+ clearSessionPrefetchInflight()
732
+ prefetchQueues.clear()
733
+ })
734
+
735
+ createEffect(() => {
736
+ const visible = new Set(visibleSessionDirs())
737
+ for (const [directory, q] of prefetchQueues) {
738
+ if (visible.has(directory)) continue
739
+ q.pending.length = 0
740
+ q.pendingSet.clear()
741
+ if (q.running === 0) prefetchQueues.delete(directory)
742
+ }
743
+ })
744
+
745
+ const queueFor = (directory: string) => {
746
+ const existing = prefetchQueues.get(directory)
747
+ if (existing) return existing
748
+
749
+ const created: PrefetchQueue = {
750
+ inflight: new Set(),
751
+ pending: [],
752
+ pendingSet: new Set(),
753
+ running: 0,
754
+ }
755
+ prefetchQueues.set(directory, created)
756
+ return created
757
+ }
758
+
759
+ const mergeByID = <T extends { id: string }>(current: T[], incoming: T[]) => {
760
+ if (current.length === 0) {
761
+ return incoming.slice().sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
762
+ }
763
+
764
+ const map = new Map<string, T>()
765
+ for (const item of current) {
766
+ map.set(item.id, item)
767
+ }
768
+ for (const item of incoming) {
769
+ map.set(item.id, item)
770
+ }
771
+ return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
772
+ }
773
+
774
+ async function prefetchMessages(directory: string, sessionID: string, token: number) {
775
+ const [store, setStore] = globalSync.child(directory, { bootstrap: false })
776
+
777
+ return runSessionPrefetch({
778
+ directory,
779
+ sessionID,
780
+ task: (rev) =>
781
+ retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
782
+ .then((messages) => {
783
+ if (prefetchToken.value !== token) return
784
+ if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
785
+
786
+ const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
787
+ const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
788
+ const sorted = mergeByID([], next)
789
+ const stale = markPrefetched(directory, sessionID)
790
+ const cursor = messages.response.headers.get("x-next-cursor") ?? undefined
791
+ const meta = {
792
+ limit: sorted.length,
793
+ cursor,
794
+ complete: !cursor,
795
+ at: Date.now(),
796
+ }
797
+
798
+ if (stale.length > 0) {
799
+ clearSessionPrefetch(directory, stale)
800
+ for (const id of stale) {
801
+ globalSync.todo.set(id, undefined)
802
+ }
803
+ }
804
+
805
+ const current = store.message[sessionID] ?? []
806
+ const merged = mergeByID(
807
+ current.filter((item): item is Message => !!item?.id),
808
+ sorted,
809
+ )
810
+
811
+ if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
812
+
813
+ batch(() => {
814
+ if (stale.length > 0) {
815
+ setStore(
816
+ produce((draft) => {
817
+ dropSessionCaches(draft, stale)
818
+ }),
819
+ )
820
+ }
821
+
822
+ setStore("message", sessionID, reconcile(merged, { key: "id" }))
823
+ setSessionPrefetch({ directory, sessionID, ...meta })
824
+
825
+ for (const message of items) {
826
+ const currentParts = store.part[message.info.id] ?? []
827
+ const mergedParts = mergeByID(
828
+ currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id),
829
+ message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id),
830
+ )
831
+
832
+ setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
833
+ }
834
+ })
835
+
836
+ return meta
837
+ })
838
+ .catch(() => undefined),
839
+ })
840
+ }
841
+
842
+ const pumpPrefetch = (directory: string) => {
843
+ const q = queueFor(directory)
844
+ if (q.running >= prefetchConcurrency) return
845
+
846
+ const sessionID = q.pending.shift()
847
+ if (!sessionID) return
848
+
849
+ q.pendingSet.delete(sessionID)
850
+ q.inflight.add(sessionID)
851
+ q.running += 1
852
+
853
+ const token = prefetchToken.value
854
+
855
+ void prefetchMessages(directory, sessionID, token).finally(() => {
856
+ q.running -= 1
857
+ q.inflight.delete(sessionID)
858
+ pumpPrefetch(directory)
859
+ })
860
+ }
861
+
862
+ const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
863
+ const directory = session.directory
864
+ if (!directory) return
865
+
866
+ const [store] = globalSync.child(directory, { bootstrap: false })
867
+ const cached = untrack(() => {
868
+ const info = getSessionPrefetch(directory, session.id)
869
+ return shouldSkipSessionPrefetch({
870
+ message: store.message[session.id] !== undefined,
871
+ info,
872
+ chunk: prefetchChunk,
873
+ })
874
+ })
875
+ if (cached) return
876
+
877
+ const q = queueFor(directory)
878
+ if (q.inflight.has(session.id)) return
879
+ if (q.pendingSet.has(session.id)) {
880
+ if (priority !== "high") return
881
+ const index = q.pending.indexOf(session.id)
882
+ if (index > 0) {
883
+ q.pending.splice(index, 1)
884
+ q.pending.unshift(session.id)
885
+ }
886
+ return
887
+ }
888
+
889
+ const lru = lruFor(directory)
890
+ const known = lru.has(session.id)
891
+ if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return
892
+
893
+ if (priority === "high") q.pending.unshift(session.id)
894
+ if (priority !== "high") q.pending.push(session.id)
895
+ q.pendingSet.add(session.id)
896
+
897
+ while (q.pending.length > prefetchPendingLimit) {
898
+ const dropped = q.pending.pop()
899
+ if (!dropped) continue
900
+ q.pendingSet.delete(dropped)
901
+ }
902
+
903
+ pumpPrefetch(directory)
904
+ }
905
+
906
+ const warm = (sessions: Session[], index: number) => {
907
+ for (let offset = 1; offset <= span; offset++) {
908
+ const next = sessions[index + offset]
909
+ if (next) prefetchSession(next, offset === 1 ? "high" : "low")
910
+
911
+ const prev = sessions[index - offset]
912
+ if (prev) prefetchSession(prev, offset === 1 ? "high" : "low")
913
+ }
914
+ }
915
+
916
+ createEffect(() => {
917
+ const sessions = currentSessions()
918
+ if (sessions.length === 0) return
919
+
920
+ const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0
921
+ if (index === -1) return
922
+
923
+ if (!params.id) {
924
+ const first = sessions[index]
925
+ if (first) prefetchSession(first, "high")
926
+ }
927
+
928
+ warm(sessions, index)
929
+ })
930
+
931
+ function navigateSessionByOffset(offset: number) {
932
+ const sessions = currentSessions()
933
+ if (sessions.length === 0) return
934
+
935
+ const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
936
+
937
+ let targetIndex: number
938
+ if (sessionIndex === -1) {
939
+ targetIndex = offset > 0 ? 0 : sessions.length - 1
940
+ } else {
941
+ targetIndex = (sessionIndex + offset + sessions.length) % sessions.length
942
+ }
943
+
944
+ const session = sessions[targetIndex]
945
+ if (!session) return
946
+
947
+ prefetchSession(session, "high")
948
+ warm(sessions, targetIndex)
949
+
950
+ navigateToSession(session)
951
+ }
952
+
953
+ function navigateProjectByOffset(offset: number) {
954
+ const projects = layout.projects.list()
955
+ if (projects.length === 0) return
956
+
957
+ const current = currentProject()?.worktree
958
+ const fallback = currentDir() ? projectRoot(currentDir()) : undefined
959
+ const active = current ?? fallback
960
+ const index = active ? projects.findIndex((project) => project.worktree === active) : -1
961
+
962
+ const target =
963
+ index === -1
964
+ ? offset > 0
965
+ ? projects[0]
966
+ : projects[projects.length - 1]
967
+ : projects[(index + offset + projects.length) % projects.length]
968
+ if (!target) return
969
+
970
+ // warm up child store to prevent flicker
971
+ globalSync.child(target.worktree)
972
+ openProject(target.worktree)
973
+ }
974
+
975
+ function navigateSessionByUnseen(offset: number) {
976
+ const sessions = currentSessions()
977
+ if (sessions.length === 0) return
978
+
979
+ const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0)
980
+ if (!hasUnseen) return
981
+
982
+ const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
983
+ const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
984
+
985
+ for (let i = 1; i <= sessions.length; i++) {
986
+ const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
987
+ const session = sessions[index]
988
+ if (!session) continue
989
+ if (notification.session.unseenCount(session.id) === 0) continue
990
+
991
+ prefetchSession(session, "high")
992
+ warm(sessions, index)
993
+
994
+ navigateToSession(session)
995
+ return
996
+ }
997
+ }
998
+
999
+ async function archiveSession(session: Session) {
1000
+ const [store, setStore] = globalSync.child(session.directory)
1001
+ const sessions = store.session ?? []
1002
+ const index = sessions.findIndex((s) => s.id === session.id)
1003
+ const nextSession = sessions[index + 1] ?? sessions[index - 1]
1004
+
1005
+ await globalSDK.client.session.update({
1006
+ directory: session.directory,
1007
+ sessionID: session.id,
1008
+ time: { archived: Date.now() },
1009
+ })
1010
+ setStore(
1011
+ produce((draft) => {
1012
+ const match = Binary.search(draft.session, session.id, (s) => s.id)
1013
+ if (match.found) draft.session.splice(match.index, 1)
1014
+ }),
1015
+ )
1016
+ if (session.id === params.id) {
1017
+ if (nextSession) {
1018
+ navigate(`/${params.dir}/session/${nextSession.id}`)
1019
+ } else {
1020
+ navigate(`/${params.dir}/session`)
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ command.register("layout", () => {
1026
+ const commands: CommandOption[] = [
1027
+ {
1028
+ id: "sidebar.toggle",
1029
+ title: language.t("command.sidebar.toggle"),
1030
+ category: language.t("command.category.view"),
1031
+ keybind: "mod+b",
1032
+ onSelect: () => layout.sidebar.toggle(),
1033
+ },
1034
+ {
1035
+ id: "project.open",
1036
+ title: language.t("command.project.open"),
1037
+ category: language.t("command.category.project"),
1038
+ keybind: "mod+o",
1039
+ onSelect: () => chooseProject(),
1040
+ },
1041
+ {
1042
+ id: "project.previous",
1043
+ title: language.t("command.project.previous"),
1044
+ category: language.t("command.category.project"),
1045
+ keybind: "mod+alt+arrowup",
1046
+ onSelect: () => navigateProjectByOffset(-1),
1047
+ },
1048
+ {
1049
+ id: "project.next",
1050
+ title: language.t("command.project.next"),
1051
+ category: language.t("command.category.project"),
1052
+ keybind: "mod+alt+arrowdown",
1053
+ onSelect: () => navigateProjectByOffset(1),
1054
+ },
1055
+ {
1056
+ id: "provider.connect",
1057
+ title: language.t("command.provider.connect"),
1058
+ category: language.t("command.category.provider"),
1059
+ onSelect: () => connectProvider(),
1060
+ },
1061
+ {
1062
+ id: "server.switch",
1063
+ title: language.t("command.server.switch"),
1064
+ category: language.t("command.category.server"),
1065
+ onSelect: () => openServer(),
1066
+ },
1067
+ {
1068
+ id: "settings.open",
1069
+ title: language.t("command.settings.open"),
1070
+ category: language.t("command.category.settings"),
1071
+ keybind: "mod+comma",
1072
+ onSelect: () => openSettings(),
1073
+ },
1074
+ {
1075
+ id: "session.previous",
1076
+ title: language.t("command.session.previous"),
1077
+ category: language.t("command.category.session"),
1078
+ keybind: "alt+arrowup",
1079
+ onSelect: () => navigateSessionByOffset(-1),
1080
+ },
1081
+ {
1082
+ id: "session.next",
1083
+ title: language.t("command.session.next"),
1084
+ category: language.t("command.category.session"),
1085
+ keybind: "alt+arrowdown",
1086
+ onSelect: () => navigateSessionByOffset(1),
1087
+ },
1088
+ {
1089
+ id: "session.previous.unseen",
1090
+ title: language.t("command.session.previous.unseen"),
1091
+ category: language.t("command.category.session"),
1092
+ keybind: "shift+alt+arrowup",
1093
+ onSelect: () => navigateSessionByUnseen(-1),
1094
+ },
1095
+ {
1096
+ id: "session.next.unseen",
1097
+ title: language.t("command.session.next.unseen"),
1098
+ category: language.t("command.category.session"),
1099
+ keybind: "shift+alt+arrowdown",
1100
+ onSelect: () => navigateSessionByUnseen(1),
1101
+ },
1102
+ {
1103
+ id: "session.archive",
1104
+ title: language.t("command.session.archive"),
1105
+ category: language.t("command.category.session"),
1106
+ keybind: "mod+shift+backspace",
1107
+ disabled: !params.dir || !params.id,
1108
+ onSelect: () => {
1109
+ const session = currentSessions().find((s) => s.id === params.id)
1110
+ if (session) archiveSession(session)
1111
+ },
1112
+ },
1113
+ {
1114
+ id: "workspace.new",
1115
+ title: language.t("workspace.new"),
1116
+ category: language.t("command.category.workspace"),
1117
+ keybind: "mod+shift+w",
1118
+ disabled: !workspaceSetting(),
1119
+ onSelect: () => {
1120
+ const project = currentProject()
1121
+ if (!project) return
1122
+ return createWorkspace(project)
1123
+ },
1124
+ },
1125
+ {
1126
+ id: "workspace.toggle",
1127
+ title: language.t("command.workspace.toggle"),
1128
+ description: language.t("command.workspace.toggle.description"),
1129
+ category: language.t("command.category.workspace"),
1130
+ slash: "workspace",
1131
+ disabled: !currentProject() || currentProject()?.vcs !== "git",
1132
+ onSelect: () => {
1133
+ const project = currentProject()
1134
+ if (!project) return
1135
+ if (project.vcs !== "git") return
1136
+ const wasEnabled = layout.sidebar.workspaces(project.worktree)()
1137
+ layout.sidebar.toggleWorkspaces(project.worktree)
1138
+ showToast({
1139
+ title: wasEnabled
1140
+ ? language.t("toast.workspace.disabled.title")
1141
+ : language.t("toast.workspace.enabled.title"),
1142
+ description: wasEnabled
1143
+ ? language.t("toast.workspace.disabled.description")
1144
+ : language.t("toast.workspace.enabled.description"),
1145
+ })
1146
+ },
1147
+ },
1148
+ {
1149
+ id: "theme.cycle",
1150
+ title: language.t("command.theme.cycle"),
1151
+ category: language.t("command.category.theme"),
1152
+ keybind: "mod+shift+t",
1153
+ onSelect: () => cycleTheme(1),
1154
+ },
1155
+ ]
1156
+
1157
+ for (const [id, definition] of availableThemeEntries()) {
1158
+ commands.push({
1159
+ id: `theme.set.${id}`,
1160
+ title: language.t("command.theme.set", { theme: definition.name ?? id }),
1161
+ category: language.t("command.category.theme"),
1162
+ onSelect: () => theme.commitPreview(),
1163
+ onHighlight: () => {
1164
+ theme.previewTheme(id)
1165
+ return () => theme.cancelPreview()
1166
+ },
1167
+ })
1168
+ }
1169
+
1170
+ commands.push({
1171
+ id: "theme.scheme.cycle",
1172
+ title: language.t("command.theme.scheme.cycle"),
1173
+ category: language.t("command.category.theme"),
1174
+ keybind: "mod+shift+s",
1175
+ onSelect: () => cycleColorScheme(1),
1176
+ })
1177
+
1178
+ for (const scheme of colorSchemeOrder) {
1179
+ commands.push({
1180
+ id: `theme.scheme.${scheme}`,
1181
+ title: language.t("command.theme.scheme.set", { scheme: colorSchemeLabel(scheme) }),
1182
+ category: language.t("command.category.theme"),
1183
+ onSelect: () => theme.commitPreview(),
1184
+ onHighlight: () => {
1185
+ theme.previewColorScheme(scheme)
1186
+ return () => theme.cancelPreview()
1187
+ },
1188
+ })
1189
+ }
1190
+
1191
+ commands.push({
1192
+ id: "language.cycle",
1193
+ title: language.t("command.language.cycle"),
1194
+ category: language.t("command.category.language"),
1195
+ onSelect: () => cycleLanguage(1),
1196
+ })
1197
+
1198
+ for (const locale of language.locales) {
1199
+ commands.push({
1200
+ id: `language.set.${locale}`,
1201
+ title: language.t("command.language.set", { language: language.label(locale) }),
1202
+ category: language.t("command.category.language"),
1203
+ onSelect: () => setLocale(locale),
1204
+ })
1205
+ }
1206
+
1207
+ return commands
1208
+ })
1209
+
1210
+ function connectProvider() {
1211
+ dialog.show(() => <DialogSelectProvider />)
1212
+ }
1213
+
1214
+ function openServer() {
1215
+ dialog.show(() => <DialogSelectServer />)
1216
+ }
1217
+
1218
+ function openSettings() {
1219
+ dialog.show(() => <DialogSettings />)
1220
+ }
1221
+
1222
+ function projectRoot(directory: string) {
1223
+ const key = workspaceKey(directory)
1224
+ const project = layout.projects
1225
+ .list()
1226
+ .find(
1227
+ (item) =>
1228
+ workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
1229
+ )
1230
+ if (project) return project.worktree
1231
+
1232
+ const known = Object.entries(store.workspaceOrder).find(
1233
+ ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
1234
+ )
1235
+ if (known) return known[0]
1236
+
1237
+ const [child] = globalSync.child(directory, { bootstrap: false })
1238
+ const id = child.project
1239
+ if (!id) return directory
1240
+
1241
+ const meta = globalSync.data.project.find((item) => item.id === id)
1242
+ return meta?.worktree ?? directory
1243
+ }
1244
+
1245
+ function activeProjectRoot(directory: string) {
1246
+ return currentProject()?.worktree ?? projectRoot(directory)
1247
+ }
1248
+
1249
+ function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
1250
+ setStore("lastProjectSession", root, { directory, id, at: Date.now() })
1251
+ return root
1252
+ }
1253
+
1254
+ function clearLastProjectSession(root: string) {
1255
+ if (!store.lastProjectSession[root]) return
1256
+ setStore(
1257
+ "lastProjectSession",
1258
+ produce((draft) => {
1259
+ delete draft[root]
1260
+ }),
1261
+ )
1262
+ }
1263
+
1264
+ function syncSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
1265
+ rememberSessionRoute(directory, id, root)
1266
+ notification.session.markViewed(id)
1267
+ const expanded = untrack(() => store.workspaceExpanded[directory])
1268
+ if (expanded === false) {
1269
+ setStore("workspaceExpanded", directory, true)
1270
+ }
1271
+ requestAnimationFrame(() => scrollToSession(id, `${directory}:${id}`))
1272
+ return root
1273
+ }
1274
+
1275
+ async function navigateToProject(directory: string | undefined) {
1276
+ if (!directory) return
1277
+ const root = projectRoot(directory)
1278
+ server.projects.touch(root)
1279
+ const project = layout.projects.list().find((item) => item.worktree === root)
1280
+ let dirs = project
1281
+ ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
1282
+ : [root]
1283
+ const canOpen = (value: string | undefined) => {
1284
+ if (!value) return false
1285
+ return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
1286
+ }
1287
+ const refreshDirs = async (target?: string) => {
1288
+ if (!target || target === root || canOpen(target)) return canOpen(target)
1289
+ const listed = await globalSDK.client.worktree
1290
+ .list({ directory: root })
1291
+ .then((x) => x.data ?? [])
1292
+ .catch(() => [] as string[])
1293
+ dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root])
1294
+ return canOpen(target)
1295
+ }
1296
+ const openSession = async (target: { directory: string; id: string }) => {
1297
+ if (!canOpen(target.directory)) return false
1298
+ const [data] = globalSync.child(target.directory, { bootstrap: false })
1299
+ if (data.session.some((item) => item.id === target.id)) {
1300
+ setStore("lastProjectSession", root, { directory: target.directory, id: target.id, at: Date.now() })
1301
+ navigateWithSidebarReset(`/${base64Encode(target.directory)}/session/${target.id}`)
1302
+ return true
1303
+ }
1304
+ const resolved = await globalSDK.client.session
1305
+ .get({ sessionID: target.id })
1306
+ .then((x) => x.data)
1307
+ .catch(() => undefined)
1308
+ if (!resolved?.directory) return false
1309
+ if (!canOpen(resolved.directory)) return false
1310
+ setStore("lastProjectSession", root, { directory: resolved.directory, id: resolved.id, at: Date.now() })
1311
+ navigateWithSidebarReset(`/${base64Encode(resolved.directory)}/session/${resolved.id}`)
1312
+ return true
1313
+ }
1314
+
1315
+ const projectSession = store.lastProjectSession[root]
1316
+ if (projectSession?.id) {
1317
+ await refreshDirs(projectSession.directory)
1318
+ const opened = await openSession(projectSession)
1319
+ if (opened) return
1320
+ clearLastProjectSession(root)
1321
+ }
1322
+
1323
+ const latest = latestRootSession(
1324
+ dirs.map((item) => globalSync.child(item, { bootstrap: false })[0]),
1325
+ Date.now(),
1326
+ )
1327
+ if (latest && (await openSession(latest))) {
1328
+ return
1329
+ }
1330
+
1331
+ const fetched = latestRootSession(
1332
+ await Promise.all(
1333
+ dirs.map(async (item) => ({
1334
+ path: { directory: item },
1335
+ session: await globalSDK.client.session
1336
+ .list({ directory: item })
1337
+ .then((x) => x.data ?? [])
1338
+ .catch(() => []),
1339
+ })),
1340
+ ),
1341
+ Date.now(),
1342
+ )
1343
+ if (fetched && (await openSession(fetched))) {
1344
+ return
1345
+ }
1346
+
1347
+ navigateWithSidebarReset(`/${base64Encode(root)}/session`)
1348
+ }
1349
+
1350
+ function navigateToSession(session: Session | undefined) {
1351
+ if (!session) return
1352
+ navigateWithSidebarReset(`/${base64Encode(session.directory)}/session/${session.id}`)
1353
+ }
1354
+
1355
+ function openProject(directory: string, navigate = true) {
1356
+ layout.projects.open(directory)
1357
+ if (navigate) return navigateToProject(directory)
1358
+ }
1359
+
1360
+ const handleDeepLinks = (urls: string[]) => {
1361
+ if (!server.isLocal()) return
1362
+
1363
+ for (const directory of collectOpenProjectDeepLinks(urls)) {
1364
+ openProject(directory)
1365
+ }
1366
+
1367
+ for (const link of collectNewSessionDeepLinks(urls)) {
1368
+ openProject(link.directory, false)
1369
+ const slug = base64Encode(link.directory)
1370
+ if (link.prompt) {
1371
+ setSessionHandoff(slug, { prompt: link.prompt })
1372
+ }
1373
+ const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
1374
+ navigateWithSidebarReset(href)
1375
+ }
1376
+ }
1377
+
1378
+ onMount(() => {
1379
+ const handler = (event: Event) => {
1380
+ const detail = (event as CustomEvent<{ urls: string[] }>).detail
1381
+ const urls = detail?.urls ?? []
1382
+ if (urls.length === 0) return
1383
+ handleDeepLinks(urls)
1384
+ }
1385
+
1386
+ handleDeepLinks(drainPendingDeepLinks(window))
1387
+ window.addEventListener(deepLinkEvent, handler as EventListener)
1388
+ onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
1389
+ })
1390
+
1391
+ async function renameProject(project: LocalProject, next: string) {
1392
+ const current = displayName(project)
1393
+ if (next === current) return
1394
+ const name = next === getFilename(project.worktree) ? "" : next
1395
+
1396
+ if (project.id && project.id !== "global") {
1397
+ await globalSDK.client.project.update({ projectID: project.id, directory: project.worktree, name })
1398
+ return
1399
+ }
1400
+
1401
+ globalSync.project.meta(project.worktree, { name })
1402
+ }
1403
+
1404
+ const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
1405
+ const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
1406
+ if (current === next) return
1407
+ setWorkspaceName(directory, next, projectId, branch)
1408
+ }
1409
+
1410
+ function closeProject(directory: string) {
1411
+ const list = layout.projects.list()
1412
+ const key = workspaceKey(directory)
1413
+ const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
1414
+ const active = workspaceKey(currentProject()?.worktree ?? "") === key
1415
+ if (index === -1) return
1416
+ const next = list[index + 1]
1417
+
1418
+ if (!active) {
1419
+ layout.projects.close(directory)
1420
+ return
1421
+ }
1422
+
1423
+ if (!next) {
1424
+ layout.projects.close(directory)
1425
+ navigate("/")
1426
+ return
1427
+ }
1428
+
1429
+ navigateWithSidebarReset(`/${base64Encode(next.worktree)}/session`)
1430
+ layout.projects.close(directory)
1431
+ queueMicrotask(() => {
1432
+ void navigateToProject(next.worktree)
1433
+ })
1434
+ }
1435
+
1436
+ function toggleProjectWorkspaces(project: LocalProject) {
1437
+ const enabled = layout.sidebar.workspaces(project.worktree)()
1438
+ if (enabled) {
1439
+ layout.sidebar.toggleWorkspaces(project.worktree)
1440
+ return
1441
+ }
1442
+ if (project.vcs !== "git") return
1443
+ layout.sidebar.toggleWorkspaces(project.worktree)
1444
+ }
1445
+
1446
+ const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />)
1447
+
1448
+ async function chooseProject() {
1449
+ function resolve(result: string | string[] | null) {
1450
+ if (Array.isArray(result)) {
1451
+ for (const directory of result) {
1452
+ openProject(directory, false)
1453
+ }
1454
+ navigateToProject(result[0])
1455
+ } else if (result) {
1456
+ openProject(result)
1457
+ }
1458
+ }
1459
+
1460
+ if (platform.openDirectoryPickerDialog && server.isLocal()) {
1461
+ const result = await platform.openDirectoryPickerDialog?.({
1462
+ title: language.t("command.project.open"),
1463
+ multiple: true,
1464
+ })
1465
+ resolve(result)
1466
+ } else {
1467
+ dialog.show(
1468
+ () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
1469
+ () => resolve(null),
1470
+ )
1471
+ }
1472
+ }
1473
+
1474
+ const deleteWorkspace = async (root: string, directory: string, leaveDeletedWorkspace = false) => {
1475
+ if (directory === root) return
1476
+
1477
+ const current = currentDir()
1478
+ const currentKey = workspaceKey(current)
1479
+ const deletedKey = workspaceKey(directory)
1480
+ const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
1481
+ if (!leaveDeletedWorkspace && shouldLeave) {
1482
+ navigateWithSidebarReset(`/${base64Encode(root)}/session`)
1483
+ }
1484
+
1485
+ setBusy(directory, true)
1486
+
1487
+ const result = await globalSDK.client.worktree
1488
+ .remove({ directory: root, worktreeRemoveInput: { directory } })
1489
+ .then((x) => x.data)
1490
+ .catch((err) => {
1491
+ showToast({
1492
+ title: language.t("workspace.delete.failed.title"),
1493
+ description: errorMessage(err, language.t("common.requestFailed")),
1494
+ })
1495
+ return false
1496
+ })
1497
+
1498
+ setBusy(directory, false)
1499
+
1500
+ if (!result) return
1501
+
1502
+ if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
1503
+ clearLastProjectSession(root)
1504
+ }
1505
+
1506
+ globalSync.set(
1507
+ "project",
1508
+ produce((draft) => {
1509
+ const project = draft.find((item) => item.worktree === root)
1510
+ if (!project) return
1511
+ project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory)
1512
+ }),
1513
+ )
1514
+ setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory))
1515
+
1516
+ layout.projects.close(directory)
1517
+ layout.projects.open(root)
1518
+
1519
+ if (shouldLeave) return
1520
+
1521
+ const nextCurrent = currentDir()
1522
+ const nextKey = workspaceKey(nextCurrent)
1523
+ const project = layout.projects.list().find((item) => item.worktree === root)
1524
+ const dirs = project
1525
+ ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
1526
+ : [root]
1527
+ const valid = dirs.some((item) => workspaceKey(item) === nextKey)
1528
+
1529
+ if (params.dir && projectRoot(nextCurrent) === root && !valid) {
1530
+ navigateWithSidebarReset(`/${base64Encode(root)}/session`)
1531
+ }
1532
+ }
1533
+
1534
+ const resetWorkspace = async (root: string, directory: string) => {
1535
+ if (directory === root) return
1536
+ setBusy(directory, true)
1537
+
1538
+ const progress = showToast({
1539
+ persistent: true,
1540
+ title: language.t("workspace.resetting.title"),
1541
+ description: language.t("workspace.resetting.description"),
1542
+ })
1543
+ const dismiss = () => toaster.dismiss(progress)
1544
+
1545
+ const sessions: Session[] = await globalSDK.client.session
1546
+ .list({ directory })
1547
+ .then((x) => x.data ?? [])
1548
+ .catch(() => [])
1549
+
1550
+ clearWorkspaceTerminals(
1551
+ directory,
1552
+ sessions.map((s) => s.id),
1553
+ platform,
1554
+ )
1555
+ await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
1556
+
1557
+ const result = await globalSDK.client.worktree
1558
+ .reset({ directory: root, worktreeResetInput: { directory } })
1559
+ .then((x) => x.data)
1560
+ .catch((err) => {
1561
+ showToast({
1562
+ title: language.t("workspace.reset.failed.title"),
1563
+ description: errorMessage(err, language.t("common.requestFailed")),
1564
+ })
1565
+ return false
1566
+ })
1567
+
1568
+ if (!result) {
1569
+ setBusy(directory, false)
1570
+ dismiss()
1571
+ return
1572
+ }
1573
+
1574
+ const archivedAt = Date.now()
1575
+ await Promise.all(
1576
+ sessions
1577
+ .filter((session) => session.time.archived === undefined)
1578
+ .map((session) =>
1579
+ globalSDK.client.session
1580
+ .update({
1581
+ sessionID: session.id,
1582
+ directory: session.directory,
1583
+ time: { archived: archivedAt },
1584
+ })
1585
+ .catch(() => undefined),
1586
+ ),
1587
+ )
1588
+
1589
+ setBusy(directory, false)
1590
+ dismiss()
1591
+
1592
+ showToast({
1593
+ title: language.t("workspace.reset.success.title"),
1594
+ description: language.t("workspace.reset.success.description"),
1595
+ actions: [
1596
+ {
1597
+ label: language.t("command.session.new"),
1598
+ onClick: () => {
1599
+ const href = `/${base64Encode(directory)}/session`
1600
+ navigate(href)
1601
+ layout.mobileSidebar.hide()
1602
+ },
1603
+ },
1604
+ {
1605
+ label: language.t("common.dismiss"),
1606
+ onClick: "dismiss",
1607
+ },
1608
+ ],
1609
+ })
1610
+ }
1611
+
1612
+ function DialogDeleteWorkspace(props: { root: string; directory: string }) {
1613
+ const name = createMemo(() => getFilename(props.directory))
1614
+ const [data, setData] = createStore({
1615
+ status: "loading" as "loading" | "ready" | "error",
1616
+ dirty: false,
1617
+ })
1618
+
1619
+ onMount(() => {
1620
+ globalSDK.client.file
1621
+ .status({ directory: props.directory })
1622
+ .then((x) => {
1623
+ const files = x.data ?? []
1624
+ const dirty = files.length > 0
1625
+ setData({ status: "ready", dirty })
1626
+ })
1627
+ .catch(() => {
1628
+ setData({ status: "error", dirty: false })
1629
+ })
1630
+ })
1631
+
1632
+ const handleDelete = () => {
1633
+ const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
1634
+ if (leaveDeletedWorkspace) {
1635
+ navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
1636
+ }
1637
+ dialog.close()
1638
+ void deleteWorkspace(props.root, props.directory, leaveDeletedWorkspace)
1639
+ }
1640
+
1641
+ const description = () => {
1642
+ if (data.status === "loading") return language.t("workspace.status.checking")
1643
+ if (data.status === "error") return language.t("workspace.status.error")
1644
+ if (!data.dirty) return language.t("workspace.status.clean")
1645
+ return language.t("workspace.status.dirty")
1646
+ }
1647
+
1648
+ return (
1649
+ <Dialog title={language.t("workspace.delete.title")} fit>
1650
+ <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
1651
+ <div class="flex flex-col gap-1">
1652
+ <span class="text-14-regular text-text-strong">
1653
+ {language.t("workspace.delete.confirm", { name: name() })}
1654
+ </span>
1655
+ <span class="text-12-regular text-text-weak">{description()}</span>
1656
+ </div>
1657
+ <div class="flex justify-end gap-2">
1658
+ <Button variant="ghost" size="large" onClick={() => dialog.close()}>
1659
+ {language.t("common.cancel")}
1660
+ </Button>
1661
+ <Button variant="primary" size="large" disabled={data.status === "loading"} onClick={handleDelete}>
1662
+ {language.t("workspace.delete.button")}
1663
+ </Button>
1664
+ </div>
1665
+ </div>
1666
+ </Dialog>
1667
+ )
1668
+ }
1669
+
1670
+ function DialogResetWorkspace(props: { root: string; directory: string }) {
1671
+ const name = createMemo(() => getFilename(props.directory))
1672
+ const [state, setState] = createStore({
1673
+ status: "loading" as "loading" | "ready" | "error",
1674
+ dirty: false,
1675
+ sessions: [] as Session[],
1676
+ })
1677
+
1678
+ const refresh = async () => {
1679
+ const sessions = await globalSDK.client.session
1680
+ .list({ directory: props.directory })
1681
+ .then((x) => x.data ?? [])
1682
+ .catch(() => [])
1683
+ const active = sessions.filter((session) => session.time.archived === undefined)
1684
+ setState({ sessions: active })
1685
+ }
1686
+
1687
+ onMount(() => {
1688
+ globalSDK.client.file
1689
+ .status({ directory: props.directory })
1690
+ .then((x) => {
1691
+ const files = x.data ?? []
1692
+ const dirty = files.length > 0
1693
+ setState({ status: "ready", dirty })
1694
+ void refresh()
1695
+ })
1696
+ .catch(() => {
1697
+ setState({ status: "error", dirty: false })
1698
+ })
1699
+ })
1700
+
1701
+ const handleReset = () => {
1702
+ dialog.close()
1703
+ void resetWorkspace(props.root, props.directory)
1704
+ }
1705
+
1706
+ const archivedCount = () => state.sessions.length
1707
+
1708
+ const description = () => {
1709
+ if (state.status === "loading") return language.t("workspace.status.checking")
1710
+ if (state.status === "error") return language.t("workspace.status.error")
1711
+ if (!state.dirty) return language.t("workspace.status.clean")
1712
+ return language.t("workspace.status.dirty")
1713
+ }
1714
+
1715
+ const archivedLabel = () => {
1716
+ const count = archivedCount()
1717
+ if (count === 0) return language.t("workspace.reset.archived.none")
1718
+ if (count === 1) return language.t("workspace.reset.archived.one")
1719
+ return language.t("workspace.reset.archived.many", { count })
1720
+ }
1721
+
1722
+ return (
1723
+ <Dialog title={language.t("workspace.reset.title")} fit>
1724
+ <div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
1725
+ <div class="flex flex-col gap-1">
1726
+ <span class="text-14-regular text-text-strong">
1727
+ {language.t("workspace.reset.confirm", { name: name() })}
1728
+ </span>
1729
+ <span class="text-12-regular text-text-weak">
1730
+ {description()} {archivedLabel()} {language.t("workspace.reset.note")}
1731
+ </span>
1732
+ </div>
1733
+ <div class="flex justify-end gap-2">
1734
+ <Button variant="ghost" size="large" onClick={() => dialog.close()}>
1735
+ {language.t("common.cancel")}
1736
+ </Button>
1737
+ <Button variant="primary" size="large" disabled={state.status === "loading"} onClick={handleReset}>
1738
+ {language.t("workspace.reset.button")}
1739
+ </Button>
1740
+ </div>
1741
+ </div>
1742
+ </Dialog>
1743
+ )
1744
+ }
1745
+
1746
+ const activeRoute = {
1747
+ session: "",
1748
+ sessionProject: "",
1749
+ directory: "",
1750
+ }
1751
+
1752
+ createEffect(
1753
+ on(
1754
+ () => {
1755
+ return [pageReady(), route().slug, params.id, currentProject()?.worktree, currentDir()] as const
1756
+ },
1757
+ ([ready, slug, id, root, dir]) => {
1758
+ if (!ready || !slug || !dir) {
1759
+ activeRoute.session = ""
1760
+ activeRoute.sessionProject = ""
1761
+ activeRoute.directory = ""
1762
+ return
1763
+ }
1764
+
1765
+ if (!id) {
1766
+ activeRoute.session = ""
1767
+ activeRoute.sessionProject = ""
1768
+ activeRoute.directory = ""
1769
+ return
1770
+ }
1771
+
1772
+ const session = `${slug}/${id}`
1773
+
1774
+ if (!root) {
1775
+ activeRoute.session = session
1776
+ activeRoute.directory = dir
1777
+ activeRoute.sessionProject = ""
1778
+ return
1779
+ }
1780
+
1781
+ if (server.projects.last() !== root) server.projects.touch(root)
1782
+
1783
+ const changed = session !== activeRoute.session || dir !== activeRoute.directory
1784
+ if (changed) {
1785
+ activeRoute.session = session
1786
+ activeRoute.directory = dir
1787
+ activeRoute.sessionProject = syncSessionRoute(dir, id, root)
1788
+ return
1789
+ }
1790
+
1791
+ if (root === activeRoute.sessionProject) return
1792
+ activeRoute.directory = dir
1793
+ activeRoute.sessionProject = rememberSessionRoute(dir, id, root)
1794
+ },
1795
+ ),
1796
+ )
1797
+
1798
+ createEffect(() => {
1799
+ const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
1800
+ document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
1801
+ })
1802
+
1803
+ const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
1804
+ const panel = createMemo(() => Math.max(side() - 64, 0))
1805
+
1806
+ const loadedSessionDirs = new Set<string>()
1807
+
1808
+ createEffect(
1809
+ on(
1810
+ visibleSessionDirs,
1811
+ (dirs) => {
1812
+ if (dirs.length === 0) {
1813
+ loadedSessionDirs.clear()
1814
+ return
1815
+ }
1816
+
1817
+ const next = new Set(dirs)
1818
+ for (const directory of next) {
1819
+ if (loadedSessionDirs.has(directory)) continue
1820
+ globalSync.project.loadSessions(directory)
1821
+ }
1822
+
1823
+ loadedSessionDirs.clear()
1824
+ for (const directory of next) {
1825
+ loadedSessionDirs.add(directory)
1826
+ }
1827
+ },
1828
+ { defer: true },
1829
+ ),
1830
+ )
1831
+
1832
+ function handleDragStart(event: unknown) {
1833
+ const id = getDraggableId(event)
1834
+ if (!id) return
1835
+ setHoverProject(undefined)
1836
+ setStore("activeProject", id)
1837
+ }
1838
+
1839
+ function handleDragOver(event: DragEvent) {
1840
+ const { draggable, droppable } = event
1841
+ if (draggable && droppable) {
1842
+ const projects = layout.projects.list()
1843
+ const fromIndex = projects.findIndex((p) => p.worktree === draggable.id.toString())
1844
+ const toIndex = projects.findIndex((p) => p.worktree === droppable.id.toString())
1845
+ if (fromIndex !== toIndex && toIndex !== -1) {
1846
+ layout.projects.move(draggable.id.toString(), toIndex)
1847
+ }
1848
+ }
1849
+ }
1850
+
1851
+ function handleDragEnd() {
1852
+ setStore("activeProject", undefined)
1853
+ }
1854
+
1855
+ function workspaceIds(project: LocalProject | undefined) {
1856
+ if (!project) return []
1857
+ const local = project.worktree
1858
+ const dirs = [local, ...(project.sandboxes ?? [])]
1859
+ const active = currentProject()
1860
+ const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
1861
+ const extra =
1862
+ directory &&
1863
+ workspaceKey(directory) !== workspaceKey(local) &&
1864
+ !dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
1865
+ ? directory
1866
+ : undefined
1867
+ const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
1868
+
1869
+ const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
1870
+ if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
1871
+ if (!extra) return ordered
1872
+ if (pending) return ordered
1873
+ return [...ordered, extra]
1874
+ }
1875
+
1876
+ const sidebarProject = createMemo(() => {
1877
+ if (layout.sidebar.opened()) return currentProject()
1878
+ const hovered = hoverProjectData()
1879
+ if (hovered) return hovered
1880
+ return currentProject()
1881
+ })
1882
+
1883
+ function handleWorkspaceDragStart(event: unknown) {
1884
+ const id = getDraggableId(event)
1885
+ if (!id) return
1886
+ setStore("activeWorkspace", id)
1887
+ }
1888
+
1889
+ function handleWorkspaceDragOver(event: DragEvent) {
1890
+ const { draggable, droppable } = event
1891
+ if (!draggable || !droppable) return
1892
+
1893
+ const project = sidebarProject()
1894
+ if (!project) return
1895
+
1896
+ const ids = workspaceIds(project)
1897
+ const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
1898
+ const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
1899
+ if (fromIndex === -1 || toIndex === -1) return
1900
+ if (fromIndex === toIndex) return
1901
+
1902
+ const result = ids.slice()
1903
+ const [item] = result.splice(fromIndex, 1)
1904
+ if (!item) return
1905
+ result.splice(toIndex, 0, item)
1906
+ setStore(
1907
+ "workspaceOrder",
1908
+ project.worktree,
1909
+ result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
1910
+ )
1911
+ }
1912
+
1913
+ function handleWorkspaceDragEnd() {
1914
+ setStore("activeWorkspace", undefined)
1915
+ }
1916
+
1917
+ const createWorkspace = async (project: LocalProject) => {
1918
+ clearSidebarHoverState()
1919
+ const created = await globalSDK.client.worktree
1920
+ .create({ directory: project.worktree })
1921
+ .then((x) => x.data)
1922
+ .catch((err) => {
1923
+ showToast({
1924
+ title: language.t("workspace.create.failed.title"),
1925
+ description: errorMessage(err, language.t("common.requestFailed")),
1926
+ })
1927
+ return undefined
1928
+ })
1929
+
1930
+ if (!created?.directory) return
1931
+
1932
+ setWorkspaceName(created.directory, created.branch, project.id, created.branch)
1933
+
1934
+ const local = project.worktree
1935
+ const key = workspaceKey(created.directory)
1936
+ const root = workspaceKey(local)
1937
+
1938
+ setBusy(created.directory, true)
1939
+ WorktreeState.pending(created.directory)
1940
+ setStore("workspaceExpanded", key, true)
1941
+ if (key !== created.directory) {
1942
+ setStore("workspaceExpanded", created.directory, true)
1943
+ }
1944
+ setStore("workspaceOrder", project.worktree, (prev) => {
1945
+ const existing = prev ?? []
1946
+ const next = existing.filter((item) => {
1947
+ const id = workspaceKey(item)
1948
+ return id !== root && id !== key
1949
+ })
1950
+ return [created.directory, ...next]
1951
+ })
1952
+
1953
+ globalSync.child(created.directory)
1954
+ navigateWithSidebarReset(`/${base64Encode(created.directory)}/session`)
1955
+ }
1956
+
1957
+ const workspaceSidebarCtx: WorkspaceSidebarContext = {
1958
+ currentDir,
1959
+ navList: currentSessions,
1960
+ sidebarExpanded,
1961
+ sidebarHovering,
1962
+ nav: () => state.nav,
1963
+ hoverSession: () => state.hoverSession,
1964
+ setHoverSession,
1965
+ clearHoverProjectSoon,
1966
+ prefetchSession,
1967
+ archiveSession,
1968
+ workspaceName,
1969
+ renameWorkspace,
1970
+ editorOpen,
1971
+ openEditor,
1972
+ closeEditor,
1973
+ setEditor,
1974
+ InlineEditor,
1975
+ isBusy,
1976
+ workspaceExpanded: (directory, local) => store.workspaceExpanded[directory] ?? local,
1977
+ setWorkspaceExpanded: (directory, value) => setStore("workspaceExpanded", directory, value),
1978
+ showResetWorkspaceDialog: (root, directory) =>
1979
+ dialog.show(() => <DialogResetWorkspace root={root} directory={directory} />),
1980
+ showDeleteWorkspaceDialog: (root, directory) =>
1981
+ dialog.show(() => <DialogDeleteWorkspace root={root} directory={directory} />),
1982
+ setScrollContainerRef: (el, mobile) => {
1983
+ if (!mobile) scrollContainerRef = el
1984
+ },
1985
+ }
1986
+
1987
+ const projectSidebarCtx: ProjectSidebarContext = {
1988
+ currentDir,
1989
+ currentProject,
1990
+ sidebarOpened: () => layout.sidebar.opened(),
1991
+ sidebarHovering,
1992
+ hoverProject: () => state.hoverProject,
1993
+ nav: () => state.nav,
1994
+ onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
1995
+ onProjectMouseLeave: (worktree) => aim.leave(worktree),
1996
+ onProjectFocus: (worktree) => aim.activate(worktree),
1997
+ onHoverOpenChanged: (worktree, hoverOpen) => {
1998
+ if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
1999
+ setState("hoverProject", hoverOpen ? worktree : undefined)
2000
+ },
2001
+ navigateToProject,
2002
+ openSidebar: () => layout.sidebar.open(),
2003
+ closeProject,
2004
+ showEditProjectDialog,
2005
+ toggleProjectWorkspaces,
2006
+ workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
2007
+ workspaceIds,
2008
+ workspaceLabel,
2009
+ sessionProps: {
2010
+ navList: currentSessions,
2011
+ sidebarExpanded,
2012
+ sidebarHovering,
2013
+ nav: () => state.nav,
2014
+ hoverSession: () => state.hoverSession,
2015
+ setHoverSession,
2016
+ clearHoverProjectSoon,
2017
+ prefetchSession,
2018
+ archiveSession,
2019
+ },
2020
+ setHoverSession,
2021
+ }
2022
+
2023
+ const SidebarPanel = (panelProps: {
2024
+ project: Accessor<LocalProject | undefined>
2025
+ mobile?: boolean
2026
+ merged?: boolean
2027
+ }) => {
2028
+ const project = panelProps.project
2029
+ const merged = createMemo(() => panelProps.mobile || (panelProps.merged ?? layout.sidebar.opened()))
2030
+ const hover = createMemo(() => !panelProps.mobile && panelProps.merged === false && !layout.sidebar.opened())
2031
+ const popover = createMemo(() => !!panelProps.mobile || panelProps.merged === false || layout.sidebar.opened())
2032
+ const empty = createMemo(() => !params.dir && layout.projects.list().length === 0)
2033
+ const projectName = createMemo(() => {
2034
+ const item = project()
2035
+ if (!item) return ""
2036
+ return item.name || getFilename(item.worktree)
2037
+ })
2038
+ const projectId = createMemo(() => project()?.id ?? "")
2039
+ const worktree = createMemo(() => project()?.worktree ?? "")
2040
+ const slug = createMemo(() => {
2041
+ const dir = worktree()
2042
+ if (!dir) return ""
2043
+ return base64Encode(dir)
2044
+ })
2045
+ const workspaces = createMemo(() => {
2046
+ const item = project()
2047
+ if (!item) return [] as string[]
2048
+ return workspaceIds(item)
2049
+ })
2050
+ const unseenCount = createMemo(() =>
2051
+ workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0),
2052
+ )
2053
+ const clearNotifications = () =>
2054
+ workspaces()
2055
+ .filter((directory) => notification.project.unseenCount(directory) > 0)
2056
+ .forEach((directory) => notification.project.markViewed(directory))
2057
+ const workspacesEnabled = createMemo(() => {
2058
+ const item = project()
2059
+ if (!item) return false
2060
+ if (item.vcs !== "git") return false
2061
+ return layout.sidebar.workspaces(item.worktree)()
2062
+ })
2063
+ const canToggle = createMemo(() => {
2064
+ const item = project()
2065
+ if (!item) return false
2066
+ return item.vcs === "git" || layout.sidebar.workspaces(item.worktree)()
2067
+ })
2068
+ const homedir = createMemo(() => globalSync.data.path.home)
2069
+
2070
+ return (
2071
+ <div
2072
+ classList={{
2073
+ "flex flex-col min-h-0 min-w-0 box-border rounded-tl-[12px] px-3": true,
2074
+ "border border-b-0 border-border-weak-base": !merged(),
2075
+ "border-l border-t border-border-weaker-base": merged(),
2076
+ "bg-background-base": merged() || hover(),
2077
+ "bg-background-stronger": !merged() && !hover(),
2078
+ "flex-1 min-w-0": panelProps.mobile,
2079
+ "max-w-full overflow-hidden": panelProps.mobile,
2080
+ }}
2081
+ style={{
2082
+ width: panelProps.mobile ? undefined : `${panel()}px`,
2083
+ }}
2084
+ >
2085
+ <Show
2086
+ when={project()}
2087
+ fallback={
2088
+ <Show when={empty()}>
2089
+ <div class="flex-1 min-h-0 -mt-4 flex items-center justify-center px-6 pb-64 text-center">
2090
+ <div class="mt-8 flex max-w-60 flex-col items-center gap-6 text-center">
2091
+ <div class="flex flex-col gap-3">
2092
+ <div class="text-14-medium text-text-strong">{language.t("sidebar.empty.title")}</div>
2093
+ <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
2094
+ {language.t("sidebar.empty.description")}
2095
+ </div>
2096
+ </div>
2097
+ <Button size="large" icon="folder-add-left" onClick={chooseProject}>
2098
+ {language.t("command.project.open")}
2099
+ </Button>
2100
+ </div>
2101
+ </div>
2102
+ </Show>
2103
+ }
2104
+ >
2105
+ <>
2106
+ <div class="shrink-0 pl-1 py-1">
2107
+ <div class="group/project flex items-start justify-between gap-2 py-2 pl-2 pr-0">
2108
+ <div class="flex flex-col min-w-0">
2109
+ <InlineEditor
2110
+ id={`project:${projectId()}`}
2111
+ value={projectName}
2112
+ onSave={(next) => {
2113
+ const item = project()
2114
+ if (!item) return
2115
+ renameProject(item, next)
2116
+ }}
2117
+ class="text-14-medium text-text-strong truncate"
2118
+ displayClass="text-14-medium text-text-strong truncate"
2119
+ stopPropagation
2120
+ />
2121
+
2122
+ <Tooltip
2123
+ placement="bottom"
2124
+ gutter={2}
2125
+ value={worktree()}
2126
+ class="shrink-0"
2127
+ contentStyle={{
2128
+ "max-width": "640px",
2129
+ transform: "translate3d(52px, 0, 0)",
2130
+ }}
2131
+ >
2132
+ <span class="text-12-regular text-text-base truncate select-text">
2133
+ {worktree().replace(homedir(), "~")}
2134
+ </span>
2135
+ </Tooltip>
2136
+ </div>
2137
+
2138
+ <DropdownMenu modal={!sidebarHovering()}>
2139
+ <DropdownMenu.Trigger
2140
+ as={IconButton}
2141
+ icon="dot-grid"
2142
+ variant="ghost"
2143
+ data-action="project-menu"
2144
+ data-project={slug()}
2145
+ class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
2146
+ classList={{
2147
+ "opacity-100": panelProps.mobile || merged(),
2148
+ "opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
2149
+ !panelProps.mobile && !merged(),
2150
+ }}
2151
+ aria-label={language.t("common.moreOptions")}
2152
+ />
2153
+ <DropdownMenu.Portal>
2154
+ <DropdownMenu.Content class="mt-1">
2155
+ <DropdownMenu.Item
2156
+ onSelect={() => {
2157
+ const item = project()
2158
+ if (!item) return
2159
+ showEditProjectDialog(item)
2160
+ }}
2161
+ >
2162
+ <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
2163
+ </DropdownMenu.Item>
2164
+ <DropdownMenu.Item
2165
+ data-action="project-workspaces-toggle"
2166
+ data-project={slug()}
2167
+ disabled={!canToggle()}
2168
+ onSelect={() => {
2169
+ const item = project()
2170
+ if (!item) return
2171
+ toggleProjectWorkspaces(item)
2172
+ }}
2173
+ >
2174
+ <DropdownMenu.ItemLabel>
2175
+ {workspacesEnabled()
2176
+ ? language.t("sidebar.workspaces.disable")
2177
+ : language.t("sidebar.workspaces.enable")}
2178
+ </DropdownMenu.ItemLabel>
2179
+ </DropdownMenu.Item>
2180
+ <DropdownMenu.Item
2181
+ data-action="project-clear-notifications"
2182
+ data-project={slug()}
2183
+ disabled={unseenCount() === 0}
2184
+ onSelect={clearNotifications}
2185
+ >
2186
+ <DropdownMenu.ItemLabel>
2187
+ {language.t("sidebar.project.clearNotifications")}
2188
+ </DropdownMenu.ItemLabel>
2189
+ </DropdownMenu.Item>
2190
+ <DropdownMenu.Separator />
2191
+ <DropdownMenu.Item
2192
+ data-action="project-close-menu"
2193
+ data-project={slug()}
2194
+ onSelect={() => {
2195
+ const dir = worktree()
2196
+ if (!dir) return
2197
+ closeProject(dir)
2198
+ }}
2199
+ >
2200
+ <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
2201
+ </DropdownMenu.Item>
2202
+ </DropdownMenu.Content>
2203
+ </DropdownMenu.Portal>
2204
+ </DropdownMenu>
2205
+ </div>
2206
+ </div>
2207
+
2208
+ <div class="flex-1 min-h-0 flex flex-col">
2209
+ <Show
2210
+ when={workspacesEnabled()}
2211
+ fallback={
2212
+ <>
2213
+ <div class="shrink-0 py-4">
2214
+ <Button
2215
+ size="large"
2216
+ icon="new-session"
2217
+ class="w-full"
2218
+ onClick={() => {
2219
+ const dir = worktree()
2220
+ if (!dir) return
2221
+ navigateWithSidebarReset(`/${base64Encode(dir)}/session`)
2222
+ }}
2223
+ >
2224
+ {language.t("command.session.new")}
2225
+ </Button>
2226
+ </div>
2227
+ <div class="flex-1 min-h-0">
2228
+ <LocalWorkspace
2229
+ ctx={workspaceSidebarCtx}
2230
+ project={project()!}
2231
+ sortNow={sortNow}
2232
+ mobile={panelProps.mobile}
2233
+ popover={popover()}
2234
+ />
2235
+ </div>
2236
+ </>
2237
+ }
2238
+ >
2239
+ <>
2240
+ <div class="shrink-0 py-4">
2241
+ <Button
2242
+ size="large"
2243
+ icon="plus-small"
2244
+ class="w-full"
2245
+ onClick={() => {
2246
+ const item = project()
2247
+ if (!item) return
2248
+ createWorkspace(item)
2249
+ }}
2250
+ >
2251
+ {language.t("workspace.new")}
2252
+ </Button>
2253
+ </div>
2254
+ <div class="relative flex-1 min-h-0">
2255
+ <DragDropProvider
2256
+ onDragStart={handleWorkspaceDragStart}
2257
+ onDragEnd={handleWorkspaceDragEnd}
2258
+ onDragOver={handleWorkspaceDragOver}
2259
+ collisionDetector={closestCenter}
2260
+ >
2261
+ <DragDropSensors />
2262
+ <ConstrainDragXAxis />
2263
+ <div
2264
+ ref={(el) => {
2265
+ if (!panelProps.mobile) scrollContainerRef = el
2266
+ }}
2267
+ class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
2268
+ >
2269
+ <SortableProvider ids={workspaces()}>
2270
+ <For each={workspaces()}>
2271
+ {(directory) => (
2272
+ <SortableWorkspace
2273
+ ctx={workspaceSidebarCtx}
2274
+ directory={directory}
2275
+ project={project()!}
2276
+ sortNow={sortNow}
2277
+ mobile={panelProps.mobile}
2278
+ popover={popover()}
2279
+ />
2280
+ )}
2281
+ </For>
2282
+ </SortableProvider>
2283
+ </div>
2284
+ <DragOverlay>
2285
+ <WorkspaceDragOverlay
2286
+ sidebarProject={sidebarProject}
2287
+ activeWorkspace={() => store.activeWorkspace}
2288
+ workspaceLabel={workspaceLabel}
2289
+ />
2290
+ </DragOverlay>
2291
+ </DragDropProvider>
2292
+ </div>
2293
+ </>
2294
+ </Show>
2295
+ </div>
2296
+ </>
2297
+ </Show>
2298
+
2299
+ <div
2300
+ class="shrink-0 px-3 py-3"
2301
+ classList={{
2302
+ hidden: store.gettingStartedDismissed || !(providers.all().length > 0 && providers.paid().length === 0),
2303
+ }}
2304
+ >
2305
+ <div class="rounded-xl bg-background-base shadow-xs-border-base" data-component="getting-started">
2306
+ <div class="p-3 flex flex-col gap-6">
2307
+ <div class="flex flex-col gap-2">
2308
+ <div class="text-14-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
2309
+ <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
2310
+ {language.t("sidebar.gettingStarted.line1")}
2311
+ </div>
2312
+ <div class="text-14-regular text-text-base" style={{ "line-height": "var(--line-height-normal)" }}>
2313
+ {language.t("sidebar.gettingStarted.line2")}
2314
+ </div>
2315
+ </div>
2316
+ <div data-component="getting-started-actions">
2317
+ <Button size="large" icon="plus-small" onClick={connectProvider}>
2318
+ {language.t("command.provider.connect")}
2319
+ </Button>
2320
+ <Button size="large" variant="ghost" onClick={() => setStore("gettingStartedDismissed", true)}>
2321
+ {language.t("toast.update.action.notYet")}
2322
+ </Button>
2323
+ </div>
2324
+ </div>
2325
+ </div>
2326
+ </div>
2327
+ </div>
2328
+ )
2329
+ }
2330
+
2331
+ const projects = () => layout.projects.list()
2332
+ const projectOverlay = () => <ProjectDragOverlay projects={projects} activeProject={() => store.activeProject} />
2333
+ const sidebarContent = (mobile?: boolean) => (
2334
+ <SidebarContent
2335
+ mobile={mobile}
2336
+ opened={() => layout.sidebar.opened()}
2337
+ aimMove={aim.move}
2338
+ projects={projects}
2339
+ renderProject={(project) => (
2340
+ <SortableProject ctx={projectSidebarCtx} project={project} sortNow={sortNow} mobile={mobile} />
2341
+ )}
2342
+ handleDragStart={handleDragStart}
2343
+ handleDragEnd={handleDragEnd}
2344
+ handleDragOver={handleDragOver}
2345
+ openProjectLabel={language.t("command.project.open")}
2346
+ openProjectKeybind={() => command.keybind("project.open")}
2347
+ onOpenProject={chooseProject}
2348
+ renderProjectOverlay={projectOverlay}
2349
+ settingsLabel={() => language.t("sidebar.settings")}
2350
+ settingsKeybind={() => command.keybind("settings.open")}
2351
+ onOpenSettings={openSettings}
2352
+ helpLabel={() => language.t("sidebar.help")}
2353
+ onOpenHelp={() => platform.openLink("https://code.reign-labs.com/desktop-feedback")}
2354
+ renderPanel={() =>
2355
+ mobile ? <SidebarPanel project={currentProject} mobile /> : <SidebarPanel project={currentProject} merged />
2356
+ }
2357
+ />
2358
+ )
2359
+
2360
+ return (
2361
+ <div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
2362
+ <Titlebar />
2363
+ <div class="flex-1 min-h-0 min-w-0 flex">
2364
+ <div class="flex-1 min-h-0 relative">
2365
+ <div class="size-full relative overflow-x-hidden">
2366
+ <nav
2367
+ aria-label={language.t("sidebar.nav.projectsAndSessions")}
2368
+ data-component="sidebar-nav-desktop"
2369
+ classList={{
2370
+ "hidden xl:block": true,
2371
+ "absolute inset-y-0 left-0": true,
2372
+ "z-10": true,
2373
+ }}
2374
+ style={{ width: `${side()}px` }}
2375
+ ref={(el) => {
2376
+ setState("nav", el)
2377
+ }}
2378
+ onMouseEnter={() => {
2379
+ disarm()
2380
+ }}
2381
+ onMouseLeave={() => {
2382
+ aim.reset()
2383
+ if (!sidebarHovering()) return
2384
+
2385
+ arm()
2386
+ }}
2387
+ >
2388
+ <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
2389
+ </nav>
2390
+
2391
+ <Show when={layout.sidebar.opened()}>
2392
+ <div
2393
+ class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
2394
+ style={{ left: `${side()}px` }}
2395
+ onPointerDown={() => setState("sizing", true)}
2396
+ >
2397
+ <ResizeHandle
2398
+ direction="horizontal"
2399
+ size={layout.sidebar.width()}
2400
+ min={244}
2401
+ max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
2402
+ onResize={(w) => {
2403
+ setState("sizing", true)
2404
+ if (sizet !== undefined) clearTimeout(sizet)
2405
+ sizet = window.setTimeout(() => setState("sizing", false), 120)
2406
+ layout.sidebar.resize(w)
2407
+ }}
2408
+ />
2409
+ </div>
2410
+ </Show>
2411
+
2412
+ <div
2413
+ class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
2414
+ style={{ left: "calc(4rem + 12px)" }}
2415
+ />
2416
+
2417
+ <div class="xl:hidden">
2418
+ <div
2419
+ classList={{
2420
+ "fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
2421
+ "opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
2422
+ "opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
2423
+ }}
2424
+ onClick={(e) => {
2425
+ if (e.target === e.currentTarget) layout.mobileSidebar.hide()
2426
+ }}
2427
+ />
2428
+ <nav
2429
+ aria-label={language.t("sidebar.nav.projectsAndSessions")}
2430
+ data-component="sidebar-nav-mobile"
2431
+ classList={{
2432
+ "@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
2433
+ "translate-x-0": layout.mobileSidebar.opened(),
2434
+ "-translate-x-full": !layout.mobileSidebar.opened(),
2435
+ }}
2436
+ onClick={(e) => e.stopPropagation()}
2437
+ >
2438
+ {sidebarContent(true)}
2439
+ </nav>
2440
+ </div>
2441
+
2442
+ <div
2443
+ classList={{
2444
+ "absolute inset-0": true,
2445
+ "xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
2446
+ "z-20": true,
2447
+ "transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
2448
+ !state.sizing,
2449
+ }}
2450
+ style={{
2451
+ "--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
2452
+ }}
2453
+ >
2454
+ <main
2455
+ classList={{
2456
+ "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
2457
+ }}
2458
+ >
2459
+ <Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
2460
+ {props.children}
2461
+ </Show>
2462
+ </main>
2463
+ </div>
2464
+
2465
+ <div
2466
+ classList={{
2467
+ "hidden xl:flex absolute inset-y-0 left-16 z-30": true,
2468
+ "opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
2469
+ "opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
2470
+ "transition-[opacity,transform] motion-reduce:transition-none": true,
2471
+ "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
2472
+ "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
2473
+ }}
2474
+ onMouseMove={disarm}
2475
+ onMouseEnter={() => {
2476
+ disarm()
2477
+ aim.reset()
2478
+ }}
2479
+ onPointerDown={disarm}
2480
+ onMouseLeave={() => {
2481
+ arm()
2482
+ }}
2483
+ >
2484
+ <Show when={peekProject()}>
2485
+ <SidebarPanel project={peekProject} merged={false} />
2486
+ </Show>
2487
+ </div>
2488
+
2489
+ <div
2490
+ classList={{
2491
+ "hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
2492
+ "opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
2493
+ "opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
2494
+ "transition-[opacity,transform] motion-reduce:transition-none": true,
2495
+ "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
2496
+ "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
2497
+ }}
2498
+ style={{ left: `calc(4rem + ${panel()}px)` }}
2499
+ >
2500
+ <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
2501
+ </div>
2502
+ </div>
2503
+ </div>
2504
+ {import.meta.env.DEV && <DebugBar />}
2505
+ </div>
2506
+ <Toast.Region />
2507
+ </div>
2508
+ )
2509
+ }