diff options
| author | David Hill <[email protected]> | 2025-11-10 13:44:12 +0000 |
|---|---|---|
| committer | David Hill <[email protected]> | 2025-11-10 13:44:12 +0000 |
| commit | c6e830c954418808dc39284a1c073aa63a6d4d21 (patch) | |
| tree | 9c3052e0509115188768a553c0be5a8441ebdd96 /packages/desktop/src/context | |
| parent | 7088bfabd773e2f076aab1c9d2468c04feff0570 (diff) | |
| parent | fc78c28df64383a9f99382093f61fc28caf6569f (diff) | |
| download | opencode-c6e830c954418808dc39284a1c073aa63a6d4d21.tar.gz opencode-c6e830c954418808dc39284a1c073aa63a6d4d21.zip | |
Merge branch 'dev' of https://github.com/sst/opencode into dev
Diffstat (limited to 'packages/desktop/src/context')
| -rw-r--r-- | packages/desktop/src/context/local.tsx | 226 | ||||
| -rw-r--r-- | packages/desktop/src/context/session.tsx | 217 | ||||
| -rw-r--r-- | packages/desktop/src/context/sync.tsx | 59 |
3 files changed, 328 insertions, 174 deletions
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx index 8fc05c452..1785bdf0c 100644 --- a/packages/desktop/src/context/local.tsx +++ b/packages/desktop/src/context/local.tsx @@ -5,6 +5,7 @@ import type { FileContent, FileNode, Model, Provider, File as FileStatus } from import { createSimpleContext } from "./helper" import { useSDK } from "./sdk" import { useSync } from "./sync" +import { makePersisted } from "@solid-primitives/storage" export type LocalFile = FileNode & Partial<{ @@ -195,18 +196,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const file = (() => { const [store, setStore] = createStore<{ node: Record<string, LocalFile> - // 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 +240,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 +326,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 +400,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) - }, } })() @@ -589,12 +457,60 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } })() + const layout = (() => { + const [store, setStore] = makePersisted( + createStore({ + sidebar: { + opened: true, + width: 240, + }, + review: { + state: "closed" as "open" | "closed" | "tab", + }, + }), + { + name: "default-layout", + }, + ) + + return { + sidebar: { + opened: createMemo(() => store.sidebar.opened), + open() { + setStore("sidebar", "opened", true) + }, + close() { + setStore("sidebar", "opened", false) + }, + toggle() { + setStore("sidebar", "opened", (x) => !x) + }, + width: createMemo(() => store.sidebar.width), + resize(width: number) { + setStore("sidebar", "width", width) + }, + }, + review: { + state: createMemo(() => store.review?.state ?? "closed"), + open() { + setStore("review", "state", "open") + }, + close() { + setStore("review", "state", "closed") + }, + tab() { + setStore("review", "state", "tab") + }, + }, + } + })() + const result = { model, agent, file, - session, context, + layout, } return result }, diff --git a/packages/desktop/src/context/session.tsx b/packages/desktop/src/context/session.tsx new file mode 100644 index 000000000..61fed945e --- /dev/null +++ b/packages/desktop/src/context/session.tsx @@ -0,0 +1,217 @@ +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 diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : [])) + + 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, + diffs, + 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 (tab !== "review") { + 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 06fc05677..bc9491fd3 100644 --- a/packages/desktop/src/context/sync.tsx +++ b/packages/desktop/src/context/sync.tsx @@ -9,7 +9,8 @@ import type { File, FileNode, Project, - Command, + FileDiff, + Todo, } from "@opencode-ai/sdk" import { createStore, produce, reconcile } from "solid-js/store" import { createMemo } from "solid-js" @@ -28,6 +29,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ config: Config path: Path session: Session[] + session_diff: { + [sessionID: string]: FileDiff[] + } + todo: { + [sessionID: string]: Todo[] + } + limit: number message: { [sessionID: string]: Message[] } @@ -44,6 +52,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ agent: [], provider: [], session: [], + session_diff: {}, + todo: {}, + limit: 10, message: {}, part: {}, node: [], @@ -68,6 +79,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) break } + case "session.diff": + setStore("session_diff", event.properties.sessionID, event.properties.diff) + break + case "todo.updated": + setStore("todo", event.properties.sessionID, event.properties.todos) + break case "message.updated": { const messages = store.message[event.properties.info.sessionID] if (!messages) { @@ -118,12 +135,13 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ path: () => sdk.client.path.get().then((x) => setStore("path", x.data!)), agent: () => sdk.client.app.agents().then((x) => setStore("agent", x.data ?? [])), session: () => - sdk.client.session.list().then((x) => - setStore( - "session", - (x.data ?? []).slice().sort((a, b) => a.id.localeCompare(b.id)), - ), - ), + sdk.client.session.list().then((x) => { + const sessions = (x.data ?? []) + .slice() + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, store.limit) + setStore("session", sessions) + }), config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)), changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)), node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)), @@ -167,22 +185,19 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (match.found) return store.session[match.index] return undefined }, - async sync(sessionID: string, isRetry = false) { - const [session, messages] = await Promise.all([ - sdk.client.session.get({ path: { id: sessionID } }), - sdk.client.session.messages({ path: { id: sessionID } }), + async sync(sessionID: string, _isRetry = false) { + const [session, messages, todo, diff] = await Promise.all([ + sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }), + sdk.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }), + sdk.client.session.todo({ path: { id: sessionID } }), + sdk.client.session.diff({ path: { id: sessionID } }), ]) - - // If no messages and this might be a new session, retry after a delay - if (!isRetry && messages.data!.length === 0) { - setTimeout(() => this.sync(sessionID, true), 500) - return - } - setStore( produce((draft) => { const match = Binary.search(draft.session, sessionID, (s) => s.id) - draft.session[match.index] = session.data! + if (match.found) draft.session[match.index] = session.data! + if (!match.found) draft.session.splice(match.index, 0, session.data!) + draft.todo[sessionID] = todo.data ?? [] draft.message[sessionID] = messages .data!.map((x) => x.info) .slice() @@ -193,9 +208,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ .map(sanitizePart) .sort((a, b) => a.id.localeCompare(b.id)) } + draft.session_diff[sessionID] = diff.data ?? [] }), ) }, + fetch: async (count = 10) => { + setStore("limit", (x) => x + count) + await load.session() + }, + more: createMemo(() => store.session.length === store.limit), }, load, absolute, |
