mirror of
https://github.com/aljazceru/opencode.git
synced 2026-01-20 08:14:57 +01:00
feat(desktop): session router, interrupt agent, visual cleanup
This commit is contained in:
@@ -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
|
||||
|
||||
213
packages/desktop/src/context/session.tsx
Normal file
213
packages/desktop/src/context/session.tsx
Normal file
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user