@jonsoc/app 1.1.34

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 (139) hide show
  1. package/AGENTS.md +30 -0
  2. package/README.md +51 -0
  3. package/bunfig.toml +2 -0
  4. package/e2e/context.spec.ts +45 -0
  5. package/e2e/file-open.spec.ts +23 -0
  6. package/e2e/file-viewer.spec.ts +35 -0
  7. package/e2e/fixtures.ts +40 -0
  8. package/e2e/home.spec.ts +21 -0
  9. package/e2e/model-picker.spec.ts +43 -0
  10. package/e2e/navigation.spec.ts +9 -0
  11. package/e2e/palette.spec.ts +15 -0
  12. package/e2e/prompt-mention.spec.ts +26 -0
  13. package/e2e/prompt-slash-open.spec.ts +22 -0
  14. package/e2e/prompt.spec.ts +62 -0
  15. package/e2e/session.spec.ts +21 -0
  16. package/e2e/settings.spec.ts +44 -0
  17. package/e2e/sidebar.spec.ts +21 -0
  18. package/e2e/terminal-init.spec.ts +25 -0
  19. package/e2e/terminal.spec.ts +16 -0
  20. package/e2e/tsconfig.json +8 -0
  21. package/e2e/utils.ts +38 -0
  22. package/happydom.ts +75 -0
  23. package/index.html +23 -0
  24. package/package.json +72 -0
  25. package/playwright.config.ts +43 -0
  26. package/public/_headers +17 -0
  27. package/public/apple-touch-icon-v3.png +1 -0
  28. package/public/apple-touch-icon.png +1 -0
  29. package/public/favicon-96x96-v3.png +1 -0
  30. package/public/favicon-96x96.png +1 -0
  31. package/public/favicon-v3.ico +1 -0
  32. package/public/favicon-v3.svg +1 -0
  33. package/public/favicon.ico +1 -0
  34. package/public/favicon.svg +1 -0
  35. package/public/oc-theme-preload.js +28 -0
  36. package/public/site.webmanifest +1 -0
  37. package/public/social-share-zen.png +1 -0
  38. package/public/social-share.png +1 -0
  39. package/public/web-app-manifest-192x192.png +1 -0
  40. package/public/web-app-manifest-512x512.png +1 -0
  41. package/script/e2e-local.ts +143 -0
  42. package/src/addons/serialize.test.ts +319 -0
  43. package/src/addons/serialize.ts +591 -0
  44. package/src/app.tsx +150 -0
  45. package/src/components/dialog-connect-provider.tsx +428 -0
  46. package/src/components/dialog-edit-project.tsx +259 -0
  47. package/src/components/dialog-fork.tsx +104 -0
  48. package/src/components/dialog-manage-models.tsx +59 -0
  49. package/src/components/dialog-select-directory.tsx +208 -0
  50. package/src/components/dialog-select-file.tsx +196 -0
  51. package/src/components/dialog-select-mcp.tsx +96 -0
  52. package/src/components/dialog-select-model-unpaid.tsx +130 -0
  53. package/src/components/dialog-select-model.tsx +162 -0
  54. package/src/components/dialog-select-provider.tsx +70 -0
  55. package/src/components/dialog-select-server.tsx +249 -0
  56. package/src/components/dialog-settings.tsx +112 -0
  57. package/src/components/file-tree.tsx +112 -0
  58. package/src/components/link.tsx +17 -0
  59. package/src/components/model-tooltip.tsx +91 -0
  60. package/src/components/prompt-input.tsx +2076 -0
  61. package/src/components/session/index.ts +5 -0
  62. package/src/components/session/session-context-tab.tsx +428 -0
  63. package/src/components/session/session-header.tsx +343 -0
  64. package/src/components/session/session-new-view.tsx +93 -0
  65. package/src/components/session/session-sortable-tab.tsx +56 -0
  66. package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
  67. package/src/components/session-context-usage.tsx +113 -0
  68. package/src/components/session-lsp-indicator.tsx +42 -0
  69. package/src/components/session-mcp-indicator.tsx +34 -0
  70. package/src/components/settings-agents.tsx +15 -0
  71. package/src/components/settings-commands.tsx +15 -0
  72. package/src/components/settings-general.tsx +306 -0
  73. package/src/components/settings-keybinds.tsx +437 -0
  74. package/src/components/settings-mcp.tsx +15 -0
  75. package/src/components/settings-models.tsx +15 -0
  76. package/src/components/settings-permissions.tsx +234 -0
  77. package/src/components/settings-providers.tsx +15 -0
  78. package/src/components/terminal.tsx +315 -0
  79. package/src/components/titlebar.tsx +156 -0
  80. package/src/context/command.tsx +308 -0
  81. package/src/context/comments.tsx +140 -0
  82. package/src/context/file.tsx +409 -0
  83. package/src/context/global-sdk.tsx +106 -0
  84. package/src/context/global-sync.tsx +898 -0
  85. package/src/context/language.tsx +161 -0
  86. package/src/context/layout-scroll.test.ts +73 -0
  87. package/src/context/layout-scroll.ts +118 -0
  88. package/src/context/layout.tsx +648 -0
  89. package/src/context/local.tsx +578 -0
  90. package/src/context/notification.tsx +173 -0
  91. package/src/context/permission.tsx +167 -0
  92. package/src/context/platform.tsx +59 -0
  93. package/src/context/prompt.tsx +245 -0
  94. package/src/context/sdk.tsx +48 -0
  95. package/src/context/server.tsx +214 -0
  96. package/src/context/settings.tsx +166 -0
  97. package/src/context/sync.tsx +320 -0
  98. package/src/context/terminal.tsx +267 -0
  99. package/src/custom-elements.d.ts +17 -0
  100. package/src/entry.tsx +76 -0
  101. package/src/env.d.ts +8 -0
  102. package/src/hooks/use-providers.ts +31 -0
  103. package/src/i18n/ar.ts +656 -0
  104. package/src/i18n/br.ts +667 -0
  105. package/src/i18n/da.ts +582 -0
  106. package/src/i18n/de.ts +591 -0
  107. package/src/i18n/en.ts +665 -0
  108. package/src/i18n/es.ts +585 -0
  109. package/src/i18n/fr.ts +592 -0
  110. package/src/i18n/ja.ts +579 -0
  111. package/src/i18n/ko.ts +580 -0
  112. package/src/i18n/no.ts +602 -0
  113. package/src/i18n/pl.ts +661 -0
  114. package/src/i18n/ru.ts +664 -0
  115. package/src/i18n/zh.ts +574 -0
  116. package/src/i18n/zht.ts +570 -0
  117. package/src/index.css +57 -0
  118. package/src/index.ts +2 -0
  119. package/src/pages/directory-layout.tsx +57 -0
  120. package/src/pages/error.tsx +290 -0
  121. package/src/pages/home.tsx +125 -0
  122. package/src/pages/layout.tsx +2599 -0
  123. package/src/pages/session.tsx +2505 -0
  124. package/src/sst-env.d.ts +10 -0
  125. package/src/utils/dom.ts +51 -0
  126. package/src/utils/id.ts +99 -0
  127. package/src/utils/index.ts +1 -0
  128. package/src/utils/perf.ts +135 -0
  129. package/src/utils/persist.ts +377 -0
  130. package/src/utils/prompt.ts +203 -0
  131. package/src/utils/same.ts +6 -0
  132. package/src/utils/solid-dnd.tsx +55 -0
  133. package/src/utils/sound.ts +110 -0
  134. package/src/utils/speech.ts +302 -0
  135. package/src/utils/worktree.ts +58 -0
  136. package/sst-env.d.ts +9 -0
  137. package/tsconfig.json +26 -0
  138. package/vite.config.ts +15 -0
  139. package/vite.js +26 -0
@@ -0,0 +1,648 @@
1
+ import { createStore, produce } from "solid-js/store"
2
+ import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
3
+ import { createSimpleContext } from "@jonsoc/ui/context"
4
+ import { useGlobalSync } from "./global-sync"
5
+ import { useGlobalSDK } from "./global-sdk"
6
+ import { useServer } from "./server"
7
+ import { Project } from "@jonsoc/sdk/v2"
8
+ import { Persist, persisted, removePersisted } from "@/utils/persist"
9
+ import { same } from "@/utils/same"
10
+ import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
11
+
12
+ const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
13
+ export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
14
+
15
+ export function getAvatarColors(key?: string) {
16
+ if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) {
17
+ return {
18
+ background: `var(--avatar-background-${key})`,
19
+ foreground: `var(--avatar-text-${key})`,
20
+ }
21
+ }
22
+ return {
23
+ background: "var(--surface-info-base)",
24
+ foreground: "var(--text-base)",
25
+ }
26
+ }
27
+
28
+ type SessionTabs = {
29
+ active?: string
30
+ all: string[]
31
+ }
32
+
33
+ type SessionView = {
34
+ scroll: Record<string, SessionScroll>
35
+ reviewOpen?: string[]
36
+ }
37
+
38
+ export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
39
+
40
+ export type ReviewDiffStyle = "unified" | "split"
41
+
42
+ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
43
+ name: "Layout",
44
+ init: () => {
45
+ const globalSdk = useGlobalSDK()
46
+ const globalSync = useGlobalSync()
47
+ const server = useServer()
48
+
49
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
50
+ typeof value === "object" && value !== null && !Array.isArray(value)
51
+
52
+ const migrate = (value: unknown) => {
53
+ if (!isRecord(value)) return value
54
+ const sidebar = value.sidebar
55
+ if (!isRecord(sidebar)) return value
56
+ if (typeof sidebar.workspaces !== "boolean") return value
57
+ return {
58
+ ...value,
59
+ sidebar: {
60
+ ...sidebar,
61
+ workspaces: {},
62
+ workspacesDefault: sidebar.workspaces,
63
+ },
64
+ }
65
+ }
66
+
67
+ const target = Persist.global("layout", ["layout.v6"])
68
+ const [store, setStore, _, ready] = persisted(
69
+ { ...target, migrate },
70
+ createStore({
71
+ sidebar: {
72
+ opened: false,
73
+ width: 344,
74
+ workspaces: {} as Record<string, boolean>,
75
+ workspacesDefault: false,
76
+ },
77
+ terminal: {
78
+ height: 280,
79
+ opened: false,
80
+ },
81
+ review: {
82
+ diffStyle: "split" as ReviewDiffStyle,
83
+ panelOpened: true,
84
+ },
85
+ session: {
86
+ width: 600,
87
+ },
88
+ mobileSidebar: {
89
+ opened: false,
90
+ },
91
+ sessionTabs: {} as Record<string, SessionTabs>,
92
+ sessionView: {} as Record<string, SessionView>,
93
+ }),
94
+ )
95
+
96
+ const MAX_SESSION_KEYS = 50
97
+ const meta = { active: undefined as string | undefined, pruned: false }
98
+ const used = new Map<string, number>()
99
+
100
+ const SESSION_STATE_KEYS = [
101
+ { key: "prompt", legacy: "prompt", version: "v2" },
102
+ { key: "terminal", legacy: "terminal", version: "v1" },
103
+ { key: "file-view", legacy: "file", version: "v1" },
104
+ ] as const
105
+
106
+ const dropSessionState = (keys: string[]) => {
107
+ for (const key of keys) {
108
+ const parts = key.split("/")
109
+ const dir = parts[0]
110
+ const session = parts[1]
111
+ if (!dir) continue
112
+
113
+ for (const entry of SESSION_STATE_KEYS) {
114
+ const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
115
+ void removePersisted(target)
116
+
117
+ const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
118
+ void removePersisted({ key: legacyKey })
119
+ }
120
+ }
121
+ }
122
+
123
+ function prune(keep?: string) {
124
+ if (!keep) return
125
+
126
+ const keys = new Set<string>()
127
+ for (const key of Object.keys(store.sessionView)) keys.add(key)
128
+ for (const key of Object.keys(store.sessionTabs)) keys.add(key)
129
+ if (keys.size <= MAX_SESSION_KEYS) return
130
+
131
+ const score = (key: string) => {
132
+ if (key === keep) return Number.MAX_SAFE_INTEGER
133
+ return used.get(key) ?? 0
134
+ }
135
+
136
+ const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
137
+ const drop = ordered.slice(MAX_SESSION_KEYS)
138
+ if (drop.length === 0) return
139
+
140
+ setStore(
141
+ produce((draft) => {
142
+ for (const key of drop) {
143
+ delete draft.sessionView[key]
144
+ delete draft.sessionTabs[key]
145
+ }
146
+ }),
147
+ )
148
+
149
+ scroll.drop(drop)
150
+ dropSessionState(drop)
151
+
152
+ for (const key of drop) {
153
+ used.delete(key)
154
+ }
155
+ }
156
+
157
+ function touch(sessionKey: string) {
158
+ meta.active = sessionKey
159
+ used.set(sessionKey, Date.now())
160
+
161
+ if (!ready()) return
162
+ if (meta.pruned) return
163
+
164
+ meta.pruned = true
165
+ prune(sessionKey)
166
+ }
167
+
168
+ const scroll = createScrollPersistence({
169
+ debounceMs: 250,
170
+ getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
171
+ onFlush: (sessionKey, next) => {
172
+ const current = store.sessionView[sessionKey]
173
+ const keep = meta.active ?? sessionKey
174
+ if (!current) {
175
+ setStore("sessionView", sessionKey, { scroll: next })
176
+ prune(keep)
177
+ return
178
+ }
179
+
180
+ setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
181
+ prune(keep)
182
+ },
183
+ })
184
+
185
+ createEffect(() => {
186
+ if (!ready()) return
187
+ if (meta.pruned) return
188
+ const active = meta.active
189
+ if (!active) return
190
+ meta.pruned = true
191
+ prune(active)
192
+ })
193
+
194
+ onMount(() => {
195
+ const flush = () => batch(() => scroll.flushAll())
196
+ const handleVisibility = () => {
197
+ if (document.visibilityState !== "hidden") return
198
+ flush()
199
+ }
200
+
201
+ window.addEventListener("pagehide", flush)
202
+ document.addEventListener("visibilitychange", handleVisibility)
203
+
204
+ onCleanup(() => {
205
+ window.removeEventListener("pagehide", flush)
206
+ document.removeEventListener("visibilitychange", handleVisibility)
207
+ scroll.dispose()
208
+ })
209
+ })
210
+
211
+ const [colors, setColors] = createStore<Record<string, AvatarColorKey>>({})
212
+
213
+ function pickAvailableColor(used: Set<string>): AvatarColorKey {
214
+ const available = AVATAR_COLOR_KEYS.filter((c) => !used.has(c))
215
+ if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)]
216
+ return available[Math.floor(Math.random() * available.length)]
217
+ }
218
+
219
+ function enrich(project: { worktree: string; expanded: boolean }) {
220
+ const [childStore] = globalSync.child(project.worktree)
221
+ const projectID = childStore.project
222
+ const metadata = projectID
223
+ ? globalSync.data.project.find((x) => x.id === projectID)
224
+ : globalSync.data.project.find((x) => x.worktree === project.worktree)
225
+
226
+ const local = childStore.projectMeta
227
+ const localOverride =
228
+ local?.name !== undefined ||
229
+ local?.commands?.start !== undefined ||
230
+ local?.icon?.override !== undefined ||
231
+ local?.icon?.color !== undefined
232
+
233
+ const base = {
234
+ ...(metadata ?? {}),
235
+ ...project,
236
+ icon: {
237
+ url: metadata?.icon?.url,
238
+ override: metadata?.icon?.override ?? childStore.icon,
239
+ color: metadata?.icon?.color,
240
+ },
241
+ }
242
+
243
+ const isGlobal = projectID === "global" || (metadata?.id === undefined && localOverride)
244
+ if (!isGlobal) return base
245
+
246
+ return {
247
+ ...base,
248
+ id: base.id ?? "global",
249
+ name: local?.name,
250
+ commands: local?.commands,
251
+ icon: {
252
+ url: base.icon?.url,
253
+ override: local?.icon?.override,
254
+ color: local?.icon?.color,
255
+ },
256
+ }
257
+ }
258
+
259
+ const roots = createMemo(() => {
260
+ const map = new Map<string, string>()
261
+ for (const project of globalSync.data.project) {
262
+ const sandboxes = project.sandboxes ?? []
263
+ for (const sandbox of sandboxes) {
264
+ map.set(sandbox, project.worktree)
265
+ }
266
+ }
267
+ return map
268
+ })
269
+
270
+ createEffect(() => {
271
+ const map = roots()
272
+ if (map.size === 0) return
273
+
274
+ const projects = server.projects.list()
275
+ const seen = new Set(projects.map((project) => project.worktree))
276
+
277
+ batch(() => {
278
+ for (const project of projects) {
279
+ const root = map.get(project.worktree)
280
+ if (!root) continue
281
+
282
+ server.projects.close(project.worktree)
283
+
284
+ if (!seen.has(root)) {
285
+ server.projects.open(root)
286
+ seen.add(root)
287
+ }
288
+
289
+ if (project.expanded) server.projects.expand(root)
290
+ }
291
+ })
292
+ })
293
+
294
+ const enriched = createMemo(() => server.projects.list().map(enrich))
295
+ const list = createMemo(() => {
296
+ const projects = enriched()
297
+ return projects.map((project) => {
298
+ const color = project.icon?.color ?? colors[project.worktree]
299
+ if (!color) return project
300
+ const icon = project.icon ? { ...project.icon, color } : { color }
301
+ return { ...project, icon }
302
+ })
303
+ })
304
+
305
+ createEffect(() => {
306
+ const projects = enriched()
307
+ if (projects.length === 0) return
308
+
309
+ if (globalSync.ready) {
310
+ for (const project of projects) {
311
+ if (!project.id) continue
312
+ if (project.id === "global") continue
313
+ globalSync.project.icon(project.worktree, project.icon?.override)
314
+ }
315
+ }
316
+
317
+ const used = new Set<string>()
318
+ for (const project of projects) {
319
+ const color = project.icon?.color ?? colors[project.worktree]
320
+ if (color) used.add(color)
321
+ }
322
+
323
+ for (const project of projects) {
324
+ if (project.icon?.color) continue
325
+ const existing = colors[project.worktree]
326
+ const color = existing ?? pickAvailableColor(used)
327
+ if (!existing) {
328
+ used.add(color)
329
+ setColors(project.worktree, color)
330
+ }
331
+ if (!project.id) continue
332
+ if (project.id === "global") {
333
+ globalSync.project.meta(project.worktree, { icon: { color } })
334
+ continue
335
+ }
336
+ void globalSdk.client.project.update({ projectID: project.id, directory: project.worktree, icon: { color } })
337
+ }
338
+ })
339
+
340
+ onMount(() => {
341
+ Promise.all(
342
+ server.projects.list().map((project) => {
343
+ return globalSync.project.loadSessions(project.worktree)
344
+ }),
345
+ )
346
+ })
347
+
348
+ return {
349
+ ready,
350
+ projects: {
351
+ list,
352
+ open(directory: string) {
353
+ const root = roots().get(directory) ?? directory
354
+ if (server.projects.list().find((x) => x.worktree === root)) return
355
+ globalSync.project.loadSessions(root)
356
+ server.projects.open(root)
357
+ },
358
+ close(directory: string) {
359
+ server.projects.close(directory)
360
+ },
361
+ expand(directory: string) {
362
+ server.projects.expand(directory)
363
+ },
364
+ collapse(directory: string) {
365
+ server.projects.collapse(directory)
366
+ },
367
+ move(directory: string, toIndex: number) {
368
+ server.projects.move(directory, toIndex)
369
+ },
370
+ },
371
+ sidebar: {
372
+ opened: createMemo(() => store.sidebar.opened),
373
+ open() {
374
+ setStore("sidebar", "opened", true)
375
+ },
376
+ close() {
377
+ setStore("sidebar", "opened", false)
378
+ },
379
+ toggle() {
380
+ setStore("sidebar", "opened", (x) => !x)
381
+ },
382
+ width: createMemo(() => store.sidebar.width),
383
+ resize(width: number) {
384
+ setStore("sidebar", "width", width)
385
+ },
386
+ workspaces(directory: string) {
387
+ return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
388
+ },
389
+ setWorkspaces(directory: string, value: boolean) {
390
+ setStore("sidebar", "workspaces", directory, value)
391
+ },
392
+ toggleWorkspaces(directory: string) {
393
+ const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
394
+ setStore("sidebar", "workspaces", directory, !current)
395
+ },
396
+ },
397
+ terminal: {
398
+ height: createMemo(() => store.terminal.height),
399
+ resize(height: number) {
400
+ setStore("terminal", "height", height)
401
+ },
402
+ },
403
+ review: {
404
+ diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
405
+ setDiffStyle(diffStyle: ReviewDiffStyle) {
406
+ if (!store.review) {
407
+ setStore("review", { diffStyle })
408
+ return
409
+ }
410
+ setStore("review", "diffStyle", diffStyle)
411
+ },
412
+ panelOpened: createMemo(() => store.review?.panelOpened ?? true),
413
+ setPanelOpened(opened: boolean) {
414
+ if (!store.review) {
415
+ setStore("review", { panelOpened: opened })
416
+ return
417
+ }
418
+ setStore("review", "panelOpened", opened)
419
+ },
420
+ },
421
+ session: {
422
+ width: createMemo(() => store.session?.width ?? 600),
423
+ resize(width: number) {
424
+ if (!store.session) {
425
+ setStore("session", { width })
426
+ return
427
+ }
428
+ setStore("session", "width", width)
429
+ },
430
+ },
431
+ mobileSidebar: {
432
+ opened: createMemo(() => store.mobileSidebar?.opened ?? false),
433
+ show() {
434
+ setStore("mobileSidebar", "opened", true)
435
+ },
436
+ hide() {
437
+ setStore("mobileSidebar", "opened", false)
438
+ },
439
+ toggle() {
440
+ setStore("mobileSidebar", "opened", (x) => !x)
441
+ },
442
+ },
443
+ view(sessionKey: string | Accessor<string>) {
444
+ const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
445
+
446
+ touch(key())
447
+ scroll.seed(key())
448
+
449
+ createEffect(
450
+ on(
451
+ key,
452
+ (value) => {
453
+ touch(value)
454
+ scroll.seed(value)
455
+ },
456
+ { defer: true },
457
+ ),
458
+ )
459
+
460
+ const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
461
+ const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
462
+ const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
463
+
464
+ function setTerminalOpened(next: boolean) {
465
+ const current = store.terminal
466
+ if (!current) {
467
+ setStore("terminal", { height: 280, opened: next })
468
+ return
469
+ }
470
+
471
+ const value = current.opened ?? false
472
+ if (value === next) return
473
+ setStore("terminal", "opened", next)
474
+ }
475
+
476
+ function setReviewPanelOpened(next: boolean) {
477
+ const current = store.review
478
+ if (!current) {
479
+ setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
480
+ return
481
+ }
482
+
483
+ const value = current.panelOpened ?? true
484
+ if (value === next) return
485
+ setStore("review", "panelOpened", next)
486
+ }
487
+
488
+ return {
489
+ scroll(tab: string) {
490
+ return scroll.scroll(key(), tab)
491
+ },
492
+ setScroll(tab: string, pos: SessionScroll) {
493
+ scroll.setScroll(key(), tab, pos)
494
+ },
495
+ terminal: {
496
+ opened: terminalOpened,
497
+ open() {
498
+ setTerminalOpened(true)
499
+ },
500
+ close() {
501
+ setTerminalOpened(false)
502
+ },
503
+ toggle() {
504
+ setTerminalOpened(!terminalOpened())
505
+ },
506
+ },
507
+ reviewPanel: {
508
+ opened: reviewPanelOpened,
509
+ open() {
510
+ setReviewPanelOpened(true)
511
+ },
512
+ close() {
513
+ setReviewPanelOpened(false)
514
+ },
515
+ toggle() {
516
+ setReviewPanelOpened(!reviewPanelOpened())
517
+ },
518
+ },
519
+ review: {
520
+ open: createMemo(() => s().reviewOpen),
521
+ setOpen(open: string[]) {
522
+ const session = key()
523
+ const current = store.sessionView[session]
524
+ if (!current) {
525
+ setStore("sessionView", session, {
526
+ scroll: {},
527
+ reviewOpen: open,
528
+ })
529
+ return
530
+ }
531
+
532
+ if (same(current.reviewOpen, open)) return
533
+ setStore("sessionView", session, "reviewOpen", open)
534
+ },
535
+ },
536
+ }
537
+ },
538
+ tabs(sessionKey: string | Accessor<string>) {
539
+ const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
540
+
541
+ touch(key())
542
+
543
+ createEffect(
544
+ on(
545
+ key,
546
+ (value) => {
547
+ touch(value)
548
+ },
549
+ { defer: true },
550
+ ),
551
+ )
552
+
553
+ const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
554
+ return {
555
+ tabs,
556
+ active: createMemo(() => tabs().active),
557
+ all: createMemo(() => tabs().all),
558
+ setActive(tab: string | undefined) {
559
+ const session = key()
560
+ if (!store.sessionTabs[session]) {
561
+ setStore("sessionTabs", session, { all: [], active: tab })
562
+ } else {
563
+ setStore("sessionTabs", session, "active", tab)
564
+ }
565
+ },
566
+ setAll(all: string[]) {
567
+ const session = key()
568
+ if (!store.sessionTabs[session]) {
569
+ setStore("sessionTabs", session, { all, active: undefined })
570
+ } else {
571
+ setStore("sessionTabs", session, "all", all)
572
+ }
573
+ },
574
+ async open(tab: string) {
575
+ const session = key()
576
+ const current = store.sessionTabs[session] ?? { all: [] }
577
+
578
+ if (tab === "review") {
579
+ if (!store.sessionTabs[session]) {
580
+ setStore("sessionTabs", session, { all: [], active: tab })
581
+ return
582
+ }
583
+ setStore("sessionTabs", session, "active", tab)
584
+ return
585
+ }
586
+
587
+ if (tab === "context") {
588
+ const all = [tab, ...current.all.filter((x) => x !== tab)]
589
+ if (!store.sessionTabs[session]) {
590
+ setStore("sessionTabs", session, { all, active: tab })
591
+ return
592
+ }
593
+ setStore("sessionTabs", session, "all", all)
594
+ setStore("sessionTabs", session, "active", tab)
595
+ return
596
+ }
597
+
598
+ if (!current.all.includes(tab)) {
599
+ if (!store.sessionTabs[session]) {
600
+ setStore("sessionTabs", session, { all: [tab], active: tab })
601
+ return
602
+ }
603
+ setStore("sessionTabs", session, "all", [...current.all, tab])
604
+ setStore("sessionTabs", session, "active", tab)
605
+ return
606
+ }
607
+
608
+ if (!store.sessionTabs[session]) {
609
+ setStore("sessionTabs", session, { all: current.all, active: tab })
610
+ return
611
+ }
612
+ setStore("sessionTabs", session, "active", tab)
613
+ },
614
+ close(tab: string) {
615
+ const session = key()
616
+ const current = store.sessionTabs[session]
617
+ if (!current) return
618
+
619
+ const all = current.all.filter((x) => x !== tab)
620
+ batch(() => {
621
+ setStore("sessionTabs", session, "all", all)
622
+ if (current.active !== tab) return
623
+
624
+ const index = current.all.findIndex((f) => f === tab)
625
+ const next = all[index - 1] ?? all[0]
626
+ setStore("sessionTabs", session, "active", next)
627
+ })
628
+ },
629
+ move(tab: string, to: number) {
630
+ const session = key()
631
+ const current = store.sessionTabs[session]
632
+ if (!current) return
633
+ const index = current.all.findIndex((f) => f === tab)
634
+ if (index === -1) return
635
+ setStore(
636
+ "sessionTabs",
637
+ session,
638
+ "all",
639
+ produce((opened) => {
640
+ opened.splice(to, 0, opened.splice(index, 1)[0])
641
+ }),
642
+ )
643
+ },
644
+ }
645
+ },
646
+ }
647
+ },
648
+ })