summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-22 19:38:50 -0600
committerAdam <[email protected]>2025-12-22 19:39:00 -0600
commit794fe8f381c846f5241800363023d892c12cf495 (patch)
treebff98689edfa635a2a9f39cb4ea61639b97f5b2d /packages/app/src/context
parenta4eebf9f08262f6bf63017710e2e6d9672ec6708 (diff)
downloadopencode-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.tsx243
-rw-r--r--packages/app/src/context/global-sdk.tsx34
-rw-r--r--packages/app/src/context/global-sync.tsx376
-rw-r--r--packages/app/src/context/layout.tsx260
-rw-r--r--packages/app/src/context/local.tsx548
-rw-r--r--packages/app/src/context/notification.tsx127
-rw-r--r--packages/app/src/context/platform.tsx41
-rw-r--r--packages/app/src/context/prompt.tsx111
-rw-r--r--packages/app/src/context/sdk.tsx30
-rw-r--r--packages/app/src/context/sync.tsx114
-rw-r--r--packages/app/src/context/terminal.tsx105
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])
+ }),
+ )
+ },
+ }
+ },
+})