mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-24 22:23:34 +00:00
fix(app): enforce single titlebar action owner (#44075)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
97536add75
commit
3c70f6df28
7 changed files with 138 additions and 76 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
27
packages/app/src/shell/titlebar/right-slot.test.ts
Normal file
27
packages/app/src/shell/titlebar/right-slot.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
65
packages/app/src/shell/titlebar/right-slot.tsx
Normal file
65
packages/app/src/shell/titlebar/right-slot.tsx
Normal 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
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue