diff options
| author | Adam <[email protected]> | 2025-12-22 19:38:50 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-22 19:39:00 -0600 |
| commit | 794fe8f381c846f5241800363023d892c12cf495 (patch) | |
| tree | bff98689edfa635a2a9f39cb4ea61639b97f5b2d /packages/app/src/context | |
| parent | a4eebf9f08262f6bf63017710e2e6d9672ec6708 (diff) | |
| download | opencode-794fe8f381c846f5241800363023d892c12cf495.tar.gz opencode-794fe8f381c846f5241800363023d892c12cf495.zip | |
chore: rename packages/desktop -> packages/app
Diffstat (limited to 'packages/app/src/context')
| -rw-r--r-- | packages/app/src/context/command.tsx | 243 | ||||
| -rw-r--r-- | packages/app/src/context/global-sdk.tsx | 34 | ||||
| -rw-r--r-- | packages/app/src/context/global-sync.tsx | 376 | ||||
| -rw-r--r-- | packages/app/src/context/layout.tsx | 260 | ||||
| -rw-r--r-- | packages/app/src/context/local.tsx | 548 | ||||
| -rw-r--r-- | packages/app/src/context/notification.tsx | 127 | ||||
| -rw-r--r-- | packages/app/src/context/platform.tsx | 41 | ||||
| -rw-r--r-- | packages/app/src/context/prompt.tsx | 111 | ||||
| -rw-r--r-- | packages/app/src/context/sdk.tsx | 30 | ||||
| -rw-r--r-- | packages/app/src/context/sync.tsx | 114 | ||||
| -rw-r--r-- | packages/app/src/context/terminal.tsx | 105 |
11 files changed, 1989 insertions, 0 deletions
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx new file mode 100644 index 000000000..f91a1cf05 --- /dev/null +++ b/packages/app/src/context/command.tsx @@ -0,0 +1,243 @@ +import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" + +const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + +export type KeybindConfig = string + +export interface Keybind { + key: string + ctrl: boolean + meta: boolean + shift: boolean + alt: boolean +} + +export interface CommandOption { + id: string + title: string + description?: string + category?: string + keybind?: KeybindConfig + slash?: string + suggested?: boolean + disabled?: boolean + onSelect?: (source?: "palette" | "keybind" | "slash") => void +} + +export function parseKeybind(config: string): Keybind[] { + if (!config || config === "none") return [] + + return config.split(",").map((combo) => { + const parts = combo.trim().toLowerCase().split("+") + const keybind: Keybind = { + key: "", + ctrl: false, + meta: false, + shift: false, + alt: false, + } + + for (const part of parts) { + switch (part) { + case "ctrl": + case "control": + keybind.ctrl = true + break + case "meta": + case "cmd": + case "command": + keybind.meta = true + break + case "mod": + if (IS_MAC) keybind.meta = true + else keybind.ctrl = true + break + case "alt": + case "option": + keybind.alt = true + break + case "shift": + keybind.shift = true + break + default: + keybind.key = part + break + } + } + + return keybind + }) +} + +export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean { + const eventKey = event.key.toLowerCase() + + for (const kb of keybinds) { + const keyMatch = kb.key === eventKey + const ctrlMatch = kb.ctrl === (event.ctrlKey || false) + const metaMatch = kb.meta === (event.metaKey || false) + const shiftMatch = kb.shift === (event.shiftKey || false) + const altMatch = kb.alt === (event.altKey || false) + + if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) { + return true + } + } + + return false +} + +export function formatKeybind(config: string): string { + if (!config || config === "none") return "" + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return "" + + const kb = keybinds[0] + const parts: string[] = [] + + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") + if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") + if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") + if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + + if (kb.key) { + const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1) + parts.push(displayKey) + } + + return IS_MAC ? parts.join("") : parts.join("+") +} + +function DialogCommand(props: { options: CommandOption[] }) { + const dialog = useDialog() + + return ( + <Dialog title="Commands"> + <List + search={{ placeholder: "Search commands", autofocus: true }} + emptyMessage="No commands found" + items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)} + key={(x) => x?.id} + filterKeys={["title", "description", "category"]} + groupBy={(x) => x.category ?? ""} + onSelect={(option) => { + if (option) { + dialog.close() + option.onSelect?.("palette") + } + }} + > + {(option) => ( + <div class="w-full flex items-center justify-between gap-4"> + <div class="flex items-center gap-2 min-w-0"> + <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span> + <Show when={option.description}> + <span class="text-14-regular text-text-weak truncate">{option.description}</span> + </Show> + </div> + <Show when={option.keybind}> + <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span> + </Show> + </div> + )} + </List> + </Dialog> + ) +} + +export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ + name: "Command", + init: () => { + const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([]) + const [suspendCount, setSuspendCount] = createSignal(0) + const dialog = useDialog() + + const options = createMemo(() => { + const all = registrations().flatMap((x) => x()) + const suggested = all.filter((x) => x.suggested && !x.disabled) + return [ + ...suggested.map((x) => ({ + ...x, + id: "suggested." + x.id, + category: "Suggested", + })), + ...all, + ] + }) + + const suspended = () => suspendCount() > 0 + + const showPalette = () => { + if (!dialog.active) { + dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />) + } + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (suspended()) return + + const paletteKeybinds = parseKeybind("mod+shift+p") + if (matchKeybind(paletteKeybinds, event)) { + event.preventDefault() + showPalette() + return + } + + for (const option of options()) { + if (option.disabled) continue + if (!option.keybind) continue + + const keybinds = parseKeybind(option.keybind) + if (matchKeybind(keybinds, event)) { + event.preventDefault() + option.onSelect?.("keybind") + return + } + } + } + + onMount(() => { + document.addEventListener("keydown", handleKeyDown) + }) + + onCleanup(() => { + document.removeEventListener("keydown", handleKeyDown) + }) + + return { + register(cb: () => CommandOption[]) { + const results = createMemo(cb) + setRegistrations((arr) => [results, ...arr]) + onCleanup(() => { + setRegistrations((arr) => arr.filter((x) => x !== results)) + }) + }, + trigger(id: string, source?: "palette" | "keybind" | "slash") { + for (const option of options()) { + if (option.id === id || option.id === "suggested." + id) { + option.onSelect?.(source) + return + } + } + }, + keybind(id: string) { + const option = options().find((x) => x.id === id || x.id === "suggested." + id) + if (!option?.keybind) return "" + return formatKeybind(option.keybind) + }, + show: showPalette, + keybinds(enabled: boolean) { + setSuspendCount((count) => count + (enabled ? -1 : 1)) + }, + suspended, + get options() { + return options() + }, + } + }, +}) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx new file mode 100644 index 000000000..3732ca085 --- /dev/null +++ b/packages/app/src/context/global-sdk.tsx @@ -0,0 +1,34 @@ +import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { usePlatform } from "./platform" + +export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ + name: "GlobalSDK", + init: (props: { url: string }) => { + const eventSdk = createOpencodeClient({ + baseUrl: props.url, + // signal: AbortSignal.timeout(1000 * 60 * 10), + }) + const emitter = createGlobalEmitter<{ + [key: string]: Event + }>() + + eventSdk.global.event().then(async (events) => { + for await (const event of events.stream) { + // console.log("event", event) + emitter.emit(event.directory ?? "global", event.payload) + } + }) + + const platform = usePlatform() + const sdk = createOpencodeClient({ + baseUrl: props.url, + signal: AbortSignal.timeout(1000 * 60 * 10), + fetch: platform.fetch, + throwOnError: true, + }) + + return { url: props.url, client: sdk, event: emitter } + }, +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx new file mode 100644 index 000000000..ae40555d6 --- /dev/null +++ b/packages/app/src/context/global-sync.tsx @@ -0,0 +1,376 @@ +import { + type Message, + type Agent, + type Session, + type Part, + type Config, + type Path, + type File, + type FileNode, + type Project, + type FileDiff, + type Todo, + type SessionStatus, + type ProviderListResponse, + type ProviderAuthResponse, + type Command, + createOpencodeClient, +} from "@opencode-ai/sdk/v2/client" +import { createStore, produce, reconcile } from "solid-js/store" +import { Binary } from "@opencode-ai/util/binary" +import { retry } from "@opencode-ai/util/retry" +import { useGlobalSDK } from "./global-sdk" +import { ErrorPage, type InitError } from "../pages/error" +import { createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js" +import { showToast } from "@opencode-ai/ui/toast" +import { getFilename } from "@opencode-ai/util/path" + +type State = { + ready: boolean + agent: Agent[] + command: Command[] + project: string + provider: ProviderListResponse + config: Config + path: Path + session: Session[] + session_status: { + [sessionID: string]: SessionStatus + } + session_diff: { + [sessionID: string]: FileDiff[] + } + todo: { + [sessionID: string]: Todo[] + } + limit: number + message: { + [sessionID: string]: Message[] + } + part: { + [messageID: string]: Part[] + } + node: FileNode[] + changes: File[] +} + +function createGlobalSync() { + const globalSDK = useGlobalSDK() + const [globalStore, setGlobalStore] = createStore<{ + ready: boolean + error?: InitError + path: Path + project: Project[] + provider: ProviderListResponse + provider_auth: ProviderAuthResponse + children: Record<string, State> + }>({ + ready: false, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + project: [], + provider: { all: [], connected: [], default: {} }, + provider_auth: {}, + children: {}, + }) + + const children: Record<string, ReturnType<typeof createStore<State>>> = {} + function child(directory: string) { + if (!directory) console.error("No directory provided") + if (!children[directory]) { + setGlobalStore("children", directory, { + project: "", + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + ready: false, + agent: [], + command: [], + session: [], + session_status: {}, + session_diff: {}, + todo: {}, + limit: 5, + message: {}, + part: {}, + node: [], + changes: [], + }) + children[directory] = createStore(globalStore.children[directory]) + bootstrapInstance(directory) + } + return children[directory] + } + + async function loadSessions(directory: string) { + const [store, setStore] = child(directory) + globalSDK.client.session + .list({ directory }) + .then((x) => { + const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 + const nonArchived = (x.data ?? []) + .slice() + .filter((s) => !s.time.archived) + .sort((a, b) => a.id.localeCompare(b.id)) + // Include up to the limit, plus any updated in the last 4 hours + const sessions = nonArchived.filter((s, i) => { + if (i < store.limit) return true + const updated = new Date(s.time.updated).getTime() + return updated > fourHoursAgo + }) + setStore("session", sessions) + }) + .catch((err) => { + console.error("Failed to load sessions", err) + const project = getFilename(directory) + showToast({ title: `Failed to load sessions for ${project}`, description: err.message }) + }) + } + + async function bootstrapInstance(directory: string) { + if (!directory) return + const [, setStore] = child(directory) + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + directory, + throwOnError: true, + }) + const load = { + project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), + provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)), + path: () => sdk.path.get().then((x) => setStore("path", x.data!)), + agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), + command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])), + session: () => loadSessions(directory), + status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), + config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + changes: () => sdk.file.status().then((x) => setStore("changes", x.data!)), + node: () => sdk.file.list({ path: "/" }).then((x) => setStore("node", x.data!)), + } + await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) + .then(() => setStore("ready", true)) + .catch((e) => setGlobalStore("error", e)) + } + + globalSDK.event.listen((e) => { + const directory = e.name + const event = e.details + + if (directory === "global") { + switch (event?.type) { + case "global.disposed": { + bootstrap() + break + } + case "project.updated": { + const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id) + if (result.found) { + setGlobalStore("project", result.index, reconcile(event.properties)) + return + } + setGlobalStore( + "project", + produce((draft) => { + draft.splice(result.index, 0, event.properties) + }), + ) + break + } + } + return + } + + const [store, setStore] = child(directory) + switch (event.type) { + case "server.instance.disposed": { + bootstrapInstance(directory) + break + } + case "session.updated": { + const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + if (event.properties.info.time.archived) { + if (result.found) { + setStore( + "session", + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } + if (result.found) { + setStore("session", result.index, reconcile(event.properties.info)) + break + } + setStore( + "session", + produce((draft) => { + draft.splice(result.index, 0, event.properties.info) + }), + ) + 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 "session.status": { + setStore("session_status", event.properties.sessionID, event.properties.status) + break + } + case "message.updated": { + const messages = store.message[event.properties.info.sessionID] + if (!messages) { + setStore("message", event.properties.info.sessionID, [event.properties.info]) + break + } + const result = Binary.search(messages, event.properties.info.id, (m) => m.id) + if (result.found) { + setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info)) + break + } + setStore( + "message", + event.properties.info.sessionID, + produce((draft) => { + draft.splice(result.index, 0, event.properties.info) + }), + ) + break + } + case "message.removed": { + const messages = store.message[event.properties.sessionID] + if (!messages) break + const result = Binary.search(messages, event.properties.messageID, (m) => m.id) + if (result.found) { + setStore( + "message", + event.properties.sessionID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } + case "message.part.updated": { + const part = event.properties.part + const parts = store.part[part.messageID] + if (!parts) { + setStore("part", part.messageID, [part]) + break + } + const result = Binary.search(parts, part.id, (p) => p.id) + if (result.found) { + setStore("part", part.messageID, result.index, reconcile(part)) + break + } + setStore( + "part", + part.messageID, + produce((draft) => { + draft.splice(result.index, 0, part) + }), + ) + break + } + case "message.part.removed": { + const parts = store.part[event.properties.messageID] + if (!parts) break + const result = Binary.search(parts, event.properties.partID, (p) => p.id) + if (result.found) { + setStore( + "part", + event.properties.messageID, + produce((draft) => { + draft.splice(result.index, 1) + }), + ) + } + break + } + } + }) + + async function bootstrap() { + const health = await globalSDK.client.global.health().then((x) => x.data) + if (!health?.healthy) { + setGlobalStore( + "error", + new Error(`Could not connect to server. Is there a server running at \`${globalSDK.url}\`?`), + ) + return + } + + return Promise.all([ + retry(() => + globalSDK.client.path.get().then((x) => { + setGlobalStore("path", x.data!) + }), + ), + retry(() => + globalSDK.client.project.list().then(async (x) => { + setGlobalStore( + "project", + x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)), + ) + }), + ), + retry(() => + globalSDK.client.provider.list().then((x) => { + setGlobalStore("provider", x.data ?? {}) + }), + ), + retry(() => + globalSDK.client.provider.auth().then((x) => { + setGlobalStore("provider_auth", x.data ?? {}) + }), + ), + ]) + .then(() => setGlobalStore("ready", true)) + .catch((e) => setGlobalStore("error", e)) + } + + onMount(() => { + bootstrap() + }) + + return { + data: globalStore, + get ready() { + return globalStore.ready + }, + get error() { + return globalStore.error + }, + child, + bootstrap, + project: { + loadSessions, + }, + } +} + +const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>() + +export function GlobalSyncProvider(props: ParentProps) { + const value = createGlobalSync() + return ( + <Switch> + <Match when={value.error}> + <ErrorPage error={value.error} /> + </Match> + <Match when={value.ready}> + <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider> + </Match> + </Switch> + ) +} + +export function useGlobalSync() { + const context = useContext(GlobalSyncContext) + if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider") + return context +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx new file mode 100644 index 000000000..c6ba5fef5 --- /dev/null +++ b/packages/app/src/context/layout.tsx @@ -0,0 +1,260 @@ +import { createStore, produce } from "solid-js/store" +import { batch, createMemo, onMount } from "solid-js" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useGlobalSync } from "./global-sync" +import { useGlobalSDK } from "./global-sdk" +import { Project } from "@opencode-ai/sdk/v2" +import { persisted } from "@/utils/persist" + +const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const +export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number] + +export function getAvatarColors(key?: string) { + if (key && AVATAR_COLOR_KEYS.includes(key as AvatarColorKey)) { + return { + background: `var(--avatar-background-${key})`, + foreground: `var(--avatar-text-${key})`, + } + } + return { + background: "var(--surface-info-base)", + foreground: "var(--text-base)", + } +} + +type SessionTabs = { + active?: string + all: string[] +} + +export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } + +export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({ + name: "Layout", + init: () => { + const globalSdk = useGlobalSDK() + const globalSync = useGlobalSync() + const [store, setStore, _, ready] = persisted( + "layout.v3", + createStore({ + projects: [] as { worktree: string; expanded: boolean }[], + sidebar: { + opened: false, + width: 280, + }, + terminal: { + opened: false, + height: 280, + }, + review: { + opened: true, + }, + session: { + width: 600, + }, + sessionTabs: {} as Record<string, SessionTabs>, + }), + ) + + const usedColors = new Set<AvatarColorKey>() + + function pickAvailableColor(): AvatarColorKey { + const available = AVATAR_COLOR_KEYS.filter((c) => !usedColors.has(c)) + if (available.length === 0) return AVATAR_COLOR_KEYS[Math.floor(Math.random() * AVATAR_COLOR_KEYS.length)] + return available[Math.floor(Math.random() * available.length)] + } + + function enrich(project: { worktree: string; expanded: boolean }) { + const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree) + return [ + { + ...project, + ...(metadata ?? {}), + }, + ] + } + + function colorize(project: LocalProject) { + if (project.icon?.color) return project + const color = pickAvailableColor() + usedColors.add(color) + project.icon = { ...project.icon, color } + if (project.id) { + globalSdk.client.project.update({ projectID: project.id, icon: { color } }) + } + return project + } + + const enriched = createMemo(() => store.projects.flatMap(enrich)) + const list = createMemo(() => enriched().flatMap(colorize)) + + onMount(() => { + Promise.all( + store.projects.map((project) => { + return globalSync.project.loadSessions(project.worktree) + }), + ) + }) + + return { + ready, + projects: { + list, + open(directory: string) { + if (store.projects.find((x) => x.worktree === directory)) { + return + } + globalSync.project.loadSessions(directory) + setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x]) + }, + close(directory: string) { + setStore("projects", (x) => x.filter((x) => x.worktree !== directory)) + }, + expand(directory: string) { + const index = store.projects.findIndex((x) => x.worktree === directory) + if (index !== -1) setStore("projects", index, "expanded", true) + }, + collapse(directory: string) { + const index = store.projects.findIndex((x) => x.worktree === directory) + if (index !== -1) setStore("projects", index, "expanded", false) + }, + move(directory: string, toIndex: number) { + setStore("projects", (projects) => { + const fromIndex = projects.findIndex((x) => x.worktree === directory) + if (fromIndex === -1 || fromIndex === toIndex) return projects + const result = [...projects] + const [item] = result.splice(fromIndex, 1) + result.splice(toIndex, 0, item) + return result + }) + }, + }, + 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) + }, + }, + terminal: { + opened: createMemo(() => store.terminal.opened), + open() { + setStore("terminal", "opened", true) + }, + close() { + setStore("terminal", "opened", false) + }, + toggle() { + setStore("terminal", "opened", (x) => !x) + }, + height: createMemo(() => store.terminal.height), + resize(height: number) { + setStore("terminal", "height", height) + }, + }, + review: { + opened: createMemo(() => store.review?.opened ?? true), + open() { + setStore("review", "opened", true) + }, + close() { + setStore("review", "opened", false) + }, + toggle() { + setStore("review", "opened", (x) => !x) + }, + }, + session: { + width: createMemo(() => store.session?.width ?? 600), + resize(width: number) { + if (!store.session) { + setStore("session", { width }) + } else { + setStore("session", "width", width) + } + }, + }, + tabs(sessionKey: string) { + const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] }) + return { + tabs, + active: createMemo(() => tabs().active), + all: createMemo(() => tabs().all), + setActive(tab: string | undefined) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + setAll(all: string[]) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all, active: undefined }) + } else { + setStore("sessionTabs", sessionKey, "all", all) + } + }, + async open(tab: string) { + const current = store.sessionTabs[sessionKey] ?? { all: [] } + if (tab !== "review") { + if (!current.all.includes(tab)) { + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [tab], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "all", [...current.all, tab]) + setStore("sessionTabs", sessionKey, "active", tab) + } + return + } + } + if (!store.sessionTabs[sessionKey]) { + setStore("sessionTabs", sessionKey, { all: [], active: tab }) + } else { + setStore("sessionTabs", sessionKey, "active", tab) + } + }, + close(tab: string) { + const current = store.sessionTabs[sessionKey] + if (!current) return + batch(() => { + setStore( + "sessionTabs", + sessionKey, + "all", + current.all.filter((x) => x !== tab), + ) + if (current.active === tab) { + const index = current.all.findIndex((f) => f === tab) + const previous = current.all[Math.max(0, index - 1)] + setStore("sessionTabs", sessionKey, "active", previous) + } + }) + }, + move(tab: string, to: number) { + const current = store.sessionTabs[sessionKey] + if (!current) return + const index = current.all.findIndex((f) => f === tab) + if (index === -1) return + setStore( + "sessionTabs", + sessionKey, + "all", + produce((opened) => { + opened.splice(to, 0, opened.splice(index, 1)[0]) + }), + ) + }, + } + }, + } + }, +}) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx new file mode 100644 index 000000000..69807a2f4 --- /dev/null +++ b/packages/app/src/context/local.tsx @@ -0,0 +1,548 @@ +import { createStore, produce, reconcile } from "solid-js/store" +import { batch, createEffect, createMemo } from "solid-js" +import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" +import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useSDK } from "./sdk" +import { useSync } from "./sync" +import { base64Encode } from "@opencode-ai/util/encode" +import { useProviders } from "@/hooks/use-providers" +import { DateTime } from "luxon" +import { persisted } from "@/utils/persist" + +export type LocalFile = FileNode & + Partial<{ + loaded: boolean + pinned: boolean + expanded: boolean + content: FileContent + selection: { startLine: number; startChar: number; endLine: number; endChar: number } + scrollTop: number + view: "raw" | "diff-unified" | "diff-split" + folded: string[] + selectedChange: number + status: FileStatus + }> +export type TextSelection = LocalFile["selection"] +export type View = LocalFile["view"] + +export type LocalModel = Omit<Model, "provider"> & { + provider: Provider + latest?: boolean +} +export type ModelKey = { providerID: string; modelID: string } + +export type FileContext = { type: "file"; path: string; selection?: TextSelection } +export type ContextItem = FileContext + +export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ + name: "Local", + init: () => { + const sdk = useSDK() + const sync = useSync() + const providers = useProviders() + + function isModelValid(model: ModelKey) { + const provider = providers.all().find((x) => x.id === model.providerID) + return ( + !!provider?.models[model.modelID] && + providers + .connected() + .map((p) => p.id) + .includes(model.providerID) + ) + } + + function getFirstValidModel(...modelFns: (() => ModelKey | undefined)[]) { + for (const modelFn of modelFns) { + const model = modelFn() + if (!model) continue + if (isModelValid(model)) return model + } + } + + // Automatically update model when agent changes + createEffect(() => { + const value = agent.current() + if (value.model) { + if (isModelValid(value.model)) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + // else + // toast.show({ + // type: "warning", + // message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + // duration: 3000, + // }) + } + }) + + const agent = (() => { + const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) + const [store, setStore] = createStore<{ + current: string + }>({ + current: list()[0].name, + }) + return { + list, + current() { + return list().find((x) => x.name === store.current)! + }, + set(name: string | undefined) { + setStore("current", name ?? list()[0].name) + }, + move(direction: 1 | -1) { + let next = list().findIndex((x) => x.name === store.current) + direction + if (next < 0) next = list().length - 1 + if (next >= list().length) next = 0 + const value = list()[next] + setStore("current", value.name) + if (value.model) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + }, + } + })() + + const model = (() => { + const [store, setStore, _, modelReady] = persisted( + "model.v1", + createStore<{ + user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] + recent: ModelKey[] + }>({ + user: [], + recent: [], + }), + ) + + const [ephemeral, setEphemeral] = createStore<{ + model: Record<string, ModelKey> + }>({ + model: {}, + }) + + const available = createMemo(() => + providers.connected().flatMap((p) => + Object.values(p.models).map((m) => ({ + ...m, + provider: p, + })), + ), + ) + + const latest = createMemo(() => + pipe( + available(), + filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), + groupBy((x) => x.provider.id), + mapValues((models) => + pipe( + models, + groupBy((x) => x.family), + values(), + (groups) => + groups.flatMap((g) => { + const first = firstBy(g, [(x) => x.release_date, "desc"]) + return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] + }), + ), + ), + values(), + flat(), + ), + ) + + const list = createMemo(() => + available().map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + latest: m.name.includes("(latest)"), + })), + ) + + const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) + + const fallbackModel = createMemo(() => { + if (sync.data.config.model) { + const [providerID, modelID] = sync.data.config.model.split("/") + if (isModelValid({ providerID, modelID })) { + return { + providerID, + modelID, + } + } + } + + for (const item of store.recent) { + if (isModelValid(item)) { + return item + } + } + + for (const p of providers.connected()) { + if (p.id in providers.default()) { + return { + providerID: p.id, + modelID: providers.default()[p.id], + } + } + } + + throw new Error("No default model found") + }) + + const current = createMemo(() => { + const a = agent.current() + const key = getFirstValidModel( + () => ephemeral.model[a.name], + () => a.model, + fallbackModel, + )! + return find(key) + }) + + const recent = createMemo(() => store.recent.map(find).filter(Boolean)) + + const cycle = (direction: 1 | -1) => { + const recentList = recent() + const currentModel = current() + if (!currentModel) return + + const index = recentList.findIndex( + (x) => x?.provider.id === currentModel.provider.id && x?.id === currentModel.id, + ) + if (index === -1) return + + let next = index + direction + if (next < 0) next = recentList.length - 1 + if (next >= recentList.length) next = 0 + + const val = recentList[next] + if (!val) return + + model.set({ + providerID: val.provider.id, + modelID: val.id, + }) + } + + function updateVisibility(model: ModelKey, visibility: "show" | "hide") { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, { visibility }) + } else { + setStore("user", store.user.length, { ...model, visibility }) + } + } + + return { + ready: modelReady, + current, + recent, + list, + cycle, + set(model: ModelKey | undefined, options?: { recent?: boolean }) { + batch(() => { + setEphemeral("model", agent.current().name, model ?? fallbackModel()) + if (model) updateVisibility(model, "show") + if (options?.recent && model) { + const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) + if (uniq.length > 5) uniq.pop() + setStore("recent", uniq) + } + }) + }, + visible(model: ModelKey) { + const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID) + return ( + user?.visibility !== "hide" && + (latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) || + user?.visibility === "show") + ) + }, + setVisibility(model: ModelKey, visible: boolean) { + updateVisibility(model, visible ? "show" : "hide") + }, + } + })() + + const file = (() => { + const [store, setStore] = createStore<{ + node: Record<string, LocalFile> + }>({ + node: Object.fromEntries(sync.data.node.map((x) => [x.path, 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))) + + // createEffect((prev: FileStatus[]) => { + // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path)) + // for (const p of removed) { + // setStore( + // "node", + // p.path, + // produce((draft) => { + // draft.status = undefined + // draft.view = "raw" + // }), + // ) + // load(p.path) + // } + // for (const p of sync.data.changes) { + // if (store.node[p.path] === undefined) { + // fetch(p.path).then(() => { + // if (store.node[p.path] === undefined) return + // setStore("node", p.path, "status", p) + // }) + // } else { + // setStore("node", p.path, "status", p) + // } + // } + // return sync.data.changes + // }, sync.data.changes) + + const changed = (path: string) => { + const node = store.node[path] + if (node?.status) return true + const set = changeset() + if (set.has(path)) return true + for (const p of set) { + if (p.startsWith(path ? path + "/" : "")) return true + } + 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 relative = (path: string) => path.replace(sync.data.path.directory + "/", "") + + const load = async (path: string) => { + const relativePath = relative(path) + await sdk.client.file.read({ path: relativePath }).then((x) => { + if (!store.node[relativePath]) return + setStore( + "node", + relativePath, + produce((draft) => { + draft.loaded = true + draft.content = x.data + }), + ) + }) + } + + const fetch = async (path: string) => { + const relativePath = relative(path) + const parent = relativePath.split("/").slice(0, -1).join("/") + if (parent) { + await list(parent) + } + } + + const init = async (path: string) => { + const relativePath = relative(path) + if (!store.node[relativePath]) await fetch(path) + if (store.node[relativePath]?.loaded) return + return load(relativePath) + } + + const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => { + const relativePath = relative(path) + if (!store.node[relativePath]) await fetch(path) + // setStore("opened", (x) => { + // if (x.includes(relativePath)) return x + // return [ + // ...opened() + // .filter((x) => x.pinned) + // .map((x) => x.path), + // relativePath, + // ] + // }) + // setStore("active", relativePath) + context.addActive() + if (options?.pinned) setStore("node", path, "pinned", true) + if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view) + if (store.node[relativePath]?.loaded) return + return load(relativePath) + } + + const list = async (path: string) => { + return sdk.client.file.list({ path: path + "/" }).then((x) => { + setStore( + "node", + produce((draft) => { + x.data!.forEach((node) => { + if (node.path in draft) return + draft[node.path] = node + }) + }), + ) + }) + } + + const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!) + const searchFilesAndDirectories = (query: string) => + sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!) + + sdk.event.listen((e) => { + const event = e.details + switch (event.type) { + case "file.watcher.updated": + const relativePath = relative(event.properties.file) + if (relativePath.startsWith(".git/")) return + if (store.node[relativePath]) load(relativePath) + break + } + }) + + return { + node: async (path: string) => { + if (!store.node[path] || !store.node[path].loaded) { + await init(path) + } + return store.node[path] + }, + update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), + open, + load, + init, + expand(path: string) { + setStore("node", path, "expanded", true) + if (store.node[path]?.loaded) return + setStore("node", path, "loaded", true) + list(path) + }, + collapse(path: string) { + setStore("node", path, "expanded", false) + }, + select(path: string, selection: TextSelection | undefined) { + setStore("node", path, "selection", selection) + }, + scroll(path: string, scrollTop: number) { + setStore("node", path, "scrollTop", scrollTop) + }, + view(path: string): View { + const n = store.node[path] + return n && n.view ? n.view : "raw" + }, + setView(path: string, view: View) { + setStore("node", path, "view", view) + }, + unfold(path: string, key: string) { + setStore("node", path, "folded", (xs) => { + const a = xs ?? [] + if (a.includes(key)) return a + return [...a, key] + }) + }, + fold(path: string, key: string) { + setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key)) + }, + folded(path: string) { + const n = store.node[path] + return n && n.folded ? n.folded : [] + }, + changeIndex(path: string) { + return store.node[path]?.selectedChange + }, + setChangeIndex(path: string, index: number | undefined) { + setStore("node", path, "selectedChange", index) + }, + changes, + changed, + children(path: string) { + return Object.values(store.node).filter( + (x) => + x.path.startsWith(path) && + x.path !== path && + !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"), + ) + }, + searchFiles, + searchFilesAndDirectories, + relative, + } + })() + + const context = (() => { + const [store, setStore] = createStore<{ + activeTab: boolean + files: string[] + activeFile?: string + items: (ContextItem & { key: string })[] + }>({ + activeTab: true, + files: [], + items: [], + }) + const files = createMemo(() => store.files.map((x) => file.node(x))) + const activeFile = createMemo(() => (store.activeFile ? file.node(store.activeFile) : undefined)) + + return { + all() { + return store.items + }, + // active() { + // return store.activeTab ? file.active() : undefined + // }, + addActive() { + setStore("activeTab", true) + }, + removeActive() { + setStore("activeTab", false) + }, + add(item: ContextItem) { + let key = item.type + switch (item.type) { + case "file": + key += `${item.path}:${item.selection?.startLine}:${item.selection?.endLine}` + break + } + if (store.items.find((x) => x.key === key)) return + setStore("items", (x) => [...x, { key, ...item }]) + }, + remove(key: string) { + setStore("items", (x) => x.filter((x) => x.key !== key)) + }, + files, + openFile(path: string) { + file.init(path).then(() => { + setStore("files", (x) => [...x, path]) + setStore("activeFile", path) + }) + }, + activeFile, + setActiveFile(path: string | undefined) { + setStore("activeFile", path) + }, + } + })() + + const result = { + slug: createMemo(() => base64Encode(sdk.directory)), + model, + agent, + file, + context, + } + return result + }, +}) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx new file mode 100644 index 000000000..2b258ebd6 --- /dev/null +++ b/packages/app/src/context/notification.tsx @@ -0,0 +1,127 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useGlobalSDK } from "./global-sdk" +import { useGlobalSync } from "./global-sync" +import { Binary } from "@opencode-ai/util/binary" +import { EventSessionError } from "@opencode-ai/sdk/v2" +import { makeAudioPlayer } from "@solid-primitives/audio" +import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" +import errorSound from "@opencode-ai/ui/audio/nope-03.aac" +import { persisted } from "@/utils/persist" + +type NotificationBase = { + directory?: string + session?: string + metadata?: any + time: number + viewed: boolean +} + +type TurnCompleteNotification = NotificationBase & { + type: "turn-complete" +} + +type ErrorNotification = NotificationBase & { + type: "error" + error: EventSessionError["properties"]["error"] +} + +export type Notification = TurnCompleteNotification | ErrorNotification + +export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ + name: "Notification", + init: () => { + let idlePlayer: ReturnType<typeof makeAudioPlayer> | undefined + let errorPlayer: ReturnType<typeof makeAudioPlayer> | undefined + + try { + idlePlayer = makeAudioPlayer(idleSound) + errorPlayer = makeAudioPlayer(errorSound) + } catch (err) { + console.log("Failed to load audio", err) + } + + const globalSDK = useGlobalSDK() + const globalSync = useGlobalSync() + + const [store, setStore, _, ready] = persisted( + "notification.v1", + createStore({ + list: [] as Notification[], + }), + ) + + globalSDK.event.listen((e) => { + const directory = e.name + const event = e.details + const base = { + directory, + time: Date.now(), + viewed: false, + } + switch (event.type) { + case "session.idle": { + const sessionID = event.properties.sessionID + const [syncStore] = globalSync.child(directory) + const match = Binary.search(syncStore.session, sessionID, (s) => s.id) + const isChild = match.found && syncStore.session[match.index].parentID + if (isChild) break + try { + idlePlayer?.play() + } catch {} + setStore("list", store.list.length, { + ...base, + type: "turn-complete", + session: sessionID, + }) + break + } + case "session.error": { + const sessionID = event.properties.sessionID + if (sessionID) { + const [syncStore] = globalSync.child(directory) + const match = Binary.search(syncStore.session, sessionID, (s) => s.id) + const isChild = match.found && syncStore.session[match.index].parentID + if (isChild) break + } + try { + errorPlayer?.play() + } catch {} + setStore("list", store.list.length, { + ...base, + type: "error", + session: sessionID ?? "global", + error: "error" in event.properties ? event.properties.error : undefined, + }) + break + } + } + }) + + return { + ready, + session: { + all(session: string) { + return store.list.filter((n) => n.session === session) + }, + unseen(session: string) { + return store.list.filter((n) => n.session === session && !n.viewed) + }, + markViewed(session: string) { + setStore("list", (n) => n.session === session, "viewed", true) + }, + }, + project: { + all(directory: string) { + return store.list.filter((n) => n.directory === directory) + }, + unseen(directory: string) { + return store.list.filter((n) => n.directory === directory && !n.viewed) + }, + markViewed(directory: string) { + setStore("list", (n) => n.directory === directory, "viewed", true) + }, + }, + } + }, +}) diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx new file mode 100644 index 000000000..73d4c7f3e --- /dev/null +++ b/packages/app/src/context/platform.tsx @@ -0,0 +1,41 @@ +import { createSimpleContext } from "@opencode-ai/ui/context" +import { AsyncStorage, SyncStorage } from "@solid-primitives/storage" + +export type Platform = { + /** Platform discriminator */ + platform: "web" | "tauri" + + /** Open a URL in the default browser */ + openLink(url: string): void + + /** Restart the app */ + restart(): Promise<void> + + /** Open native directory picker dialog (Tauri only) */ + openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null> + + /** Open native file picker dialog (Tauri only) */ + openFilePickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null> + + /** Save file picker dialog (Tauri only) */ + saveFilePickerDialog?(opts?: { title?: string; defaultPath?: string }): Promise<string | null> + + /** Storage mechanism, defaults to localStorage */ + storage?: (name?: string) => SyncStorage | AsyncStorage + + /** Check for updates (Tauri only) */ + checkUpdate?(): Promise<{ updateAvailable: boolean; version?: string }> + + /** Install updates (Tauri only) */ + update?(): Promise<void> + + /** Fetch override */ + fetch?: typeof fetch +} + +export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ + name: "Platform", + init: (props: { value: Platform }) => { + return props.value + }, +}) diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx new file mode 100644 index 000000000..8d3590cd9 --- /dev/null +++ b/packages/app/src/context/prompt.tsx @@ -0,0 +1,111 @@ +import { createStore } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { useParams } from "@solidjs/router" +import { TextSelection } from "./local" +import { persisted } from "@/utils/persist" + +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 interface ImageAttachmentPart { + type: "image" + id: string + filename: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart +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 + } + if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) { + return false + } + } + return true +} + +function cloneSelection(selection?: TextSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({ + name: "Prompt", + init: () => { + const params = useParams() + const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore, _, ready] = persisted( + name(), + createStore<{ + prompt: Prompt + cursor?: number + }>({ + prompt: clonePrompt(DEFAULT_PROMPT), + cursor: undefined, + }), + ) + + return { + ready, + current: createMemo(() => store.prompt), + cursor: createMemo(() => store.cursor), + dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)), + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } + }, +}) diff --git a/packages/app/src/context/sdk.tsx b/packages/app/src/context/sdk.tsx new file mode 100644 index 000000000..4d1c797c9 --- /dev/null +++ b/packages/app/src/context/sdk.tsx @@ -0,0 +1,30 @@ +import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { createGlobalEmitter } from "@solid-primitives/event-bus" +import { useGlobalSDK } from "./global-sdk" +import { usePlatform } from "./platform" + +export const { use: useSDK, provider: SDKProvider } = createSimpleContext({ + name: "SDK", + init: (props: { directory: string }) => { + const platform = usePlatform() + const globalSDK = useGlobalSDK() + const sdk = createOpencodeClient({ + baseUrl: globalSDK.url, + signal: AbortSignal.timeout(1000 * 60 * 10), + fetch: platform.fetch, + directory: props.directory, + throwOnError: true, + }) + + const emitter = createGlobalEmitter<{ + [key in Event["type"]]: Extract<Event, { type: key }> + }>() + + globalSDK.event.on(props.directory, async (event) => { + emitter.emit(event.type, event) + }) + + return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url } + }, +}) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx new file mode 100644 index 000000000..941b8b629 --- /dev/null +++ b/packages/app/src/context/sync.tsx @@ -0,0 +1,114 @@ +import { produce } from "solid-js/store" +import { createMemo } from "solid-js" +import { Binary } from "@opencode-ai/util/binary" +import { retry } from "@opencode-ai/util/retry" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useGlobalSync } from "./global-sync" +import { useSDK } from "./sdk" +import type { Message, Part } from "@opencode-ai/sdk/v2/client" + +export const { use: useSync, provider: SyncProvider } = createSimpleContext({ + name: "Sync", + init: () => { + const globalSync = useGlobalSync() + const sdk = useSDK() + const [store, setStore] = globalSync.child(sdk.directory) + const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/") + + return { + data: store, + set: setStore, + get ready() { + return store.ready + }, + get project() { + const match = Binary.search(globalSync.data.project, store.project, (p) => p.id) + if (match.found) return globalSync.data.project[match.index] + return undefined + }, + session: { + get(sessionID: string) { + const match = Binary.search(store.session, sessionID, (s) => s.id) + if (match.found) return store.session[match.index] + return undefined + }, + addOptimisticMessage(input: { + sessionID: string + messageID: string + parts: Part[] + agent: string + model: { providerID: string; modelID: string } + }) { + const message: Message = { + id: input.messageID, + sessionID: input.sessionID, + role: "user", + time: { created: Date.now() }, + agent: input.agent, + model: input.model, + } + setStore( + produce((draft) => { + const messages = draft.message[input.sessionID] + if (!messages) { + draft.message[input.sessionID] = [message] + } else { + const result = Binary.search(messages, input.messageID, (m) => m.id) + messages.splice(result.index, 0, message) + } + draft.part[input.messageID] = input.parts.slice() + }), + ) + }, + async sync(sessionID: string, _isRetry = false) { + const [session, messages, todo, diff] = await Promise.all([ + retry(() => sdk.client.session.get({ sessionID })), + retry(() => sdk.client.session.messages({ sessionID, limit: 100 })), + retry(() => sdk.client.session.todo({ sessionID })), + retry(() => sdk.client.session.diff({ sessionID })), + ]) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + 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() + .sort((a, b) => a.id.localeCompare(b.id)) + for (const message of messages.data!) { + draft.part[message.info.id] = message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)) + } + draft.session_diff[sessionID] = diff.data ?? [] + }), + ) + }, + fetch: async (count = 10) => { + setStore("limit", (x) => x + count) + await 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) + }) + }, + more: createMemo(() => store.session.length >= store.limit), + archive: async (sessionID: string) => { + await sdk.client.session.update({ sessionID, time: { archived: Date.now() } }) + setStore( + produce((draft) => { + const match = Binary.search(draft.session, sessionID, (s) => s.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ) + }, + }, + absolute, + get directory() { + return store.path.directory + }, + } + }, +}) diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx new file mode 100644 index 000000000..6f7c11dea --- /dev/null +++ b/packages/app/src/context/terminal.tsx @@ -0,0 +1,105 @@ +import { createStore, produce } from "solid-js/store" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { batch, createMemo } from "solid-js" +import { useParams } from "@solidjs/router" +import { useSDK } from "./sdk" +import { persisted } from "@/utils/persist" + +export type LocalPTY = { + id: string + title: string + rows?: number + cols?: number + buffer?: string + scrollY?: number +} + +export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({ + name: "Terminal", + init: () => { + const sdk = useSDK() + const params = useParams() + const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`) + + const [store, setStore, _, ready] = persisted( + name(), + createStore<{ + active?: string + all: LocalPTY[] + }>({ + all: [], + }), + ) + + return { + ready, + all: createMemo(() => Object.values(store.all)), + active: createMemo(() => store.active), + new() { + sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => { + const id = pty.data?.id + if (!id) return + setStore("all", [ + ...store.all, + { + id, + title: pty.data?.title ?? "Terminal", + }, + ]) + setStore("active", id) + }) + }, + update(pty: Partial<LocalPTY> & { id: string }) { + setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x))) + sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + }, + async clone(id: string) { + const index = store.all.findIndex((x) => x.id === id) + const pty = store.all[index] + if (!pty) return + const clone = await sdk.client.pty.create({ + title: pty.title, + }) + if (!clone.data) return + setStore("all", index, { + ...pty, + ...clone.data, + }) + if (store.active === pty.id) { + setStore("active", clone.data.id) + } + }, + open(id: string) { + setStore("active", id) + }, + async close(id: string) { + batch(() => { + setStore( + "all", + store.all.filter((x) => x.id !== id), + ) + if (store.active === id) { + const index = store.all.findIndex((f) => f.id === id) + const previous = store.all[Math.max(0, index - 1)] + setStore("active", previous?.id) + } + }) + await sdk.client.pty.remove({ ptyID: id }) + }, + move(id: string, to: number) { + const index = store.all.findIndex((f) => f.id === id) + if (index === -1) return + setStore( + "all", + produce((all) => { + all.splice(to, 0, all.splice(index, 1)[0]) + }), + ) + }, + } + }, +}) |
