From 3c70f6df28be53c9bb85a9ee3b3adb97d09fe9ed Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:03:24 +1000 Subject: [PATCH] fix(app): enforce single titlebar action owner (#44075) Co-authored-by: Hona <10430890+Hona@users.noreply.github.com> --- packages/app/src/new-session/screen.tsx | 4 +- packages/app/src/new-session/view.tsx | 23 +++---- .../app/src/session/header/session-header.tsx | 17 ++--- packages/app/src/shell/shell.tsx | 53 ++++++++------- .../app/src/shell/titlebar/right-slot.test.ts | 27 ++++++++ .../app/src/shell/titlebar/right-slot.tsx | 65 +++++++++++++++++++ packages/app/src/shell/titlebar/titlebar.tsx | 25 +------ 7 files changed, 138 insertions(+), 76 deletions(-) create mode 100644 packages/app/src/shell/titlebar/right-slot.test.ts create mode 100644 packages/app/src/shell/titlebar/right-slot.tsx diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index af5fda3f4b..03b3ad87c6 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -1,6 +1,5 @@ import { createPromptProjectController } from "@/new-session/project/selector" import { useSettingsDialog } from "@/settings/command" -import { useTitlebarRightMount } from "@/shell/titlebar/titlebar" import { useSettings } from "@/settings/model" import { useTabs, type DraftTab } from "@/shell/tabs/tabs" import { useSearchParams } from "@solidjs/router" @@ -15,7 +14,6 @@ import { useNewSessionCommands } from "./commands" /** The draft-only Session page. Submitting promotes the draft into a real Session. */ export default function NewSessionPage(props: { draftId: string }) { const settings = useSettings() - const rightMount = useTitlebarRightMount() const [search, setSearch] = useSearchParams<{ draftId?: string; prompt?: string }>() const tabs = useTabs() const openWorkspaces = useSettingsDialog("workspaces") @@ -69,7 +67,7 @@ export default function NewSessionPage(props: { draftId: string }) { return (
{suspendUntilPromptReady()} - +
diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 6da266d6e9..a639f9d26b 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -4,7 +4,6 @@ import { Icon } from "@opencode-ai/ui/icon" import { Wordmark } from "@opencode-ai/ui/wordmark" import { Show, createMemo, createSignal } from "solid-js" import { createStore } from "solid-js/store" -import { Portal } from "solid-js/web" import createPresence from "solid-presence" import { Composer } from "@/composer/composer" import type { ComposerModel } from "@/composer/model" @@ -15,6 +14,7 @@ import { type PromptProjectController, } from "@/new-session/project/selector" import { StatusPopover } from "@/shell/status/status-popover" +import { TitlebarRight } from "@/shell/titlebar/right-slot" import { useLanguage } from "@/runtime/i18n/language" import { useWorkspaceLocation } from "@/workspaces/location" import { useProviders } from "@/providers/catalog/providers" @@ -87,21 +87,16 @@ export function NewSessionView(props: { ) } -export function NewSessionStatus(props: { mount: HTMLElement | null; visible: boolean }) { +export function NewSessionStatus(props: { visible: boolean }) { const language = useLanguage() - return ( - - {(mount) => ( - - - - - - - - )} - + + + + + + + ) } diff --git a/packages/app/src/session/header/session-header.tsx b/packages/app/src/session/header/session-header.tsx index fe00f56cf8..bfd42b068a 100644 --- a/packages/app/src/session/header/session-header.tsx +++ b/packages/app/src/session/header/session-header.tsx @@ -1,13 +1,12 @@ -import { createMemo, Show } from "solid-js" +import { createMemo } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" -import { Portal } from "solid-js/web" import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useSettings } from "@/settings/model" import { useSessionLayout } from "@/session/session-layout" import { reviewTooltipKeybind } from "@/shell/commands/tooltip-keybind" import { StatusPopover } from "@/shell/status/status-popover" -import { useTitlebarRightMount } from "@/shell/titlebar/titlebar" +import { TitlebarRight } from "@/shell/titlebar/right-slot" import { SessionHeaderActions, type SessionHeaderActionsState } from "./session-header-actions" export function SessionHeader() { @@ -28,15 +27,9 @@ export function SessionHeader() { onReviewToggle: () => view().reviewPanel.toggle(), })) - const rightMount = useTitlebarRightMount() - return ( - - {(mount) => ( - - - - )} - + + + ) } diff --git a/packages/app/src/shell/shell.tsx b/packages/app/src/shell/shell.tsx index db2b579f5f..ed93f9a56e 100644 --- a/packages/app/src/shell/shell.tsx +++ b/packages/app/src/shell/shell.tsx @@ -3,6 +3,7 @@ import { createStore } from "solid-js/store" import { Titlebar, type TitlebarUpdate } from "@/shell/titlebar/titlebar" import { usePlatform } from "@/runtime/platform/platform" import { ToastRegion } from "@/shell/notifications/toast" +import { TitlebarRightProvider } from "@/shell/titlebar/right-slot" const DebugBar = lazy(() => import("@/shell/debug/debug-bar").then((module) => ({ default: module.DebugBar }))) @@ -23,30 +24,32 @@ export default function Layout(props: ParentProps) { } return ( -
- setState("debugTools", (value) => !value) } - : undefined - } - /> -
- {props.children} -
- - - - - - -
+ +
+ setState("debugTools", (value) => !value) } + : undefined + } + /> +
+ {props.children} +
+ + + + + + +
+
) } diff --git a/packages/app/src/shell/titlebar/right-slot.test.ts b/packages/app/src/shell/titlebar/right-slot.test.ts new file mode 100644 index 0000000000..b879b379e4 --- /dev/null +++ b/packages/app/src/shell/titlebar/right-slot.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createTitlebarRightSlot } from "./right-slot" + +describe("titlebar right slot", () => { + test("selects the latest owner and restores the previous owner after overlap", () => { + createRoot((dispose) => { + const slot = createTitlebarRightSlot() + const committed = slot.createRegistration() + committed.register() + expect(committed.active()).toBe(true) + + const shadow = slot.createRegistration() + shadow.register() + expect(committed.active()).toBe(false) + expect(shadow.active()).toBe(true) + + shadow.unregister() + expect(committed.active()).toBe(true) + expect(shadow.active()).toBe(false) + + committed.unregister() + expect(committed.active()).toBe(false) + dispose() + }) + }) +}) diff --git a/packages/app/src/shell/titlebar/right-slot.tsx b/packages/app/src/shell/titlebar/right-slot.tsx new file mode 100644 index 0000000000..e58403663e --- /dev/null +++ b/packages/app/src/shell/titlebar/right-slot.tsx @@ -0,0 +1,65 @@ +import { createContext, onCleanup, onMount, Show, useContext, type ParentProps } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" + +type Registration = { + active: () => boolean + register: () => void + unregister: () => void +} + +type TitlebarRightSlot = { + createRegistration: () => Registration + mount: () => HTMLElement | undefined + setMount: (mount: HTMLElement) => void +} + +const TitlebarRightContext = createContext() + +export function TitlebarRightProvider(props: ParentProps) { + return ( + {props.children} + ) +} + +export function createTitlebarRightSlot(): TitlebarRightSlot { + const [store, setStore] = createStore<{ mount?: HTMLElement; registrations: symbol[] }>({ registrations: [] }) + return { + mount: () => store.mount, + setMount: (mount) => setStore("mount", mount), + createRegistration() { + const id = Symbol() + return { + active: () => store.registrations.at(-1) === id, + register: () => setStore("registrations", (items) => [...items, id]), + unregister: () => setStore("registrations", (items) => items.filter((item) => item !== id)), + } + }, + } +} + +export function TitlebarRightMount() { + const slot = useTitlebarRightSlot() + return
+} + +export function TitlebarRight(props: ParentProps) { + const slot = useTitlebarRightSlot() + const registration = slot.createRegistration() + onMount(() => { + registration.register() + onCleanup(registration.unregister) + }) + + return ( + + {(mount) => {props.children}} + + ) +} + +function useTitlebarRightSlot() { + const slot = useContext(TitlebarRightContext) + if (!slot) throw new Error("TitlebarRight must be used within TitlebarRightProvider") + return slot +} diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index 9c0c85c499..c80a37bf05 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -1,15 +1,4 @@ -import { - createEffect, - createMemo, - createResource, - createSignal, - Match, - on, - onMount, - Show, - Switch, - untrack, -} from "solid-js" +import { createEffect, createMemo, createResource, Match, createSignal, Show, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -34,6 +23,7 @@ import { tabKey, useTabs } from "@/shell/tabs/tabs" import type { ComposerState } from "@/composer/persistence" import "./titlebar.css" import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind" +import { TitlebarRightMount } from "@/shell/titlebar/right-slot" const titlebarHeight = 36 const minTitlebarZoom = 0.25 @@ -46,15 +36,6 @@ export type TitlebarUpdate = { install: () => void } -export function useTitlebarRightMount() { - const language = useLanguage() - const [mount, setMount] = createSignal(null) - const sync = () => setMount(document.getElementById("opencode-titlebar-right")) - onMount(sync) - createEffect(on(language.direction, sync, { defer: true })) - return mount -} - export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { const platform = usePlatform() const command = useCommand() @@ -421,7 +402,7 @@ function TitlebarRight(props: { state: TitlebarRightState }) { -
+
) }