Преглед на файлове

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.
David Hill преди 1 месец
родител
ревизия
59090bc1b3

+ 0 - 4
packages/app/src/components/session/session-header.tsx

@@ -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")}

+ 56 - 32
packages/app/src/components/status-popover.tsx

@@ -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

+ 2 - 0
packages/app/src/pages/layout.tsx

@@ -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 />

+ 8 - 0
packages/app/src/pages/layout/sidebar-shell.tsx

@@ -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"