@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.
- package/AGENTS.md +30 -0
- package/README.md +51 -0
- package/bunfig.toml +2 -0
- package/e2e/context.spec.ts +45 -0
- package/e2e/file-open.spec.ts +23 -0
- package/e2e/file-viewer.spec.ts +35 -0
- package/e2e/fixtures.ts +40 -0
- package/e2e/home.spec.ts +21 -0
- package/e2e/model-picker.spec.ts +43 -0
- package/e2e/navigation.spec.ts +9 -0
- package/e2e/palette.spec.ts +15 -0
- package/e2e/prompt-mention.spec.ts +26 -0
- package/e2e/prompt-slash-open.spec.ts +22 -0
- package/e2e/prompt.spec.ts +62 -0
- package/e2e/session.spec.ts +21 -0
- package/e2e/settings.spec.ts +44 -0
- package/e2e/sidebar.spec.ts +21 -0
- package/e2e/terminal-init.spec.ts +25 -0
- package/e2e/terminal.spec.ts +16 -0
- package/e2e/tsconfig.json +8 -0
- package/e2e/utils.ts +38 -0
- package/happydom.ts +75 -0
- package/index.html +23 -0
- package/package.json +72 -0
- package/playwright.config.ts +43 -0
- package/public/_headers +17 -0
- package/public/apple-touch-icon-v3.png +1 -0
- package/public/apple-touch-icon.png +1 -0
- package/public/favicon-96x96-v3.png +1 -0
- package/public/favicon-96x96.png +1 -0
- package/public/favicon-v3.ico +1 -0
- package/public/favicon-v3.svg +1 -0
- package/public/favicon.ico +1 -0
- package/public/favicon.svg +1 -0
- package/public/oc-theme-preload.js +28 -0
- package/public/site.webmanifest +1 -0
- package/public/social-share-zen.png +1 -0
- package/public/social-share.png +1 -0
- package/public/web-app-manifest-192x192.png +1 -0
- package/public/web-app-manifest-512x512.png +1 -0
- package/script/e2e-local.ts +143 -0
- package/src/addons/serialize.test.ts +319 -0
- package/src/addons/serialize.ts +591 -0
- package/src/app.tsx +150 -0
- package/src/components/dialog-connect-provider.tsx +428 -0
- package/src/components/dialog-edit-project.tsx +259 -0
- package/src/components/dialog-fork.tsx +104 -0
- package/src/components/dialog-manage-models.tsx +59 -0
- package/src/components/dialog-select-directory.tsx +208 -0
- package/src/components/dialog-select-file.tsx +196 -0
- package/src/components/dialog-select-mcp.tsx +96 -0
- package/src/components/dialog-select-model-unpaid.tsx +130 -0
- package/src/components/dialog-select-model.tsx +162 -0
- package/src/components/dialog-select-provider.tsx +70 -0
- package/src/components/dialog-select-server.tsx +249 -0
- package/src/components/dialog-settings.tsx +112 -0
- package/src/components/file-tree.tsx +112 -0
- package/src/components/link.tsx +17 -0
- package/src/components/model-tooltip.tsx +91 -0
- package/src/components/prompt-input.tsx +2076 -0
- package/src/components/session/index.ts +5 -0
- package/src/components/session/session-context-tab.tsx +428 -0
- package/src/components/session/session-header.tsx +343 -0
- package/src/components/session/session-new-view.tsx +93 -0
- package/src/components/session/session-sortable-tab.tsx +56 -0
- package/src/components/session/session-sortable-terminal-tab.tsx +187 -0
- package/src/components/session-context-usage.tsx +113 -0
- package/src/components/session-lsp-indicator.tsx +42 -0
- package/src/components/session-mcp-indicator.tsx +34 -0
- package/src/components/settings-agents.tsx +15 -0
- package/src/components/settings-commands.tsx +15 -0
- package/src/components/settings-general.tsx +306 -0
- package/src/components/settings-keybinds.tsx +437 -0
- package/src/components/settings-mcp.tsx +15 -0
- package/src/components/settings-models.tsx +15 -0
- package/src/components/settings-permissions.tsx +234 -0
- package/src/components/settings-providers.tsx +15 -0
- package/src/components/terminal.tsx +315 -0
- package/src/components/titlebar.tsx +156 -0
- package/src/context/command.tsx +308 -0
- package/src/context/comments.tsx +140 -0
- package/src/context/file.tsx +409 -0
- package/src/context/global-sdk.tsx +106 -0
- package/src/context/global-sync.tsx +898 -0
- package/src/context/language.tsx +161 -0
- package/src/context/layout-scroll.test.ts +73 -0
- package/src/context/layout-scroll.ts +118 -0
- package/src/context/layout.tsx +648 -0
- package/src/context/local.tsx +578 -0
- package/src/context/notification.tsx +173 -0
- package/src/context/permission.tsx +167 -0
- package/src/context/platform.tsx +59 -0
- package/src/context/prompt.tsx +245 -0
- package/src/context/sdk.tsx +48 -0
- package/src/context/server.tsx +214 -0
- package/src/context/settings.tsx +166 -0
- package/src/context/sync.tsx +320 -0
- package/src/context/terminal.tsx +267 -0
- package/src/custom-elements.d.ts +17 -0
- package/src/entry.tsx +76 -0
- package/src/env.d.ts +8 -0
- package/src/hooks/use-providers.ts +31 -0
- package/src/i18n/ar.ts +656 -0
- package/src/i18n/br.ts +667 -0
- package/src/i18n/da.ts +582 -0
- package/src/i18n/de.ts +591 -0
- package/src/i18n/en.ts +665 -0
- package/src/i18n/es.ts +585 -0
- package/src/i18n/fr.ts +592 -0
- package/src/i18n/ja.ts +579 -0
- package/src/i18n/ko.ts +580 -0
- package/src/i18n/no.ts +602 -0
- package/src/i18n/pl.ts +661 -0
- package/src/i18n/ru.ts +664 -0
- package/src/i18n/zh.ts +574 -0
- package/src/i18n/zht.ts +570 -0
- package/src/index.css +57 -0
- package/src/index.ts +2 -0
- package/src/pages/directory-layout.tsx +57 -0
- package/src/pages/error.tsx +290 -0
- package/src/pages/home.tsx +125 -0
- package/src/pages/layout.tsx +2599 -0
- package/src/pages/session.tsx +2505 -0
- package/src/sst-env.d.ts +10 -0
- package/src/utils/dom.ts +51 -0
- package/src/utils/id.ts +99 -0
- package/src/utils/index.ts +1 -0
- package/src/utils/perf.ts +135 -0
- package/src/utils/persist.ts +377 -0
- package/src/utils/prompt.ts +203 -0
- package/src/utils/same.ts +6 -0
- package/src/utils/solid-dnd.tsx +55 -0
- package/src/utils/sound.ts +110 -0
- package/src/utils/speech.ts +302 -0
- package/src/utils/worktree.ts +58 -0
- package/sst-env.d.ts +9 -0
- package/tsconfig.json +26 -0
- package/vite.config.ts +15 -0
- 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
|
+
})
|