@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,173 @@
1
+ import { createStore } from "solid-js/store"
2
+ import { createEffect, onCleanup } from "solid-js"
3
+ import { useParams } from "@solidjs/router"
4
+ import { createSimpleContext } from "@jonsoc/ui/context"
5
+ import { useGlobalSDK } from "./global-sdk"
6
+ import { useGlobalSync } from "./global-sync"
7
+ import { usePlatform } from "@/context/platform"
8
+ import { useLanguage } from "@/context/language"
9
+ import { useSettings } from "@/context/settings"
10
+ import { Binary } from "@jonsoc/util/binary"
11
+ import { base64Decode, base64Encode } from "@jonsoc/util/encode"
12
+ import { EventSessionError } from "@jonsoc/sdk/v2"
13
+ import { Persist, persisted } from "@/utils/persist"
14
+ import { playSound, soundSrc } from "@/utils/sound"
15
+
16
+ type NotificationBase = {
17
+ directory?: string
18
+ session?: string
19
+ metadata?: any
20
+ time: number
21
+ viewed: boolean
22
+ }
23
+
24
+ type TurnCompleteNotification = NotificationBase & {
25
+ type: "turn-complete"
26
+ }
27
+
28
+ type ErrorNotification = NotificationBase & {
29
+ type: "error"
30
+ error: EventSessionError["properties"]["error"]
31
+ }
32
+
33
+ export type Notification = TurnCompleteNotification | ErrorNotification
34
+
35
+ const MAX_NOTIFICATIONS = 500
36
+ const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
37
+
38
+ function pruneNotifications(list: Notification[]) {
39
+ const cutoff = Date.now() - NOTIFICATION_TTL_MS
40
+ const pruned = list.filter((n) => n.time >= cutoff)
41
+ if (pruned.length <= MAX_NOTIFICATIONS) return pruned
42
+ return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
43
+ }
44
+
45
+ export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
46
+ name: "Notification",
47
+ init: () => {
48
+ const params = useParams()
49
+ const globalSDK = useGlobalSDK()
50
+ const globalSync = useGlobalSync()
51
+ const platform = usePlatform()
52
+ const settings = useSettings()
53
+ const language = useLanguage()
54
+
55
+ const [store, setStore, _, ready] = persisted(
56
+ Persist.global("notification", ["notification.v1"]),
57
+ createStore({
58
+ list: [] as Notification[],
59
+ }),
60
+ )
61
+
62
+ const meta = { pruned: false }
63
+
64
+ createEffect(() => {
65
+ if (!ready()) return
66
+ if (meta.pruned) return
67
+ meta.pruned = true
68
+ setStore("list", pruneNotifications(store.list))
69
+ })
70
+
71
+ const append = (notification: Notification) => {
72
+ setStore("list", (list) => pruneNotifications([...list, notification]))
73
+ }
74
+
75
+ const unsub = globalSDK.event.listen((e) => {
76
+ const directory = e.name
77
+ const event = e.details
78
+ const time = Date.now()
79
+ const activeDirectory = params.dir ? base64Decode(params.dir) : undefined
80
+ const activeSession = params.id
81
+ const viewed = (sessionID?: string) => {
82
+ if (!activeDirectory) return false
83
+ if (!activeSession) return false
84
+ if (!sessionID) return false
85
+ if (directory !== activeDirectory) return false
86
+ return sessionID === activeSession
87
+ }
88
+ switch (event.type) {
89
+ case "session.idle": {
90
+ const sessionID = event.properties.sessionID
91
+ const [syncStore] = globalSync.child(directory)
92
+ const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
93
+ const session = match.found ? syncStore.session[match.index] : undefined
94
+ if (session?.parentID) break
95
+
96
+ playSound(soundSrc(settings.sounds.agent()))
97
+
98
+ append({
99
+ directory,
100
+ time,
101
+ viewed: viewed(sessionID),
102
+ type: "turn-complete",
103
+ session: sessionID,
104
+ })
105
+
106
+ const href = `/${base64Encode(directory)}/session/${sessionID}`
107
+ if (settings.notifications.agent()) {
108
+ void platform.notify(
109
+ language.t("notification.session.responseReady.title"),
110
+ session?.title ?? sessionID,
111
+ href,
112
+ )
113
+ }
114
+ break
115
+ }
116
+ case "session.error": {
117
+ const sessionID = event.properties.sessionID
118
+ const [syncStore] = globalSync.child(directory)
119
+ const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
120
+ const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
121
+ if (session?.parentID) break
122
+
123
+ playSound(soundSrc(settings.sounds.errors()))
124
+
125
+ const error = "error" in event.properties ? event.properties.error : undefined
126
+ append({
127
+ directory,
128
+ time,
129
+ viewed: viewed(sessionID),
130
+ type: "error",
131
+ session: sessionID ?? "global",
132
+ error,
133
+ })
134
+ const description =
135
+ session?.title ??
136
+ (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
137
+ const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
138
+ if (settings.notifications.errors()) {
139
+ void platform.notify(language.t("notification.session.error.title"), description, href)
140
+ }
141
+ break
142
+ }
143
+ }
144
+ })
145
+ onCleanup(unsub)
146
+
147
+ return {
148
+ ready,
149
+ session: {
150
+ all(session: string) {
151
+ return store.list.filter((n) => n.session === session)
152
+ },
153
+ unseen(session: string) {
154
+ return store.list.filter((n) => n.session === session && !n.viewed)
155
+ },
156
+ markViewed(session: string) {
157
+ setStore("list", (n) => n.session === session, "viewed", true)
158
+ },
159
+ },
160
+ project: {
161
+ all(directory: string) {
162
+ return store.list.filter((n) => n.directory === directory)
163
+ },
164
+ unseen(directory: string) {
165
+ return store.list.filter((n) => n.directory === directory && !n.viewed)
166
+ },
167
+ markViewed(directory: string) {
168
+ setStore("list", (n) => n.directory === directory, "viewed", true)
169
+ },
170
+ },
171
+ }
172
+ },
173
+ })
@@ -0,0 +1,167 @@
1
+ import { createMemo, onCleanup } from "solid-js"
2
+ import { createStore, produce } from "solid-js/store"
3
+ import { createSimpleContext } from "@jonsoc/ui/context"
4
+ import type { PermissionRequest } from "@jonsoc/sdk/v2/client"
5
+ import { Persist, persisted } from "@/utils/persist"
6
+ import { useGlobalSDK } from "@/context/global-sdk"
7
+ import { useGlobalSync } from "./global-sync"
8
+ import { useParams } from "@solidjs/router"
9
+ import { base64Decode, base64Encode } from "@jonsoc/util/encode"
10
+
11
+ type PermissionRespondFn = (input: {
12
+ sessionID: string
13
+ permissionID: string
14
+ response: "once" | "always" | "reject"
15
+ directory?: string
16
+ }) => void
17
+
18
+ function shouldAutoAccept(perm: PermissionRequest) {
19
+ return perm.permission === "edit"
20
+ }
21
+
22
+ function isNonAllowRule(rule: unknown) {
23
+ if (!rule) return false
24
+ if (typeof rule === "string") return rule !== "allow"
25
+ if (typeof rule !== "object") return false
26
+ if (Array.isArray(rule)) return false
27
+
28
+ for (const action of Object.values(rule)) {
29
+ if (action !== "allow") return true
30
+ }
31
+
32
+ return false
33
+ }
34
+
35
+ function hasAutoAcceptPermissionConfig(permission: unknown) {
36
+ if (!permission) return false
37
+ if (typeof permission === "string") return permission !== "allow"
38
+ if (typeof permission !== "object") return false
39
+ if (Array.isArray(permission)) return false
40
+
41
+ const config = permission as Record<string, unknown>
42
+ if (isNonAllowRule(config.edit)) return true
43
+ if (isNonAllowRule(config.write)) return true
44
+
45
+ return false
46
+ }
47
+
48
+ export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
49
+ name: "Permission",
50
+ init: () => {
51
+ const params = useParams()
52
+ const globalSDK = useGlobalSDK()
53
+ const globalSync = useGlobalSync()
54
+
55
+ const permissionsEnabled = createMemo(() => {
56
+ const directory = params.dir ? base64Decode(params.dir) : undefined
57
+ if (!directory) return false
58
+ const [store] = globalSync.child(directory)
59
+ return hasAutoAcceptPermissionConfig(store.config.permission)
60
+ })
61
+
62
+ const [store, setStore, _, ready] = persisted(
63
+ Persist.global("permission", ["permission.v3"]),
64
+ createStore({
65
+ autoAcceptEdits: {} as Record<string, boolean>,
66
+ }),
67
+ )
68
+
69
+ const responded = new Set<string>()
70
+
71
+ const respond: PermissionRespondFn = (input) => {
72
+ globalSDK.client.permission.respond(input).catch(() => {
73
+ responded.delete(input.permissionID)
74
+ })
75
+ }
76
+
77
+ function respondOnce(permission: PermissionRequest, directory?: string) {
78
+ if (responded.has(permission.id)) return
79
+ responded.add(permission.id)
80
+ respond({
81
+ sessionID: permission.sessionID,
82
+ permissionID: permission.id,
83
+ response: "once",
84
+ directory,
85
+ })
86
+ }
87
+
88
+ function acceptKey(sessionID: string, directory?: string) {
89
+ if (!directory) return sessionID
90
+ return `${base64Encode(directory)}/${sessionID}`
91
+ }
92
+
93
+ function isAutoAccepting(sessionID: string, directory?: string) {
94
+ const key = acceptKey(sessionID, directory)
95
+ return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
96
+ }
97
+
98
+ const unsubscribe = globalSDK.event.listen((e) => {
99
+ const event = e.details
100
+ if (event?.type !== "permission.asked") return
101
+
102
+ const perm = event.properties
103
+ if (!isAutoAccepting(perm.sessionID, e.name)) return
104
+ if (!shouldAutoAccept(perm)) return
105
+
106
+ respondOnce(perm, e.name)
107
+ })
108
+ onCleanup(unsubscribe)
109
+
110
+ function enable(sessionID: string, directory: string) {
111
+ const key = acceptKey(sessionID, directory)
112
+ setStore(
113
+ produce((draft) => {
114
+ draft.autoAcceptEdits[key] = true
115
+ delete draft.autoAcceptEdits[sessionID]
116
+ }),
117
+ )
118
+
119
+ globalSDK.client.permission
120
+ .list({ directory })
121
+ .then((x) => {
122
+ for (const perm of x.data ?? []) {
123
+ if (!perm?.id) continue
124
+ if (perm.sessionID !== sessionID) continue
125
+ if (!shouldAutoAccept(perm)) continue
126
+ respondOnce(perm, directory)
127
+ }
128
+ })
129
+ .catch(() => undefined)
130
+ }
131
+
132
+ function disable(sessionID: string, directory?: string) {
133
+ const key = directory ? acceptKey(sessionID, directory) : undefined
134
+ setStore(
135
+ produce((draft) => {
136
+ if (key) delete draft.autoAcceptEdits[key]
137
+ delete draft.autoAcceptEdits[sessionID]
138
+ }),
139
+ )
140
+ }
141
+
142
+ return {
143
+ ready,
144
+ respond,
145
+ autoResponds(permission: PermissionRequest, directory?: string) {
146
+ return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
147
+ },
148
+ isAutoAccepting,
149
+ toggleAutoAccept(sessionID: string, directory: string) {
150
+ if (isAutoAccepting(sessionID, directory)) {
151
+ disable(sessionID, directory)
152
+ return
153
+ }
154
+
155
+ enable(sessionID, directory)
156
+ },
157
+ enableAutoAccept(sessionID: string, directory: string) {
158
+ if (isAutoAccepting(sessionID, directory)) return
159
+ enable(sessionID, directory)
160
+ },
161
+ disableAutoAccept(sessionID: string, directory?: string) {
162
+ disable(sessionID, directory)
163
+ },
164
+ permissionsEnabled,
165
+ }
166
+ },
167
+ })
@@ -0,0 +1,59 @@
1
+ import { createSimpleContext } from "@jonsoc/ui/context"
2
+ import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
3
+
4
+ export type Platform = {
5
+ /** Platform discriminator */
6
+ platform: "web" | "desktop"
7
+
8
+ /** Desktop OS (Tauri only) */
9
+ os?: "macos" | "windows" | "linux"
10
+
11
+ /** App version */
12
+ version?: string
13
+
14
+ /** Open a URL in the default browser */
15
+ openLink(url: string): void
16
+
17
+ /** Restart the app */
18
+ restart(): Promise<void>
19
+
20
+ /** Send a system notification (optional deep link) */
21
+ notify(title: string, description?: string, href?: string): Promise<void>
22
+
23
+ /** Open directory picker dialog (native on Tauri, server-backed on web) */
24
+ openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
25
+
26
+ /** Open native file picker dialog (Tauri only) */
27
+ openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
28
+
29
+ /** Save file picker dialog (Tauri only) */
30
+ saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null>
31
+
32
+ /** Storage mechanism, defaults to localStorage */
33
+ storage?: (name?: string) => SyncStorage | AsyncStorage
34
+
35
+ /** Check for updates (Tauri only) */
36
+ checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }>
37
+
38
+ /** Install updates (Tauri only) */
39
+ update?(): Promise<void>
40
+
41
+ /** Fetch override */
42
+ fetch?: typeof fetch
43
+
44
+ /** Get the configured default server URL (desktop only) */
45
+ getDefaultServerUrl?(): Promise<string | null>
46
+
47
+ /** Set the default server URL to use on app startup (desktop only) */
48
+ setDefaultServerUrl?(url: string | null): Promise<void>
49
+
50
+ /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
51
+ parseMarkdown?(markdown: string): Promise<string>
52
+ }
53
+
54
+ export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
55
+ name: "Platform",
56
+ init: (props: { value: Platform }) => {
57
+ return props.value
58
+ },
59
+ })
@@ -0,0 +1,245 @@
1
+ import { createStore } from "solid-js/store"
2
+ import { createSimpleContext } from "@jonsoc/ui/context"
3
+ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
4
+ import { useParams } from "@solidjs/router"
5
+ import type { FileSelection } from "@/context/file"
6
+ import { Persist, persisted } from "@/utils/persist"
7
+ import { checksum } from "@jonsoc/util/encode"
8
+
9
+ interface PartBase {
10
+ content: string
11
+ start: number
12
+ end: number
13
+ }
14
+
15
+ export interface TextPart extends PartBase {
16
+ type: "text"
17
+ }
18
+
19
+ export interface FileAttachmentPart extends PartBase {
20
+ type: "file"
21
+ path: string
22
+ selection?: FileSelection
23
+ }
24
+
25
+ export interface AgentPart extends PartBase {
26
+ type: "agent"
27
+ name: string
28
+ }
29
+
30
+ export interface ImageAttachmentPart {
31
+ type: "image"
32
+ id: string
33
+ filename: string
34
+ mime: string
35
+ dataUrl: string
36
+ }
37
+
38
+ export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
39
+ export type Prompt = ContentPart[]
40
+
41
+ export type FileContextItem = {
42
+ type: "file"
43
+ path: string
44
+ selection?: FileSelection
45
+ comment?: string
46
+ commentID?: string
47
+ preview?: string
48
+ }
49
+
50
+ export type ContextItem = FileContextItem
51
+
52
+ export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
53
+
54
+ function isSelectionEqual(a?: FileSelection, b?: FileSelection) {
55
+ if (!a && !b) return true
56
+ if (!a || !b) return false
57
+ return (
58
+ a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar
59
+ )
60
+ }
61
+
62
+ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
63
+ if (promptA.length !== promptB.length) return false
64
+ for (let i = 0; i < promptA.length; i++) {
65
+ const partA = promptA[i]
66
+ const partB = promptB[i]
67
+ if (partA.type !== partB.type) return false
68
+ if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
69
+ return false
70
+ }
71
+ if (partA.type === "file") {
72
+ const fileA = partA as FileAttachmentPart
73
+ const fileB = partB as FileAttachmentPart
74
+ if (fileA.path !== fileB.path) return false
75
+ if (!isSelectionEqual(fileA.selection, fileB.selection)) return false
76
+ }
77
+ if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
78
+ return false
79
+ }
80
+ if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
81
+ return false
82
+ }
83
+ }
84
+ return true
85
+ }
86
+
87
+ function cloneSelection(selection?: FileSelection) {
88
+ if (!selection) return undefined
89
+ return { ...selection }
90
+ }
91
+
92
+ function clonePart(part: ContentPart): ContentPart {
93
+ if (part.type === "text") return { ...part }
94
+ if (part.type === "image") return { ...part }
95
+ if (part.type === "agent") return { ...part }
96
+ return {
97
+ ...part,
98
+ selection: cloneSelection(part.selection),
99
+ }
100
+ }
101
+
102
+ function clonePrompt(prompt: Prompt): Prompt {
103
+ return prompt.map(clonePart)
104
+ }
105
+
106
+ const WORKSPACE_KEY = "__workspace__"
107
+ const MAX_PROMPT_SESSIONS = 20
108
+
109
+ type PromptSession = ReturnType<typeof createPromptSession>
110
+
111
+ type PromptCacheEntry = {
112
+ value: PromptSession
113
+ dispose: VoidFunction
114
+ }
115
+
116
+ function createPromptSession(dir: string, id: string | undefined) {
117
+ const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
118
+
119
+ const [store, setStore, _, ready] = persisted(
120
+ Persist.scoped(dir, id, "prompt", [legacy]),
121
+ createStore<{
122
+ prompt: Prompt
123
+ cursor?: number
124
+ context: {
125
+ items: (ContextItem & { key: string })[]
126
+ }
127
+ }>({
128
+ prompt: clonePrompt(DEFAULT_PROMPT),
129
+ cursor: undefined,
130
+ context: {
131
+ items: [],
132
+ },
133
+ }),
134
+ )
135
+
136
+ function keyForItem(item: ContextItem) {
137
+ if (item.type !== "file") return item.type
138
+ const start = item.selection?.startLine
139
+ const end = item.selection?.endLine
140
+ const key = `${item.type}:${item.path}:${start}:${end}`
141
+
142
+ if (item.commentID) {
143
+ return `${key}:c=${item.commentID}`
144
+ }
145
+
146
+ const comment = item.comment?.trim()
147
+ if (!comment) return key
148
+ const digest = checksum(comment) ?? comment
149
+ return `${key}:c=${digest.slice(0, 8)}`
150
+ }
151
+
152
+ return {
153
+ ready,
154
+ current: createMemo(() => store.prompt),
155
+ cursor: createMemo(() => store.cursor),
156
+ dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
157
+ context: {
158
+ items: createMemo(() => store.context.items),
159
+ add(item: ContextItem) {
160
+ const key = keyForItem(item)
161
+ if (store.context.items.find((x) => x.key === key)) return
162
+ setStore("context", "items", (items) => [...items, { key, ...item }])
163
+ },
164
+ remove(key: string) {
165
+ setStore("context", "items", (items) => items.filter((x) => x.key !== key))
166
+ },
167
+ },
168
+ set(prompt: Prompt, cursorPosition?: number) {
169
+ const next = clonePrompt(prompt)
170
+ batch(() => {
171
+ setStore("prompt", next)
172
+ if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
173
+ })
174
+ },
175
+ reset() {
176
+ batch(() => {
177
+ setStore("prompt", clonePrompt(DEFAULT_PROMPT))
178
+ setStore("cursor", 0)
179
+ })
180
+ },
181
+ }
182
+ }
183
+
184
+ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
185
+ name: "Prompt",
186
+ gate: false,
187
+ init: () => {
188
+ const params = useParams()
189
+ const cache = new Map<string, PromptCacheEntry>()
190
+
191
+ const disposeAll = () => {
192
+ for (const entry of cache.values()) {
193
+ entry.dispose()
194
+ }
195
+ cache.clear()
196
+ }
197
+
198
+ onCleanup(disposeAll)
199
+
200
+ const prune = () => {
201
+ while (cache.size > MAX_PROMPT_SESSIONS) {
202
+ const first = cache.keys().next().value
203
+ if (!first) return
204
+ const entry = cache.get(first)
205
+ entry?.dispose()
206
+ cache.delete(first)
207
+ }
208
+ }
209
+
210
+ const load = (dir: string, id: string | undefined) => {
211
+ const key = `${dir}:${id ?? WORKSPACE_KEY}`
212
+ const existing = cache.get(key)
213
+ if (existing) {
214
+ cache.delete(key)
215
+ cache.set(key, existing)
216
+ return existing.value
217
+ }
218
+
219
+ const entry = createRoot((dispose) => ({
220
+ value: createPromptSession(dir, id),
221
+ dispose,
222
+ }))
223
+
224
+ cache.set(key, entry)
225
+ prune()
226
+ return entry.value
227
+ }
228
+
229
+ const session = createMemo(() => load(params.dir!, params.id))
230
+
231
+ return {
232
+ ready: () => session().ready(),
233
+ current: () => session().current(),
234
+ cursor: () => session().cursor(),
235
+ dirty: () => session().dirty(),
236
+ context: {
237
+ items: () => session().context.items(),
238
+ add: (item: ContextItem) => session().context.add(item),
239
+ remove: (key: string) => session().context.remove(key),
240
+ },
241
+ set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
242
+ reset: () => session().reset(),
243
+ }
244
+ },
245
+ })
@@ -0,0 +1,48 @@
1
+ import { createOpencodeClient, type Event } from "@jonsoc/sdk/v2/client"
2
+ import { createSimpleContext } from "@jonsoc/ui/context"
3
+ import { createGlobalEmitter } from "@solid-primitives/event-bus"
4
+ import { createEffect, createMemo, onCleanup } from "solid-js"
5
+ import { useGlobalSDK } from "./global-sdk"
6
+ import { usePlatform } from "./platform"
7
+
8
+ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
9
+ name: "SDK",
10
+ init: (props: { directory: string }) => {
11
+ const platform = usePlatform()
12
+ const globalSDK = useGlobalSDK()
13
+
14
+ const directory = createMemo(() => props.directory)
15
+ const client = createMemo(() =>
16
+ createOpencodeClient({
17
+ baseUrl: globalSDK.url,
18
+ fetch: platform.fetch,
19
+ directory: directory(),
20
+ throwOnError: true,
21
+ }),
22
+ )
23
+
24
+ const emitter = createGlobalEmitter<{
25
+ [key in Event["type"]]: Extract<Event, { type: key }>
26
+ }>()
27
+
28
+ createEffect(() => {
29
+ const unsub = globalSDK.event.on(directory(), (event) => {
30
+ emitter.emit(event.type, event)
31
+ })
32
+ onCleanup(unsub)
33
+ })
34
+
35
+ return {
36
+ get directory() {
37
+ return directory()
38
+ },
39
+ get client() {
40
+ return client()
41
+ },
42
+ event: emitter,
43
+ get url() {
44
+ return globalSDK.url
45
+ },
46
+ }
47
+ },
48
+ })