summaryrefslogtreecommitdiffhomepage
path: root/packages/desktop/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-11-05 11:55:31 -0600
committerAdam <[email protected]>2025-11-05 11:55:35 -0600
commitd525fbf82940442d8d47265b7d7d0a9af8c282bc (patch)
tree5404bdca83992a030fea1693227343d2d401376c /packages/desktop/src/context
parent69a499f80786b2656121cee4469f39df2e6c40e7 (diff)
downloadopencode-d525fbf82940442d8d47265b7d7d0a9af8c282bc.tar.gz
opencode-d525fbf82940442d8d47265b7d7d0a9af8c282bc.zip
feat(desktop): session router, interrupt agent, visual cleanup
Diffstat (limited to 'packages/desktop/src/context')
-rw-r--r--packages/desktop/src/context/local.tsx176
-rw-r--r--packages/desktop/src/context/session.tsx213
-rw-r--r--packages/desktop/src/context/sync.tsx14
3 files changed, 235 insertions, 168 deletions
diff --git a/packages/desktop/src/context/local.tsx b/packages/desktop/src/context/local.tsx
index 8fc05c452..cef6c5555 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<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 +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 000000000..77ab3bc25
--- /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 06fc05677..c60206b0b 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"