feat(app): move status button to sidebar rail

Relocate the status popover trigger from the title bar into the left project rail above Settings/Help, keeping the health dot indicator.
pull/17327/head
David Hill 2026-03-12 21:39:44 +00:00
parent 3ba9ab2c0a
commit 59090bc1b3
4 changed files with 66 additions and 36 deletions

View File

@ -21,7 +21,6 @@ import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover"
const OPEN_APPS = [
"vscode",
@ -409,9 +408,6 @@ export function SessionHeader() {
</div>
</Show>
<div class="flex items-center gap-1">
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}

View File

@ -11,9 +11,9 @@ import { createStore, reconcile } from "solid-js/store"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
@ -129,8 +129,10 @@ const useDefaultServerKey = (
}
const useMcpToggle = (input: {
sync: ReturnType<typeof useSync>
sdk: ReturnType<typeof useSDK>
get: () =>
| { store: ReturnType<ReturnType<typeof useGlobalSync>["child"]>[0]; set: (...args: unknown[]) => void }
| undefined
client: () => ReturnType<ReturnType<typeof useGlobalSDK>["createClient"]> | undefined
language: ReturnType<typeof useLanguage>
}) => {
const [loading, setLoading] = createSignal<string | null>(null)
@ -139,13 +141,18 @@ const useMcpToggle = (input: {
if (loading()) return
setLoading(name)
const child = input.get()
const cli = input.client()
if (!child || !cli) {
setLoading(null)
return
}
try {
const status = input.sync.data.mcp[name]
await (status?.status === "connected"
? input.sdk.client.mcp.disconnect({ name })
: input.sdk.client.mcp.connect({ name }))
const result = await input.sdk.client.mcp.status()
if (result.data) input.sync.set("mcp", result.data)
const status = child.store.mcp[name]
await (status?.status === "connected" ? cli.mcp.disconnect({ name }) : cli.mcp.connect({ name }))
const result = await cli.mcp.status()
if (result.data) child.set("mcp", result.data)
} catch (err) {
showToast({
variant: "error",
@ -160,15 +167,26 @@ const useMcpToggle = (input: {
return { loading, toggle }
}
export function StatusPopover() {
const sync = useSync()
const sdk = useSDK()
export function StatusPopover(props: { directory: string; placement?: "right-end" | "bottom-end" }) {
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const server = useServer()
const platform = usePlatform()
const dialog = useDialog()
const language = useLanguage()
const navigate = useNavigate()
const child = createMemo(() => {
if (!props.directory) return
const [store, set] = globalSync.child(props.directory)
return { store, set }
})
const client = createMemo(() => {
if (!props.directory) return
return globalSDK.createClient({ directory: props.directory, throwOnError: true })
})
const [shown, setShown] = createSignal(false)
const servers = createMemo(() => {
const current = server.current
@ -179,14 +197,18 @@ export function StatusPopover() {
})
const health = useServerHealth(servers)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const mcp = useMcpToggle({ sync, sdk, language })
const mcp = useMcpToggle({
language,
get: () => child(),
client: () => client(),
})
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
const mcpNames = createMemo(() => Object.keys(child()?.store.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => child()?.store.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
const lspItems = createMemo(() => sync.data.lsp ?? [])
const lspItems = createMemo(() => child()?.store.lsp ?? [])
const lspCount = createMemo(() => lspItems().length)
const plugins = createMemo(() => sync.data.config.plugin ?? [])
const plugins = createMemo(() => child()?.store.config.plugin ?? [])
const pluginCount = createMemo(() => plugins().length)
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
const overallHealthy = createMemo(() => {
@ -202,21 +224,24 @@ export function StatusPopover() {
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs={Button}
triggerProps={{
variant: "ghost",
class: "titlebar-icon w-8 h-6 p-0 box-border",
"aria-label": language.t("status.popover.trigger"),
style: { scale: 1 },
}}
triggerAs={"button"}
triggerProps={
{
type: "button",
"data-component": "icon-button",
"data-icon": "status",
"data-variant": "ghost",
"data-size": "large",
class: "data-[expanded]:bg-surface-base-active",
"aria-label": language.t("status.popover.trigger"),
} as any
}
trigger={
<div class="relative size-4">
<div class="badge-mask-tight size-4 flex items-center justify-center">
<Icon name={shown() ? "status-active" : "status"} size="small" />
</div>
<div class="relative size-full flex items-center justify-center">
<Icon name={shown() ? "status-active" : "status"} size="normal" />
<div
classList={{
"absolute -top-px -right-px size-1.5 rounded-full": true,
"absolute top-1 right-1 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,
@ -226,8 +251,7 @@ export function StatusPopover() {
}
class="[&_[data-slot=popover-body]]:p-0 w-[360px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={4}
placement="bottom-end"
shift={-168}
placement={props.placement ?? "right-end"}
>
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
<Tabs

View File

@ -2181,6 +2181,7 @@ export default function Layout(props: ParentProps) {
const sidebarContent = (mobile?: boolean) => (
<SidebarContent
mobile={mobile}
dir={currentDir()}
opened={() => layout.sidebar.opened()}
aimMove={aim.move}
projects={projects}
@ -2199,6 +2200,7 @@ export default function Layout(props: ParentProps) {
onOpenSettings={openSettings}
helpLabel={() => language.t("sidebar.help")}
onOpenHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")}
statusLabel={() => language.t("status.popover.trigger")}
renderPanel={() =>
mobile ? (
<SidebarPanel project={currentProject()} mobile />

View File

@ -11,9 +11,11 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { type LocalProject } from "@/context/layout"
import { StatusPopover } from "@/components/status-popover"
export const SidebarContent = (props: {
mobile?: boolean
dir: string
opened: Accessor<boolean>
aimMove: (event: MouseEvent) => void
projects: Accessor<LocalProject[]>
@ -30,6 +32,7 @@ export const SidebarContent = (props: {
onOpenSettings: () => void
helpLabel: Accessor<string>
onOpenHelp: () => void
statusLabel: Accessor<string>
renderPanel: () => JSX.Element
}): JSX.Element => {
const expanded = createMemo(() => !!props.mobile || props.opened())
@ -90,6 +93,11 @@ export const SidebarContent = (props: {
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-6 flex flex-col items-center gap-2">
<Show when={!!props.dir}>
<Tooltip placement={placement()} value={props.statusLabel()}>
<StatusPopover directory={props.dir} placement={props.mobile ? "bottom-end" : "right-end"} />
</Tooltip>
</Show>
<TooltipKeybind placement={placement()} title={props.settingsLabel()} keybind={props.settingsKeybind() ?? ""}>
<IconButton
icon="settings-gear"