diff --git a/bun.lock b/bun.lock
index 679f837e..af3e4f2d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -121,6 +121,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
+ "@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.3",
"@thisbeyond/solid-dnd": "0.7.5",
@@ -364,7 +365,7 @@
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@openauthjs/openauth": "0.0.0-20250322224806",
- "@pierre/precision-diffs": "0.4.1",
+ "@pierre/precision-diffs": "0.4.2",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
@@ -1033,7 +1034,7 @@
"@petamoriken/float16": ["@petamoriken/float16@3.9.3", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
- "@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.1", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/transformers": "3.13.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.13.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-AoozHakINGyNJFgbYc/1PlDK0yunrAxbtXEMBe9fdu8RLkNjVtYRTLw7EF2mM/YuVoVRjj2HT/2VJ4a2rMyDOA=="],
+ "@pierre/precision-diffs": ["@pierre/precision-diffs@0.4.2", "", { "dependencies": { "@shikijs/core": "3.14.0", "@shikijs/transformers": "3.14.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "shiki": "3.14.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-C6LbruH24BCp4awI47D5iMtVaZZD6GkzqBoDw+Sfu7DB3hC9y/rZr1C2BD7AUzAKwByTfFnh16Zp11ipfPqLKw=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
@@ -1309,6 +1310,8 @@
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="],
+ "@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
+
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-IWoptVc0SWYgmpBPpCMehS5b07+tpFcvw15tOQ3QbXedSYn6KP8zCjPkHNzMxcOvOicTneleeZDP7lqmz+PQ6g=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
@@ -3725,11 +3728,11 @@
"@parcel/watcher-wasm/napi-wasm": ["napi-wasm@1.1.3", "", { "bundled": true }, "sha512-h/4nMGsHjZDCYmQVNODIrYACVJ+I9KItbG+0si6W/jSjdA9JbWDoU4LLeMXVcEQGHjttI2tuXqDrbGF7qkUHHg=="],
- "@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-3P8rGsg2Eh2qIHekwuQjzWhKI4jV97PhvYjYUzGqjvJfqdQPz+nMlfWahU24GZAyW1FxFI1sYjyhfh5CoLmIUA=="],
+ "@pierre/precision-diffs/@shikijs/core": ["@shikijs/core@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-qRSeuP5vlYHCNUIrpEBQFO7vSkR7jn7Kv+5X3FO/zBKVDGQbcnlScD3XhkrHi/R8Ltz0kEjvFR9Szp/XMRbFMw=="],
- "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/types": "3.13.0" } }, "sha512-833lcuVzcRiG+fXvgslWsM2f4gHpjEgui1ipIknSizRuTgMkNZupiXE5/TVJ6eSYfhNBFhBZKkReKWO2GgYmqA=="],
+ "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/transformers@3.14.0", "", { "dependencies": { "@shikijs/core": "3.14.0", "@shikijs/types": "3.14.0" } }, "sha512-i67zQnY9wLMMnKasonVW1L9fKneSLZDj1ePsA4o0AZWU4uUobmJY9baRDa36z+a9/g0aG76/2tybQvm4hrwxIQ=="],
- "@pierre/precision-diffs/shiki": ["shiki@3.13.0", "", { "dependencies": { "@shikijs/core": "3.13.0", "@shikijs/engine-javascript": "3.13.0", "@shikijs/engine-oniguruma": "3.13.0", "@shikijs/langs": "3.13.0", "@shikijs/themes": "3.13.0", "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-aZW4l8Og16CokuCLf8CF8kq+KK2yOygapU5m3+hoGw0Mdosc6fPitjM+ujYarppj5ZIKGyPDPP1vqmQhr+5/0g=="],
+ "@pierre/precision-diffs/shiki": ["shiki@3.14.0", "", { "dependencies": { "@shikijs/core": "3.14.0", "@shikijs/engine-javascript": "3.14.0", "@shikijs/engine-oniguruma": "3.14.0", "@shikijs/langs": "3.14.0", "@shikijs/themes": "3.14.0", "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-J0yvpLI7LSig3Z3acIuDLouV5UCKQqu8qOArwMx+/yPVC3WRMgrP67beaG8F+j4xfEWE0eVC4GeBCIXeOPra1g=="],
"@poppinss/dumper/supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
@@ -4327,19 +4330,19 @@
"@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
- "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+ "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/types@3.14.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ=="],
- "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+ "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.14.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ=="],
- "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-Ty7xv32XCp8u0eQt8rItpMs6rU9Ki6LJ1dQOW3V/56PKDcpvfHPnYFbsx5FFUP2Yim34m/UkazidamMNVR4vKg=="],
+ "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-3v1kAXI2TsWQuwv86cREH/+FK9Pjw3dorVEykzQDhwrZj0lwsHYlfyARaKmn6vr5Gasf8aeVpb8JkzeWspxOLQ=="],
- "@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-O42rBGr4UDSlhT2ZFMxqM7QzIU+IcpoTMzb3W7AlziI1ZF7R8eS2M0yt5Ry35nnnTX/LTLXFPUjRFCIW+Operg=="],
+ "@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-TNcYTYMbJyy+ZjzWtt0bG5y4YyMIWC2nyePz+CFMWqm+HnZZyy9SWMgo8Z6KBJVIZnx8XUXS8U2afO6Y0g1Oug=="],
- "@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-672c3WAETDYHwrRP0yLy3W1QYB89Hbpj+pO4KhxK6FzIrDI2FoEXNiNCut6BQmEApYLfuYfpgOZaqbY+E9b8wQ=="],
+ "@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/langs@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0" } }, "sha512-DIB2EQY7yPX1/ZH7lMcwrK5pl+ZkP/xoSpUzg9YC8R+evRCCiSQ7yyrvEyBsMnfZq4eBzLzBlugMyTAf13+pzg=="],
- "@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.13.0", "", { "dependencies": { "@shikijs/types": "3.13.0" } }, "sha512-Vxw1Nm1/Od8jyA7QuAenaV78BG2nSr3/gCGdBkLpfLscddCkzkL36Q5b67SrLLfvAJTOUzW39x4FHVCFriPVgg=="],
+ "@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/themes@3.14.0", "", { "dependencies": { "@shikijs/types": "3.14.0" } }, "sha512-fAo/OnfWckNmv4uBoUu6dSlkcBc+SA1xzj5oUSaz5z3KqHtEbUypg/9xxgJARtM6+7RVm0Q6Xnty41xA1ma1IA=="],
- "@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.13.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-oM9P+NCFri/mmQ8LoFGVfVyemm5Hi27330zuOBp0annwJdKH1kOLndw3zCtAVDehPLg9fKqoEx3Ht/wNZxolfw=="],
+ "@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/types@3.14.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-bQGgC6vrY8U/9ObG1Z/vTro+uclbjjD/uG58RvfxKZVD5p9Yc1ka3tVyEFy7BNJLzxuWyHH5NWynP9zZZS59eQ=="],
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
diff --git a/package.json b/package.json
index 55125896..de6c62a5 100644
--- a/package.json
+++ b/package.json
@@ -28,7 +28,7 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
- "@pierre/precision-diffs": "0.4.1",
+ "@pierre/precision-diffs": "0.4.2",
"@solidjs/meta": "0.29.4",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index 8eb2786d..094e71b0 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -31,6 +31,7 @@
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
+ "@solid-primitives/storage": "4.3.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.3",
"@thisbeyond/solid-dnd": "0.7.5",
diff --git a/packages/desktop/src/components/message-progress.tsx b/packages/desktop/src/components/message-progress.tsx
index c0037f57..a9be2ae5 100644
--- a/packages/desktop/src/components/message-progress.tsx
+++ b/packages/desktop/src/components/message-progress.tsx
@@ -70,9 +70,8 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
const rawStatus = createMemo(() => {
- const defaultStatus = "Working..."
const last = lastPart()
- if (!last) return defaultStatus
+ if (!last) return undefined
if (last.type === "tool") {
switch (last.tool) {
@@ -102,7 +101,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
} else if (last.type === "text") {
return "Gathering thoughts..."
}
- return defaultStatus
+ return undefined
})
const [status, setStatus] = createSignal(rawStatus())
@@ -111,11 +110,11 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
createEffect(() => {
const newStatus = rawStatus()
- if (newStatus === status()) return
+ if (newStatus === status() || !newStatus) return
const timeSinceLastChange = Date.now() - lastStatusChange
- if (timeSinceLastChange >= 1000) {
+ if (timeSinceLastChange >= 1500) {
setStatus(newStatus)
lastStatusChange = Date.now()
if (statusTimeout) {
@@ -145,7 +144,7 @@ export function MessageProgress(props: { assistantMessages: () => AssistantMessa
{/* )} */}
{/* */}
- {status()}
+ {status() ?? "Considering next steps..."}
0}>
void
class?: string
ref?: (el: HTMLDivElement) => void
}
export const PromptInput: Component
= (props) => {
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const sync = useSync()
const local = useLocal()
+ const session = useSession()
let editorRef!: HTMLDivElement
- const defaultParts = [{ type: "text", content: "", start: 0, end: 0 } as const]
const [store, setStore] = createStore<{
- contentParts: ContentPart[]
popoverIsOpen: boolean
}>({
- contentParts: defaultParts,
popoverIsOpen: false,
})
- const isEmpty = createMemo(() => isEqual(store.contentParts, defaultParts))
+ createEffect(() => {
+ session.id
+ editorRef.focus()
+ })
+
const isFocused = createFocusSignal(() => editorRef)
const handlePaste = (event: ClipboardEvent) => {
@@ -71,14 +61,16 @@ export const PromptInput: Component = (props) => {
}
})
+ const handleFileSelect = (path: string | undefined) => {
+ if (!path) return
+ addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
+ setStore("popoverIsOpen", false)
+ }
+
const { flat, active, onInput, onKeyDown, refetch } = useFilteredList({
items: local.file.searchFilesAndDirectories,
key: (x) => x,
- onSelect: (path) => {
- if (!path) return
- addPart({ type: "file", path, content: "@" + getFilename(path), start: 0, end: 0 })
- setStore("popoverIsOpen", false)
- },
+ onSelect: handleFileSelect,
})
createEffect(() => {
@@ -88,10 +80,10 @@ export const PromptInput: Component = (props) => {
createEffect(
on(
- () => store.contentParts,
+ () => session.prompt.current(),
(currentParts) => {
const domParts = parseFromDOM()
- if (isEqual(currentParts, domParts)) return
+ if (isPromptEqual(currentParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
@@ -122,8 +114,18 @@ export const PromptInput: Component = (props) => {
),
)
- const parseFromDOM = (): ContentPart[] => {
- const newParts: ContentPart[] = []
+ createEffect(
+ on(
+ () => session.prompt.cursor(),
+ (cursor) => {
+ if (cursor === undefined) return
+ queueMicrotask(() => setCursorPosition(editorRef, cursor))
+ },
+ ),
+ )
+
+ const parseFromDOM = (): Prompt => {
+ const newParts: Prompt = []
let position = 0
editorRef.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
@@ -150,7 +152,7 @@ export const PromptInput: Component = (props) => {
}
}
})
- if (newParts.length === 0) newParts.push(...defaultParts)
+ if (newParts.length === 0) newParts.push(...DEFAULT_PROMPT)
return newParts
}
@@ -167,12 +169,15 @@ export const PromptInput: Component = (props) => {
setStore("popoverIsOpen", false)
}
- setStore("contentParts", rawParts)
+ session.prompt.set(rawParts, cursorPosition)
}
const addPart = (part: ContentPart) => {
const cursorPosition = getCursorPosition(editorRef)
- const rawText = store.contentParts.map((p) => p.content).join("")
+ const rawText = session.prompt
+ .current()
+ .map((p) => p.content)
+ .join("")
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
@@ -198,7 +203,7 @@ export const PromptInput: Component = (props) => {
parts: nextParts,
inserted,
cursorPositionAfter,
- } = store.contentParts.reduce(
+ } = session.prompt.current().reduce(
(acc, item) => {
if (acc.inserted) {
acc.parts.push({ ...item, start: acc.runningIndex, end: acc.runningIndex + item.content.length })
@@ -257,7 +262,7 @@ export const PromptInput: Component = (props) => {
)
if (!inserted) {
- const baseParts = store.contentParts.filter((item) => !(item.type === "text" && item.content === ""))
+ const baseParts = session.prompt.current().filter((item) => !(item.type === "text" && item.content === ""))
const runningIndex = baseParts.reduce((sum, p) => sum + p.content.length, 0)
const appendedAcc = { parts: [...baseParts] as ContentPart[], runningIndex }
if (part.type === "text") {
@@ -270,20 +275,27 @@ export const PromptInput: Component = (props) => {
end: appendedAcc.runningIndex + part.content.length,
})
}
- const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : defaultParts
- setStore("contentParts", next)
- setStore("popoverIsOpen", false)
+ const next = appendedAcc.parts.length > 0 ? appendedAcc.parts : DEFAULT_PROMPT
const nextCursor = rawText.length + part.content.length
+ session.prompt.set(next, nextCursor)
+ setStore("popoverIsOpen", false)
queueMicrotask(() => setCursorPosition(editorRef, nextCursor))
return
}
- setStore("contentParts", nextParts)
+ session.prompt.set(nextParts, cursorPositionAfter)
setStore("popoverIsOpen", false)
queueMicrotask(() => setCursorPosition(editorRef, cursorPositionAfter))
}
+ const abort = () =>
+ sdk.client.session.abort({
+ path: {
+ id: session.id!,
+ },
+ })
+
const handleKeyDown = (event: KeyboardEvent) => {
if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
onKeyDown(event)
@@ -293,14 +305,101 @@ export const PromptInput: Component = (props) => {
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event)
}
+ if (event.key === "Escape") {
+ if (store.popoverIsOpen) {
+ setStore("popoverIsOpen", false)
+ } else if (session.working()) {
+ abort()
+ }
+ }
}
- const handleSubmit = (event: Event) => {
+ const handleSubmit = async (event: Event) => {
event.preventDefault()
- if (store.contentParts.length > 0) {
- props.onSubmit([...store.contentParts])
- setStore("contentParts", defaultParts)
+ const text = session.prompt
+ .current()
+ .map((part) => part.content)
+ .join("")
+ if (text.trim().length === 0) {
+ if (session.working()) abort()
+ return
}
+
+ let existing = session.info()
+ if (!existing) {
+ const created = await sdk.client.session.create()
+ existing = created.data ?? undefined
+ }
+ if (!existing) return
+
+ navigate(`/session/${existing.id}`)
+ if (!session.id) {
+ // session.layout.setOpenedTabs(
+ // session.layout.copyTabs("", session.id)
+ }
+ session.layout.setActiveTab(undefined)
+ const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
+
+ const attachments = session.prompt.current().filter((part) => part.type === "file")
+
+ // const activeFile = local.context.active()
+ // if (activeFile) {
+ // registerAttachment(
+ // activeFile.path,
+ // activeFile.selection,
+ // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
+ // )
+ // }
+
+ // for (const contextFile of local.context.all()) {
+ // registerAttachment(
+ // contextFile.path,
+ // contextFile.selection,
+ // formatAttachmentLabel(contextFile.path, contextFile.selection),
+ // )
+ // }
+
+ const attachmentParts = attachments.map((attachment) => {
+ const absolute = toAbsolutePath(attachment.path)
+ const query = attachment.selection
+ ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
+ : ""
+ return {
+ type: "file" as const,
+ mime: "text/plain",
+ url: `file://${absolute}${query}`,
+ filename: getFilename(attachment.path),
+ source: {
+ type: "file" as const,
+ text: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ path: absolute,
+ },
+ }
+ })
+
+ session.prompt.set(DEFAULT_PROMPT, 0)
+
+ await sdk.client.session.prompt({
+ path: { id: existing.id },
+ body: {
+ agent: local.agent.current()!.name,
+ model: {
+ modelID: local.model.current()!.id,
+ providerID: local.model.current()!.provider.id,
+ },
+ parts: [
+ {
+ type: "text",
+ text,
+ },
+ ...attachmentParts,
+ ],
+ },
+ })
}
return (
@@ -310,11 +409,12 @@ export const PromptInput: Component = (props) => {
0} fallback={No matching files
}>
{(i) => (
- handleFileSelect(i)}
>
@@ -326,7 +426,7 @@ export const PromptInput: Component
= (props) => {
-
+
)}
@@ -354,7 +454,7 @@ export const PromptInput: Component = (props) => {
"[&>[data-type=file]]:text-icon-info-active": true,
}}
/>
-
+
Plan and build anything
@@ -419,29 +519,18 @@ export const PromptInput: Component = (props) => {
)}
-
+
)
}
-function isEqual(arrA: ContentPart[], arrB: ContentPart[]): boolean {
- if (arrA.length !== arrB.length) return false
- for (let i = 0; i < arrA.length; i++) {
- const partA = arrA[i]
- const partB = arrB[i]
- if (partA.type !== partB.type) return false
- if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
- return false
- }
- if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
- return false
- }
- }
- return true
-}
-
function getCursorPosition(parent: HTMLElement): number {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return 0
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 8fc05c45..cef6c555 100644
--- a/packages/desktop/src/context/local.tsx
+++ b/packages/desktop/src/context/local.tsx
@@ -195,18 +195,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const file = (() => {
const [store, setStore] = createStore<{
node: Record
- // opened: string[]
- // active?: string
}>({
node: Object.fromEntries(sync.data.node.map((x) => [x.path, x])),
- // opened: [],
})
- // const active = createMemo(() => {
- // if (!store.active) return undefined
- // return store.node[store.active]
- // })
- // const opened = createMemo(() => store.opened.map((x) => store.node[x]))
const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path)))
const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b)))
@@ -247,18 +239,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return false
}
- const resetNode = (path: string) => {
- setStore("node", path, {
- loaded: undefined,
- pinned: undefined,
- content: undefined,
- selection: undefined,
- scrollTop: undefined,
- folded: undefined,
- view: undefined,
- selectedChange: undefined,
- })
- }
+ // const resetNode = (path: string) => {
+ // setStore("node", path, {
+ // loaded: undefined,
+ // pinned: undefined,
+ // content: undefined,
+ // selection: undefined,
+ // scrollTop: undefined,
+ // folded: undefined,
+ // view: undefined,
+ // selectedChange: undefined,
+ // })
+ // }
const relative = (path: string) => path.replace(sync.data.path.directory + "/", "")
@@ -333,31 +325,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
- case "message.part.updated":
- const part = event.properties.part
- if (part.type === "tool" && part.state.status === "completed") {
- switch (part.tool) {
- case "read":
- break
- case "edit":
- // load(part.state.input["filePath"] as string)
- break
- default:
- break
- }
- }
- break
case "file.watcher.updated":
- // setTimeout(sync.load.changes, 1000)
- // const relativePath = relative(event.properties.file)
- // if (relativePath.startsWith(".git/")) return
- // load(relativePath)
+ const relativePath = relative(event.properties.file)
+ if (relativePath.startsWith(".git/")) return
+ load(relativePath)
break
}
})
return {
- node: (path: string) => store.node[path],
+ node: async (path: string) => {
+ if (!store.node[path]) {
+ await init(path)
+ }
+ return store.node[path]
+ },
update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)),
open,
load,
@@ -417,121 +399,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
searchFiles,
searchFilesAndDirectories,
relative,
- // active,
- // opened,
- // close(path: string) {
- // setStore("opened", (opened) => opened.filter((x) => x !== path))
- // if (store.active === path) {
- // const index = store.opened.findIndex((f) => f === path)
- // const previous = store.opened[Math.max(0, index - 1)]
- // setStore("active", previous)
- // }
- // resetNode(path)
- // },
- // move(path: string, to: number) {
- // const index = store.opened.findIndex((f) => f === path)
- // if (index === -1) return
- // setStore(
- // "opened",
- // produce((opened) => {
- // opened.splice(to, 0, opened.splice(index, 1)[0])
- // }),
- // )
- // setStore("node", path, "pinned", true)
- // },
- }
- })()
-
- const session = (() => {
- const [store, setStore] = createStore<{
- active?: string
- tabs: Record<
- string,
- {
- active?: string
- opened: string[]
- }
- >
- }>({
- tabs: {
- "": {
- opened: [],
- },
- },
- })
-
- const active = createMemo(() => {
- if (!store.active) return undefined
- return sync.session.get(store.active)
- })
-
- createEffect(() => {
- if (!store.active) return
- sync.session.sync(store.active)
-
- if (!store.tabs[store.active]) {
- setStore("tabs", store.active, {
- opened: [],
- })
- }
- })
-
- const tabs = createMemo(() => store.tabs[store.active ?? ""])
-
- return {
- active,
- setActive(sessionId: string | undefined) {
- setStore("active", sessionId)
- },
- clearActive() {
- setStore("active", undefined)
- },
- tabs,
- copyTabs(from: string, to: string) {
- setStore("tabs", to, {
- opened: store.tabs[from]?.opened ?? [],
- })
- },
- setActiveTab(tab: string | undefined) {
- setStore("tabs", store.active ?? "", "active", tab)
- },
- async open(tab: string) {
- if (tab !== "chat") {
- await file.open(tab)
- }
- if (!tabs()?.opened?.includes(tab)) {
- setStore("tabs", store.active ?? "", "opened", [...(tabs()?.opened ?? []), tab])
- }
- setStore("tabs", store.active ?? "", "active", tab)
- },
- close(tab: string) {
- batch(() => {
- if (!tabs()) return
- setStore("tabs", store.active ?? "", {
- active: tabs()!.active,
- opened: tabs()!.opened.filter((x) => x !== tab),
- })
- if (tabs()!.active === tab) {
- const index = tabs()!.opened.findIndex((f) => f === tab)
- const previous = tabs()!.opened[Math.max(0, index - 1)]
- setStore("tabs", store.active ?? "", "active", previous)
- }
- })
- },
- move(tab: string, to: number) {
- if (!tabs()) return
- const index = tabs()!.opened.findIndex((f) => f === tab)
- if (index === -1) return
- setStore(
- "tabs",
- store.active ?? "",
- "opened",
- produce((opened) => {
- opened.splice(to, 0, opened.splice(index, 1)[0])
- }),
- )
- // setStore("node", path, "pinned", true)
- },
}
})()
@@ -593,7 +460,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
file,
- session,
context,
}
return result
diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx
new file mode 100644
index 00000000..77ab3bc2
--- /dev/null
+++ b/packages/desktop/src/context/session.tsx
@@ -0,0 +1,213 @@
+import { createStore, produce } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+import { batch, createEffect, createMemo } from "solid-js"
+import { useSync } from "./sync"
+import { makePersisted } from "@solid-primitives/storage"
+import { TextSelection, useLocal } from "./local"
+import { pipe, sumBy } from "remeda"
+import { AssistantMessage } from "@opencode-ai/sdk"
+
+export const { use: useSession, provider: SessionProvider } = createSimpleContext({
+ name: "Session",
+ init: (props: { sessionId?: string }) => {
+ const sync = useSync()
+ const local = useLocal()
+
+ const [store, setStore] = makePersisted(
+ createStore<{
+ prompt: Prompt
+ cursorPosition?: number
+ messageId?: string
+ tabs: {
+ active?: string
+ opened: string[]
+ }
+ }>({
+ prompt: [{ type: "text", content: "", start: 0, end: 0 }],
+ tabs: {
+ opened: [],
+ },
+ }),
+ {
+ name: props.sessionId ?? "new-session",
+ },
+ )
+
+ createEffect(() => {
+ if (!props.sessionId) return
+ sync.session.sync(props.sessionId)
+ })
+
+ const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
+ const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
+ const userMessages = createMemo(() =>
+ messages()
+ .filter((m) => m.role === "user")
+ .sort((a, b) => b.id.localeCompare(a.id)),
+ )
+ const lastUserMessage = createMemo(() => {
+ return userMessages()?.at(0)
+ })
+ const activeMessage = createMemo(() => {
+ if (!store.messageId) return lastUserMessage()
+ return userMessages()?.find((m) => m.id === store.messageId)
+ })
+ const working = createMemo(() => {
+ if (!props.sessionId) return false
+ const last = lastUserMessage()
+ if (!last) return false
+ const assistantMessages = sync.data.message[props.sessionId]?.filter(
+ (m) => m.role === "assistant" && m.parentID == last?.id,
+ ) as AssistantMessage[]
+ const error = assistantMessages?.find((m) => m?.error)?.error
+ return !last?.summary?.body && !error
+ })
+
+ const cost = createMemo(() => {
+ const total = pipe(
+ messages(),
+ sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
+ )
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ }).format(total)
+ })
+
+ const last = createMemo(
+ () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
+ )
+ const model = createMemo(() =>
+ last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
+ )
+
+ const tokens = createMemo(() => {
+ if (!last()) return
+ const tokens = last().tokens
+ return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
+ })
+
+ const context = createMemo(() => {
+ const total = tokens()
+ const limit = model()?.limit.context
+ if (!total || !limit) return 0
+ return Math.round((total / limit) * 100)
+ })
+
+ return {
+ id: props.sessionId,
+ info,
+ working,
+ prompt: {
+ current: createMemo(() => store.prompt),
+ cursor: createMemo(() => store.cursorPosition),
+ dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+ set(prompt: Prompt, cursorPosition?: number) {
+ batch(() => {
+ setStore("prompt", prompt)
+ if (cursorPosition !== undefined) setStore("cursorPosition", cursorPosition)
+ })
+ },
+ },
+ messages: {
+ all: messages,
+ user: userMessages,
+ last: lastUserMessage,
+ active: activeMessage,
+ setActive(id: string | undefined) {
+ setStore("messageId", id)
+ },
+ },
+ usage: {
+ tokens,
+ cost,
+ context,
+ },
+ layout: {
+ tabs: store.tabs,
+ setActiveTab(tab: string | undefined) {
+ setStore("tabs", "active", tab)
+ },
+ setOpenedTabs(tabs: string[]) {
+ setStore("tabs", "opened", tabs)
+ },
+ async openTab(tab: string) {
+ if (tab === "chat") {
+ setStore("tabs", "active", undefined)
+ return
+ }
+ if (tab.startsWith("file://")) {
+ await local.file.open(tab.replace("file://", ""))
+ }
+ if (!store.tabs.opened.includes(tab)) {
+ setStore("tabs", "opened", [...store.tabs.opened, tab])
+ }
+ setStore("tabs", "active", tab)
+ },
+ closeTab(tab: string) {
+ batch(() => {
+ setStore(
+ "tabs",
+ "opened",
+ store.tabs.opened.filter((x) => x !== tab),
+ )
+ if (store.tabs.active === tab) {
+ const index = store.tabs.opened.findIndex((f) => f === tab)
+ const previous = store.tabs.opened[Math.max(0, index - 1)]
+ setStore("tabs", "active", previous)
+ }
+ })
+ },
+ moveTab(tab: string, to: number) {
+ const index = store.tabs.opened.findIndex((f) => f === tab)
+ if (index === -1) return
+ setStore(
+ "tabs",
+ "opened",
+ produce((opened) => {
+ opened.splice(to, 0, opened.splice(index, 1)[0])
+ }),
+ )
+ // setStore("node", path, "pinned", true)
+ },
+ },
+ }
+ },
+})
+
+interface PartBase {
+ content: string
+ start: number
+ end: number
+}
+
+export interface TextPart extends PartBase {
+ type: "text"
+}
+
+export interface FileAttachmentPart extends PartBase {
+ type: "file"
+ path: string
+ selection?: TextSelection
+}
+
+export type ContentPart = TextPart | FileAttachmentPart
+export type Prompt = ContentPart[]
+
+export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
+ if (promptA.length !== promptB.length) return false
+ for (let i = 0; i < promptA.length; i++) {
+ const partA = promptA[i]
+ const partB = promptB[i]
+ if (partA.type !== partB.type) return false
+ if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
+ return false
+ }
+ if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
+ return false
+ }
+ }
+ return true
+}
diff --git a/packages/desktop/src/context/sync.tsx b/packages/desktop/src/context/sync.tsx
index 06fc0567..c60206b0 100644
--- a/packages/desktop/src/context/sync.tsx
+++ b/packages/desktop/src/context/sync.tsx
@@ -1,16 +1,4 @@
-import type {
- Message,
- Agent,
- Provider,
- Session,
- Part,
- Config,
- Path,
- File,
- FileNode,
- Project,
- Command,
-} from "@opencode-ai/sdk"
+import type { Message, Agent, Provider, Session, Part, Config, Path, File, FileNode, Project } from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { createMemo } from "solid-js"
import { Binary } from "@/utils/binary"
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 9fe5da2f..de9994af 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -7,7 +7,9 @@ import { Fonts, ShikiProvider, MarkedProvider } from "@opencode-ai/ui"
import { SDKProvider } from "./context/sdk"
import { SyncProvider } from "./context/sync"
import { LocalProvider } from "./context/local"
-import Home from "@/pages"
+import Layout from "@/pages/layout"
+import SessionLayout from "@/pages/session-layout"
+import Session from "@/pages/session"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@@ -32,8 +34,10 @@ render(
-
-
+
+
+
+
diff --git a/packages/desktop/src/pages/index.tsx b/packages/desktop/src/pages/index.tsx
deleted file mode 100644
index 5f04c3db..00000000
--- a/packages/desktop/src/pages/index.tsx
+++ /dev/null
@@ -1,857 +0,0 @@
-import {
- Button,
- List,
- SelectDialog,
- Tooltip,
- IconButton,
- Tabs,
- Icon,
- Accordion,
- Diff,
- Collapsible,
- DiffChanges,
- Message,
- Typewriter,
- Card,
- Code,
-} from "@opencode-ai/ui"
-import { FileIcon } from "@/ui"
-import FileTree from "@/components/file-tree"
-import { MessageProgress } from "@/components/message-progress"
-import { For, onCleanup, onMount, Show, Match, Switch, createSignal, createEffect, createMemo } from "solid-js"
-import { useLocal, type LocalFile } from "@/context/local"
-import { createStore } from "solid-js/store"
-import { getDirectory, getFilename } from "@/utils"
-import { ContentPart, PromptInput } from "@/components/prompt-input"
-import { DateTime } from "luxon"
-import {
- DragDropProvider,
- DragDropSensors,
- DragOverlay,
- SortableProvider,
- closestCenter,
- createSortable,
- useDragDropContext,
-} from "@thisbeyond/solid-dnd"
-import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
-import type { JSX } from "solid-js"
-import { useSync } from "@/context/sync"
-import { useSDK } from "@/context/sdk"
-import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
-import { Markdown } from "@opencode-ai/ui"
-import { Spinner } from "@/components/spinner"
-
-export default function Page() {
- const local = useLocal()
- const sync = useSync()
- const sdk = useSDK()
- const [store, setStore] = createStore({
- clickTimer: undefined as number | undefined,
- fileSelectOpen: false,
- })
- let inputRef!: HTMLDivElement
- let messageScrollElement!: HTMLDivElement
- const [activeItem, setActiveItem] = createSignal(undefined)
-
- const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
-
- onMount(() => {
- document.addEventListener("keydown", handleKeyDown)
- })
-
- onCleanup(() => {
- document.removeEventListener("keydown", handleKeyDown)
- })
-
- const handleKeyDown = (event: KeyboardEvent) => {
- if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
- event.preventDefault()
- return
- }
- if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
- event.preventDefault()
- setStore("fileSelectOpen", true)
- return
- }
-
- const focused = document.activeElement === inputRef
- if (focused) {
- if (event.key === "Escape") {
- inputRef?.blur()
- }
- return
- }
-
- // if (local.file.active()) {
- // const active = local.file.active()!
- // if (event.key === "Enter" && active.selection) {
- // local.context.add({
- // type: "file",
- // path: active.path,
- // selection: { ...active.selection },
- // })
- // return
- // }
- //
- // if (event.getModifierState(MOD)) {
- // if (event.key.toLowerCase() === "a") {
- // return
- // }
- // if (event.key.toLowerCase() === "c") {
- // return
- // }
- // }
- // }
-
- if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
- inputRef?.focus()
- }
- }
-
- const resetClickTimer = () => {
- if (!store.clickTimer) return
- clearTimeout(store.clickTimer)
- setStore("clickTimer", undefined)
- }
-
- const startClickTimer = () => {
- const newClickTimer = setTimeout(() => {
- setStore("clickTimer", undefined)
- }, 300)
- setStore("clickTimer", newClickTimer as unknown as number)
- }
-
- const handleFileClick = async (file: LocalFile) => {
- if (store.clickTimer) {
- resetClickTimer()
- local.file.update(file.path, { ...file, pinned: true })
- } else {
- local.file.open(file.path)
- startClickTimer()
- }
- }
-
- // const navigateChange = (dir: 1 | -1) => {
- // const active = local.file.active()
- // if (!active) return
- // const current = local.file.changeIndex(active.path)
- // const next = current === undefined ? (dir === 1 ? 0 : -1) : current + dir
- // local.file.setChangeIndex(active.path, next)
- // }
-
- const handleTabChange = (path: string) => {
- local.session.setActiveTab(path)
- if (path === "chat") return
- local.session.open(path)
- }
-
- const handleTabClose = (file: LocalFile) => {
- local.session.close(file.path)
- }
-
- const handleDragStart = (event: unknown) => {
- const id = getDraggableId(event)
- if (!id) return
- setActiveItem(id)
- }
-
- const handleDragOver = (event: DragEvent) => {
- const { draggable, droppable } = event
- if (draggable && droppable) {
- const currentFiles = local.session.tabs()?.opened.map((file) => file)
- const fromIndex = currentFiles?.indexOf(draggable.id.toString())
- const toIndex = currentFiles?.indexOf(droppable.id.toString())
- if (fromIndex !== toIndex && toIndex !== undefined) {
- local.session.move(draggable.id.toString(), toIndex)
- }
- }
- }
-
- const handleDragEnd = () => {
- setActiveItem(undefined)
- }
-
- // const scrollDiffItem = (element: HTMLElement) => {
- // element.scrollIntoView({ block: "start", behavior: "instant" })
- // }
-
- const handleDiffTriggerClick = (event: MouseEvent) => {
- // disabling scroll to diff for now
- return
- // const target = event.currentTarget as HTMLElement
- // queueMicrotask(() => {
- // if (target.getAttribute("aria-expanded") !== "true") return
- // const item = target.closest('[data-slot="accordion-item"]') as HTMLElement | null
- // if (!item) return
- // scrollDiffItem(item)
- // })
- }
-
- const handlePromptSubmit = async (parts: ContentPart[]) => {
- const existingSession = local.session.active()
- let session = existingSession
- if (!session) {
- const created = await sdk.client.session.create()
- session = created.data ?? undefined
- }
- if (!session) return
-
- local.session.setActive(session.id)
- if (!existingSession) {
- local.session.copyTabs("", session.id)
- }
- local.session.setActiveTab(undefined)
- const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
-
- const text = parts.map((part) => part.content).join("")
- const attachments = parts.filter((part) => part.type === "file")
-
- // const activeFile = local.context.active()
- // if (activeFile) {
- // registerAttachment(
- // activeFile.path,
- // activeFile.selection,
- // activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
- // )
- // }
-
- // for (const contextFile of local.context.all()) {
- // registerAttachment(
- // contextFile.path,
- // contextFile.selection,
- // formatAttachmentLabel(contextFile.path, contextFile.selection),
- // )
- // }
-
- const attachmentParts = attachments.map((attachment) => {
- const absolute = toAbsolutePath(attachment.path)
- const query = attachment.selection
- ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
- : ""
- return {
- type: "file" as const,
- mime: "text/plain",
- url: `file://${absolute}${query}`,
- filename: getFilename(attachment.path),
- source: {
- type: "file" as const,
- text: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- path: absolute,
- },
- }
- })
-
- await sdk.client.session.prompt({
- path: { id: session.id },
- body: {
- agent: local.agent.current()!.name,
- model: {
- modelID: local.model.current()!.id,
- providerID: local.model.current()!.provider.id,
- },
- parts: [
- {
- type: "text",
- text,
- },
- ...attachmentParts,
- ],
- },
- })
- }
-
- const handleNewSession = () => {
- local.session.setActive(undefined)
- inputRef?.focus()
- }
-
- const TabVisual = (props: { file: LocalFile }): JSX.Element => {
- return (
-
-
-
- {props.file.name}
-
-
-
-
- M
-
-
- A
-
-
- D
-
-
-
-
- )
- }
-
- const SortableTab = (props: {
- file: LocalFile
- onTabClick: (file: LocalFile) => void
- onTabClose: (file: LocalFile) => void
- }): JSX.Element => {
- const sortable = createSortable(props.file.path)
-
- return (
- // @ts-ignore
-
-
-
- props.onTabClick(props.file)}
- >
-
- props.onTabClose(props.file)}
- />
-
-
-
-
- )
- }
-
- const ConstrainDragYAxis = (): JSX.Element => {
- const context = useDragDropContext()
- if (!context) return <>>
- const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
- const transformer: Transformer = {
- id: "constrain-y-axis",
- order: 100,
- callback: (transform) => ({ ...transform, y: 0 }),
- }
- onDragStart((event) => {
- const id = getDraggableId(event)
- if (!id) return
- addTransformer("draggables", id, transformer)
- })
- onDragEnd((event) => {
- const id = getDraggableId(event)
- if (!id) return
- removeTransformer("draggables", id, transformer.id)
- })
- return <>>
- }
-
- const getDraggableId = (event: unknown): string | undefined => {
- if (typeof event !== "object" || event === null) return undefined
- if (!("draggable" in event)) return undefined
- const draggable = (event as { draggable?: { id?: unknown } }).draggable
- if (!draggable) return undefined
- return typeof draggable.id === "string" ? draggable.id : undefined
- }
-
- return (
-
-
-
-
-
- {getFilename(sync.data.path.directory)}
-
-
-
-
x.id}
- current={local.session.active()}
- onSelect={(s) => local.session.setActive(s?.id)}
- onHover={(s) => (!!s ? sync.session.sync(s?.id) : undefined)}
- >
- {(session) => {
- const diffs = createMemo(() => session.summary?.diffs ?? [])
- const filesChanged = createMemo(() => diffs().length)
- const updated = DateTime.fromMillis(session.time.updated)
- return (
-
-
-
-
- {session.title}
-
-
- {Math.abs(updated.diffNow().as("seconds")) < 60
- ? "Now"
- : updated
- .toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
- ?.replace(" ago", "")
- ?.replace(/ days?/, "d")
- ?.replace(" min.", "m")
- ?.replace(" hr.", "h")}
-
-
-
- {`${filesChanged() || "No"} file${filesChanged() !== 1 ? "s" : ""} changed`}
-
-
-
-
- )
- }}
-
-
-
-
-
-
-
-
-
-
-
- Chat
- {/* */}
- {/* */}
- {/* {local.session.context() ?? 0}%
*/}
- {/* */}
-
- {/* Review */}
-
-
- {(file) => (
-
- )}
-
-
-
- setStore("fileSelectOpen", true)}
- />
-
-
-
-
-
-
- New session
-
-
-
- {getDirectory(sync.data.path.directory)}
- {getFilename(sync.data.path.directory)}
-
-
-
-
-
- Last modified
-
- {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
-
-
-
-
- }
- >
- {(session) => {
- const [store, setStore] = createStore<{
- messageId?: string
- }>()
-
- const messages = createMemo(() => sync.data.message[session().id] ?? [])
- const userMessages = createMemo(() =>
- messages()
- .filter((m) => m.role === "user")
- .sort((a, b) => b.id.localeCompare(a.id)),
- )
- const lastUserMessage = createMemo(() => {
- return userMessages()?.at(0)
- })
- const activeMessage = createMemo(() => {
- if (!store.messageId) return lastUserMessage()
- return userMessages()?.find((m) => m.id === store.messageId)
- })
-
- return (
-
-
-
1}>
-
-
- {(message) => {
- const diffs = createMemo(() => message.summary?.diffs ?? [])
- const assistantMessages = createMemo(() => {
- return sync.data.message[session().id]?.filter(
- (m) => m.role === "assistant" && m.parentID == message.id,
- ) as AssistantMessageType[]
- })
- const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
- const working = createMemo(() => !message.summary?.body && !error())
-
- return (
- -
-
-
- )
- }}
-
-
-
-
-
-
- )
- }}
-
-
-
- {/* */}
-
- {(file) => (
-
- {(() => {
- {
- /* const view = local.file.view(file) */
- }
- {
- /* const showRaw = view === "raw" || !file.content?.diff */
- }
- {
- /* const code = showRaw ? (file.content?.content ?? "") : (file.content?.diff ?? "") */
- }
- const node = local.file.node(file)
- return (
-
- )
- })()}
-
- )}
-
-
-
- {(() => {
- const id = activeItem()
- if (!id) return null
- const draggedFile = local.file.node(id)
- if (!draggedFile) return null
- return (
-
-
-
- )
- })()}
-
-
-
-
{
- inputRef = el
- }}
- onSubmit={handlePromptSubmit}
- />
-
-
-
-
-
- No changes
}
- >
-
-
- {(path) => (
- -
-
-
- )}
-
-
-
-
-
-
-
- x}
- onOpenChange={(open) => setStore("fileSelectOpen", open)}
- onSelect={(x) => (x ? local.session.open(x) : undefined)}
- >
- {(i) => (
-
-
-
-
-
- {getDirectory(i)}
-
- {getFilename(i)}
-
-
-
-
- )}
-
-
-
- )
-}
diff --git a/packages/desktop/src/pages/layout.tsx b/packages/desktop/src/pages/layout.tsx
new file mode 100644
index 00000000..afec9ee3
--- /dev/null
+++ b/packages/desktop/src/pages/layout.tsx
@@ -0,0 +1,75 @@
+import { Button, Tooltip, DiffChanges } from "@opencode-ai/ui"
+import { createMemo, ParentProps } from "solid-js"
+import { getFilename } from "@/utils"
+import { DateTime } from "luxon"
+import { useSync } from "@/context/sync"
+import { VList } from "virtua/solid"
+import { A, useParams } from "@solidjs/router"
+
+export default function Layout(props: ParentProps) {
+ const params = useParams()
+ const sync = useSync()
+ return (
+
+
+
+
+
+ {getFilename(sync.data.path.directory)}
+
+
+
+
{props.children}
+
+
+ )
+}
diff --git a/packages/desktop/src/pages/session-layout.tsx b/packages/desktop/src/pages/session-layout.tsx
new file mode 100644
index 00000000..9a24608f
--- /dev/null
+++ b/packages/desktop/src/pages/session-layout.tsx
@@ -0,0 +1,12 @@
+import { Show, type ParentProps } from "solid-js"
+import { SessionProvider } from "@/context/session"
+import { useParams } from "@solidjs/router"
+
+export default function Layout(props: ParentProps) {
+ const params = useParams()
+ return (
+
+ {props.children}
+
+ )
+}
diff --git a/packages/desktop/src/pages/session.tsx b/packages/desktop/src/pages/session.tsx
new file mode 100644
index 00000000..9c633f4f
--- /dev/null
+++ b/packages/desktop/src/pages/session.tsx
@@ -0,0 +1,693 @@
+import {
+ SelectDialog,
+ IconButton,
+ Tabs,
+ Icon,
+ Accordion,
+ Diff,
+ Collapsible,
+ DiffChanges,
+ Message,
+ Typewriter,
+ Card,
+ Code,
+ Tooltip,
+ ProgressCircle,
+} from "@opencode-ai/ui"
+import { FileIcon } from "@/ui"
+import { MessageProgress } from "@/components/message-progress"
+import {
+ For,
+ onCleanup,
+ onMount,
+ Show,
+ Match,
+ Switch,
+ createSignal,
+ createEffect,
+ createMemo,
+ createResource,
+} from "solid-js"
+import { useLocal, type LocalFile } from "@/context/local"
+import { createStore } from "solid-js/store"
+import { getDirectory, getFilename } from "@/utils"
+import { PromptInput } from "@/components/prompt-input"
+import { DateTime } from "luxon"
+import {
+ DragDropProvider,
+ DragDropSensors,
+ DragOverlay,
+ SortableProvider,
+ closestCenter,
+ createSortable,
+ useDragDropContext,
+} from "@thisbeyond/solid-dnd"
+import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
+import type { JSX } from "solid-js"
+import { useSync } from "@/context/sync"
+import { type AssistantMessage as AssistantMessageType } from "@opencode-ai/sdk"
+import { Markdown } from "@opencode-ai/ui"
+import { Spinner } from "@/components/spinner"
+import { useSession } from "@/context/session"
+
+export default function Page() {
+ const local = useLocal()
+ const sync = useSync()
+ const session = useSession()
+ const [store, setStore] = createStore({
+ clickTimer: undefined as number | undefined,
+ fileSelectOpen: false,
+ activeDraggable: undefined as string | undefined,
+ })
+ let inputRef!: HTMLDivElement
+ let messageScrollElement!: HTMLDivElement
+
+ const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
+
+ onMount(() => {
+ document.addEventListener("keydown", handleKeyDown)
+ })
+
+ onCleanup(() => {
+ document.removeEventListener("keydown", handleKeyDown)
+ })
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
+ event.preventDefault()
+ return
+ }
+ if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
+ event.preventDefault()
+ setStore("fileSelectOpen", true)
+ return
+ }
+
+ const focused = document.activeElement === inputRef
+ if (focused) {
+ if (event.key === "Escape") {
+ inputRef?.blur()
+ }
+ return
+ }
+
+ // if (local.file.active()) {
+ // const active = local.file.active()!
+ // if (event.key === "Enter" && active.selection) {
+ // local.context.add({
+ // type: "file",
+ // path: active.path,
+ // selection: { ...active.selection },
+ // })
+ // return
+ // }
+ //
+ // if (event.getModifierState(MOD)) {
+ // if (event.key.toLowerCase() === "a") {
+ // return
+ // }
+ // if (event.key.toLowerCase() === "c") {
+ // return
+ // }
+ // }
+ // }
+
+ if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
+ inputRef?.focus()
+ }
+ }
+
+ const resetClickTimer = () => {
+ if (!store.clickTimer) return
+ clearTimeout(store.clickTimer)
+ setStore("clickTimer", undefined)
+ }
+
+ const startClickTimer = () => {
+ const newClickTimer = setTimeout(() => {
+ setStore("clickTimer", undefined)
+ }, 300)
+ setStore("clickTimer", newClickTimer as unknown as number)
+ }
+
+ const handleTabClick = async (tab: string) => {
+ if (store.clickTimer) {
+ resetClickTimer()
+ // local.file.update(file.path, { ...file, pinned: true })
+ } else {
+ if (tab.startsWith("file://")) {
+ local.file.open(tab.replace("file://", ""))
+ }
+ startClickTimer()
+ }
+ }
+
+ const handleDragStart = (event: unknown) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ setStore("activeDraggable", id)
+ }
+
+ const handleDragOver = (event: DragEvent) => {
+ const { draggable, droppable } = event
+ if (draggable && droppable) {
+ const currentTabs = session.layout.tabs.opened
+ const fromIndex = currentTabs?.indexOf(draggable.id.toString())
+ const toIndex = currentTabs?.indexOf(droppable.id.toString())
+ if (fromIndex !== toIndex && toIndex !== undefined) {
+ session.layout.moveTab(draggable.id.toString(), toIndex)
+ }
+ }
+ }
+
+ const handleDragEnd = () => {
+ setStore("activeDraggable", undefined)
+ }
+
+ const FileVisual = (props: { file: LocalFile }): JSX.Element => {
+ return (
+
+
+
+ {props.file.name}
+
+
+
+
+ M
+
+
+ A
+
+
+ D
+
+
+
+
+ )
+ }
+
+ const SortableTab = (props: {
+ tab: string
+ onTabClick: (tab: string) => void
+ onTabClose: (tab: string) => void
+ }): JSX.Element => {
+ const sortable = createSortable(props.tab)
+
+ const [file] = createResource(
+ () => props.tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+
+ return (
+ // @ts-ignore
+
+
+ props.onTabClick(props.tab)}>
+
+ {(f) => }
+
+ props.onTabClose(props.tab)}
+ />
+
+
+
+ )
+ }
+
+ const ConstrainDragYAxis = (): JSX.Element => {
+ const context = useDragDropContext()
+ if (!context) return <>>
+ const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
+ const transformer: Transformer = {
+ id: "constrain-y-axis",
+ order: 100,
+ callback: (transform) => ({ ...transform, y: 0 }),
+ }
+ onDragStart((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ addTransformer("draggables", id, transformer)
+ })
+ onDragEnd((event) => {
+ const id = getDraggableId(event)
+ if (!id) return
+ removeTransformer("draggables", id, transformer.id)
+ })
+ return <>>
+ }
+
+ const getDraggableId = (event: unknown): string | undefined => {
+ if (typeof event !== "object" || event === null) return undefined
+ if (!("draggable" in event)) return undefined
+ const draggable = (event as { draggable?: { id?: unknown } }).draggable
+ if (!draggable) return undefined
+ return typeof draggable.id === "string" ? draggable.id : undefined
+ }
+
+ return (
+
+
+
+
+
+
+
+
+ Chat
+
+
+ {session.usage.context() ?? 0}%
+
+
+ {/* Review */}
+
+
+ {(tab) => }
+
+
+
+ setStore("fileSelectOpen", true)}
+ />
+
+
+
+
+
+
+ New session
+
+
+
+ {getDirectory(sync.data.path.directory)}
+ {getFilename(sync.data.path.directory)}
+
+
+
+
+
+ Last modified
+
+ {DateTime.fromMillis(sync.data.project.time.created).toRelative()}
+
+
+
+
+ }
+ >
+ {(_) => {
+ return (
+
+
+
1}>
+
+
+ {(message) => {
+ const assistantMessages = createMemo(() => {
+ if (!session.id) return []
+ return sync.data.message[session.id]?.filter(
+ (m) => m.role === "assistant" && m.parentID == message.id,
+ ) as AssistantMessageType[]
+ })
+ const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+ const working = createMemo(() => !message.summary?.body && !error())
+
+ return (
+ -
+
+
+ )
+ }}
+
+
+
+
+
+
+ )
+ }}
+
+
+
+ {/* */}
+
+ {(tab) => {
+ const [file] = createResource(
+ () => tab,
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+
+
+
+ {(f) => (
+
+ )}
+
+
+
+ )
+ }}
+
+
+
+
+ {(draggedFile) => {
+ const [file] = createResource(
+ () => draggedFile(),
+ async (tab) => {
+ if (tab.startsWith("file://")) {
+ return local.file.node(tab.replace("file://", ""))
+ }
+ return undefined
+ },
+ )
+ return (
+
+ {(f) => }
+
+ )
+ }}
+
+
+
+
+
{
+ inputRef = el
+ }}
+ />
+
+
+ {/* */}
+
+
+ No changes
}>
+
+
+ {(path) => (
+ -
+
+
+ )}
+
+
+
+
+
+ x}
+ onOpenChange={(open) => setStore("fileSelectOpen", open)}
+ onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
+ >
+ {(i) => (
+
+
+
+
+
+ {getDirectory(i)}
+
+ {getFilename(i)}
+
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx
index f1c7efad..a3164978 100644
--- a/packages/ui/src/components/code.tsx
+++ b/packages/ui/src/components/code.tsx
@@ -11,20 +11,21 @@ export type CodeProps = FileOptions & {
export function Code(props: CodeProps) {
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["file", "class", "classList", "annotations"])
- const file = () => local.file
createEffect(() => {
const instance = new File({
theme: { dark: "oc-1-dark", light: "oc-1-light" }, // or any Shiki theme
overflow: "wrap", // or 'scroll'
themeType: "system", // 'system', 'light', or 'dark'
+ disableFileHeader: true,
disableLineNumbers: false, // optional
// lang: 'typescript', // optional - auto-detected from filename if not provided
...others,
})
+ container.innerHTML = ""
instance.render({
- file: file(),
+ file: local.file,
lineAnnotations: local.annotations,
containerWrapper: container,
})
diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx
index f3ca74a8..6297a642 100644
--- a/packages/ui/src/components/diff.tsx
+++ b/packages/ui/src/components/diff.tsx
@@ -154,6 +154,7 @@ export function Diff(props: DiffProps) {
...others,
})
+ container.innerHTML = ""
instance.render({
oldFile: local.before,
newFile: local.after,
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index a2e12729..61799720 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -150,6 +150,7 @@ const newIcons = {
"code-lines": ``,
"square-arrow-top-right": ``,
"circle-ban-sign": ``,
+ stop: ``,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/input.tsx b/packages/ui/src/components/input.tsx
index 509e242c..55e84c3a 100644
--- a/packages/ui/src/components/input.tsx
+++ b/packages/ui/src/components/input.tsx
@@ -2,22 +2,37 @@ import { TextField as Kobalte } from "@kobalte/core/text-field"
import { Show, splitProps } from "solid-js"
import type { ComponentProps } from "solid-js"
-export interface InputProps extends ComponentProps {
+export interface InputProps
+ extends ComponentProps,
+ Pick, "value" | "onChange" | "onKeyDown"> {
label?: string
hideLabel?: boolean
description?: string
}
export function Input(props: InputProps) {
- const [local, others] = splitProps(props, ["class", "label", "hideLabel", "description", "placeholder"])
+ const [local, others] = splitProps(props, [
+ "class",
+ "label",
+ "hideLabel",
+ "description",
+ "value",
+ "onChange",
+ "onKeyDown",
+ ])
return (
-
+
{local.label}
-
+
{local.description}
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index aaba61fd..766979ae 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -62,7 +62,13 @@ export function List(props: ListProps) {
})
return (
-
+
{(item) => (