fix(app): enforce single titlebar action owner (#44075)

Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-08-22 17:03:24 +10:00 committed by GitHub
parent 97536add75
commit 3c70f6df28
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 138 additions and 76 deletions

View file

@ -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 (
<div class="relative size-full overflow-hidden flex flex-col">
{suspendUntilPromptReady()}
<NewSessionStatus mount={rightMount()} visible={settings.visibility.status()} />
<NewSessionStatus visible={settings.visibility.status()} />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<NewSessionView composer={model} project={project} workspace={workspace} />
</div>

View file

@ -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 (
<Show when={props.mount} keyed>
{(mount) => (
<Portal mount={mount}>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</Portal>
)}
</Show>
<TitlebarRight>
<Show when={props.visible}>
<Tooltip appearance="standard" placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
</TitlebarRight>
)
}

View file

@ -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 (
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<SessionHeaderActions state={actions()} />
</Portal>
)}
</Show>
<TitlebarRight>
<SessionHeaderActions state={actions()} />
</TitlebarRight>
)
}

View file

@ -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 (
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
<TitlebarRightProvider>
<div
class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text"
style={{
"padding-top": "env(safe-area-inset-top, 0px)",
"padding-bottom": "env(safe-area-inset-bottom, 0px)",
}}
>
<Titlebar
update={update}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
/>
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
<Suspense>{props.children}</Suspense>
</main>
<Show when={import.meta.env.DEV && state.debugTools}>
<Suspense>
<DebugBar inline />
</Suspense>
</Show>
<ToastRegion />
</div>
</TitlebarRightProvider>
)
}

View file

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

View file

@ -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<TitlebarRightSlot>()
export function TitlebarRightProvider(props: ParentProps) {
return (
<TitlebarRightContext.Provider value={createTitlebarRightSlot()}>{props.children}</TitlebarRightContext.Provider>
)
}
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 <div ref={slot.setMount} id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
}
export function TitlebarRight(props: ParentProps) {
const slot = useTitlebarRightSlot()
const registration = slot.createRegistration()
onMount(() => {
registration.register()
onCleanup(registration.unregister)
})
return (
<Show when={registration.active() && slot.mount()} keyed>
{(mount) => <Portal mount={mount}>{props.children}</Portal>}
</Show>
)
}
function useTitlebarRightSlot() {
const slot = useContext(TitlebarRightContext)
if (!slot) throw new Error("TitlebarRight must be used within TitlebarRightProvider")
return slot
}

View file

@ -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<HTMLElement | null>(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 }) {
<Show when={props.state.update.visible}>
<TitlebarUpdateIconButton state={props.state.update} />
</Show>
<div id="opencode-titlebar-right" class="flex shrink-0 items-center justify-end gap-0" />
<TitlebarRightMount />
</div>
)
}