diff options
| author | OpeOginni <[email protected]> | 2026-01-24 19:03:36 +0100 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-01-24 12:03:36 -0600 |
| commit | 67ea21b55a281d6feb00820f1fec94da50165678 (patch) | |
| tree | 44fc751afe3120de59e5e8adac17b1b40d519bb0 /packages/app/src/components | |
| parent | f4cf3f4976b8e8e60b90098bba57c11ffa115a6a (diff) | |
| download | opencode-67ea21b55a281d6feb00820f1fec94da50165678.tar.gz opencode-67ea21b55a281d6feb00820f1fec94da50165678.zip | |
feat(web): implement new server management for web and desktop (#8513)
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/dialog-select-server.tsx | 531 | ||||
| -rw-r--r-- | packages/app/src/components/session-lsp-indicator.tsx | 42 | ||||
| -rw-r--r-- | packages/app/src/components/session-mcp-indicator.tsx | 34 | ||||
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 162 | ||||
| -rw-r--r-- | packages/app/src/components/status-popover.tsx | 364 |
5 files changed, 838 insertions, 295 deletions
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 4466fdde1..e62aa93be 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -1,23 +1,47 @@ -import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createResource, createEffect, createMemo, onCleanup, Show, createSignal } 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 { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" import { usePlatform } from "@/context/platform" import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { useNavigate } from "@solidjs/router" import { useLanguage } from "@/context/language" +import { Popover } from "@opencode-ai/ui/popover" +import { useGlobalSDK } from "@/context/global-sdk" type ServerStatus = { healthy: boolean; version?: string } -async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> { +interface AddRowProps { + value: string + placeholder: string + adding: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onBlur: () => void +} + +interface EditRowProps { + value: string + placeholder: string + busy: boolean + error: string + status: boolean | undefined + onChange: (value: string) => void + onKeyDown: (event: KeyboardEvent) => void + onBlur: () => void +} + +async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> { const sdk = createOpencodeClient({ baseUrl: url, - fetch, + fetch: platform.fetch, signal: AbortSignal.timeout(3000), }) return sdk.global @@ -26,21 +50,139 @@ async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promis .catch(() => ({ healthy: false })) } +function AddRow(props: AddRowProps) { + return ( + <div class="flex items-center gap-3 px-4 min-w-0 flex-1"> + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": props.status === true, + "bg-icon-critical-base": props.status === false, + "bg-border-weak-base": props.status === undefined, + }} + /> + <div class="flex-1 min-w-0"> + <TextField + type="text" + hideLabel + placeholder={props.placeholder} + value={props.value} + autofocus + validationState={props.error ? "invalid" : "valid"} + error={props.error} + disabled={props.adding} + onChange={props.onChange} + onKeyDown={props.onKeyDown} + onBlur={props.onBlur} + /> + </div> + </div> + ) +} + +function EditRow(props: EditRowProps) { + return ( + <div class="flex items-center gap-3 px-4 min-w-0 flex-1" onClick={(event) => event.stopPropagation()}> + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": props.status === true, + "bg-icon-critical-base": props.status === false, + "bg-border-weak-base": props.status === undefined, + }} + /> + <div class="flex-1 min-w-0"> + <TextField + type="text" + hideLabel + placeholder={props.placeholder} + value={props.value} + autofocus + validationState={props.error ? "invalid" : "valid"} + error={props.error} + disabled={props.busy} + onChange={props.onChange} + onKeyDown={props.onKeyDown} + onBlur={props.onBlur} + /> + </div> + </div> + ) +} + export function DialogSelectServer() { const navigate = useNavigate() const dialog = useDialog() const server = useServer() const platform = usePlatform() + const globalSDK = useGlobalSDK() const language = useLanguage() const [store, setStore] = createStore({ - url: "", - adding: false, - error: "", status: {} as Record<string, ServerStatus | undefined>, + addServer: { + url: "", + adding: false, + error: "", + showForm: false, + status: undefined as boolean | undefined, + }, + editServer: { + id: undefined as string | undefined, + value: "", + error: "", + busy: false, + status: undefined as boolean | undefined, + }, }) const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.()) const isDesktop = platform.platform === "desktop" + const looksComplete = (value: string) => { + const normalized = normalizeServerUrl(value) + if (!normalized) return false + const host = normalized.replace(/^https?:\/\//, "").split("/")[0] + if (!host) return false + if (host.includes("localhost") || host.startsWith("127.0.0.1")) return true + return host.includes(".") || host.includes(":") + } + + const previewStatus = async (value: string, setStatus: (value: boolean | undefined) => void) => { + setStatus(undefined) + if (!looksComplete(value)) return + const normalized = normalizeServerUrl(value) + if (!normalized) return + const result = await checkHealth(normalized, platform) + setStatus(result.healthy) + } + + const resetAdd = () => { + setStore("addServer", { + url: "", + error: "", + showForm: false, + status: undefined, + }) + } + + const resetEdit = () => { + setStore("editServer", { + id: undefined, + value: "", + error: "", + status: undefined, + busy: false, + }) + } + + const replaceServer = (original: string, next: string) => { + const active = server.url + const nextActive = active === original ? next : active + + server.add(next) + if (nextActive) server.setActive(nextActive) + server.remove(original) + } + const items = createMemo(() => { const current = server.url const list = server.list @@ -74,7 +216,7 @@ export function DialogSelectServer() { const results: Record<string, ServerStatus> = {} await Promise.all( items().map(async (url) => { - results[url] = await checkHealth(url, platform.fetch) + results[url] = await checkHealth(url, platform) }), ) setStore("status", reconcile(results)) @@ -87,7 +229,7 @@ export function DialogSelectServer() { onCleanup(() => clearInterval(interval)) }) - function select(value: string, persist?: boolean) { + async function select(value: string, persist?: boolean) { if (!persist && store.status[value]?.healthy === false) return dialog.close() if (persist) { @@ -99,24 +241,101 @@ export function DialogSelectServer() { navigate("/") } - async function handleSubmit(e: SubmitEvent) { - e.preventDefault() - const value = normalizeServerUrl(store.url) - if (!value) return + const handleAddChange = (value: string) => { + if (store.addServer.adding) return + setStore("addServer", { url: value, error: "" }) + void previewStatus(value, (next) => setStore("addServer", { status: next })) + } + + const scrollListToBottom = () => { + const scroll = document.querySelector<HTMLDivElement>('[data-component="list"] [data-slot="list-scroll"]') + if (!scroll) return + requestAnimationFrame(() => { + scroll.scrollTop = scroll.scrollHeight + }) + } + + const handleEditChange = (value: string) => { + if (store.editServer.busy) return + setStore("editServer", { value, error: "" }) + void previewStatus(value, (next) => setStore("editServer", { status: next })) + } + + async function handleAdd(value: string) { + if (store.addServer.adding) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetAdd() + return + } - setStore("adding", true) - setStore("error", "") + setStore("addServer", { adding: true, error: "" }) - const result = await checkHealth(value, platform.fetch) - setStore("adding", false) + const result = await checkHealth(normalized, platform) + setStore("addServer", { adding: false }) if (!result.healthy) { - setStore("error", language.t("dialog.server.add.error")) + setStore("addServer", { error: language.t("dialog.server.add.error") }) return } - setStore("url", "") - select(value, true) + resetAdd() + await select(normalized, true) + } + + async function handleEdit(original: string, value: string) { + if (store.editServer.busy) return + const normalized = normalizeServerUrl(value) + if (!normalized) { + resetEdit() + return + } + + if (normalized === original) { + resetEdit() + return + } + + setStore("editServer", { busy: true, error: "" }) + + const result = await checkHealth(normalized, platform) + setStore("editServer", { busy: false }) + + if (!result.healthy) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } + + replaceServer(original, normalized) + + resetEdit() + } + + const handleAddKey = (event: KeyboardEvent) => { + event.stopPropagation() + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + handleAdd(store.addServer.url) + } + + const blurAdd = () => { + if (!store.addServer.url.trim()) { + resetAdd() + return + } + handleAdd(store.addServer.url) + } + + const handleEditKey = (event: KeyboardEvent, original: string) => { + event.stopPropagation() + if (event.key === "Escape") { + event.preventDefault() + resetEdit() + return + } + if (event.key !== "Enter" || event.isComposing) return + event.preventDefault() + handleEdit(original, store.editServer.value) } async function handleRemove(url: string) { @@ -124,125 +343,185 @@ export function DialogSelectServer() { } return ( - <Dialog title={language.t("dialog.server.title")} description={language.t("dialog.server.description")}> - <div class="flex flex-col gap-4 pb-4"> + <Dialog title={language.t("dialog.server.title")}> + <div class="flex flex-col gap-2 pb-4"> <List search={{ placeholder: language.t("dialog.server.search.placeholder"), autofocus: true }} emptyMessage={language.t("dialog.server.empty")} items={sortedItems} key={(x) => x} - current={current()} onSelect={(x) => { if (x) select(x) }} + divider={true} + class="[&_[data-slot=list-scroll]]:max-h-[300px] [&_[data-slot=list-scroll]]:overflow-y-auto [&_[data-slot=list-items]]:bg-surface-raised-base [&_[data-slot=list-items]]:rounded-md [&_[data-slot=list-item]]:py-3" + add={ + store.addServer.showForm + ? { + render: () => ( + <AddRow + value={store.addServer.url} + placeholder={language.t("dialog.server.add.placeholder")} + adding={store.addServer.adding} + error={store.addServer.error} + status={store.addServer.status} + onChange={handleAddChange} + onKeyDown={handleAddKey} + onBlur={blurAdd} + /> + ), + } + : undefined + } > - {(i) => ( - <div class="flex items-center gap-2 min-w-0 flex-1 group/item"> - <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">{serverDisplayName(i)}</span> - <span class="text-text-weak">{store.status[i]?.version}</span> + {(i) => { + const [popoverOpen, setPopoverOpen] = createSignal(false) + return ( + <div class="flex items-center gap-3 min-w-0 flex-1 group/item"> + <Show + when={store.editServer.id !== i} + fallback={ + <EditRow + value={store.editServer.value} + placeholder={language.t("dialog.server.add.placeholder")} + busy={store.editServer.busy} + error={store.editServer.error} + status={store.editServer.status} + onChange={handleEditChange} + onKeyDown={(event) => handleEditKey(event, i)} + onBlur={() => handleEdit(i, store.editServer.value)} + /> + } + > + <div + class="flex items-center gap-3 px-4 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">{serverDisplayName(i)}</span> + <Show when={store.status[i]?.version}> + <span class="text-text-weak text-14-regular">{store.status[i]?.version}</span> + </Show> + <Show when={defaultUrl() === i}> + <span class="text-text-weak bg-surface-base text-14-regular px-1.5 rounded-xs"> + {language.t("dialog.server.status.default")} + </span> + </Show> + </div> + </Show> + <Show when={store.editServer.id !== i}> + <div class="flex items-center justify-center gap-5 px-4"> + <Show when={current() === i}> + <p class="text-text-weak text-12-regular">{language.t("dialog.server.current")}</p> + </Show> + + <div onClick={(e) => e.stopPropagation()} onPointerDown={(e) => e.stopPropagation()}> + <Popover + open={popoverOpen()} + onOpenChange={setPopoverOpen} + placement="bottom-start" + trigger={ + <IconButton + icon="dot-grid" + variant="ghost" + class="bg-transparent transition-opacity shrink-0 hover:scale-110 size-8" + onPointerDown={(event: PointerEvent) => event.stopPropagation()} + /> + } + class="w-max !min-w-fit !max-w-none" + > + <div class="flex flex-col gap-1"> + <Button + variant="ghost" + size="normal" + class="justify-start text-md" + onClick={(e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + setStore("editServer", { + id: i, + value: i, + error: "", + status: store.status[i]?.healthy, + }) + }} + > + {language.t("dialog.server.menu.edit")} + </Button> + <Show when={isDesktop && defaultUrl() !== i}> + <Button + variant="ghost" + size="normal" + class="justify-start text-md" + onClick={async (e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + await platform.setDefaultServerUrl?.(i) + defaultUrlActions.refetch(i) + }} + > + {language.t("dialog.server.menu.default")} + </Button> + </Show> + <Show when={isDesktop && defaultUrl() === i}> + <Button + variant="ghost" + size="normal" + class="justify-start text-md" + onClick={async (e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + await platform.setDefaultServerUrl?.(null) + defaultUrlActions.refetch(null) + }} + > + {language.t("dialog.server.menu.defaultRemove")} + </Button> + </Show> + <div class="h-px bg-border-weak-base my-1" /> + <Button + variant="ghost" + size="normal" + class="justify-start text-md text-text-on-critical-base hover:bg-surface-critical-weak" + onClick={(e: MouseEvent) => { + e.stopPropagation() + setPopoverOpen(false) + handleRemove(i) + }} + > + {language.t("dialog.server.menu.delete")} + </Button> + </div> + </Popover> + </div> + </div> + </Show> </div> - <Show when={current() !== i && server.list.includes(i)}> - <IconButton - icon="circle-x" - variant="ghost" - class="bg-transparent transition-opacity shrink-0 hover:scale-110" - aria-label={language.t("dialog.server.action.remove")} - onClick={(e) => { - e.stopPropagation() - handleRemove(i) - }} - /> - </Show> - </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">{language.t("dialog.server.add.title")}</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={language.t("dialog.server.add.url")} - hideLabel - placeholder={language.t("dialog.server.add.placeholder")} - 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 ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")} - </Button> - </div> - </form> + <div class="px-6"> + <Button + variant="secondary" + icon="plus-small" + size="large" + onClick={() => { + setStore("addServer", { showForm: true, url: "", error: "" }) + scrollListToBottom() + }} + class="px-3 py-4" + > + {store.addServer.adding ? language.t("dialog.server.add.checking") : language.t("dialog.server.add.button")} + </Button> </div> - - <Show when={isDesktop}> - <div class="mt-6 px-3 flex flex-col gap-1.5"> - <div class="px-3"> - <h3 class="text-14-regular text-text-weak">{language.t("dialog.server.default.title")}</h3> - <p class="text-12-regular text-text-weak mt-1">{language.t("dialog.server.default.description")}</p> - </div> - <div class="flex items-center gap-2 px-3 py-2"> - <Show - when={defaultUrl()} - fallback={ - <Show - when={server.url} - fallback={ - <span class="text-14-regular text-text-weak">{language.t("dialog.server.default.none")}</span> - } - > - <Button - variant="secondary" - size="small" - onClick={async () => { - await platform.setDefaultServerUrl?.(server.url) - defaultUrlActions.refetch(server.url) - }} - > - {language.t("dialog.server.default.set")} - </Button> - </Show> - } - > - <div class="flex items-center gap-2 flex-1 min-w-0"> - <span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span> - </div> - <Button - variant="ghost" - size="small" - onClick={async () => { - await platform.setDefaultServerUrl?.(null) - defaultUrlActions.refetch() - }} - > - {language.t("dialog.server.default.clear")} - </Button> - </Show> - </div> - </div> - </Show> </div> </Dialog> ) diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx deleted file mode 100644 index dab92920e..000000000 --- a/packages/app/src/components/session-lsp-indicator.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { createMemo, Show } from "solid-js" -import { useSync } from "@/context/sync" -import { useLanguage } from "@/context/language" -import { Tooltip } from "@opencode-ai/ui/tooltip" - -export function SessionLspIndicator() { - const sync = useSync() - const language = useLanguage() - - const lspStats = createMemo(() => { - const lsp = sync.data.lsp ?? [] - const connected = lsp.filter((s) => s.status === "connected").length - const hasError = lsp.some((s) => s.status === "error") - const total = lsp.length - return { connected, hasError, total } - }) - - const tooltipContent = createMemo(() => { - const lsp = sync.data.lsp ?? [] - if (lsp.length === 0) return language.t("lsp.tooltip.none") - return lsp.map((s) => s.name).join(", ") - }) - - return ( - <Show when={lspStats().total > 0}> - <Tooltip placement="top" value={tooltipContent()}> - <div class="flex items-center gap-1 px-2 cursor-default select-none"> - <div - classList={{ - "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"> - {language.t("lsp.label.connected", { count: lspStats().connected })} - </span> - </div> - </Tooltip> - </Show> - ) -} diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx deleted file mode 100644 index 489223b9b..000000000 --- a/packages/app/src/components/session-mcp-indicator.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createMemo, Show } from "solid-js" -import { Button } from "@opencode-ai/ui/button" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useSync } from "@/context/sync" -import { DialogSelectMcp } from "@/components/dialog-select-mcp" - -export function SessionMcpIndicator() { - const sync = useSync() - const dialog = useDialog() - - const mcpStats = createMemo(() => { - const mcp = sync.data.mcp ?? {} - const entries = Object.entries(mcp) - const enabled = entries.filter(([, status]) => status.status === "connected").length - const failed = entries.some(([, status]) => status.status === "failed") - const total = entries.length - return { enabled, failed, total } - }) - - return ( - <Show when={mcpStats().total > 0}> - <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}> - <div - classList={{ - "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> - </Button> - </Show> - ) -} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 1e06e8ed6..4b7f9f4ad 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -20,14 +20,13 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" import { Keybind } from "@opencode-ai/ui/keybind" +import { StatusPopover } from "../status-popover" export function SessionHeader() { const globalSDK = useGlobalSDK() const layout = useLayout() const params = useParams() const command = useCommand() - // const server = useServer() - // const dialog = useDialog() const sync = useSync() const platform = usePlatform() const language = useLanguage() @@ -154,96 +153,7 @@ export function SessionHeader() { {(mount) => ( <Portal mount={mount()}> <div class="flex items-center gap-3"> - {/* <div class="hidden md: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() === true, */} - {/* "bg-icon-critical-base": server.healthy() === false, */} - {/* "bg-border-weak-base": server.healthy() === undefined, */} - {/* }} */} - {/* /> */} - {/* <Icon name="server" size="small" class="text-icon-weak" /> */} - {/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */} - {/* </Button> */} - {/* <SessionLspIndicator /> */} - {/* <SessionMcpIndicator /> */} - {/* </div> */} - <div class="flex items-center gap-1"> - <div class="hidden md:block shrink-0"> - <TooltipKeybind - title={language.t("command.review.toggle")} - keybind={command.keybind("review.toggle")} - > - <Button - variant="ghost" - class="group/review-toggle size-6 p-0" - onClick={() => view().reviewPanel.toggle()} - aria-label={language.t("command.review.toggle")} - aria-expanded={view().reviewPanel.opened()} - aria-controls="review-panel" - tabIndex={showReview() ? 0 : -1} - > - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"} - class="group-hover/review-toggle:hidden" - /> - <Icon - size="small" - name="layout-right-partial" - class="hidden group-hover/review-toggle:inline-block" - /> - <Icon - size="small" - name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"} - class="hidden group-active/review-toggle:inline-block" - /> - </div> - </Button> - </TooltipKeybind> - </div> - <TooltipKeybind - class="hidden md:block shrink-0" - title={language.t("command.terminal.toggle")} - keybind={command.keybind("terminal.toggle")} - > - <Button - variant="ghost" - class="group/terminal-toggle size-6 p-0" - onClick={() => view().terminal.toggle()} - aria-label={language.t("command.terminal.toggle")} - aria-expanded={view().terminal.opened()} - aria-controls="terminal-panel" - > - <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> - <Icon - size="small" - name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} - class="group-hover/terminal-toggle:hidden" - /> - <Icon - size="small" - name="layout-bottom-partial" - class="hidden group-hover/terminal-toggle:inline-block" - /> - <Icon - size="small" - name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} - class="hidden group-active/terminal-toggle:inline-block" - /> - </div> - </Button> - </TooltipKeybind> - </div> + <StatusPopover /> <Show when={showShare()}> <div class="flex items-center"> <Popover @@ -253,9 +163,11 @@ export function SessionHeader() { ? language.t("session.share.popover.description.shared") : language.t("session.share.popover.description.unshared") } + gutter={8} triggerAs={Button} triggerProps={{ variant: "secondary", + class: "rounded-sm w-[60px] h-[24px]", classList: { "rounded-r-none": shareUrl() !== undefined }, style: { scale: 1 }, }} @@ -308,7 +220,7 @@ export function SessionHeader() { </Show> </div> </Popover> - <Show when={shareUrl()} fallback={<div class="size-6" aria-hidden="true" />}> + <Show when={shareUrl()} fallback={<div aria-hidden="true" />}> <Tooltip value={ state.copied @@ -334,6 +246,70 @@ export function SessionHeader() { </Show> </div> </Show> + <div class="hidden md:block shrink-0"> + <TooltipKeybind + title={language.t("command.terminal.toggle")} + keybind={command.keybind("terminal.toggle")} + > + <Button + variant="ghost" + class="group/terminal-toggle size-5 p-0" + onClick={() => view().terminal.toggle()} + aria-label={language.t("command.terminal.toggle")} + aria-expanded={view().terminal.opened()} + aria-controls="terminal-panel" + > + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + size="small" + name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"} + class="group-hover/terminal-toggle:hidden" + /> + <Icon + size="small" + name="layout-bottom-partial" + class="hidden group-hover/terminal-toggle:inline-block" + /> + <Icon + size="small" + name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"} + class="hidden group-active/terminal-toggle:inline-block" + /> + </div> + </Button> + </TooltipKeybind> + </div> + <div class="hidden md:block shrink-0"> + <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}> + <Button + variant="ghost" + class="group/review-toggle size-5 p-0" + onClick={() => view().reviewPanel.toggle()} + aria-label={language.t("command.review.toggle")} + aria-expanded={view().reviewPanel.opened()} + aria-controls="review-panel" + tabIndex={showReview() ? 0 : -1} + > + <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0"> + <Icon + size="small" + name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"} + class="group-hover/review-toggle:hidden" + /> + <Icon + size="small" + name="layout-right-partial" + class="hidden group-hover/review-toggle:inline-block" + /> + <Icon + size="small" + name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"} + class="hidden group-active/review-toggle:inline-block" + /> + </div> + </Button> + </TooltipKeybind> + </div> </div> </Portal> )} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx new file mode 100644 index 000000000..5b65f62bc --- /dev/null +++ b/packages/app/src/components/status-popover.tsx @@ -0,0 +1,364 @@ +import { createEffect, createMemo, createSignal, For, onCleanup, Show } from "solid-js" +import { createStore, reconcile } from "solid-js/store" +import { useNavigate } from "@solidjs/router" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Popover } from "@opencode-ai/ui/popover" +import { Tabs } from "@opencode-ai/ui/tabs" +import { Button } from "@opencode-ai/ui/button" +import { Switch } from "@opencode-ai/ui/switch" +import { Icon } from "@opencode-ai/ui/icon" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server" +import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" +import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { DialogSelectServer } from "./dialog-select-server" + +type ServerStatus = { healthy: boolean; version?: string } + +async function checkHealth(url: string, platform: ReturnType<typeof usePlatform>): Promise<ServerStatus> { + const sdk = createOpencodeClient({ + baseUrl: url, + fetch: platform.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 StatusPopover() { + const sync = useSync() + const sdk = useSDK() + const server = useServer() + const platform = usePlatform() + const dialog = useDialog() + const language = useLanguage() + const navigate = useNavigate() + + const [loading, setLoading] = createSignal<string | null>(null) + const [store, setStore] = createStore({ + status: {} as Record<string, ServerStatus | undefined>, + }) + + const servers = 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 sortedServers = createMemo(() => { + const list = servers() + if (!list.length) return list + const active = server.url + const order = new Map(list.map((url, index) => [url, index] as const)) + const rank = (value?: ServerStatus) => { + if (value?.healthy === true) return 0 + if (value?.healthy === false) return 2 + return 1 + } + return list.slice().sort((a, b) => { + if (a === active) return -1 + if (b === active) return 1 + const diff = rank(store.status[a]) - rank(store.status[b]) + if (diff !== 0) return diff + return (order.get(a) ?? 0) - (order.get(b) ?? 0) + }) + }) + + async function refreshHealth() { + const results: Record<string, ServerStatus> = {} + await Promise.all( + servers().map(async (url) => { + results[url] = await checkHealth(url, platform) + }), + ) + setStore("status", reconcile(results)) + } + + createEffect(() => { + servers() + refreshHealth() + const interval = setInterval(refreshHealth, 10_000) + onCleanup(() => clearInterval(interval)) + }) + + const mcpItems = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) + + const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) + + const toggleMcp = async (name: string) => { + if (loading()) return + setLoading(name) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setLoading(null) + } + + const lspItems = createMemo(() => sync.data.lsp ?? []) + const lspCount = createMemo(() => lspItems().length) + const plugins = createMemo(() => sync.data.config.plugin ?? []) + const pluginCount = createMemo(() => plugins().length) + + const overallHealthy = createMemo(() => { + const serverHealthy = server.healthy() === true + const anyMcpIssue = mcpItems().some((m) => m.status !== "connected" && m.status !== "disabled") + return serverHealthy && !anyMcpIssue + }) + + const serverCount = createMemo(() => sortedServers().length) + + const [defaultServerUrl, setDefaultServerUrl] = createSignal<string | undefined>() + + createEffect(() => { + const result = platform.getDefaultServerUrl?.() + if (result instanceof Promise) { + result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined)) + return + } + if (result) setDefaultServerUrl(normalizeServerUrl(result)) + }) + + return ( + <Popover + triggerAs={Button} + triggerProps={{ + variant: "ghost", + class: "rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none", + style: { scale: 1 }, + }} + trigger={ + <div class="flex items-center gap-1.5"> + <div + classList={{ + "size-1.5 rounded-full": true, + "bg-icon-success-base": overallHealthy(), + "bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined, + "bg-border-weak-base": server.healthy() === undefined, + }} + /> + <span class="text-12-regular text-text-strong">Status</span> + </div> + } + class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] mx-5 bg-transparent border-0 shadow-none rounded-xl" + gutter={8} + > + <div class="flex items-center gap-1 w-[360px] border border-border-weak-base rounded-xl"> + <Tabs + aria-label="Server Configurations" + class="tabs" + data-component="tabs" + data-active="servers" + defaultValue="servers" + variant="alt" + style={{ + "background-color": "var(--background-strong)", + "border-radius": "12px", + overflow: "hidden", + }} + > + <Tabs.List + data-slot="tablist" + style={{ + "background-color": "transparent", + "border-bottom": "none", + padding: "8px 16px 0", + gap: "16px", + height: "40px", + }} + > + <Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular"> + {serverCount() > 0 ? `${serverCount()} ` : ""}Servers + </Tabs.Trigger> + <Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular"> + {mcpConnected() > 0 ? `${mcpConnected()} ` : ""}MCP + </Tabs.Trigger> + <Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular"> + {lspCount() > 0 ? `${lspCount()} ` : ""}LSP + </Tabs.Trigger> + <Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular"> + {pluginCount() > 0 ? `${pluginCount()} ` : ""}Plugins + </Tabs.Trigger> + </Tabs.List> + + <Tabs.Content value="servers"> + <div class="flex flex-col px-2 pb-2"> + <div class="flex flex-col p-2 bg-background-base"> + <For each={sortedServers()}> + {(url) => { + const isActive = () => url === server.url + const isDefault = () => url === defaultServerUrl() + const status = () => store.status[url] + const isBlocked = () => status()?.healthy === false + return ( + <button + type="button" + class="flex items-center gap-2 w-full px-2 py-1 rounded-md transition-colors text-left" + classList={{ + "opacity-50": isBlocked(), + "hover:bg-surface-raised-base-hover": !isBlocked(), + "cursor-not-allowed": isBlocked(), + }} + aria-disabled={isBlocked()} + onClick={() => { + if (isBlocked()) return + server.setActive(url) + navigate("/") + }} + > + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": status()?.healthy === true, + "bg-icon-critical-base": status()?.healthy === false, + "bg-border-weak-base": status() === undefined, + }} + /> + <span class="text-14-regular text-text-base truncate">{serverDisplayName(url)}</span> + <Show when={status()?.version}> + <span class="text-12-regular text-text-weak">{status()?.version}</span> + </Show> + <Show when={isDefault()}> + <span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md"> + Default + </span> + </Show> + <div class="flex-1" /> + <Show when={isActive()}> + <Icon name="check" size="small" class="text-icon-weak shrink-0" /> + </Show> + </button> + ) + }} + </For> + + <Button + variant="secondary" + class="mt-2 self-start" + onClick={() => dialog.show(() => <DialogSelectServer />)} + > + Manage servers + </Button> + </div> + </div> + </Tabs.Content> + + <Tabs.Content value="mcp"> + <div class="flex flex-col px-2 pb-2"> + <div class="flex flex-col p-2 bg-background-base"> + <Show + when={mcpItems().length > 0} + fallback={ + <div class="text-14-regular text-text-weak text-center py-4">No MCP servers configured</div> + } + > + <For each={mcpItems()}> + {(item) => { + const enabled = () => item.status === "connected" + return ( + <button + type="button" + class="flex items-center gap-2 w-full px-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" + onClick={() => toggleMcp(item.name)} + disabled={loading() === item.name} + > + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": item.status === "connected", + "bg-icon-critical-base": item.status === "failed", + "bg-border-weak-base": item.status === "disabled", + "bg-icon-warning-base": + item.status === "needs_auth" || item.status === "needs_client_registration", + }} + /> + <span class="text-14-regular text-text-base truncate flex-1">{item.name}</span> + <div onClick={(event) => event.stopPropagation()}> + <Switch + checked={enabled()} + disabled={loading() === item.name} + onChange={() => toggleMcp(item.name)} + /> + </div> + </button> + ) + }} + </For> + </Show> + </div> + </div> + </Tabs.Content> + + <Tabs.Content value="lsp"> + <div class="flex flex-col px-2 pb-2"> + <div class="flex flex-col p-2 bg-background-base"> + <Show + when={lspItems().length > 0} + fallback={ + <div class="text-14-regular text-text-weak text-center py-4"> + LSPs auto-detected from file types + </div> + } + > + <For each={lspItems()}> + {(item) => ( + <div class="flex items-center gap-2 w-full px-2 py-1"> + <div + classList={{ + "size-1.5 rounded-full shrink-0": true, + "bg-icon-success-base": item.status === "connected", + "bg-icon-critical-base": item.status === "error", + }} + /> + <span class="text-14-regular text-text-base truncate">{item.name || item.id}</span> + </div> + )} + </For> + </Show> + </div> + </div> + </Tabs.Content> + + <Tabs.Content value="plugins"> + <div class="flex flex-col px-2 pb-2"> + <div class="flex flex-col p-2 bg-background-base"> + <Show + when={plugins().length > 0} + fallback={ + <div class="text-14-regular text-text-weak text-center py-4"> + Plugins configured in{" "} + <code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm">opencode.json</code> + </div> + } + > + <For each={plugins()}> + {(plugin) => ( + <div class="flex items-center gap-2 w-full px-2 py-1"> + <div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" /> + <span class="text-14-regular text-text-base truncate">{plugin}</span> + </div> + )} + </For> + </Show> + </div> + </div> + </Tabs.Content> + </Tabs> + </div> + </Popover> + ) +} |
