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 }) {
-
+
)
}