summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-30 07:24:35 -0600
committerAdam <[email protected]>2025-12-30 07:24:40 -0600
commite0e07c5d48ef0671f63ca8bd9119169ced493fac (patch)
tree9377eab8b3cc5d3c2976ccae2d32e22db46ce816
parent281f9e623673e6bbfd9a5f9a8f9aae496abc99f2 (diff)
downloadopencode-e0e07c5d48ef0671f63ca8bd9119169ced493fac.tar.gz
opencode-e0e07c5d48ef0671f63ca8bd9119169ced493fac.zip
feat(app): change server
-rw-r--r--packages/app/src/app.tsx93
-rw-r--r--packages/app/src/components/dialog-select-server.tsx170
-rw-r--r--packages/app/src/components/session-lsp-indicator.tsx10
-rw-r--r--packages/app/src/components/session-mcp-indicator.tsx10
-rw-r--r--packages/app/src/components/status-bar.tsx29
-rw-r--r--packages/app/src/context/global-sdk.tsx23
-rw-r--r--packages/app/src/context/layout.tsx30
-rw-r--r--packages/app/src/context/server.tsx186
-rw-r--r--packages/app/src/pages/home.tsx18
-rw-r--r--packages/app/src/pages/layout.tsx11
-rw-r--r--packages/ui/src/components/dialog.css2
-rw-r--r--packages/ui/src/components/list.css22
-rw-r--r--packages/ui/src/components/list.tsx10
-rw-r--r--packages/ui/src/context/dialog.tsx4
14 files changed, 526 insertions, 92 deletions
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 9e38d5e98..dd2491961 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -1,5 +1,5 @@
import "@/index.css"
-import { ErrorBoundary, Show } from "solid-js"
+import { ErrorBoundary, Show, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -12,6 +12,7 @@ import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
+import { ServerProvider, useServer } from "@/context/server"
import { TerminalProvider } from "@/context/terminal"
import { PromptProvider } from "@/context/prompt"
import { NotificationProvider } from "@/context/notification"
@@ -30,18 +31,30 @@ declare global {
}
}
-const url = iife(() => {
+const serverDefaults = iife(() => {
const param = new URLSearchParams(document.location.search).get("url")
- if (param) return param
+ if (param) return { url: param, forced: true }
- if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
- if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
+ if (location.hostname.includes("opencode.ai")) return { url: "http://localhost:4096", forced: false }
+ if (window.__OPENCODE__) return { url: `http://127.0.0.1:${window.__OPENCODE__.port}`, forced: false }
if (import.meta.env.DEV)
- return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+ return {
+ url: `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`,
+ forced: false,
+ }
- return window.location.origin
+ return { url: window.location.origin, forced: false }
})
+function ServerKey(props: ParentProps) {
+ const server = useServer()
+ return (
+ <Show when={server.url} keyed>
+ {props.children}
+ </Show>
+ )
+}
+
export function App() {
return (
<MetaProvider>
@@ -52,38 +65,42 @@ export function App() {
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
- <GlobalSDKProvider url={url}>
- <GlobalSyncProvider>
- <LayoutProvider>
- <NotificationProvider>
- <Router
- root={(props) => (
- <CommandProvider>
- <Layout>{props.children}</Layout>
- </CommandProvider>
- )}
- >
- <Route path="/" component={Home} />
- <Route path="/:dir" component={DirectoryLayout}>
- <Route path="/" component={() => <Navigate href="session" />} />
- <Route
- path="/session/:id?"
- component={(p) => (
- <Show when={p.params.id ?? "new"} keyed>
- <TerminalProvider>
- <PromptProvider>
- <Session />
- </PromptProvider>
- </TerminalProvider>
- </Show>
+ <ServerProvider defaultUrl={serverDefaults.url} forceUrl={serverDefaults.forced}>
+ <ServerKey>
+ <GlobalSDKProvider>
+ <GlobalSyncProvider>
+ <LayoutProvider>
+ <NotificationProvider>
+ <Router
+ root={(props) => (
+ <CommandProvider>
+ <Layout>{props.children}</Layout>
+ </CommandProvider>
)}
- />
- </Route>
- </Router>
- </NotificationProvider>
- </LayoutProvider>
- </GlobalSyncProvider>
- </GlobalSDKProvider>
+ >
+ <Route path="/" component={Home} />
+ <Route path="/:dir" component={DirectoryLayout}>
+ <Route path="/" component={() => <Navigate href="session" />} />
+ <Route
+ path="/session/:id?"
+ component={(p) => (
+ <Show when={p.params.id ?? "new"} keyed>
+ <TerminalProvider>
+ <PromptProvider>
+ <Session />
+ </PromptProvider>
+ </TerminalProvider>
+ </Show>
+ )}
+ />
+ </Route>
+ </Router>
+ </NotificationProvider>
+ </LayoutProvider>
+ </GlobalSyncProvider>
+ </GlobalSDKProvider>
+ </ServerKey>
+ </ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
new file mode 100644
index 000000000..b041bbab6
--- /dev/null
+++ b/packages/app/src/components/dialog-select-server.tsx
@@ -0,0 +1,170 @@
+import { createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Button } from "@opencode-ai/ui/button"
+import { useServer } from "@/context/server"
+import { usePlatform } from "@/context/platform"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { useNavigate } from "@solidjs/router"
+
+type ServerStatus = { healthy: boolean; version?: string }
+
+function displayName(url: string) {
+ return url
+ .replace(/^https?:\/\//, "")
+ .replace(/\/+$/, "")
+ .split("/")[0]
+}
+
+function normalize(input: string) {
+ const trimmed = input.trim()
+ if (!trimmed) return
+ const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
+ const cleaned = withProtocol.replace(/\/+$/, "")
+ return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
+}
+
+async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
+ const sdk = createOpencodeClient({
+ baseUrl: url,
+ fetch,
+ signal: AbortSignal.timeout(3000),
+ })
+ return sdk.global
+ .health()
+ .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
+ .catch(() => ({ healthy: false }))
+}
+
+export function DialogSelectServer() {
+ const navigate = useNavigate()
+ const dialog = useDialog()
+ const server = useServer()
+ const platform = usePlatform()
+ const [store, setStore] = createStore({
+ url: "",
+ adding: false,
+ error: "",
+ status: {} as Record<string, ServerStatus | undefined>,
+ })
+
+ const items = createMemo(() => {
+ const current = server.url
+ const list = server.list
+ if (!current) return list
+ if (!list.includes(current)) return [current, ...list]
+ return [current, ...list.filter((x) => x !== current)]
+ })
+
+ const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
+
+ async function refreshHealth() {
+ const results: Record<string, ServerStatus> = {}
+ await Promise.all(
+ items().map(async (url) => {
+ results[url] = await checkHealth(url, platform.fetch)
+ }),
+ )
+ setStore("status", reconcile(results))
+ }
+
+ createEffect(() => {
+ items()
+ refreshHealth()
+ const interval = setInterval(refreshHealth, 10_000)
+ onCleanup(() => clearInterval(interval))
+ })
+
+ function select(value: string) {
+ if (store.status[value]?.healthy === false) return
+ dialog.close()
+ server.setActive(value)
+ navigate("/")
+ }
+
+ async function handleSubmit(e: SubmitEvent) {
+ e.preventDefault()
+ const value = normalize(store.url)
+ if (!value) return
+
+ setStore("adding", true)
+ setStore("error", "")
+
+ const result = await checkHealth(value, platform.fetch)
+ setStore("adding", false)
+
+ if (!result.healthy) {
+ setStore("error", "Could not connect to server")
+ return
+ }
+
+ setStore("url", "")
+ select(value)
+ }
+
+ return (
+ <Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
+ <div class="flex flex-col gap-4 pb-4">
+ <List
+ search={{ placeholder: "Search servers", autofocus: true }}
+ emptyMessage="No servers yet"
+ items={items}
+ key={(x) => x}
+ current={current()}
+ onSelect={(x) => {
+ if (x) select(x)
+ }}
+ >
+ {(i) => (
+ <div
+ class="flex items-center gap-2 min-w-0 flex-1"
+ classList={{ "opacity-50": store.status[i]?.healthy === false }}
+ >
+ <div
+ classList={{
+ "size-1.5 rounded-full shrink-0": true,
+ "bg-icon-success-base": store.status[i]?.healthy === true,
+ "bg-icon-critical-base": store.status[i]?.healthy === false,
+ "bg-border-weak-base": store.status[i] === undefined,
+ }}
+ />
+ <span class="truncate">{displayName(i)}</span>
+ <span class="text-text-weak">{store.status[i]?.version}</span>
+ </div>
+ )}
+ </List>
+
+ <div class="mt-6 px-3 flex flex-col gap-1.5">
+ <div class="px-3">
+ <h3 class="text-14-regular text-text-weak">Add a server</h3>
+ </div>
+ <form onSubmit={handleSubmit}>
+ <div class="flex items-start gap-2">
+ <div class="flex-1 min-w-0 h-auto">
+ <TextField
+ type="text"
+ label="Server URL"
+ hideLabel
+ placeholder="http://localhost:4096"
+ value={store.url}
+ onChange={(v) => {
+ setStore("url", v)
+ setStore("error", "")
+ }}
+ validationState={store.error ? "invalid" : "valid"}
+ error={store.error}
+ />
+ </div>
+ <Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
+ {store.adding ? "Checking..." : "Add"}
+ </Button>
+ </div>
+ </form>
+ </div>
+ </div>
+ </Dialog>
+ )
+}
diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx
index 98d6d6dfd..ac3a39997 100644
--- a/packages/app/src/components/session-lsp-indicator.tsx
+++ b/packages/app/src/components/session-lsp-indicator.tsx
@@ -1,5 +1,4 @@
import { createMemo, Show } from "solid-js"
-import { Icon } from "@opencode-ai/ui/icon"
import { useSync } from "@/context/sync"
import { Tooltip } from "@opencode-ai/ui/tooltip"
@@ -24,12 +23,11 @@ export function SessionLspIndicator() {
<Show when={lspStats().total > 0}>
<Tooltip placement="top" value={tooltipContent()}>
<div class="flex items-center gap-1 px-2 cursor-default select-none">
- <Icon
- name="code"
- size="small"
+ <div
classList={{
- "text-icon-critical-base": lspStats().hasError,
- "text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
+ "size-1.5 rounded-full": true,
+ "bg-icon-critical-base": lspStats().hasError,
+ "bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
}}
/>
<span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx
index 17a6f2e1a..489223b9b 100644
--- a/packages/app/src/components/session-mcp-indicator.tsx
+++ b/packages/app/src/components/session-mcp-indicator.tsx
@@ -1,6 +1,5 @@
import { createMemo, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
@@ -21,12 +20,11 @@ export function SessionMcpIndicator() {
return (
<Show when={mcpStats().total > 0}>
<Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
- <Icon
- name="mcp"
- size="small"
+ <div
classList={{
- "text-icon-critical-base": mcpStats().failed,
- "text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
+ "size-1.5 rounded-full": true,
+ "bg-icon-critical-base": mcpStats().failed,
+ "bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
}}
/>
<span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
diff --git a/packages/app/src/components/status-bar.tsx b/packages/app/src/components/status-bar.tsx
index d8a88503f..bfc428fca 100644
--- a/packages/app/src/components/status-bar.tsx
+++ b/packages/app/src/components/status-bar.tsx
@@ -1,10 +1,14 @@
import { createMemo, Show, type ParentProps } from "solid-js"
-import { usePlatform } from "@/context/platform"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
+import { useServer } from "@/context/server"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Button } from "@opencode-ai/ui/button"
+import { DialogSelectServer } from "@/components/dialog-select-server"
export function StatusBar(props: ParentProps) {
- const platform = usePlatform()
+ const dialog = useDialog()
+ const server = useServer()
const sync = useSync()
const globalSync = useGlobalSync()
@@ -19,9 +23,24 @@ export function StatusBar(props: ParentProps) {
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
<div class="flex items-center gap-3">
- <Show when={platform.version}>
- <span class="text-12-regular text-text-weak">v{platform.version}</span>
- </Show>
+ <div class="flex items-center gap-1">
+ <Button
+ size="small"
+ variant="ghost"
+ onClick={() => {
+ dialog.show(() => <DialogSelectServer />)
+ }}
+ >
+ <div
+ classList={{
+ "size-1.5 rounded-full": true,
+ "bg-icon-success-base": server.healthy(),
+ "bg-icon-critical-base": !server.healthy(),
+ }}
+ />
+ <span class="text-12-regular text-text-weak">{server.name}</span>
+ </Button>
+ </div>
<Show when={directoryDisplay()}>
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
</Show>
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx
index 3732ca085..515be1d7a 100644
--- a/packages/app/src/context/global-sdk.tsx
+++ b/packages/app/src/context/global-sdk.tsx
@@ -1,34 +1,41 @@
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 { onCleanup } from "solid-js"
import { usePlatform } from "./platform"
+import { useServer } from "./server"
export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
name: "GlobalSDK",
- init: (props: { url: string }) => {
+ init: () => {
+ const server = useServer()
+ const abort = new AbortController()
+
const eventSdk = createOpencodeClient({
- baseUrl: props.url,
- // signal: AbortSignal.timeout(1000 * 60 * 10),
+ baseUrl: server.url,
+ signal: abort.signal,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
- eventSdk.global.event().then(async (events) => {
+ void (async () => {
+ const events = await eventSdk.global.event()
for await (const event of events.stream) {
- // console.log("event", event)
emitter.emit(event.directory ?? "global", event.payload)
}
- })
+ })().catch(() => undefined)
+
+ onCleanup(() => abort.abort())
const platform = usePlatform()
const sdk = createOpencodeClient({
- baseUrl: props.url,
+ baseUrl: server.url,
signal: AbortSignal.timeout(1000 * 60 * 10),
fetch: platform.fetch,
throwOnError: true,
})
- return { url: props.url, client: sdk, event: emitter }
+ return { url: server.url, client: sdk, event: emitter }
},
})
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 4ccab98e3..e57f69f8f 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -3,6 +3,7 @@ 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 { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2"
import { persisted } from "@/utils/persist"
@@ -34,10 +35,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
init: () => {
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
+ const server = useServer()
const [store, setStore, _, ready] = persisted(
- "layout.v3",
+ "layout.v4",
createStore({
- projects: [] as { worktree: string; expanded: boolean }[],
sidebar: {
opened: false,
width: 280,
@@ -86,12 +87,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return project
}
- const enriched = createMemo(() => store.projects.flatMap(enrich))
+ const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
onMount(() => {
Promise.all(
- store.projects.map((project) => {
+ server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
@@ -102,32 +103,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
- if (store.projects.find((x) => x.worktree === directory)) {
+ if (server.projects.list().find((x) => x.worktree === directory)) {
return
}
globalSync.project.loadSessions(directory)
- setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
+ server.projects.open(directory)
},
close(directory: string) {
- setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
+ server.projects.close(directory)
},
expand(directory: string) {
- const index = store.projects.findIndex((x) => x.worktree === directory)
- if (index !== -1) setStore("projects", index, "expanded", true)
+ server.projects.expand(directory)
},
collapse(directory: string) {
- const index = store.projects.findIndex((x) => x.worktree === directory)
- if (index !== -1) setStore("projects", index, "expanded", false)
+ server.projects.collapse(directory)
},
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
- })
+ server.projects.move(directory, toIndex)
},
},
sidebar: {
diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx
new file mode 100644
index 000000000..73b83f461
--- /dev/null
+++ b/packages/app/src/context/server.tsx
@@ -0,0 +1,186 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { usePlatform } from "@/context/platform"
+import { persisted } from "@/utils/persist"
+
+type StoredProject = { worktree: string; expanded: boolean }
+
+function normalize(input: string) {
+ const trimmed = input.trim()
+ if (!trimmed) return
+ const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
+ const cleaned = withProtocol.replace(/\/+$/, "")
+ return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
+}
+
+function displayName(url: string) {
+ return url
+ .replace(/^https?:\/\//, "")
+ .replace(/\/+$/, "")
+ .split("/")[0]
+}
+
+export const { use: useServer, provider: ServerProvider } = createSimpleContext({
+ name: "Server",
+ init: (props: { defaultUrl: string; forceUrl?: boolean }) => {
+ const platform = usePlatform()
+ const fallback = () => normalize(props.defaultUrl)
+ const [forced, setForced] = createSignal(props.forceUrl ?? false)
+
+ const [store, setStore, _, ready] = persisted(
+ "server.v2",
+ createStore({
+ list: [] as string[],
+ active: "",
+ projects: {} as Record<string, StoredProject[]>,
+ }),
+ )
+
+ function setActive(input: string) {
+ const url = normalize(input)
+ if (!url) return
+ batch(() => {
+ if (!store.list.includes(url)) {
+ setStore("list", (list) => [url, ...list])
+ }
+ setStore("active", url)
+ })
+ }
+
+ function remove(input: string) {
+ const url = normalize(input)
+ if (!url) return
+
+ const list = store.list.filter((x) => x !== url)
+ const next = store.active === url ? (list[0] ?? fallback() ?? "") : store.active
+
+ batch(() => {
+ setStore("list", list)
+ setStore("active", next)
+ })
+ }
+
+ createEffect(() => {
+ if (!ready()) return
+
+ const url = fallback()
+ if (!url) return
+
+ if (forced()) {
+ batch(() => {
+ if (!store.list.includes(url)) {
+ setStore("list", (list) => [url, ...list])
+ }
+ if (store.active !== url) {
+ setStore("active", url)
+ }
+ })
+ setForced(false)
+ return
+ }
+
+ if (store.list.length === 0) {
+ batch(() => {
+ setStore("list", [url])
+ setStore("active", url)
+ })
+ return
+ }
+
+ if (store.active && store.list.includes(store.active)) return
+ setStore("active", store.list[0])
+ })
+
+ const isReady = createMemo(() => ready() && !!store.active)
+
+ const [healthy, { refetch }] = createResource(
+ () => store.active,
+ async (url) => {
+ if (!url) return true
+
+ const sdk = createOpencodeClient({
+ baseUrl: url,
+ fetch: platform.fetch,
+ signal: AbortSignal.timeout(2000),
+ })
+ return sdk.global
+ .health()
+ .then((x) => x.data?.healthy === true)
+ .catch(() => false)
+ },
+ { initialValue: true },
+ )
+
+ createEffect(() => {
+ if (!store.active) return
+ const interval = setInterval(() => refetch(), 10_000)
+ onCleanup(() => clearInterval(interval))
+ })
+
+ const projectsList = createMemo(() => store.projects[store.active] ?? [])
+
+ return {
+ ready: isReady,
+ healthy,
+ get url() {
+ return store.active
+ },
+ get name() {
+ return displayName(store.active)
+ },
+ get list() {
+ return store.list
+ },
+ setActive,
+ add: setActive,
+ remove,
+ projects: {
+ list: projectsList,
+ open(directory: string) {
+ const url = store.active
+ if (!url) return
+ const current = store.projects[url] ?? []
+ if (current.find((x) => x.worktree === directory)) return
+ setStore("projects", url, [{ worktree: directory, expanded: true }, ...current])
+ },
+ close(directory: string) {
+ const url = store.active
+ if (!url) return
+ const current = store.projects[url] ?? []
+ setStore(
+ "projects",
+ url,
+ current.filter((x) => x.worktree !== directory),
+ )
+ },
+ expand(directory: string) {
+ const url = store.active
+ if (!url) return
+ const current = store.projects[url] ?? []
+ const index = current.findIndex((x) => x.worktree === directory)
+ if (index !== -1) setStore("projects", url, index, "expanded", true)
+ },
+ collapse(directory: string) {
+ const url = store.active
+ if (!url) return
+ const current = store.projects[url] ?? []
+ const index = current.findIndex((x) => x.worktree === directory)
+ if (index !== -1) setStore("projects", url, index, "expanded", false)
+ },
+ move(directory: string, toIndex: number) {
+ const url = store.active
+ if (!url) return
+ const current = store.projects[url] ?? []
+ const fromIndex = current.findIndex((x) => x.worktree === directory)
+ if (fromIndex === -1 || fromIndex === toIndex) return
+ const result = [...current]
+ const [item] = result.splice(fromIndex, 1)
+ result.splice(toIndex, 0, item)
+ setStore("projects", url, result)
+ },
+ },
+ }
+ },
+})
diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx
index 129a50320..94332fcc6 100644
--- a/packages/app/src/pages/home.tsx
+++ b/packages/app/src/pages/home.tsx
@@ -10,6 +10,8 @@ import { usePlatform } from "@/context/platform"
import { DateTime } from "luxon"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
+import { DialogSelectServer } from "@/components/dialog-select-server"
+import { useServer } from "@/context/server"
export default function Home() {
const sync = useGlobalSync()
@@ -17,6 +19,7 @@ export default function Home() {
const platform = usePlatform()
const dialog = useDialog()
const navigate = useNavigate()
+ const server = useServer()
const homedir = createMemo(() => sync.data.path.home)
function openProject(directory: string) {
@@ -52,6 +55,21 @@ export default function Home() {
return (
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
+ <Button
+ size="large"
+ variant="ghost"
+ class="mt-4 mx-auto text-14-regular text-text-weak"
+ onClick={() => dialog.show(() => <DialogSelectServer />)}
+ >
+ <div
+ classList={{
+ "size-2 rounded-full": true,
+ "bg-icon-success-base": server.healthy(),
+ "bg-icon-critical-base": !server.healthy(),
+ }}
+ />
+ {server.name}
+ </Button>
<Switch>
<Match when={sync.data.project.length > 0}>
<div class="mt-20 w-full flex flex-col gap-4">
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 46ea3bd4d..aea7be3aa 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -50,6 +50,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogEditProject } from "@/components/dialog-edit-project"
+import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
@@ -353,6 +354,12 @@ export default function Layout(props: ParentProps) {
onSelect: () => connectProvider(),
},
{
+ id: "server.switch",
+ title: "Switch server",
+ category: "Server",
+ onSelect: () => openServer(),
+ },
+ {
id: "session.previous",
title: "Previous session",
category: "Session",
@@ -427,6 +434,10 @@ export default function Layout(props: ParentProps) {
dialog.show(() => <DialogSelectProvider />)
}
+ function openServer() {
+ dialog.show(() => <DialogSelectServer />)
+ }
+
function navigateToProject(directory: string | undefined) {
if (!directory) return
const lastSession = store.lastSession[directory]
diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css
index 782f2570c..ff8587421 100644
--- a/packages/ui/src/components/dialog.css
+++ b/packages/ui/src/components/dialog.css
@@ -83,7 +83,7 @@
[data-slot="dialog-description"] {
display: flex;
padding: 16px;
- padding-left: 20px;
+ padding-left: 24px;
padding-top: 0;
margin-top: -8px;
justify-content: space-between;
diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css
index 852bf486c..b896c4376 100644
--- a/packages/ui/src/components/list.css
+++ b/packages/ui/src/components/list.css
@@ -53,6 +53,8 @@
}
> [data-component="icon-button"] {
+ width: 20px;
+ height: 20px;
background-color: transparent;
&:hover:not(:disabled),
@@ -185,11 +187,25 @@
letter-spacing: var(--letter-spacing-normal);
[data-slot="list-item-selected-icon"] {
- color: var(--icon-strong-base);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ aspect-ratio: 1/1;
+ [data-component="icon"] {
+ color: var(--icon-strong-base);
+ }
}
[data-slot="list-item-active-icon"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ aspect-ratio: 1/1;
display: none;
- color: var(--icon-strong-base);
+ [data-component="icon"] {
+ color: var(--icon-strong-base);
+ }
}
[data-slot="list-item-extra-icon"] {
@@ -201,7 +217,7 @@
border-radius: var(--radius-md);
background: var(--surface-raised-base-hover);
[data-slot="list-item-active-icon"] {
- display: block;
+ display: inline-flex;
}
[data-slot="list-item-extra-icon"] {
display: block !important;
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index a549c19e9..b94405c81 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -206,10 +206,16 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
>
{props.children(item)}
<Show when={item === props.current}>
- <Icon data-slot="list-item-selected-icon" name="check-small" />
+ <span data-slot="list-item-selected-icon">
+ <Icon name="check-small" />
+ </span>
</Show>
<Show when={props.activeIcon}>
- {(icon) => <Icon data-slot="list-item-active-icon" name={icon()} />}
+ {(icon) => (
+ <span data-slot="list-item-active-icon">
+ <Icon name={icon()} />
+ </span>
+ )}
</Show>
</button>
)}
diff --git a/packages/ui/src/context/dialog.tsx b/packages/ui/src/context/dialog.tsx
index 8e1a6aad8..f85eb48df 100644
--- a/packages/ui/src/context/dialog.tsx
+++ b/packages/ui/src/context/dialog.tsx
@@ -1,6 +1,5 @@
import {
createContext,
- createEffect,
createSignal,
getOwner,
Owner,
@@ -70,9 +69,6 @@ function init() {
export function DialogProvider(props: ParentProps) {
const ctx = init()
- createEffect(() => {
- console.log("active", ctx.active)
- })
return (
<Context.Provider value={ctx}>
{props.children}