import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { LayoutRoute, useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" import { TitlebarTabStrip } from "@/components/titlebar-tab-strip" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { useGlobal } from "@/context/global" import { ServerConnection, useServer } from "@/context/server" import { tabKey, useTabs } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" import { normalizeSessionInfo } from "@/utils/session" const legacyTitlebarHeight = 40 const v2TitlebarHeight = 36 const minTitlebarZoom = 0.25 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const macTrafficLightsBaseWidth = 84 export type TitlebarUpdate = { version: () => string | undefined installing: () => boolean install: () => void } export function useTitlebarRightMount() { const [mount, setMount] = createSignal(null) onMount(() => setMount(document.getElementById("opencode-titlebar-right"))) return mount } export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) { const layout = useLayout() const platform = usePlatform() const command = useCommand() const language = useLanguage() const settings = useSettings() const server = useServer() const navigate = useNavigate() const location = useLocation() const params = useParams() const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns()) const mobile = createMediaQuery("(max-width: 767px)") const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom") const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const web = createMemo(() => platform.platform === "web") const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.()) const zoom = () => platform.webviewZoom?.() ?? 1 const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1) const minHeight = () => { const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight if (mac()) return `${height / zoom()}px` if (windows()) return `${height / Math.min(titlebarZoom(), 1)}px` return undefined } const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px` const [history, setHistory] = createStore({ stack: [] as string[], index: 0, action: undefined as "back" | "forward" | undefined, }) const path = () => `${location.pathname}${location.search}${location.hash}` const creating = createMemo(() => { const route = layout.route() if (route.type === "draft" || route.type === "dir-new-sesssion") return true if (!params.dir) return false if (params.id) return false const parts = location.pathname.replace(/\/+$/, "").split("/") return parts.at(-1) === "session" }) createEffect(() => { const current = path() untrack(() => { const next = applyPath(history, current) if (next === history) return setHistory(next) }) }) const canBack = createMemo(() => history.index > 0) const canForward = createMemo(() => history.index < history.stack.length - 1) const hasProjects = createMemo(() => layout.projects.list().length > 0) const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true)) const updateState = createMemo(() => { const installing = props.update?.installing() ?? false const version = props.update?.version() return { visible: version !== undefined || installing, installing, label: "Update", ariaLabel: language.t("toast.update.action.installRestart"), title: version ? `Update ${version}` : undefined, onInstall: () => props.update?.install(), } }) const v2RightState = createMemo(() => ({ update: updateState(), })) const back = () => { const next = backPath(history) if (!next) return setHistory(next.state) navigate(next.to) } const forward = () => { const next = forwardPath(history) if (!next) return setHistory(next.state) navigate(next.to) } command.register(() => [ { id: "common.goBack", title: language.t("common.goBack"), category: language.t("command.category.view"), keybind: "mod+[", onSelect: back, }, { id: "common.goForward", title: language.t("common.goForward"), category: language.t("command.category.view"), keybind: "mod+]", onSelect: forward, }, ]) return (
{(_) => { const layout = useLayout() const global = useGlobal() const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs const [session] = createResource( () => { const route = layout.route() if (route.type !== "session") return undefined const conn = global.servers .list() .find((item) => ServerConnection.key(item) === (route.server ?? server.key)) return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => sdk.api.session .get({ sessionID: route.sessionId }) .then(normalizeSessionInfo) .catch(() => {}), ) const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return if (route.type === "draft") { return tabsStore.find((item) => item.type === "draft" && item.draftID === route.draftID) } if (route.type === "session") { const main = tabsStore.find( (item) => item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, ) if (main) return main const s = session() if (s?.parentID) { const parentID = s.parentID const parent = tabsStore.find( (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, ) if (parent) return parent } } } const currentTab = () => matchRoute(layout.route()) createEffect(() => { const route = layout.route() if (!tabs.ready()) return const tab = currentTab() if (tab) { tabs.remember(tab) return } if (route.type === "session") { const s = session() if (!s) return const sessionId = s.parentID ?? s.id const next = { server: route.server ?? server.key, sessionId } tabsStoreActions.addSessionTab(next) } }) makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => { const detail = readSessionTabsRemovedDetail(event) if (!detail) return tabsStoreActions.removeSessions(detail) }) const openNewTab = () => { const route = layout.route() const activeSession = session() if (route.type === "session" && activeSession) { const sessionTab = { type: "session" as const, server: route.server ?? server.key, sessionId: activeSession.id, } const model = tabs.stateValue(sessionTab, "prompt")?.model.current() tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model) return } const activeTab = currentTab() if (activeTab?.type === "draft") { const model = tabs.stateValue(activeTab, "prompt")?.model.current() tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) return } if (route.type === "home") { const selection = layout.home.selection() const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server) const project = conn ? global .ensureServerCtx(conn) .projects.list() .find((item) => item.worktree === selection.directory) : undefined if (conn && project) { tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "") return } } const current = layout.projects.list()[0] if (current) { tabs.newDraft({ server: server.key, directory: current.worktree }, "") return } const fallback = global.servers.list().flatMap((conn) => { const project = global.ensureServerCtx(conn).projects.list()[0] return project ? [{ server: ServerConnection.key(conn), project }] : [] })[0] if (!fallback) return tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "") } const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) command.register("titlebar-home", () => [ { id: "home.toggle", title: language.t("home.title"), category: language.t("command.category.view"), keybind: "mod+b", hidden: true, onSelect: toggleHome, }, ]) command.register("tabs", () => { const current = currentTab() return [ { id: "tab.new", category: "tab", title: language.t("command.session.new"), keybind: "mod+t,mod+n", hidden: true, onSelect: openNewTab, }, current && { id: "tab.close", category: "tab", title: language.t("command.tab.close"), keybind: "mod+w", hidden: true, onSelect: () => { tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab)) }, }, { id: "tab.reopenClosed", category: language.t("command.category.file"), title: language.t("command.tab.reopenClosed"), keybind: "mod+shift+t", onSelect: () => tabsStoreActions.reopenClosedTab(), }, ].filter((v) => v !== undefined) }) const [tabsAreOverflowing, setTabsAreOverflowing] = createSignal(false) return (
{language.t("home.title")} } class="shrink-0" > } state={layout.route().type === "home" ? "pressed" : undefined} onClick={toggleHome} aria-label={language.t("home.title")} aria-pressed={layout.route().type === "home"} /> { tabs.select(tab) el?.scrollIntoView({ behavior: "instant" }) }} onClose={(tab) => { const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab)) if (index !== -1) tabsStoreActions.closeTab(index) }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> {language.t("command.session.new")} } > } onClick={openNewTab} aria-label={language.t("command.session.new")} />
) }}
) } type TitlebarUpdatePillState = { visible: boolean installing: boolean label: string ariaLabel: string title?: string onInstall: () => void } type TitlebarV2RightState = { update: TitlebarUpdatePillState } function TitlebarV2Right(props: { state: TitlebarV2RightState }) { return (
) } function TitlebarUpdateIconButton(props: { state: TitlebarUpdatePillState }) { return (
) } function ChannelIndicator(props: { debugTools?: { visible: boolean; toggle: () => void } }) { const channel = import.meta.env.VITE_OPENCODE_CHANNEL if (channel === "dev" && props.debugTools) { return ( ) } return ( <> {["beta", "dev"].includes(channel) && (
{channel.toUpperCase()}
)} ) }