@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,898 @@
1
+ import {
2
+ type Message,
3
+ type Agent,
4
+ type Session,
5
+ type Part,
6
+ type Config,
7
+ type Path,
8
+ type Project,
9
+ type FileDiff,
10
+ type Todo,
11
+ type SessionStatus,
12
+ type ProviderListResponse,
13
+ type ProviderAuthResponse,
14
+ type Command,
15
+ type McpStatus,
16
+ type LspStatus,
17
+ type VcsInfo,
18
+ type PermissionRequest,
19
+ type QuestionRequest,
20
+ createOpencodeClient,
21
+ } from "@jonsoc/sdk/v2/client"
22
+ import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
23
+ import { Binary } from "@jonsoc/util/binary"
24
+ import { retry } from "@jonsoc/util/retry"
25
+ import { useGlobalSDK } from "./global-sdk"
26
+ import { ErrorPage, type InitError } from "../pages/error"
27
+ import {
28
+ batch,
29
+ createContext,
30
+ createEffect,
31
+ untrack,
32
+ getOwner,
33
+ runWithOwner,
34
+ useContext,
35
+ onCleanup,
36
+ onMount,
37
+ type Accessor,
38
+ type ParentProps,
39
+ Switch,
40
+ Match,
41
+ } from "solid-js"
42
+ import { showToast } from "@jonsoc/ui/toast"
43
+ import { getFilename } from "@jonsoc/util/path"
44
+ import { usePlatform } from "./platform"
45
+ import { useLanguage } from "@/context/language"
46
+ import { Persist, persisted } from "@/utils/persist"
47
+
48
+ type ProjectMeta = {
49
+ name?: string
50
+ icon?: {
51
+ override?: string
52
+ color?: string
53
+ }
54
+ commands?: {
55
+ start?: string
56
+ }
57
+ }
58
+
59
+ type State = {
60
+ status: "loading" | "partial" | "complete"
61
+ agent: Agent[]
62
+ command: Command[]
63
+ project: string
64
+ projectMeta: ProjectMeta | undefined
65
+ icon: string | undefined
66
+ provider: ProviderListResponse
67
+ config: Config
68
+ path: Path
69
+ session: Session[]
70
+ sessionTotal: number
71
+ session_status: {
72
+ [sessionID: string]: SessionStatus
73
+ }
74
+ session_diff: {
75
+ [sessionID: string]: FileDiff[]
76
+ }
77
+ todo: {
78
+ [sessionID: string]: Todo[]
79
+ }
80
+ permission: {
81
+ [sessionID: string]: PermissionRequest[]
82
+ }
83
+ question: {
84
+ [sessionID: string]: QuestionRequest[]
85
+ }
86
+ mcp: {
87
+ [name: string]: McpStatus
88
+ }
89
+ lsp: LspStatus[]
90
+ vcs: VcsInfo | undefined
91
+ limit: number
92
+ message: {
93
+ [sessionID: string]: Message[]
94
+ }
95
+ part: {
96
+ [messageID: string]: Part[]
97
+ }
98
+ }
99
+
100
+ type VcsCache = {
101
+ store: Store<{ value: VcsInfo | undefined }>
102
+ setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
103
+ ready: Accessor<boolean>
104
+ }
105
+
106
+ type MetaCache = {
107
+ store: Store<{ value: ProjectMeta | undefined }>
108
+ setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
109
+ ready: Accessor<boolean>
110
+ }
111
+
112
+ type IconCache = {
113
+ store: Store<{ value: string | undefined }>
114
+ setStore: SetStoreFunction<{ value: string | undefined }>
115
+ ready: Accessor<boolean>
116
+ }
117
+
118
+ type ChildOptions = {
119
+ bootstrap?: boolean
120
+ }
121
+
122
+ function createGlobalSync() {
123
+ const globalSDK = useGlobalSDK()
124
+ const platform = usePlatform()
125
+ const language = useLanguage()
126
+ const owner = getOwner()
127
+ if (!owner) throw new Error("GlobalSync must be created within owner")
128
+ const vcsCache = new Map<string, VcsCache>()
129
+ const metaCache = new Map<string, MetaCache>()
130
+ const iconCache = new Map<string, IconCache>()
131
+
132
+ const [projectCache, setProjectCache, , projectCacheReady] = persisted(
133
+ Persist.global("globalSync.project", ["globalSync.project.v1"]),
134
+ createStore({ value: [] as Project[] }),
135
+ )
136
+
137
+ const sanitizeProject = (project: Project) => {
138
+ if (!project.icon?.url && !project.icon?.override) return project
139
+ return {
140
+ ...project,
141
+ icon: {
142
+ ...project.icon,
143
+ url: undefined,
144
+ override: undefined,
145
+ },
146
+ }
147
+ }
148
+ const [globalStore, setGlobalStore] = createStore<{
149
+ ready: boolean
150
+ error?: InitError
151
+ path: Path
152
+ project: Project[]
153
+ provider: ProviderListResponse
154
+ provider_auth: ProviderAuthResponse
155
+ config: Config
156
+ reload: undefined | "pending" | "complete"
157
+ }>({
158
+ ready: false,
159
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
160
+ project: projectCache.value,
161
+ provider: { all: [], connected: [], default: {} },
162
+ provider_auth: {},
163
+ config: {},
164
+ reload: undefined,
165
+ })
166
+ let bootstrapQueue: string[] = []
167
+
168
+ createEffect(() => {
169
+ if (!projectCacheReady()) return
170
+ if (globalStore.project.length !== 0) return
171
+ const cached = projectCache.value
172
+ if (cached.length === 0) return
173
+ setGlobalStore("project", cached)
174
+ })
175
+
176
+ createEffect(() => {
177
+ if (!projectCacheReady()) return
178
+ const projects = globalStore.project
179
+ if (projects.length === 0) {
180
+ const cachedLength = untrack(() => projectCache.value.length)
181
+ if (cachedLength !== 0) return
182
+ }
183
+ setProjectCache("value", projects.map(sanitizeProject))
184
+ })
185
+
186
+ createEffect(async () => {
187
+ if (globalStore.reload !== "complete") return
188
+ if (bootstrapQueue.length) {
189
+ for (const directory of bootstrapQueue) {
190
+ bootstrapInstance(directory)
191
+ }
192
+ bootstrap()
193
+ }
194
+ bootstrapQueue = []
195
+ setGlobalStore("reload", undefined)
196
+ })
197
+
198
+ const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
199
+ const booting = new Map<string, Promise<void>>()
200
+ const sessionLoads = new Map<string, Promise<void>>()
201
+ const sessionMeta = new Map<string, { limit: number }>()
202
+
203
+ function ensureChild(directory: string) {
204
+ if (!directory) console.error("No directory provided")
205
+ if (!children[directory]) {
206
+ const cache = runWithOwner(owner, () =>
207
+ persisted(
208
+ Persist.workspace(directory, "vcs", ["vcs.v1"]),
209
+ createStore({ value: undefined as VcsInfo | undefined }),
210
+ ),
211
+ )
212
+ if (!cache) throw new Error("Failed to create persisted cache")
213
+ vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
214
+
215
+ const meta = runWithOwner(owner, () =>
216
+ persisted(
217
+ Persist.workspace(directory, "project", ["project.v1"]),
218
+ createStore({ value: undefined as ProjectMeta | undefined }),
219
+ ),
220
+ )
221
+ if (!meta) throw new Error("Failed to create persisted project metadata")
222
+ metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
223
+
224
+ const icon = runWithOwner(owner, () =>
225
+ persisted(
226
+ Persist.workspace(directory, "icon", ["icon.v1"]),
227
+ createStore({ value: undefined as string | undefined }),
228
+ ),
229
+ )
230
+ if (!icon) throw new Error("Failed to create persisted project icon")
231
+ iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
232
+
233
+ const init = () => {
234
+ const child = createStore<State>({
235
+ project: "",
236
+ projectMeta: meta[0].value,
237
+ icon: icon[0].value,
238
+ provider: { all: [], connected: [], default: {} },
239
+ config: {},
240
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
241
+ status: "loading" as const,
242
+ agent: [],
243
+ command: [],
244
+ session: [],
245
+ sessionTotal: 0,
246
+ session_status: {},
247
+ session_diff: {},
248
+ todo: {},
249
+ permission: {},
250
+ question: {},
251
+ mcp: {},
252
+ lsp: [],
253
+ vcs: cache[0].value,
254
+ limit: 5,
255
+ message: {},
256
+ part: {},
257
+ })
258
+
259
+ children[directory] = child
260
+
261
+ createEffect(() => {
262
+ child[1]("projectMeta", meta[0].value)
263
+ })
264
+
265
+ createEffect(() => {
266
+ child[1]("icon", icon[0].value)
267
+ })
268
+ }
269
+
270
+ runWithOwner(owner, init)
271
+ }
272
+ const childStore = children[directory]
273
+ if (!childStore) throw new Error("Failed to create store")
274
+ return childStore
275
+ }
276
+
277
+ function child(directory: string, options: ChildOptions = {}) {
278
+ const childStore = ensureChild(directory)
279
+ const shouldBootstrap = options.bootstrap ?? true
280
+ if (shouldBootstrap && childStore[0].status === "loading") {
281
+ void bootstrapInstance(directory)
282
+ }
283
+ return childStore
284
+ }
285
+
286
+ async function loadSessions(directory: string) {
287
+ const pending = sessionLoads.get(directory)
288
+ if (pending) return pending
289
+
290
+ const [store, setStore] = child(directory, { bootstrap: false })
291
+ const meta = sessionMeta.get(directory)
292
+ if (meta && meta.limit >= store.limit) return
293
+
294
+ const promise = globalSDK.client.session
295
+ .list({ directory, roots: true })
296
+ .then((x) => {
297
+ const nonArchived = (x.data ?? [])
298
+ .filter((s) => !!s?.id)
299
+ .filter((s) => !s.time?.archived)
300
+ .slice()
301
+ .sort((a, b) => a.id.localeCompare(b.id))
302
+
303
+ // Read the current limit at resolve-time so callers that bump the limit while
304
+ // a request is in-flight still get the expanded result.
305
+ const limit = store.limit
306
+
307
+ const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
308
+ if (sandboxWorkspace) {
309
+ setStore("sessionTotal", nonArchived.length)
310
+ setStore("session", reconcile(nonArchived, { key: "id" }))
311
+ sessionMeta.set(directory, { limit })
312
+ return
313
+ }
314
+
315
+ const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
316
+ // Include up to the limit, plus any updated in the last 4 hours
317
+ const sessions = nonArchived.filter((s, i) => {
318
+ if (i < limit) return true
319
+ const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
320
+ return updated > fourHoursAgo
321
+ })
322
+ // Store total session count (used for "load more" pagination)
323
+ setStore("sessionTotal", nonArchived.length)
324
+ setStore("session", reconcile(sessions, { key: "id" }))
325
+ sessionMeta.set(directory, { limit })
326
+ })
327
+ .catch((err) => {
328
+ console.error("Failed to load sessions", err)
329
+ const project = getFilename(directory)
330
+ showToast({ title: language.t("toast.session.listFailed.title", { project }), description: err.message })
331
+ })
332
+
333
+ sessionLoads.set(directory, promise)
334
+ promise.finally(() => {
335
+ sessionLoads.delete(directory)
336
+ })
337
+ return promise
338
+ }
339
+
340
+ async function bootstrapInstance(directory: string) {
341
+ if (!directory) return
342
+ const pending = booting.get(directory)
343
+ if (pending) return pending
344
+
345
+ const promise = (async () => {
346
+ const [store, setStore] = ensureChild(directory)
347
+ const cache = vcsCache.get(directory)
348
+ if (!cache) return
349
+ const meta = metaCache.get(directory)
350
+ if (!meta) return
351
+ const sdk = createOpencodeClient({
352
+ baseUrl: globalSDK.url,
353
+ fetch: platform.fetch,
354
+ directory,
355
+ throwOnError: true,
356
+ })
357
+
358
+ setStore("status", "loading")
359
+
360
+ createEffect(() => {
361
+ if (!cache.ready()) return
362
+ const cached = cache.store.value
363
+ if (!cached?.branch) return
364
+ setStore("vcs", (value) => value ?? cached)
365
+ })
366
+
367
+ // projectMeta is synced from persisted storage in ensureChild.
368
+
369
+ const blockingRequests = {
370
+ project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
371
+ provider: () =>
372
+ sdk.provider.list().then((x) => {
373
+ const data = x.data!
374
+ setStore("provider", {
375
+ ...data,
376
+ all: data.all.map((provider) => ({
377
+ ...provider,
378
+ models: Object.fromEntries(
379
+ Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
380
+ ),
381
+ })),
382
+ })
383
+ }),
384
+ agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
385
+ config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
386
+ }
387
+
388
+ try {
389
+ await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
390
+ } catch (err) {
391
+ console.error("Failed to bootstrap instance", err)
392
+ const project = getFilename(directory)
393
+ const message = err instanceof Error ? err.message : String(err)
394
+ showToast({ title: `Failed to reload ${project}`, description: message })
395
+ setStore("status", "partial")
396
+ return
397
+ }
398
+
399
+ if (store.status !== "complete") setStore("status", "partial")
400
+
401
+ Promise.all([
402
+ sdk.path.get().then((x) => setStore("path", x.data!)),
403
+ sdk.command.list().then((x) => setStore("command", x.data ?? [])),
404
+ sdk.session.status().then((x) => setStore("session_status", x.data!)),
405
+ loadSessions(directory),
406
+ sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
407
+ sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
408
+ sdk.vcs.get().then((x) => {
409
+ const next = x.data ?? store.vcs
410
+ setStore("vcs", next)
411
+ if (next?.branch) cache.setStore("value", next)
412
+ }),
413
+ sdk.permission.list().then((x) => {
414
+ const grouped: Record<string, PermissionRequest[]> = {}
415
+ for (const perm of x.data ?? []) {
416
+ if (!perm?.id || !perm.sessionID) continue
417
+ const existing = grouped[perm.sessionID]
418
+ if (existing) {
419
+ existing.push(perm)
420
+ continue
421
+ }
422
+ grouped[perm.sessionID] = [perm]
423
+ }
424
+
425
+ batch(() => {
426
+ for (const sessionID of Object.keys(store.permission)) {
427
+ if (grouped[sessionID]) continue
428
+ setStore("permission", sessionID, [])
429
+ }
430
+ for (const [sessionID, permissions] of Object.entries(grouped)) {
431
+ setStore(
432
+ "permission",
433
+ sessionID,
434
+ reconcile(
435
+ permissions
436
+ .filter((p) => !!p?.id)
437
+ .slice()
438
+ .sort((a, b) => a.id.localeCompare(b.id)),
439
+ { key: "id" },
440
+ ),
441
+ )
442
+ }
443
+ })
444
+ }),
445
+ sdk.question.list().then((x) => {
446
+ const grouped: Record<string, QuestionRequest[]> = {}
447
+ for (const question of x.data ?? []) {
448
+ if (!question?.id || !question.sessionID) continue
449
+ const existing = grouped[question.sessionID]
450
+ if (existing) {
451
+ existing.push(question)
452
+ continue
453
+ }
454
+ grouped[question.sessionID] = [question]
455
+ }
456
+
457
+ batch(() => {
458
+ for (const sessionID of Object.keys(store.question)) {
459
+ if (grouped[sessionID]) continue
460
+ setStore("question", sessionID, [])
461
+ }
462
+ for (const [sessionID, questions] of Object.entries(grouped)) {
463
+ setStore(
464
+ "question",
465
+ sessionID,
466
+ reconcile(
467
+ questions
468
+ .filter((q) => !!q?.id)
469
+ .slice()
470
+ .sort((a, b) => a.id.localeCompare(b.id)),
471
+ { key: "id" },
472
+ ),
473
+ )
474
+ }
475
+ })
476
+ }),
477
+ ]).then(() => {
478
+ setStore("status", "complete")
479
+ })
480
+ })()
481
+
482
+ booting.set(directory, promise)
483
+ promise.finally(() => {
484
+ booting.delete(directory)
485
+ })
486
+ return promise
487
+ }
488
+
489
+ const unsub = globalSDK.event.listen((e) => {
490
+ const directory = e.name
491
+ const event = e.details
492
+
493
+ if (directory === "global") {
494
+ switch (event?.type) {
495
+ case "global.disposed": {
496
+ if (globalStore.reload) return
497
+ bootstrap()
498
+ break
499
+ }
500
+ case "project.updated": {
501
+ const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
502
+ if (result.found) {
503
+ setGlobalStore("project", result.index, reconcile(event.properties))
504
+ return
505
+ }
506
+ setGlobalStore(
507
+ "project",
508
+ produce((draft) => {
509
+ draft.splice(result.index, 0, event.properties)
510
+ }),
511
+ )
512
+ break
513
+ }
514
+ }
515
+ return
516
+ }
517
+
518
+ const existing = children[directory]
519
+ if (!existing) return
520
+
521
+ const [store, setStore] = existing
522
+ switch (event.type) {
523
+ case "server.instance.disposed": {
524
+ if (globalStore.reload) {
525
+ bootstrapQueue.push(directory)
526
+ return
527
+ }
528
+ bootstrapInstance(directory)
529
+ break
530
+ }
531
+ case "session.created": {
532
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
533
+ if (result.found) {
534
+ setStore("session", result.index, reconcile(event.properties.info))
535
+ break
536
+ }
537
+ setStore(
538
+ "session",
539
+ produce((draft) => {
540
+ draft.splice(result.index, 0, event.properties.info)
541
+ }),
542
+ )
543
+ if (!event.properties.info.parentID) {
544
+ setStore("sessionTotal", store.sessionTotal + 1)
545
+ }
546
+ break
547
+ }
548
+ case "session.updated": {
549
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
550
+ if (event.properties.info.time.archived) {
551
+ if (result.found) {
552
+ setStore(
553
+ "session",
554
+ produce((draft) => {
555
+ draft.splice(result.index, 1)
556
+ }),
557
+ )
558
+ }
559
+ if (event.properties.info.parentID) break
560
+ setStore("sessionTotal", (value) => Math.max(0, value - 1))
561
+ break
562
+ }
563
+ if (result.found) {
564
+ setStore("session", result.index, reconcile(event.properties.info))
565
+ break
566
+ }
567
+ setStore(
568
+ "session",
569
+ produce((draft) => {
570
+ draft.splice(result.index, 0, event.properties.info)
571
+ }),
572
+ )
573
+ break
574
+ }
575
+ case "session.deleted": {
576
+ const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
577
+ if (result.found) {
578
+ setStore(
579
+ "session",
580
+ produce((draft) => {
581
+ draft.splice(result.index, 1)
582
+ }),
583
+ )
584
+ }
585
+ if (event.properties.info.parentID) break
586
+ setStore("sessionTotal", (value) => Math.max(0, value - 1))
587
+ break
588
+ }
589
+ case "session.diff":
590
+ setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
591
+ break
592
+ case "todo.updated":
593
+ setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
594
+ break
595
+ case "session.status": {
596
+ setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
597
+ break
598
+ }
599
+ case "message.updated": {
600
+ const messages = store.message[event.properties.info.sessionID]
601
+ if (!messages) {
602
+ setStore("message", event.properties.info.sessionID, [event.properties.info])
603
+ break
604
+ }
605
+ const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
606
+ if (result.found) {
607
+ setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
608
+ break
609
+ }
610
+ setStore(
611
+ "message",
612
+ event.properties.info.sessionID,
613
+ produce((draft) => {
614
+ draft.splice(result.index, 0, event.properties.info)
615
+ }),
616
+ )
617
+ break
618
+ }
619
+ case "message.removed": {
620
+ const messages = store.message[event.properties.sessionID]
621
+ if (!messages) break
622
+ const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
623
+ if (result.found) {
624
+ setStore(
625
+ "message",
626
+ event.properties.sessionID,
627
+ produce((draft) => {
628
+ draft.splice(result.index, 1)
629
+ }),
630
+ )
631
+ }
632
+ break
633
+ }
634
+ case "message.part.updated": {
635
+ const part = event.properties.part
636
+ const parts = store.part[part.messageID]
637
+ if (!parts) {
638
+ setStore("part", part.messageID, [part])
639
+ break
640
+ }
641
+ const result = Binary.search(parts, part.id, (p) => p.id)
642
+ if (result.found) {
643
+ setStore("part", part.messageID, result.index, reconcile(part))
644
+ break
645
+ }
646
+ setStore(
647
+ "part",
648
+ part.messageID,
649
+ produce((draft) => {
650
+ draft.splice(result.index, 0, part)
651
+ }),
652
+ )
653
+ break
654
+ }
655
+ case "message.part.removed": {
656
+ const parts = store.part[event.properties.messageID]
657
+ if (!parts) break
658
+ const result = Binary.search(parts, event.properties.partID, (p) => p.id)
659
+ if (result.found) {
660
+ setStore(
661
+ "part",
662
+ event.properties.messageID,
663
+ produce((draft) => {
664
+ draft.splice(result.index, 1)
665
+ }),
666
+ )
667
+ }
668
+ break
669
+ }
670
+ case "vcs.branch.updated": {
671
+ const next = { branch: event.properties.branch }
672
+ setStore("vcs", next)
673
+ const cache = vcsCache.get(directory)
674
+ if (cache) cache.setStore("value", next)
675
+ break
676
+ }
677
+ case "permission.asked": {
678
+ const sessionID = event.properties.sessionID
679
+ const permissions = store.permission[sessionID]
680
+ if (!permissions) {
681
+ setStore("permission", sessionID, [event.properties])
682
+ break
683
+ }
684
+
685
+ const result = Binary.search(permissions, event.properties.id, (p) => p.id)
686
+ if (result.found) {
687
+ setStore("permission", sessionID, result.index, reconcile(event.properties))
688
+ break
689
+ }
690
+
691
+ setStore(
692
+ "permission",
693
+ sessionID,
694
+ produce((draft) => {
695
+ draft.splice(result.index, 0, event.properties)
696
+ }),
697
+ )
698
+ break
699
+ }
700
+ case "permission.replied": {
701
+ const permissions = store.permission[event.properties.sessionID]
702
+ if (!permissions) break
703
+ const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
704
+ if (!result.found) break
705
+ setStore(
706
+ "permission",
707
+ event.properties.sessionID,
708
+ produce((draft) => {
709
+ draft.splice(result.index, 1)
710
+ }),
711
+ )
712
+ break
713
+ }
714
+ case "question.asked": {
715
+ const sessionID = event.properties.sessionID
716
+ const questions = store.question[sessionID]
717
+ if (!questions) {
718
+ setStore("question", sessionID, [event.properties])
719
+ break
720
+ }
721
+
722
+ const result = Binary.search(questions, event.properties.id, (q) => q.id)
723
+ if (result.found) {
724
+ setStore("question", sessionID, result.index, reconcile(event.properties))
725
+ break
726
+ }
727
+
728
+ setStore(
729
+ "question",
730
+ sessionID,
731
+ produce((draft) => {
732
+ draft.splice(result.index, 0, event.properties)
733
+ }),
734
+ )
735
+ break
736
+ }
737
+ case "question.replied":
738
+ case "question.rejected": {
739
+ const questions = store.question[event.properties.sessionID]
740
+ if (!questions) break
741
+ const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
742
+ if (!result.found) break
743
+ setStore(
744
+ "question",
745
+ event.properties.sessionID,
746
+ produce((draft) => {
747
+ draft.splice(result.index, 1)
748
+ }),
749
+ )
750
+ break
751
+ }
752
+ case "lsp.updated": {
753
+ const sdk = createOpencodeClient({
754
+ baseUrl: globalSDK.url,
755
+ fetch: platform.fetch,
756
+ directory,
757
+ throwOnError: true,
758
+ })
759
+ sdk.lsp.status().then((x) => setStore("lsp", x.data ?? []))
760
+ break
761
+ }
762
+ }
763
+ })
764
+ onCleanup(unsub)
765
+
766
+ async function bootstrap() {
767
+ const health = await globalSDK.client.global
768
+ .health()
769
+ .then((x) => x.data)
770
+ .catch(() => undefined)
771
+ if (!health?.healthy) {
772
+ setGlobalStore("error", new Error(language.t("error.globalSync.connectFailed", { url: globalSDK.url })))
773
+ return
774
+ }
775
+
776
+ return Promise.all([
777
+ retry(() =>
778
+ globalSDK.client.path.get().then((x) => {
779
+ setGlobalStore("path", x.data!)
780
+ }),
781
+ ),
782
+ retry(() =>
783
+ globalSDK.client.config.get().then((x) => {
784
+ setGlobalStore("config", x.data!)
785
+ }),
786
+ ),
787
+ retry(() =>
788
+ globalSDK.client.project.list().then(async (x) => {
789
+ const projects = (x.data ?? [])
790
+ .filter((p) => !!p?.id)
791
+ .filter((p) => !!p.worktree && !p.worktree.includes("jonsoc-test"))
792
+ .slice()
793
+ .sort((a, b) => a.id.localeCompare(b.id))
794
+ setGlobalStore("project", projects)
795
+ }),
796
+ ),
797
+ retry(() =>
798
+ globalSDK.client.provider.list().then((x) => {
799
+ const data = x.data!
800
+ setGlobalStore("provider", {
801
+ ...data,
802
+ all: data.all.map((provider) => ({
803
+ ...provider,
804
+ models: Object.fromEntries(
805
+ Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"),
806
+ ),
807
+ })),
808
+ })
809
+ }),
810
+ ),
811
+ retry(() =>
812
+ globalSDK.client.provider.auth().then((x) => {
813
+ setGlobalStore("provider_auth", x.data ?? {})
814
+ }),
815
+ ),
816
+ ])
817
+ .then(() => setGlobalStore("ready", true))
818
+ .catch((e) => setGlobalStore("error", e))
819
+ }
820
+
821
+ onMount(() => {
822
+ bootstrap()
823
+ })
824
+
825
+ function projectMeta(directory: string, patch: ProjectMeta) {
826
+ const [store, setStore] = ensureChild(directory)
827
+ const cached = metaCache.get(directory)
828
+ if (!cached) return
829
+ const previous = store.projectMeta ?? {}
830
+ const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
831
+ const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
832
+ const next = {
833
+ ...previous,
834
+ ...patch,
835
+ icon,
836
+ commands,
837
+ }
838
+ cached.setStore("value", next)
839
+ setStore("projectMeta", next)
840
+ }
841
+
842
+ function projectIcon(directory: string, value: string | undefined) {
843
+ const [store, setStore] = ensureChild(directory)
844
+ const cached = iconCache.get(directory)
845
+ if (!cached) return
846
+ if (store.icon === value) return
847
+ cached.setStore("value", value)
848
+ setStore("icon", value)
849
+ }
850
+
851
+ return {
852
+ data: globalStore,
853
+ set: setGlobalStore,
854
+ get ready() {
855
+ return globalStore.ready
856
+ },
857
+ get error() {
858
+ return globalStore.error
859
+ },
860
+ child,
861
+ bootstrap,
862
+ updateConfig: async (config: Config) => {
863
+ setGlobalStore("reload", "pending")
864
+ const response = await globalSDK.client.config.update({ config })
865
+ setTimeout(() => {
866
+ setGlobalStore("reload", "complete")
867
+ }, 1000)
868
+ return response
869
+ },
870
+ project: {
871
+ loadSessions,
872
+ meta: projectMeta,
873
+ icon: projectIcon,
874
+ },
875
+ }
876
+ }
877
+
878
+ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
879
+
880
+ export function GlobalSyncProvider(props: ParentProps) {
881
+ const value = createGlobalSync()
882
+ return (
883
+ <Switch>
884
+ <Match when={value.error}>
885
+ <ErrorPage error={value.error} />
886
+ </Match>
887
+ <Match when={value.ready}>
888
+ <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
889
+ </Match>
890
+ </Switch>
891
+ )
892
+ }
893
+
894
+ export function useGlobalSync() {
895
+ const context = useContext(GlobalSyncContext)
896
+ if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
897
+ return context
898
+ }