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,937 @@
1
+ import { createStore, produce } from "solid-js/store"
2
+ import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
3
+ import { createSimpleContext } from "@reign-labs/ui/context"
4
+ import { useGlobalSync } from "./global-sync"
5
+ import { useGlobalSDK } from "./global-sdk"
6
+ import { useServer } from "./server"
7
+ import { usePlatform } from "./platform"
8
+ import { Project } from "@reign-labs/sdk/v2"
9
+ import { Persist, persisted, removePersisted } from "@/utils/persist"
10
+ import { decode64 } from "@/utils/base64"
11
+ import { same } from "@/utils/same"
12
+ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
13
+ import { createPathHelpers } from "./file/path"
14
+
15
+ const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
16
+ const DEFAULT_PANEL_WIDTH = 344
17
+ const DEFAULT_SESSION_WIDTH = 600
18
+ const DEFAULT_TERMINAL_HEIGHT = 280
19
+ export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
20
+
21
+ export function getAvatarColors(key?: string) {
22
+ if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
23
+ return {
24
+ background: `var(--avatar-background-${key})`,
25
+ foreground: `var(--avatar-text-${key})`,
26
+ }
27
+ }
28
+ return {
29
+ background: "var(--surface-info-base)",
30
+ foreground: "var(--text-base)",
31
+ }
32
+ }
33
+
34
+ type SessionTabs = {
35
+ active?: string
36
+ all: string[]
37
+ }
38
+
39
+ type SessionView = {
40
+ scroll: Record<string, SessionScroll>
41
+ reviewOpen?: string[]
42
+ pendingMessage?: string
43
+ pendingMessageAt?: number
44
+ }
45
+
46
+ type TabHandoff = {
47
+ dir: string
48
+ id: string
49
+ at: number
50
+ }
51
+
52
+ export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
53
+
54
+ export type ReviewDiffStyle = "unified" | "split"
55
+
56
+ export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
57
+ touch(key)
58
+ seed(key)
59
+ return key
60
+ }
61
+
62
+ export function createSessionKeyReader(sessionKey: string | Accessor<string>, ensure: (key: string) => void) {
63
+ const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
64
+ return () => {
65
+ const value = key()
66
+ ensure(value)
67
+ return value
68
+ }
69
+ }
70
+
71
+ export function pruneSessionKeys(input: {
72
+ keep?: string
73
+ max: number
74
+ used: Map<string, number>
75
+ view: string[]
76
+ tabs: string[]
77
+ }) {
78
+ if (!input.keep) return []
79
+
80
+ const keys = new Set<string>([...input.view, ...input.tabs])
81
+ if (keys.size <= input.max) return []
82
+
83
+ const score = (key: string) => {
84
+ if (key === input.keep) return Number.MAX_SAFE_INTEGER
85
+ return input.used.get(key) ?? 0
86
+ }
87
+
88
+ return Array.from(keys)
89
+ .sort((a, b) => score(b) - score(a))
90
+ .slice(input.max)
91
+ }
92
+
93
+ function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
94
+ const all = current?.all ?? []
95
+ if (tab === "review") return { all: all.filter((x) => x !== "review"), active: tab }
96
+ if (tab === "context") return { all: [tab, ...all.filter((x) => x !== tab)], active: tab }
97
+ if (!all.includes(tab)) return { all: [...all, tab], active: tab }
98
+ return { all, active: tab }
99
+ }
100
+
101
+ const sessionPath = (key: string) => {
102
+ const dir = key.split("/")[0]
103
+ if (!dir) return
104
+ const root = decode64(dir)
105
+ if (!root) return
106
+ return createPathHelpers(() => root)
107
+ }
108
+
109
+ const normalizeSessionTab = (path: ReturnType<typeof createPathHelpers> | undefined, tab: string) => {
110
+ if (!tab.startsWith("file://")) return tab
111
+ if (!path) return tab
112
+ return path.tab(tab)
113
+ }
114
+
115
+ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | undefined, all: string[]) => {
116
+ const seen = new Set<string>()
117
+ return all.flatMap((tab) => {
118
+ const value = normalizeSessionTab(path, tab)
119
+ if (seen.has(value)) return []
120
+ seen.add(value)
121
+ return [value]
122
+ })
123
+ }
124
+
125
+ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
126
+ const path = sessionPath(key)
127
+ return {
128
+ all: normalizeSessionTabList(path, tabs.all),
129
+ active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
130
+ }
131
+ }
132
+
133
+ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
134
+ name: "Layout",
135
+ init: () => {
136
+ const globalSdk = useGlobalSDK()
137
+ const globalSync = useGlobalSync()
138
+ const server = useServer()
139
+ const platform = usePlatform()
140
+
141
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
142
+ typeof value === "object" && value !== null && !Array.isArray(value)
143
+
144
+ const migrate = (value: unknown) => {
145
+ if (!isRecord(value)) return value
146
+
147
+ const sidebar = value.sidebar
148
+ const migratedSidebar = (() => {
149
+ if (!isRecord(sidebar)) return sidebar
150
+ if (typeof sidebar.workspaces !== "boolean") return sidebar
151
+ return {
152
+ ...sidebar,
153
+ workspaces: {},
154
+ workspacesDefault: sidebar.workspaces,
155
+ }
156
+ })()
157
+
158
+ const review = value.review
159
+ const fileTree = value.fileTree
160
+ const migratedFileTree = (() => {
161
+ if (!isRecord(fileTree)) return fileTree
162
+ if (fileTree.tab === "changes" || fileTree.tab === "all") return fileTree
163
+
164
+ const width = typeof fileTree.width === "number" ? fileTree.width : DEFAULT_PANEL_WIDTH
165
+ return {
166
+ ...fileTree,
167
+ opened: true,
168
+ width: width === 260 ? DEFAULT_PANEL_WIDTH : width,
169
+ tab: "changes",
170
+ }
171
+ })()
172
+
173
+ const migratedReview = (() => {
174
+ if (!isRecord(review)) return review
175
+ if (typeof review.panelOpened === "boolean") return review
176
+
177
+ const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
178
+ return {
179
+ ...review,
180
+ panelOpened: opened,
181
+ }
182
+ })()
183
+
184
+ const sessionTabs = value.sessionTabs
185
+ const migratedSessionTabs = (() => {
186
+ if (!isRecord(sessionTabs)) return sessionTabs
187
+
188
+ let changed = false
189
+ const next = Object.fromEntries(
190
+ Object.entries(sessionTabs).map(([key, tabs]) => {
191
+ if (!isRecord(tabs) || !Array.isArray(tabs.all)) return [key, tabs]
192
+
193
+ const current = {
194
+ all: tabs.all.filter((tab): tab is string => typeof tab === "string"),
195
+ active: typeof tabs.active === "string" ? tabs.active : undefined,
196
+ }
197
+ const normalized = normalizeStoredSessionTabs(key, current)
198
+ if (current.all.length !== tabs.all.length) changed = true
199
+ if (!same(current.all, normalized.all) || current.active !== normalized.active) changed = true
200
+ if (tabs.active !== undefined && typeof tabs.active !== "string") changed = true
201
+ return [key, normalized]
202
+ }),
203
+ )
204
+
205
+ if (!changed) return sessionTabs
206
+ return next
207
+ })()
208
+
209
+ if (
210
+ migratedSidebar === sidebar &&
211
+ migratedReview === review &&
212
+ migratedFileTree === fileTree &&
213
+ migratedSessionTabs === sessionTabs
214
+ ) {
215
+ return value
216
+ }
217
+
218
+ return {
219
+ ...value,
220
+ sidebar: migratedSidebar,
221
+ review: migratedReview,
222
+ fileTree: migratedFileTree,
223
+ sessionTabs: migratedSessionTabs,
224
+ }
225
+ }
226
+
227
+ const target = Persist.global("layout", ["layout.v6"])
228
+ const [store, setStore, _, ready] = persisted(
229
+ { ...target, migrate },
230
+ createStore({
231
+ sidebar: {
232
+ opened: false,
233
+ width: DEFAULT_PANEL_WIDTH,
234
+ workspaces: {} as Record<string, boolean>,
235
+ workspacesDefault: false,
236
+ },
237
+ terminal: {
238
+ height: DEFAULT_TERMINAL_HEIGHT,
239
+ opened: false,
240
+ },
241
+ review: {
242
+ diffStyle: "split" as ReviewDiffStyle,
243
+ panelOpened: true,
244
+ },
245
+ fileTree: {
246
+ opened: true,
247
+ width: DEFAULT_PANEL_WIDTH,
248
+ tab: "changes" as "changes" | "all",
249
+ },
250
+ session: {
251
+ width: DEFAULT_SESSION_WIDTH,
252
+ },
253
+ mobileSidebar: {
254
+ opened: false,
255
+ },
256
+ sessionTabs: {} as Record<string, SessionTabs>,
257
+ sessionView: {} as Record<string, SessionView>,
258
+ handoff: {
259
+ tabs: undefined as TabHandoff | undefined,
260
+ },
261
+ }),
262
+ )
263
+
264
+ const MAX_SESSION_KEYS = 50
265
+ const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
266
+ const usage = {
267
+ active: undefined as string | undefined,
268
+ pruned: false,
269
+ used: new Map<string, number>(),
270
+ }
271
+
272
+ const SESSION_STATE_KEYS = [
273
+ { key: "prompt", legacy: "prompt", version: "v2" },
274
+ { key: "terminal", legacy: "terminal", version: "v1" },
275
+ { key: "file-view", legacy: "file", version: "v1" },
276
+ ] as const
277
+
278
+ const dropSessionState = (keys: string[]) => {
279
+ for (const key of keys) {
280
+ const parts = key.split("/")
281
+ const dir = parts[0]
282
+ const session = parts[1]
283
+ if (!dir) continue
284
+
285
+ for (const entry of SESSION_STATE_KEYS) {
286
+ const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
287
+ void removePersisted(target, platform)
288
+
289
+ const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
290
+ void removePersisted({ key: legacyKey }, platform)
291
+ }
292
+ }
293
+ }
294
+
295
+ function prune(keep?: string) {
296
+ const drop = pruneSessionKeys({
297
+ keep,
298
+ max: MAX_SESSION_KEYS,
299
+ used: usage.used,
300
+ view: Object.keys(store.sessionView),
301
+ tabs: Object.keys(store.sessionTabs),
302
+ })
303
+ if (drop.length === 0) return
304
+
305
+ setStore(
306
+ produce((draft) => {
307
+ for (const key of drop) {
308
+ delete draft.sessionView[key]
309
+ delete draft.sessionTabs[key]
310
+ }
311
+ }),
312
+ )
313
+
314
+ scroll.drop(drop)
315
+ dropSessionState(drop)
316
+
317
+ for (const key of drop) {
318
+ usage.used.delete(key)
319
+ }
320
+ }
321
+
322
+ function touch(sessionKey: string) {
323
+ usage.active = sessionKey
324
+ usage.used.set(sessionKey, Date.now())
325
+
326
+ if (!ready()) return
327
+ if (usage.pruned) return
328
+
329
+ usage.pruned = true
330
+ prune(sessionKey)
331
+ }
332
+
333
+ const scroll = createScrollPersistence({
334
+ debounceMs: 250,
335
+ getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
336
+ onFlush: (sessionKey, next) => {
337
+ const current = store.sessionView[sessionKey]
338
+ const keep = usage.active ?? sessionKey
339
+ if (!current) {
340
+ setStore("sessionView", sessionKey, { scroll: next })
341
+ prune(keep)
342
+ return
343
+ }
344
+
345
+ setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
346
+ prune(keep)
347
+ },
348
+ })
349
+
350
+ const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
351
+
352
+ createEffect(() => {
353
+ if (!ready()) return
354
+ if (usage.pruned) return
355
+ const active = usage.active
356
+ if (!active) return
357
+ usage.pruned = true
358
+ prune(active)
359
+ })
360
+
361
+ onMount(() => {
362
+ const flush = () => batch(() => scroll.flushAll())
363
+ const handleVisibility = () => {
364
+ if (document.visibilityState !== "hidden") return
365
+ flush()
366
+ }
367
+
368
+ window.addEventListener("pagehide", flush)
369
+ document.addEventListener("visibilitychange", handleVisibility)
370
+
371
+ onCleanup(() => {
372
+ window.removeEventListener("pagehide", flush)
373
+ document.removeEventListener("visibilitychange", handleVisibility)
374
+ scroll.dispose()
375
+ })
376
+ })
377
+
378
+ const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
379
+ const colorRequested = new Map<string, AvatarColorKey>()
380
+
381
+ function pickAvailableColor(used: Set<string>): AvatarColorKey {
382
+ const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
383
+ if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
384
+ return available[Math.floor(Math.random() * available.length)]
385
+ }
386
+
387
+ function enrich(project: { worktree: string; expanded: boolean }) {
388
+ const [childStore] = globalSync.child(project.worktree, { bootstrap: false })
389
+ const projectID = childStore.project
390
+ const metadata = projectID
391
+ ? globalSync.data.project.find((x) => x.id === projectID)
392
+ : globalSync.data.project.find((x) => x.worktree === project.worktree)
393
+
394
+ const local = childStore.projectMeta
395
+ const localOverride =
396
+ local?.name !== undefined ||
397
+ local?.commands?.start !== undefined ||
398
+ local?.icon?.override !== undefined ||
399
+ local?.icon?.color !== undefined
400
+
401
+ const base = {
402
+ ...(metadata ?? {}),
403
+ ...project,
404
+ icon: {
405
+ url: metadata?.icon?.url,
406
+ override: metadata?.icon?.override ?? childStore.icon,
407
+ color: metadata?.icon?.color,
408
+ },
409
+ }
410
+
411
+ const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
412
+ if (!isGlobal) return base
413
+
414
+ return {
415
+ ...base,
416
+ id: base.id ?? "global",
417
+ name: local?.name,
418
+ commands: local?.commands,
419
+ icon: {
420
+ url: base.icon?.url,
421
+ override: local?.icon?.override,
422
+ color: local?.icon?.color,
423
+ },
424
+ }
425
+ }
426
+
427
+ const roots = createMemo(() => {
428
+ const map = new Map<string, string>()
429
+ for (const project of globalSync.data.project) {
430
+ const sandboxes = project.sandboxes ?? []
431
+ for (const sandbox of sandboxes) {
432
+ map.set(sandbox, project.worktree)
433
+ }
434
+ }
435
+ return map
436
+ })
437
+
438
+ const rootFor = (directory: string) => {
439
+ const map = roots()
440
+ if (map.size === 0) return directory
441
+
442
+ const visited = new Set<string>()
443
+ const chain = [directory]
444
+
445
+ while (chain.length) {
446
+ const current = chain[chain.length - 1]
447
+ if (!current) return directory
448
+
449
+ const next = map.get(current)
450
+ if (!next) return current
451
+
452
+ if (visited.has(next)) return directory
453
+ visited.add(next)
454
+ chain.push(next)
455
+ }
456
+
457
+ return directory
458
+ }
459
+
460
+ createEffect(() => {
461
+ const projects = server.projects.list()
462
+ const seen = new Set(projects.map((project) => project.worktree))
463
+
464
+ batch(() => {
465
+ for (const project of projects) {
466
+ const root = rootFor(project.worktree)
467
+ if (root === project.worktree) continue
468
+
469
+ server.projects.close(project.worktree)
470
+
471
+ if (!seen.has(root)) {
472
+ server.projects.open(root)
473
+ seen.add(root)
474
+ }
475
+
476
+ if (project.expanded) server.projects.expand(root)
477
+ }
478
+ })
479
+ })
480
+
481
+ const enriched = createMemo(() => server.projects.list().map(enrich))
482
+ const list = createMemo(() => {
483
+ const projects = enriched()
484
+ return projects.map((project) => {
485
+ const color = project.icon?.color ?? colors[project.worktree]
486
+ if (!color) return project
487
+ const icon = project.icon ? { ...project.icon, color } : { color }
488
+ return { ...project, icon }
489
+ })
490
+ })
491
+
492
+ createEffect(() => {
493
+ const projects = enriched()
494
+ if (projects.length === 0) return
495
+ if (!globalSync.ready) return
496
+
497
+ for (const project of projects) {
498
+ if (!project.id) continue
499
+ if (project.id === "global") continue
500
+ globalSync.project.icon(project.worktree, project.icon?.override)
501
+ }
502
+ })
503
+
504
+ createEffect(() => {
505
+ const projects = enriched()
506
+ if (projects.length === 0) return
507
+
508
+ for (const project of projects) {
509
+ if (project.icon?.color) colorRequested.delete(project.worktree)
510
+ }
511
+
512
+ const used = new Set<string>()
513
+ for (const project of projects) {
514
+ const color = project.icon?.color ?? colors[project.worktree]
515
+ if (color) used.add(color)
516
+ }
517
+
518
+ for (const project of projects) {
519
+ if (project.icon?.color) continue
520
+ const worktree = project.worktree
521
+ const existing = colors[worktree]
522
+ const color = existing ?? pickAvailableColor(used)
523
+ if (!existing) {
524
+ used.add(color)
525
+ setColors(worktree, color)
526
+ }
527
+ if (!project.id) continue
528
+
529
+ const requested = colorRequested.get(worktree)
530
+ if (requested === color) continue
531
+ colorRequested.set(worktree, color)
532
+
533
+ if (project.id === "global") {
534
+ globalSync.project.meta(worktree, { icon: { color } })
535
+ continue
536
+ }
537
+
538
+ void globalSdk.client.project
539
+ .update({ projectID: project.id, directory: worktree, icon: { color } })
540
+ .catch(() => {
541
+ if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
542
+ })
543
+ }
544
+ })
545
+
546
+ onMount(() => {
547
+ Promise.all(
548
+ server.projects.list().map((project) => {
549
+ return globalSync.project.loadSessions(project.worktree)
550
+ }),
551
+ )
552
+ })
553
+
554
+ return {
555
+ ready,
556
+ handoff: {
557
+ tabs: createMemo(() => store.handoff?.tabs),
558
+ setTabs(dir: string, id: string) {
559
+ setStore("handoff", "tabs", { dir, id, at: Date.now() })
560
+ },
561
+ clearTabs() {
562
+ if (!store.handoff?.tabs) return
563
+ setStore("handoff", "tabs", undefined)
564
+ },
565
+ },
566
+ projects: {
567
+ list,
568
+ open(directory: string) {
569
+ const root = rootFor(directory)
570
+ if (server.projects.list().find((x) => x.worktree === root)) return
571
+ globalSync.project.loadSessions(root)
572
+ server.projects.open(root)
573
+ },
574
+ close(directory: string) {
575
+ server.projects.close(directory)
576
+ },
577
+ expand(directory: string) {
578
+ server.projects.expand(directory)
579
+ },
580
+ collapse(directory: string) {
581
+ server.projects.collapse(directory)
582
+ },
583
+ move(directory: string, toIndex: number) {
584
+ server.projects.move(directory, toIndex)
585
+ },
586
+ },
587
+ sidebar: {
588
+ opened: createMemo(() => store.sidebar.opened),
589
+ open() {
590
+ setStore("sidebar", "opened", true)
591
+ },
592
+ close() {
593
+ setStore("sidebar", "opened", false)
594
+ },
595
+ toggle() {
596
+ setStore("sidebar", "opened", (x) => !x)
597
+ },
598
+ width: createMemo(() => store.sidebar.width),
599
+ resize(width: number) {
600
+ setStore("sidebar", "width", width)
601
+ },
602
+ workspaces(directory: string) {
603
+ return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
604
+ },
605
+ setWorkspaces(directory: string, value: boolean) {
606
+ setStore("sidebar", "workspaces", directory, value)
607
+ },
608
+ toggleWorkspaces(directory: string) {
609
+ const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
610
+ setStore("sidebar", "workspaces", directory, !current)
611
+ },
612
+ },
613
+ terminal: {
614
+ height: createMemo(() => store.terminal.height),
615
+ resize(height: number) {
616
+ setStore("terminal", "height", height)
617
+ },
618
+ },
619
+ review: {
620
+ diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
621
+ setDiffStyle(diffStyle: ReviewDiffStyle) {
622
+ if (!store.review) {
623
+ setStore("review", { diffStyle, panelOpened: true })
624
+ return
625
+ }
626
+ setStore("review", "diffStyle", diffStyle)
627
+ },
628
+ },
629
+ fileTree: {
630
+ opened: createMemo(() => store.fileTree?.opened ?? true),
631
+ width: createMemo(() => store.fileTree?.width ?? DEFAULT_PANEL_WIDTH),
632
+ tab: createMemo(() => store.fileTree?.tab ?? "changes"),
633
+ setTab(tab: "changes" | "all") {
634
+ if (!store.fileTree) {
635
+ setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab })
636
+ return
637
+ }
638
+ setStore("fileTree", "tab", tab)
639
+ },
640
+ open() {
641
+ if (!store.fileTree) {
642
+ setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
643
+ return
644
+ }
645
+ setStore("fileTree", "opened", true)
646
+ },
647
+ close() {
648
+ if (!store.fileTree) {
649
+ setStore("fileTree", { opened: false, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
650
+ return
651
+ }
652
+ setStore("fileTree", "opened", false)
653
+ },
654
+ toggle() {
655
+ if (!store.fileTree) {
656
+ setStore("fileTree", { opened: true, width: DEFAULT_PANEL_WIDTH, tab: "changes" })
657
+ return
658
+ }
659
+ setStore("fileTree", "opened", (x) => !x)
660
+ },
661
+ resize(width: number) {
662
+ if (!store.fileTree) {
663
+ setStore("fileTree", { opened: true, width, tab: "changes" })
664
+ return
665
+ }
666
+ setStore("fileTree", "width", width)
667
+ },
668
+ },
669
+ session: {
670
+ width: createMemo(() => store.session?.width ?? DEFAULT_SESSION_WIDTH),
671
+ resize(width: number) {
672
+ if (!store.session) {
673
+ setStore("session", { width })
674
+ return
675
+ }
676
+ setStore("session", "width", width)
677
+ },
678
+ },
679
+ mobileSidebar: {
680
+ opened: createMemo(() => store.mobileSidebar?.opened ?? false),
681
+ show() {
682
+ setStore("mobileSidebar", "opened", true)
683
+ },
684
+ hide() {
685
+ setStore("mobileSidebar", "opened", false)
686
+ },
687
+ toggle() {
688
+ setStore("mobileSidebar", "opened", (x) => !x)
689
+ },
690
+ },
691
+ pendingMessage: {
692
+ set(sessionKey: string, messageID: string) {
693
+ const at = Date.now()
694
+ touch(sessionKey)
695
+ const current = store.sessionView[sessionKey]
696
+ if (!current) {
697
+ setStore("sessionView", sessionKey, {
698
+ scroll: {},
699
+ pendingMessage: messageID,
700
+ pendingMessageAt: at,
701
+ })
702
+ prune(usage.active ?? sessionKey)
703
+ return
704
+ }
705
+
706
+ setStore(
707
+ "sessionView",
708
+ sessionKey,
709
+ produce((draft) => {
710
+ draft.pendingMessage = messageID
711
+ draft.pendingMessageAt = at
712
+ }),
713
+ )
714
+ },
715
+ consume(sessionKey: string) {
716
+ const current = store.sessionView[sessionKey]
717
+ const message = current?.pendingMessage
718
+ const at = current?.pendingMessageAt
719
+ if (!message || !at) return
720
+
721
+ setStore(
722
+ "sessionView",
723
+ sessionKey,
724
+ produce((draft) => {
725
+ delete draft.pendingMessage
726
+ delete draft.pendingMessageAt
727
+ }),
728
+ )
729
+
730
+ if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
731
+ return message
732
+ },
733
+ },
734
+ view(sessionKey: string | Accessor<string>) {
735
+ const key = createSessionKeyReader(sessionKey, ensureKey)
736
+ const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
737
+ const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
738
+ const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
739
+
740
+ function setTerminalOpened(next: boolean) {
741
+ const current = store.terminal
742
+ if (!current) {
743
+ setStore("terminal", { height: DEFAULT_TERMINAL_HEIGHT, opened: next })
744
+ return
745
+ }
746
+
747
+ const value = current.opened ?? false
748
+ if (value === next) return
749
+ setStore("terminal", "opened", next)
750
+ }
751
+
752
+ function setReviewPanelOpened(next: boolean) {
753
+ const current = store.review
754
+ if (!current) {
755
+ setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
756
+ return
757
+ }
758
+
759
+ const value = current.panelOpened ?? true
760
+ if (value === next) return
761
+ setStore("review", "panelOpened", next)
762
+ }
763
+
764
+ return {
765
+ scroll(tab: string) {
766
+ return scroll.scroll(key(), tab)
767
+ },
768
+ setScroll(tab: string, pos: SessionScroll) {
769
+ scroll.setScroll(key(), tab, pos)
770
+ },
771
+ terminal: {
772
+ opened: terminalOpened,
773
+ open() {
774
+ setTerminalOpened(true)
775
+ },
776
+ close() {
777
+ setTerminalOpened(false)
778
+ },
779
+ toggle() {
780
+ setTerminalOpened(!terminalOpened())
781
+ },
782
+ },
783
+ reviewPanel: {
784
+ opened: reviewPanelOpened,
785
+ open() {
786
+ setReviewPanelOpened(true)
787
+ },
788
+ close() {
789
+ setReviewPanelOpened(false)
790
+ },
791
+ toggle() {
792
+ setReviewPanelOpened(!reviewPanelOpened())
793
+ },
794
+ },
795
+ review: {
796
+ open: createMemo(() => s().reviewOpen ?? []),
797
+ setOpen(open: string[]) {
798
+ const session = key()
799
+ const next = Array.from(new Set(open))
800
+ const current = store.sessionView[session]
801
+ if (!current) {
802
+ setStore("sessionView", session, {
803
+ scroll: {},
804
+ reviewOpen: next,
805
+ })
806
+ return
807
+ }
808
+
809
+ if (same(current.reviewOpen, next)) return
810
+ setStore("sessionView", session, "reviewOpen", next)
811
+ },
812
+ openPath(path: string) {
813
+ const session = key()
814
+ const current = store.sessionView[session]
815
+ if (!current) {
816
+ setStore("sessionView", session, {
817
+ scroll: {},
818
+ reviewOpen: [path],
819
+ })
820
+ return
821
+ }
822
+
823
+ if (!current.reviewOpen) {
824
+ setStore("sessionView", session, "reviewOpen", [path])
825
+ return
826
+ }
827
+
828
+ if (current.reviewOpen.includes(path)) return
829
+ setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path)
830
+ },
831
+ closePath(path: string) {
832
+ const session = key()
833
+ const current = store.sessionView[session]?.reviewOpen
834
+ if (!current) return
835
+
836
+ const index = current.indexOf(path)
837
+ if (index === -1) return
838
+ setStore(
839
+ "sessionView",
840
+ session,
841
+ "reviewOpen",
842
+ produce((draft) => {
843
+ if (!draft) return
844
+ draft.splice(index, 1)
845
+ }),
846
+ )
847
+ },
848
+ togglePath(path: string) {
849
+ const session = key()
850
+ const current = store.sessionView[session]?.reviewOpen
851
+ if (!current || !current.includes(path)) {
852
+ this.openPath(path)
853
+ return
854
+ }
855
+
856
+ this.closePath(path)
857
+ },
858
+ },
859
+ }
860
+ },
861
+ tabs(sessionKey: string | Accessor<string>) {
862
+ const key = createSessionKeyReader(sessionKey, ensureKey)
863
+ const path = createMemo(() => sessionPath(key()))
864
+ const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
865
+ const normalize = (tab: string) => normalizeSessionTab(path(), tab)
866
+ const normalizeAll = (all: string[]) => normalizeSessionTabList(path(), all)
867
+ return {
868
+ tabs,
869
+ active: createMemo(() => tabs().active),
870
+ all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
871
+ setActive(tab: string | undefined) {
872
+ const session = key()
873
+ const next = tab ? normalize(tab) : tab
874
+ if (!store.sessionTabs[session]) {
875
+ setStore("sessionTabs", session, { all: [], active: next })
876
+ } else {
877
+ setStore("sessionTabs", session, "active", next)
878
+ }
879
+ },
880
+ setAll(all: string[]) {
881
+ const session = key()
882
+ const next = normalizeAll(all).filter((tab) => tab !== "review")
883
+ if (!store.sessionTabs[session]) {
884
+ setStore("sessionTabs", session, { all: next, active: undefined })
885
+ } else {
886
+ setStore("sessionTabs", session, "all", next)
887
+ }
888
+ },
889
+ async open(tab: string) {
890
+ const session = key()
891
+ const next = nextSessionTabsForOpen(store.sessionTabs[session], normalize(tab))
892
+ setStore("sessionTabs", session, next)
893
+ },
894
+ close(tab: string) {
895
+ const session = key()
896
+ const current = store.sessionTabs[session]
897
+ if (!current) return
898
+
899
+ if (tab === "review") {
900
+ if (current.active !== tab) return
901
+ setStore("sessionTabs", session, "active", current.all[0])
902
+ return
903
+ }
904
+
905
+ const all = current.all.filter((x) => x !== tab)
906
+ if (current.active !== tab) {
907
+ setStore("sessionTabs", session, "all", all)
908
+ return
909
+ }
910
+
911
+ const index = current.all.findIndex((f) => f === tab)
912
+ const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
913
+ batch(() => {
914
+ setStore("sessionTabs", session, "all", all)
915
+ setStore("sessionTabs", session, "active", next)
916
+ })
917
+ },
918
+ move(tab: string, to: number) {
919
+ const session = key()
920
+ const current = store.sessionTabs[session]
921
+ if (!current) return
922
+ const index = current.all.findIndex((f) => f === tab)
923
+ if (index === -1) return
924
+ setStore(
925
+ "sessionTabs",
926
+ session,
927
+ "all",
928
+ produce((opened) => {
929
+ opened.splice(to, 0, opened.splice(index, 1)[0])
930
+ }),
931
+ )
932
+ },
933
+ }
934
+ },
935
+ }
936
+ },
937
+ })