import { createEffect, createMemo, For, mapArray, Match, Show, startTransition, Switch, untrack } from "solid-js" import { createStore, produce } from "solid-js/store" import { useLocation, useMatch, 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 { useTheme } from "@opencode-ai/ui/theme/context" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { getProjectAvatarVariant, useLayout, type LocalProject } 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 { useServerSync } from "@/context/server-sync" import { decodeDirectory } from "@/pages/directory-layout" import { iife } from "@opencode-ai/core/util/iife" import { base64Encode } from "@opencode-ai/core/util/encode" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" import { makeEventListener } from "@solid-primitives/event-listener" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT, type SessionTabsRemovedDetail, } from "@/components/titlebar-session-events" type TauriDesktopWindow = { startDragging?: () => Promise toggleMaximize?: () => Promise } type TauriThemeWindow = { setTheme?: (theme?: "light" | "dark" | null) => Promise } type TauriApi = { window?: { getCurrentWindow?: () => TauriDesktopWindow } webviewWindow?: { getCurrentWebviewWindow?: () => TauriThemeWindow } } const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const legacyTitlebarHeight = 40 const v2TitlebarHeight = 36 const minTitlebarZoom = 0.25 const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each. const makeSessionHref = (b64Dir: string, sessionId: string) => `/${b64Dir}/session/${sessionId}` export type TitlebarUpdate = { version: () => string | undefined installing: () => boolean install: () => void } export function Titlebar(props: { update?: TitlebarUpdate }) { const layout = useLayout() const platform = usePlatform() const command = useCommand() const language = useLanguage() const settings = useSettings() const theme = useTheme() const navigate = useNavigate() const location = useLocation() const params = useParams() const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns()) const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const electronWindows = createMemo(() => windows() && !tauriApi()) const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") const web = createMemo(() => platform.platform === "web") 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(() => { 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, }, ]) const getWin = () => { if (platform.platform !== "desktop") return return currentDesktopWindow() } createEffect(() => { if (platform.platform !== "desktop") return const scheme = theme.colorScheme() const value = scheme === "system" ? null : scheme const win = currentThemeWindow() if (!win?.setTheme) return void win.setTheme(value).catch(() => undefined) }) const interactive = (target: EventTarget | null) => { if (!(target instanceof Element)) return false const selector = "button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']" return !!target.closest(selector) } const drag = (e: MouseEvent) => { if (platform.platform !== "desktop") return if (e.buttons !== 1) return if (interactive(e.target)) return const win = getWin() if (!win?.startDragging) return e.preventDefault() void win.startDragging().catch(() => undefined) } const maximize = (e: MouseEvent) => { if (platform.platform !== "desktop") return if (interactive(e.target)) return if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return const win = getWin() if (!win?.toggleMaximize) return e.preventDefault() void win.toggleMaximize().catch(() => undefined) } return (
{(_) => { const serverSync = useServerSync() const navigate = useNavigate() const homeMatch = useMatch(() => "/") const newSessionHref = () => { if (params.dir) return `/${params.dir}/session` const project = layout.projects.list()[0] if (!project) return "/" return `/${base64Encode(project.worktree)}/session` } type Tab = { dir: string; sessionId: string; href: string } const [tabsStore, tabsStoreActions] = iife(() => { const [store, setStore] = createStore( iife(() => { if (!params.dir || !params.id) return [] return [ { dir: decodeDirectory(params.dir) ?? "", sessionId: params.id, href: makeSessionHref(params.dir, params.id), }, ] }), ) const actions = { addTab: (tab: Tab) => { setStore( produce((tabs) => { if (tabs.some((t) => t.href === tab.href)) return tabs.push(tab) }), ) }, removeTab: (href: string) => { void startTransition(() => { setStore( produce((tabs) => { const index = tabs.findIndex((t) => t.href === href) if (index === -1) return tabs.splice(index, 1) const nextTab = tabs[index] ?? tabs[tabs.length - 1] if (nextTab) navigate(nextTab.href) else navigate("/") }), ) }) }, removeSessions: (input: SessionTabsRemovedDetail) => { void startTransition(() => { setStore( produce((tabs) => { const sessionIDs = new Set(input.sessionIDs) const currentHref = params.dir && params.id ? makeSessionHref(params.dir, params.id) : undefined const currentIndex = currentHref ? tabs.findIndex((tab) => tab.href === currentHref) : -1 const removedCurrent = currentIndex !== -1 && tabs[currentIndex]?.dir === input.directory && sessionIDs.has(tabs[currentIndex]?.sessionId ?? "") for (let i = tabs.length - 1; i >= 0; i--) { const tab = tabs[i] if (!tab) continue if (tab.dir !== input.directory) continue if (!sessionIDs.has(tab.sessionId)) continue tabs.splice(i, 1) } if (!removedCurrent) return const nextTab = tabs[currentIndex] ?? tabs[tabs.length - 1] if (nextTab) navigate(nextTab.href) else navigate("/") }), ) }) }, } return [store, actions] }) makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => { const detail = readSessionTabsRemovedDetail(event) if (!detail) return tabsStoreActions.removeSessions(detail) }) createEffect(() => { const params = useParams() if (!(params.dir && params.id)) return tabsStoreActions.addTab({ dir: decodeDirectory(params.dir) ?? "", sessionId: params.id, href: makeSessionHref(params.dir, params.id), }) }) const projects = createMemo(() => layout.projects.list()) const projectByID = createMemo( () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), ) const currentSessionTab = () => { if (!params.dir || !params.id) return const href = makeSessionHref(params.dir, params.id) return tabsStore.find((tab) => tab.href === href) } const closeCurrentSessionTab = () => { const tab = currentSessionTab() if (!tab) return false tabsStoreActions.removeTab(tab.href) return true } const closeNewSessionTab = () => { if (!(params.dir && !params.id)) return false const last = tabsStore[tabsStore.length - 1] if (last) navigate(last.href) else navigate("/") return true } const openNewTab = () => navigate(newSessionHref()) const closeActiveTab = () => closeCurrentSessionTab() || closeNewSessionTab() command.register(() => { const commands = [ { id: "tab.new", category: "tab", title: language.t("command.session.new"), keybind: "mod+t", hidden: true, onSelect: openNewTab, }, { id: "tab.close", category: "tab", title: language.t("command.tab.close"), keybind: "mod+w", hidden: true, onSelect: closeActiveTab, }, { id: `tab.prev`, category: "tab", title: "", keybind: `mod+option+ArrowLeft`, hidden: true, onSelect: () => { let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) if (index === -1) return index -= 1 if (index === -1) index = tabsStore.length - 1 const next = tabsStore[index] if (next) navigate(next.href) }, }, { id: `tab.next`, category: "tab", title: "", keybind: `mod+option+ArrowRight`, hidden: true, onSelect: () => { let index = tabsStore.findIndex((tab) => tab.href === currentSessionTab()?.href) if (index === -1) return index += 1 if (index === tabsStore.length) index = 0 const next = tabsStore[index] if (next) navigate(next.href) }, }, ...Array.from({ length: 9 }, (_, i) => { const index = i const number = index + 1 return { id: `tab.${number}`, category: "tab", title: "", keybind: `mod+${number}`, disabled: layout.projects.list().length <= index, hidden: true, onSelect: () => { const tab = tabsStore[index] if (tab) navigate(tab.href) }, } }), ] return commands }) const tabsEnriched = iife(() => { const base = mapArray( () => tabsStore, (tab) => { const sync = serverSync.createDirSyncContext(tab.dir) const session = sync.session.get(tab.sessionId) return session ? { ...tab, info: session } : null }, ) return () => base().flatMap((s) => (s ? [s] : [])) }) return (
} state={!!homeMatch() ? "pressed" : undefined} />
{(tab, i) => ( <> {i() !== 0 && (
)} tabsStoreActions.removeTab(tab.href)} /> )}
} as="a" href={newSessionHref()} aria-label={language.t("command.session.new")} /> } > navigate(tabsEnriched().at(-1)?.href ?? "/")} />
) }}
{/*
*/}
{!tauriApi() &&
}
) } 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 TabNavItem(props: { href: string title: string project?: LocalProject directory: string sessionId: string hideClose?: boolean onClose: () => void }) { const match = useMatch(() => props.href) const isActive = () => !!match() const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() props.onClose() } return (
{ if (event.button !== 1) return closeTab(event) }} > {props.title}
} />
) } function ProjectTabAvatar(props: { project?: LocalProject; directory: string; sessionId: string }) { const directory = () => props.directory const sessionId = () => props.sessionId const state = useSessionTabAvatarState(directory, sessionId) return ( ) } function NewSessionTabItem(props: { href: string; title: string; onClose: () => void }) { const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() props.onClose() } return (
{ if (event.button !== 1) return closeTab(event) }} > {props.title}
{ event.preventDefault() event.stopPropagation() }} onClick={closeTab} icon={} aria-label="Close tab" />
) } function ChannelIndicator() { return ( <> {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
)} ) }