@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,214 @@
1
+ import { createOpencodeClient } from "@jonsoc/sdk/v2/client"
2
+ import { createSimpleContext } from "@jonsoc/ui/context"
3
+ import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
4
+ import { createStore } from "solid-js/store"
5
+ import { usePlatform } from "@/context/platform"
6
+ import { Persist, persisted } from "@/utils/persist"
7
+
8
+ type StoredProject = { worktree: string; expanded: boolean }
9
+
10
+ export function normalizeServerUrl(input: string) {
11
+ const trimmed = input.trim()
12
+ if (!trimmed) return
13
+ const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
14
+ return withProtocol.replace(/\/+$/, "")
15
+ }
16
+
17
+ export function serverDisplayName(url: string) {
18
+ if (!url) return ""
19
+ return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
20
+ }
21
+
22
+ function projectsKey(url: string) {
23
+ if (!url) return ""
24
+ const host = url.replace(/^https?:\/\//, "").split(":")[0]
25
+ if (host === "localhost" || host === "127.0.0.1") return "local"
26
+ return url
27
+ }
28
+
29
+ export const { use: useServer, provider: ServerProvider } = createSimpleContext({
30
+ name: "Server",
31
+ init: (props: { defaultUrl: string }) => {
32
+ const platform = usePlatform()
33
+
34
+ const [store, setStore, _, ready] = persisted(
35
+ Persist.global("server", ["server.v3"]),
36
+ createStore({
37
+ list: [] as string[],
38
+ projects: {} as Record<string, StoredProject[]>,
39
+ lastProject: {} as Record<string, string>,
40
+ }),
41
+ )
42
+
43
+ const [active, setActiveRaw] = createSignal("")
44
+
45
+ function setActive(input: string) {
46
+ const url = normalizeServerUrl(input)
47
+ if (!url) return
48
+ setActiveRaw(url)
49
+ }
50
+
51
+ function add(input: string) {
52
+ const url = normalizeServerUrl(input)
53
+ if (!url) return
54
+
55
+ const fallback = normalizeServerUrl(props.defaultUrl)
56
+ if (fallback && url === fallback) {
57
+ setActiveRaw(url)
58
+ return
59
+ }
60
+
61
+ batch(() => {
62
+ if (!store.list.includes(url)) {
63
+ setStore("list", store.list.length, url)
64
+ }
65
+ setActiveRaw(url)
66
+ })
67
+ }
68
+
69
+ function remove(input: string) {
70
+ const url = normalizeServerUrl(input)
71
+ if (!url) return
72
+
73
+ const list = store.list.filter((x) => x !== url)
74
+ const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
75
+
76
+ batch(() => {
77
+ setStore("list", list)
78
+ setActiveRaw(next)
79
+ })
80
+ }
81
+
82
+ createEffect(() => {
83
+ if (!ready()) return
84
+ if (active()) return
85
+ const url = normalizeServerUrl(props.defaultUrl)
86
+ if (!url) return
87
+ setActiveRaw(url)
88
+ })
89
+
90
+ const isReady = createMemo(() => ready() && !!active())
91
+
92
+ const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
93
+
94
+ const check = (url: string) => {
95
+ const sdk = createOpencodeClient({
96
+ baseUrl: url,
97
+ fetch: platform.fetch,
98
+ signal: AbortSignal.timeout(3000),
99
+ })
100
+ return sdk.global
101
+ .health()
102
+ .then((x) => x.data?.healthy === true)
103
+ .catch(() => false)
104
+ }
105
+
106
+ createEffect(() => {
107
+ const url = active()
108
+ if (!url) return
109
+
110
+ setHealthy(undefined)
111
+
112
+ let alive = true
113
+ let busy = false
114
+
115
+ const run = () => {
116
+ if (busy) return
117
+ busy = true
118
+ void check(url)
119
+ .then((next) => {
120
+ if (!alive) return
121
+ setHealthy(next)
122
+ })
123
+ .finally(() => {
124
+ busy = false
125
+ })
126
+ }
127
+
128
+ run()
129
+ const interval = setInterval(run, 10_000)
130
+
131
+ onCleanup(() => {
132
+ alive = false
133
+ clearInterval(interval)
134
+ })
135
+ })
136
+
137
+ const origin = createMemo(() => projectsKey(active()))
138
+ const projectsList = createMemo(() => store.projects[origin()] ?? [])
139
+ const isLocal = createMemo(() => origin() === "local")
140
+
141
+ return {
142
+ ready: isReady,
143
+ healthy,
144
+ isLocal,
145
+ get url() {
146
+ return active()
147
+ },
148
+ get name() {
149
+ return serverDisplayName(active())
150
+ },
151
+ get list() {
152
+ return store.list
153
+ },
154
+ setActive,
155
+ add,
156
+ remove,
157
+ projects: {
158
+ list: projectsList,
159
+ open(directory: string) {
160
+ const key = origin()
161
+ if (!key) return
162
+ const current = store.projects[key] ?? []
163
+ if (current.find((x) => x.worktree === directory)) return
164
+ setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
165
+ },
166
+ close(directory: string) {
167
+ const key = origin()
168
+ if (!key) return
169
+ const current = store.projects[key] ?? []
170
+ setStore(
171
+ "projects",
172
+ key,
173
+ current.filter((x) => x.worktree !== directory),
174
+ )
175
+ },
176
+ expand(directory: string) {
177
+ const key = origin()
178
+ if (!key) return
179
+ const current = store.projects[key] ?? []
180
+ const index = current.findIndex((x) => x.worktree === directory)
181
+ if (index !== -1) setStore("projects", key, index, "expanded", true)
182
+ },
183
+ collapse(directory: string) {
184
+ const key = origin()
185
+ if (!key) return
186
+ const current = store.projects[key] ?? []
187
+ const index = current.findIndex((x) => x.worktree === directory)
188
+ if (index !== -1) setStore("projects", key, index, "expanded", false)
189
+ },
190
+ move(directory: string, toIndex: number) {
191
+ const key = origin()
192
+ if (!key) return
193
+ const current = store.projects[key] ?? []
194
+ const fromIndex = current.findIndex((x) => x.worktree === directory)
195
+ if (fromIndex === -1 || fromIndex === toIndex) return
196
+ const result = [...current]
197
+ const [item] = result.splice(fromIndex, 1)
198
+ result.splice(toIndex, 0, item)
199
+ setStore("projects", key, result)
200
+ },
201
+ last() {
202
+ const key = origin()
203
+ if (!key) return
204
+ return store.lastProject[key]
205
+ },
206
+ touch(directory: string) {
207
+ const key = origin()
208
+ if (!key) return
209
+ setStore("lastProject", key, directory)
210
+ },
211
+ },
212
+ }
213
+ },
214
+ })
@@ -0,0 +1,166 @@
1
+ import { createStore, reconcile } from "solid-js/store"
2
+ import { createEffect, createMemo } from "solid-js"
3
+ import { createSimpleContext } from "@jonsoc/ui/context"
4
+ import { persisted } from "@/utils/persist"
5
+
6
+ export interface NotificationSettings {
7
+ agent: boolean
8
+ permissions: boolean
9
+ errors: boolean
10
+ }
11
+
12
+ export interface SoundSettings {
13
+ agent: string
14
+ permissions: string
15
+ errors: string
16
+ }
17
+
18
+ export interface Settings {
19
+ general: {
20
+ autoSave: boolean
21
+ navigatorAlwaysOpen: boolean
22
+ }
23
+ appearance: {
24
+ fontSize: number
25
+ font: string
26
+ }
27
+ keybinds: Record<string, string>
28
+ permissions: {
29
+ autoApprove: boolean
30
+ }
31
+ notifications: NotificationSettings
32
+ sounds: SoundSettings
33
+ }
34
+
35
+ const defaultSettings: Settings = {
36
+ general: {
37
+ autoSave: true,
38
+ navigatorAlwaysOpen: false,
39
+ },
40
+ appearance: {
41
+ fontSize: 14,
42
+ font: "ibm-plex-mono",
43
+ },
44
+ keybinds: {},
45
+ permissions: {
46
+ autoApprove: false,
47
+ },
48
+ notifications: {
49
+ agent: true,
50
+ permissions: true,
51
+ errors: false,
52
+ },
53
+ sounds: {
54
+ agent: "staplebops-01",
55
+ permissions: "staplebops-02",
56
+ errors: "nope-03",
57
+ },
58
+ }
59
+
60
+ const monoFallback =
61
+ 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
62
+
63
+ const monoFonts: Record<string, string> = {
64
+ "ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
65
+ "cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
66
+ "fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
67
+ hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
68
+ inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
69
+ "intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
70
+ "jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
71
+ "meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
72
+ "roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
73
+ "source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
74
+ "ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
75
+ }
76
+
77
+ export function monoFontFamily(font: string | undefined) {
78
+ return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
79
+ }
80
+
81
+ export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
82
+ name: "Settings",
83
+ init: () => {
84
+ const [store, setStore, _, ready] = persisted("settings.v3", createStore<Settings>(defaultSettings))
85
+
86
+ createEffect(() => {
87
+ if (typeof document === "undefined") return
88
+ document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
89
+ })
90
+
91
+ return {
92
+ ready,
93
+ get current() {
94
+ return store
95
+ },
96
+ general: {
97
+ autoSave: createMemo(() => store.general?.autoSave ?? defaultSettings.general.autoSave),
98
+ setAutoSave(value: boolean) {
99
+ setStore("general", "autoSave", value)
100
+ },
101
+ navigatorAlwaysOpen: createMemo(
102
+ () => store.general?.navigatorAlwaysOpen ?? defaultSettings.general.navigatorAlwaysOpen,
103
+ ),
104
+ setNavigatorAlwaysOpen(value: boolean) {
105
+ setStore("general", "navigatorAlwaysOpen", value)
106
+ },
107
+ },
108
+ appearance: {
109
+ fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize),
110
+ setFontSize(value: number) {
111
+ setStore("appearance", "fontSize", value)
112
+ },
113
+ font: createMemo(() => store.appearance?.font ?? defaultSettings.appearance.font),
114
+ setFont(value: string) {
115
+ setStore("appearance", "font", value)
116
+ },
117
+ },
118
+ keybinds: {
119
+ get: (action: string) => store.keybinds?.[action],
120
+ set(action: string, keybind: string) {
121
+ setStore("keybinds", action, keybind)
122
+ },
123
+ reset(action: string) {
124
+ setStore("keybinds", action, undefined!)
125
+ },
126
+ resetAll() {
127
+ setStore("keybinds", reconcile({}))
128
+ },
129
+ },
130
+ permissions: {
131
+ autoApprove: createMemo(() => store.permissions?.autoApprove ?? defaultSettings.permissions.autoApprove),
132
+ setAutoApprove(value: boolean) {
133
+ setStore("permissions", "autoApprove", value)
134
+ },
135
+ },
136
+ notifications: {
137
+ agent: createMemo(() => store.notifications?.agent ?? defaultSettings.notifications.agent),
138
+ setAgent(value: boolean) {
139
+ setStore("notifications", "agent", value)
140
+ },
141
+ permissions: createMemo(() => store.notifications?.permissions ?? defaultSettings.notifications.permissions),
142
+ setPermissions(value: boolean) {
143
+ setStore("notifications", "permissions", value)
144
+ },
145
+ errors: createMemo(() => store.notifications?.errors ?? defaultSettings.notifications.errors),
146
+ setErrors(value: boolean) {
147
+ setStore("notifications", "errors", value)
148
+ },
149
+ },
150
+ sounds: {
151
+ agent: createMemo(() => store.sounds?.agent ?? defaultSettings.sounds.agent),
152
+ setAgent(value: string) {
153
+ setStore("sounds", "agent", value)
154
+ },
155
+ permissions: createMemo(() => store.sounds?.permissions ?? defaultSettings.sounds.permissions),
156
+ setPermissions(value: string) {
157
+ setStore("sounds", "permissions", value)
158
+ },
159
+ errors: createMemo(() => store.sounds?.errors ?? defaultSettings.sounds.errors),
160
+ setErrors(value: string) {
161
+ setStore("sounds", "errors", value)
162
+ },
163
+ },
164
+ }
165
+ },
166
+ })
@@ -0,0 +1,320 @@
1
+ import { batch, createMemo } from "solid-js"
2
+ import { createStore, produce, reconcile } from "solid-js/store"
3
+ import { Binary } from "@jonsoc/util/binary"
4
+ import { retry } from "@jonsoc/util/retry"
5
+ import { createSimpleContext } from "@jonsoc/ui/context"
6
+ import { useGlobalSync } from "./global-sync"
7
+ import { useSDK } from "./sdk"
8
+ import type { Message, Part } from "@jonsoc/sdk/v2/client"
9
+
10
+ const keyFor = (directory: string, id: string) => `${directory}\n${id}`
11
+
12
+ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
13
+ name: "Sync",
14
+ init: () => {
15
+ const globalSync = useGlobalSync()
16
+ const sdk = useSDK()
17
+
18
+ type Child = ReturnType<(typeof globalSync)["child"]>
19
+ type Store = Child[0]
20
+ type Setter = Child[1]
21
+
22
+ const current = createMemo(() => globalSync.child(sdk.directory))
23
+ const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
24
+ const chunk = 400
25
+ const inflight = new Map<string, Promise<void>>()
26
+ const inflightDiff = new Map<string, Promise<void>>()
27
+ const inflightTodo = new Map<string, Promise<void>>()
28
+ const [meta, setMeta] = createStore({
29
+ limit: {} as Record<string, number>,
30
+ complete: {} as Record<string, boolean>,
31
+ loading: {} as Record<string, boolean>,
32
+ })
33
+
34
+ const getSession = (sessionID: string) => {
35
+ const store = current()[0]
36
+ const match = Binary.search(store.session, sessionID, (s) => s.id)
37
+ if (match.found) return store.session[match.index]
38
+ return undefined
39
+ }
40
+
41
+ const limitFor = (count: number) => {
42
+ if (count <= chunk) return chunk
43
+ return Math.ceil(count / chunk) * chunk
44
+ }
45
+
46
+ const hydrateMessages = (directory: string, store: Store, sessionID: string) => {
47
+ const key = keyFor(directory, sessionID)
48
+ if (meta.limit[key] !== undefined) return
49
+
50
+ const messages = store.message[sessionID]
51
+ if (!messages) return
52
+
53
+ const limit = limitFor(messages.length)
54
+ setMeta("limit", key, limit)
55
+ setMeta("complete", key, messages.length < limit)
56
+ }
57
+
58
+ const loadMessages = async (input: {
59
+ directory: string
60
+ client: typeof sdk.client
61
+ setStore: Setter
62
+ sessionID: string
63
+ limit: number
64
+ }) => {
65
+ const key = keyFor(input.directory, input.sessionID)
66
+ if (meta.loading[key]) return
67
+
68
+ setMeta("loading", key, true)
69
+ await retry(() => input.client.session.messages({ sessionID: input.sessionID, limit: input.limit }))
70
+ .then((messages) => {
71
+ const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
72
+ const next = items
73
+ .map((x) => x.info)
74
+ .filter((m) => !!m?.id)
75
+ .slice()
76
+ .sort((a, b) => a.id.localeCompare(b.id))
77
+
78
+ batch(() => {
79
+ input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
80
+
81
+ for (const message of items) {
82
+ input.setStore(
83
+ "part",
84
+ message.info.id,
85
+ reconcile(
86
+ message.parts
87
+ .filter((p) => !!p?.id)
88
+ .slice()
89
+ .sort((a, b) => a.id.localeCompare(b.id)),
90
+ { key: "id" },
91
+ ),
92
+ )
93
+ }
94
+
95
+ setMeta("limit", key, input.limit)
96
+ setMeta("complete", key, next.length < input.limit)
97
+ })
98
+ })
99
+ .finally(() => {
100
+ setMeta("loading", key, false)
101
+ })
102
+ }
103
+
104
+ return {
105
+ get data() {
106
+ return current()[0]
107
+ },
108
+ get set(): Setter {
109
+ return current()[1]
110
+ },
111
+ get status() {
112
+ return current()[0].status
113
+ },
114
+ get ready() {
115
+ return current()[0].status !== "loading"
116
+ },
117
+ get project() {
118
+ const store = current()[0]
119
+ const match = Binary.search(globalSync.data.project, store.project, (p) => p.id)
120
+ if (match.found) return globalSync.data.project[match.index]
121
+ return undefined
122
+ },
123
+ session: {
124
+ get: getSession,
125
+ addOptimisticMessage(input: {
126
+ sessionID: string
127
+ messageID: string
128
+ parts: Part[]
129
+ agent: string
130
+ model: { providerID: string; modelID: string }
131
+ }) {
132
+ const message: Message = {
133
+ id: input.messageID,
134
+ sessionID: input.sessionID,
135
+ role: "user",
136
+ time: { created: Date.now() },
137
+ agent: input.agent,
138
+ model: input.model,
139
+ }
140
+ current()[1](
141
+ produce((draft) => {
142
+ const messages = draft.message[input.sessionID]
143
+ if (!messages) {
144
+ draft.message[input.sessionID] = [message]
145
+ } else {
146
+ const result = Binary.search(messages, input.messageID, (m) => m.id)
147
+ messages.splice(result.index, 0, message)
148
+ }
149
+ draft.part[input.messageID] = input.parts
150
+ .filter((p) => !!p?.id)
151
+ .slice()
152
+ .sort((a, b) => a.id.localeCompare(b.id))
153
+ }),
154
+ )
155
+ },
156
+ async sync(sessionID: string) {
157
+ const directory = sdk.directory
158
+ const client = sdk.client
159
+ const [store, setStore] = globalSync.child(directory)
160
+ const hasSession = (() => {
161
+ const match = Binary.search(store.session, sessionID, (s) => s.id)
162
+ return match.found
163
+ })()
164
+
165
+ hydrateMessages(directory, store, sessionID)
166
+
167
+ const hasMessages = store.message[sessionID] !== undefined
168
+ if (hasSession && hasMessages) return
169
+
170
+ const key = keyFor(directory, sessionID)
171
+ const pending = inflight.get(key)
172
+ if (pending) return pending
173
+
174
+ const limit = meta.limit[key] ?? chunk
175
+
176
+ const sessionReq = hasSession
177
+ ? Promise.resolve()
178
+ : retry(() => client.session.get({ sessionID })).then((session) => {
179
+ const data = session.data
180
+ if (!data) return
181
+ setStore(
182
+ "session",
183
+ produce((draft) => {
184
+ const match = Binary.search(draft, sessionID, (s) => s.id)
185
+ if (match.found) {
186
+ draft[match.index] = data
187
+ return
188
+ }
189
+ draft.splice(match.index, 0, data)
190
+ }),
191
+ )
192
+ })
193
+
194
+ const messagesReq = hasMessages
195
+ ? Promise.resolve()
196
+ : loadMessages({
197
+ directory,
198
+ client,
199
+ setStore,
200
+ sessionID,
201
+ limit,
202
+ })
203
+
204
+ const promise = Promise.all([sessionReq, messagesReq])
205
+ .then(() => {})
206
+ .finally(() => {
207
+ inflight.delete(key)
208
+ })
209
+
210
+ inflight.set(key, promise)
211
+ return promise
212
+ },
213
+ async diff(sessionID: string) {
214
+ const directory = sdk.directory
215
+ const client = sdk.client
216
+ const [store, setStore] = globalSync.child(directory)
217
+ if (store.session_diff[sessionID] !== undefined) return
218
+
219
+ const key = keyFor(directory, sessionID)
220
+ const pending = inflightDiff.get(key)
221
+ if (pending) return pending
222
+
223
+ const promise = retry(() => client.session.diff({ sessionID }))
224
+ .then((diff) => {
225
+ setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
226
+ })
227
+ .finally(() => {
228
+ inflightDiff.delete(key)
229
+ })
230
+
231
+ inflightDiff.set(key, promise)
232
+ return promise
233
+ },
234
+ async todo(sessionID: string) {
235
+ const directory = sdk.directory
236
+ const client = sdk.client
237
+ const [store, setStore] = globalSync.child(directory)
238
+ if (store.todo[sessionID] !== undefined) return
239
+
240
+ const key = keyFor(directory, sessionID)
241
+ const pending = inflightTodo.get(key)
242
+ if (pending) return pending
243
+
244
+ const promise = retry(() => client.session.todo({ sessionID }))
245
+ .then((todo) => {
246
+ setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
247
+ })
248
+ .finally(() => {
249
+ inflightTodo.delete(key)
250
+ })
251
+
252
+ inflightTodo.set(key, promise)
253
+ return promise
254
+ },
255
+ history: {
256
+ more(sessionID: string) {
257
+ const store = current()[0]
258
+ const key = keyFor(sdk.directory, sessionID)
259
+ if (store.message[sessionID] === undefined) return false
260
+ if (meta.limit[key] === undefined) return false
261
+ if (meta.complete[key]) return false
262
+ return true
263
+ },
264
+ loading(sessionID: string) {
265
+ const key = keyFor(sdk.directory, sessionID)
266
+ return meta.loading[key] ?? false
267
+ },
268
+ async loadMore(sessionID: string, count = chunk) {
269
+ const directory = sdk.directory
270
+ const client = sdk.client
271
+ const [, setStore] = globalSync.child(directory)
272
+ const key = keyFor(directory, sessionID)
273
+ if (meta.loading[key]) return
274
+ if (meta.complete[key]) return
275
+
276
+ const currentLimit = meta.limit[key] ?? chunk
277
+ await loadMessages({
278
+ directory,
279
+ client,
280
+ setStore,
281
+ sessionID,
282
+ limit: currentLimit + count,
283
+ })
284
+ },
285
+ },
286
+ fetch: async (count = 10) => {
287
+ const directory = sdk.directory
288
+ const client = sdk.client
289
+ const [store, setStore] = globalSync.child(directory)
290
+ setStore("limit", (x) => x + count)
291
+ await client.session.list().then((x) => {
292
+ const sessions = (x.data ?? [])
293
+ .filter((s) => !!s?.id)
294
+ .slice()
295
+ .sort((a, b) => a.id.localeCompare(b.id))
296
+ .slice(0, store.limit)
297
+ setStore("session", reconcile(sessions, { key: "id" }))
298
+ })
299
+ },
300
+ more: createMemo(() => current()[0].session.length >= current()[0].limit),
301
+ archive: async (sessionID: string) => {
302
+ const directory = sdk.directory
303
+ const client = sdk.client
304
+ const [, setStore] = globalSync.child(directory)
305
+ await client.session.update({ sessionID, time: { archived: Date.now() } })
306
+ setStore(
307
+ produce((draft) => {
308
+ const match = Binary.search(draft.session, sessionID, (s) => s.id)
309
+ if (match.found) draft.session.splice(match.index, 1)
310
+ }),
311
+ )
312
+ },
313
+ },
314
+ absolute,
315
+ get directory() {
316
+ return current()[0].path.directory
317
+ },
318
+ }
319
+ },
320
+ })