From 7ce9f3a6e2190d7faad0e00837448b4820f69503 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 13 Jun 2026 14:27:33 +0200 Subject: [PATCH 1/4] feat(app): add v2 home tab toggle --- packages/app/src/components/titlebar.tsx | 155 ++++------ packages/app/src/context/command.tsx | 113 ++++---- packages/app/src/context/layout.tsx | 16 +- packages/app/src/context/tabs.tsx | 267 ++++++++++++------ packages/app/src/desktop-menu.ts | 2 +- packages/app/src/pages/directory-layout.tsx | 8 +- packages/app/src/pages/layout.tsx | 2 +- packages/app/src/pages/session.tsx | 56 +++- .../ui/src/v2/components/icon-button-v2.css | 1 + 9 files changed, 373 insertions(+), 247 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 9fc49c0a4196..744a3ae10fe9 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -7,12 +7,11 @@ import { Match, onMount, Show, - startTransition, Switch, untrack, } from "solid-js" import { createStore } from "solid-js/store" -import { useLocation, useMatch, useNavigate, useParams } from "@solidjs/router" +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" @@ -20,15 +19,16 @@ 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 { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { getProjectAvatarVariant, LayoutRoute, useLayout, type LocalProject } from "@/context/layout" +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 { 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" @@ -39,7 +39,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp import { useGlobal } from "@/context/global" import { decode64 } from "@/utils/base64" import { ServerConnection, useServer } from "@/context/server" -import { tabHref, useTabs, type Tab } from "@/context/tabs" +import { tabHref, useTabs } from "@/context/tabs" import "./titlebar.css" type TauriDesktopWindow = { @@ -251,9 +251,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { {(_) => { - const serverSync = useServerSync() const navigate = useNavigate() - const homeMatch = useMatch(() => "/") const layout = useLayout() const newSessionHref = () => { @@ -266,72 +264,29 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { } const tabs = useTabs() - const tabsStore = tabs.store - const tabsStoreActions = tabs - const navigateTab = (tab: Tab) => { - const href = tabHref(tab) - if (tab.server === server.key) { - navigate(href) - return - } - void startTransition(() => { - server.setActive(tab.server) - navigate(href) - }) - } - - 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 sync = serverSync.createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (session?.parentID) { - const parentID = session.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) return - - if (route.type === "session") { - const sync = serverSync.createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (!session) return - const sessionId = session.parentID ?? session.id - const next = { - server: route.server ?? server.key, - dirBase64: route.dirBase64, - sessionId, - } - tabsStoreActions.addSessionTab(next) - } - }) + const currentTab = () => tabs.current(layout.route()) makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => { const detail = readSessionTabsRemovedDetail(event) if (!detail) return - tabsStoreActions.removeSessions(detail) + tabs.removeSessions(detail) }) const openNewTab = () => navigate(newSessionHref()) + const toggleHome = () => tabs.toggleHome(layout.route()) + const canToggleHome = () => tabs.canToggleHome(layout.route()) + + command.register("titlebar-home", () => [ + { + id: "home.toggle", + title: language.t("home.title"), + category: language.t("command.category.view"), + keybind: "mod+b", + hidden: true, + disabled: !canToggleHome(), + onSelect: toggleHome, + }, + ]) command.register("tabs", () => { const current = currentTab() @@ -352,7 +307,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: "mod+w", hidden: true, onSelect: () => { - tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab)) + tabs.removeTab(tabs.store.findIndex((tab) => current === tab)) }, }, { @@ -362,14 +317,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: `mod+option+ArrowLeft`, hidden: true, onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) + let index = tabs.store.findIndex((tab) => tab === currentTab()) if (index === -1) return index -= 1 - if (index === -1) index = tabsStore.length - 1 + if (index === -1) index = tabs.store.length - 1 - const next = tabsStore[index] - if (next) navigateTab(next) + const next = tabs.store[index] + if (next) tabs.select(next) }, }, { @@ -379,14 +334,14 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: `mod+option+ArrowRight`, hidden: true, onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) + let index = tabs.store.findIndex((tab) => tab === currentTab()) if (index === -1) return index += 1 - if (index === tabsStore.length) index = 0 + if (index === tabs.store.length) index = 0 - const next = tabsStore[index] - if (next) navigateTab(next) + const next = tabs.store[index] + if (next) tabs.select(next) }, }, ...Array.from({ length: 9 }, (_, i) => { @@ -400,8 +355,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { disabled: layout.projects.list().length <= index, hidden: true, onSelect: () => { - const tab = tabsStore[index] - if (tab) navigateTab(tab) + const tab = tabs.store[index] + if (tab) tabs.select(tab) }, } }), @@ -427,15 +382,29 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { - } - state={!!homeMatch() ? "pressed" : undefined} - /> + + {language.t("home.title")} + + + } + class="shrink-0" + > + } + state={layout.route().type === "home" ? "pressed" : undefined} + onClick={toggleHome} + disabled={!canToggleHome()} + aria-label={language.t("home.title")} + aria-pressed={layout.route().type === "home"} + /> +
createResizeObserver(el, refreshTabsAreOverflowing)} > - + {(tab, i) => { let ref!: HTMLDivElement @@ -469,10 +438,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { title={language.t("command.session.new")} active={currentTab() === tab} onNavigate={() => { - navigateTab(tab) + tabs.select(tab) ref.scrollIntoView({ behavior: "instant" }) }} - onClose={() => tabsStoreActions.removeTab(i())} + onClose={() => tabs.removeTab(i())} /> ) @@ -488,11 +457,11 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { directory={decode64(tab.dirBase64)!} sessionId={tab.sessionId} onNavigate={() => { - navigateTab(tab) + tabs.select(tab) ref.scrollIntoView({ behavior: "instant" }) }} - onClose={() => tabsStoreActions.removeTab(i())} + onClose={() => tabs.removeTab(i())} active={currentTab() === tab} activeServer={tab.server === server.key} forceTruncate={tabsAreOverflowing()} @@ -517,8 +486,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { href={`/${params.dir}/session`} title={language.t("command.session.new")} onClose={() => { - const tab = tabsStore.at(-1) - if (tab) navigateTab(tab) + const tab = tabs.store.at(-1) + if (tab) tabs.select(tab) else navigate("/") }} /> diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index e979ad6a0595..ae6f4774dfbb 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -170,13 +170,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean return false } -export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { - if (!config || config === "none") return "" - - const keybinds = parseKeybind(config) - if (keybinds.length === 0) return "" - - const kb = keybinds[0] +function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) { const parts: string[] = [] if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t)) @@ -184,40 +178,55 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t)) if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t)) - if (kb.key) { - const keys: Record = { - arrowup: "↑", - arrowdown: "↓", - arrowleft: "←", - arrowright: "→", - comma: ",", - plus: "+", - } - const named: Record = { - backspace: "common.key.backspace", - delete: "common.key.delete", - end: "common.key.end", - enter: "common.key.enter", - esc: "common.key.esc", - escape: "common.key.esc", - home: "common.key.home", - insert: "common.key.insert", - pagedown: "common.key.pageDown", - pageup: "common.key.pageUp", - space: "common.key.space", - tab: "common.key.tab", - } - const key = kb.key.toLowerCase() - const displayKey = - keys[key] ?? - (named[key] - ? keyText(named[key], t) - : key.length === 1 - ? key.toUpperCase() - : key.charAt(0).toUpperCase() + key.slice(1)) - parts.push(displayKey) + if (!kb.key) return parts + + const keys: Record = { + arrowup: "↑", + arrowdown: "↓", + arrowleft: "←", + arrowright: "→", + comma: ",", + plus: "+", + } + const named: Record = { + backspace: "common.key.backspace", + delete: "common.key.delete", + end: "common.key.end", + enter: "common.key.enter", + esc: "common.key.esc", + escape: "common.key.esc", + home: "common.key.home", + insert: "common.key.insert", + pagedown: "common.key.pageDown", + pageup: "common.key.pageUp", + space: "common.key.space", + tab: "common.key.tab", } + const key = kb.key.toLowerCase() + const displayKey = + keys[key] ?? + (named[key] + ? keyText(named[key], t) + : key.length === 1 + ? key.toUpperCase() + : key.charAt(0).toUpperCase() + key.slice(1)) + parts.push(displayKey) + + return parts +} + +export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] { + if (!config || config === "none") return [] + + const keybinds = parseKeybind(config) + if (keybinds.length === 0) return [] + + return displayKeybindParts(keybinds[0], t) +} +export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { + const parts = formatKeybindParts(config, t) + if (parts.length === 0) return "" return IS_MAC ? parts.join("") : parts.join("+") } @@ -402,25 +411,31 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }) } + const keybindConfig = (id: string) => { + if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND + + const base = actionId(id) + const option = options().find((x) => actionId(x.id) === base) + if (option?.keybind) return option.keybind + + return bind(base, catalog[base]?.keybind) + } + return { register, trigger(id: string, source?: CommandSource) { run(id, source) }, keybind(id: string) { - if (id === PALETTE_ID) { - return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t) - } - - const base = actionId(id) - const option = options().find((x) => actionId(x.id) === base) - if (option?.keybind) return formatKeybind(option.keybind, language.t) - - const meta = catalog[base] - const config = bind(base, meta?.keybind) + const config = keybindConfig(id) if (!config) return "" return formatKeybind(config, language.t) }, + keybindParts(id: string) { + const config = keybindConfig(id) + if (!config) return [] + return formatKeybindParts(config, language.t) + }, show: showPalette, keybinds(enabled: boolean) { setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index f9e954b98b11..8fa15d3a9d04 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -77,9 +77,15 @@ export type ReviewDiffStyle = "unified" | "split" export type LayoutRoute = | { type: "home" } - | { type: "draft"; draftID: string; server?: ServerConnection.Key } - | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } - | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } + | { type: "draft"; draftID: string; server: ServerConnection.Key } + | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server: ServerConnection.Key } + | { type: "session"; dir: string; dirBase64: string; sessionId: string; server: ServerConnection.Key } + +type ParsedLayoutRoute = + | { type: "home" } + | { type: "draft"; draftID: string } + | { type: "dir-new-sesssion"; dir: string; dirBase64: string } + | { type: "session"; dir: string; dirBase64: string; sessionId: string } function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { const all = current?.all ?? [] @@ -121,7 +127,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { } } -const currentRoute = (pathname: string, search: string): LayoutRoute => { +const currentRoute = (pathname: string, search: string): ParsedLayoutRoute => { const parts = pathname.split("/").filter(Boolean) if (parts.length === 0) return { type: "home" } @@ -151,7 +157,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const server = useServer() const platform = usePlatform() const location = useLocation() - const route = createMemo(() => { + const route = createMemo(() => { const value = currentRoute(location.pathname, location.search) if (value.type === "home") return value return { ...value, server: server.key } diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 7f6274cf9922..034a7e856331 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -4,11 +4,12 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" -import { createEffect, startTransition } from "solid-js" +import { batch, createEffect, startTransition } from "solid-js" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" +import type { LayoutRoute } from "./layout" export type SessionTab = { type: "session" @@ -27,6 +28,17 @@ export type DraftTab = { export type Tab = SessionTab | DraftTab +export type SessionTabTarget = { + server: ServerConnection.Key + dirBase64: string + sessionID: string + rootSessionID: string +} + +type ActiveState = { + active?: string +} + export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => @@ -34,6 +46,9 @@ export const tabHref = (tab: Tab) => export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) +const sessionRouteKey = (server: ServerConnection.Key, dirBase64: string, sessionID: string) => + `${server}\n${dirBase64}\n${sessionID}` + export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { const dirBase64 = base64Encode(session.directory) return tabs.some( @@ -48,20 +63,23 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ init: () => { const server = useServer() const platform = usePlatform() - const fallback = server.key - const [store, setStore, _, ready] = persisted( + const defaultServer = server.key + const [tabs, setTabs, , tabsReady] = persisted( { ...Persist.global("tabs"), migrate: (value: unknown) => { if (!Array.isArray(value)) return value return value.map((tab) => { if (!tab || typeof tab !== "object" || "server" in tab) return tab - return { ...tab, server: fallback } + return { ...tab, server: defaultServer } }) }, }, createStore([]), ) + const [active, setActive, , activeReady] = persisted(Persist.global("tabs.active"), createStore({})) + const [sessionRoutes, setSessionRoutes] = createStore>({}) + const ready = () => tabsReady() && activeReady() const params = useParams() const navigate = useNavigate() @@ -73,52 +91,112 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } + const removeSessionRoutes = (tab: string) => { + setSessionRoutes( + produce((routes) => { + for (const [route, target] of Object.entries(routes)) { + if (target === tab) delete routes[route] + } + }), + ) + } + + const pruneSessionRoutes = (tabs: Set) => { + setSessionRoutes( + produce((routes) => { + for (const [route, tab] of Object.entries(routes)) { + if (!tabs.has(tab)) delete routes[route] + } + }), + ) + } + + const removeSessionRouteIDs = (input: SessionTabsRemovedDetail) => { + setSessionRoutes( + produce((routes) => { + for (const sessionID of input.sessionIDs) { + delete routes[sessionRouteKey(server.key, base64Encode(input.directory), sessionID)] + } + }), + ) + } + createEffect(() => { if (!ready()) return const servers = new Set(server.list.map(ServerConnection.key)) - if (store.every((tab) => servers.has(tab.server))) return - setStore((tabs) => tabs.filter((tab) => servers.has(tab.server))) + if (tabs.every((tab) => servers.has(tab.server))) return + const next = tabs.filter((tab) => servers.has(tab.server)) + setTabs(() => next) + if (active.active && !next.some((tab) => tabKey(tab) === active.active)) setActive("active", undefined) + pruneSessionRoutes(new Set(next.map(tabKey))) }) - const navigateTab = (tab: Tab) => { - const href = tabHref(tab) + const navigateTab = (tab: Tab, href = tabHref(tab)) => { if (tab.server === server.key) { - navigate(href) + batch(() => { + setActive("active", tabKey(tab)) + navigate(href) + }) return } void startTransition(() => { + setActive("active", tabKey(tab)) server.setActive(tab.server) navigate(href) }) } + const current = (route: LayoutRoute) => { + if (route.type === "home" || route.type === "dir-new-sesssion") return + if (route.type === "draft") { + return tabs.find((tab) => tab.type === "draft" && tab.draftID === route.draftID) + } + const key = sessionRoutes[sessionRouteKey(route.server, route.dirBase64, route.sessionId)] + if (key) return tabs.find((tab) => tabKey(tab) === key) + return tabs.find( + (tab) => + tab.type === "session" && + tab.server === route.server && + tab.dirBase64 === route.dirBase64 && + tab.sessionId === route.sessionId, + ) + } + + const canToggleHome = (route: LayoutRoute) => { + if (!ready()) return false + if (route.type === "home") return true + return current(route) !== undefined + } + const actions = { - addSessionTab: (tab: Omit) => { - const next = { type: "session" as const, ...tab } - if (closing.has(tabKey(next))) return - setStore( - produce((tabs) => { - if (tabs.some((item) => tabKey(item) === tabKey(next))) return - tabs.push(next) - }), - ) + enterSession(input: SessionTabTarget) { + const tab = { + type: "session" as const, + server: input.server, + dirBase64: input.dirBase64, + sessionId: input.rootSessionID, + } + const key = tabKey(tab) + if (closing.has(key)) return + batch(() => { + if (!tabs.some((item) => tabKey(item) === key)) setTabs(tabs.length, tab) + setActive("active", key) + setSessionRoutes(sessionRouteKey(input.server, input.dirBase64, input.sessionID), key) + }) }, draft(draftID: string) { - const tab = store.find((item) => item.type === "draft" && item.draftID === draftID) + const tab = tabs.find((item) => item.type === "draft" && item.draftID === draftID) if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) return tab }, newDraft(draft: Omit, prompt?: string) { const draftID = uuid() - setStore( - produce((tabs) => { - tabs.push({ type: "draft", draftID, ...draft }) - }), - ) - navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + const tab = { type: "draft" as const, draftID, ...draft } + setTabs(tabs.length, tab) + navigateTab(tab, prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) }, updateDraft(draftID: string, draft: Partial>) { - setStore( + setTabs( (tab) => tab.type === "draft" && tab.draftID === draftID, produce((tab) => Object.assign(tab, draft)), ) @@ -128,89 +206,108 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ // replaces the draft tab with a session tab, so the draft route would stop resolving // and fall back home. Navigate to the new session first so we leave /new-session // before the draft is removed from the store. - const active = location.pathname === "/new-session" && location.query.draftId === draftID + const viewing = location.pathname === "/new-session" && location.query.draftId === draftID startTransition(() => { - setStore( - produce((tabs) => { - const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) - if (index !== -1) tabs[index] = { type: "session", ...session } - }), - ) - if (active) navigateTab({ type: "session", ...session }) + batch(() => { + setTabs( + produce((tabs) => { + const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) + if (index === -1) return + tabs[index] = { type: "session", ...session } + }), + ) + const previous = `draft:${draftID}` + const next = tabKey({ type: "session", ...session }) + if (active.active === previous) setActive("active", next) + }) + if (viewing) navigateTab({ type: "session", ...session }) }) removeDraftPersisted(draftID) }, removeTab: (index: number) => { - const tab = store[index] + const tab = tabs[index] if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = store[index + 1] ?? store[index - 1] + const nextTab = tabs[index + 1] ?? tabs[index - 1] closing.add(key) void startTransition(() => { - setStore( - produce((tabs) => { - tabs.splice(index, 1) - }), - ) + setTabs(produce((tabs) => void tabs.splice(index, 1))) + if (active.active === key) setActive("active", nextTab && tabKey(nextTab)) + removeSessionRoutes(key) if (nextTab) navigateTab(nextTab) else navigate("/") }).finally(() => closing.delete(key)) if (draftID) removeDraftPersisted(draftID) }, removeServer(key: ServerConnection.Key) { - const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) - setStore((tabs) => tabs.filter((tab) => tab.server !== key)) + const drafts = tabs.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) + const next = tabs.filter((tab) => tab.server !== key) + setTabs(() => next) + if (active.active && !next.some((tab) => tabKey(tab) === active.active)) setActive("active", undefined) + pruneSessionRoutes(new Set(next.map(tabKey))) for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { + const sessionIDs = new Set(input.sessionIDs) + const currentKey = + params.dir && params.id + ? (sessionRoutes[sessionRouteKey(server.key, params.dir, params.id)] ?? + tabKey({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })) + : undefined + removeSessionRouteIDs(input) + const currentIndex = currentKey ? tabs.findIndex((tab) => tabKey(tab) === currentKey) : -1 + const removed = tabs.filter( + (tab) => + tab.type === "session" && + tab.server === server.key && + atob(tab.dirBase64) === input.directory && + sessionIDs.has(tab.sessionId), + ) + if (removed.length === 0) return + + const removedKeys = new Set(removed.map(tabKey)) + const next = tabs.filter((tab) => !removedKeys.has(tabKey(tab))) + const removedCurrent = !!currentKey && removedKeys.has(currentKey) + const nextTab = removedCurrent + ? (tabs.slice(currentIndex + 1).find((tab) => !removedKeys.has(tabKey(tab))) ?? + tabs.slice(0, currentIndex).findLast((tab) => !removedKeys.has(tabKey(tab)))) + : undefined + void startTransition(() => { - setStore( - produce((tabs) => { - const sessionIDs = new Set(input.sessionIDs) - const currentHref = - params.dir && params.id - ? tabHref({ - type: "session", - server: server.key, - dirBase64: params.dir, - sessionId: params.id, - }) - : undefined - const currentIndex = currentHref - ? tabs.findIndex( - (tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref, - ) - : -1 - const currentTab = tabs[currentIndex] - const removedCurrent = - currentTab?.type === "session" && - currentTab.server === server.key && - atob(currentTab.dirBase64) === input.directory && - sessionIDs.has(currentTab.sessionId) - - for (let i = tabs.length - 1; i >= 0; i--) { - const tab = tabs[i] - if (!tab || tab.type !== "session") continue - if (tab.server !== server.key) continue - if (atob(tab.dirBase64) !== input.directory) continue - if (!sessionIDs.has(tab.sessionId)) continue - tabs.splice(i, 1) - } - - if (!removedCurrent) return - const nextTab = - tabs.slice(currentIndex).find((tab) => tab.type === "session") ?? - tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session") - if (nextTab) navigateTab(nextTab) - else navigate("/") - }), - ) + batch(() => { + setTabs(() => next) + if (active.active && removedKeys.has(active.active)) + setActive("active", nextTab ? tabKey(nextTab) : undefined) + pruneSessionRoutes(new Set(next.map(tabKey))) + }) + if (nextTab) navigateTab(nextTab) + if (removedCurrent && !nextTab) navigate("/") }) }, + canToggleHome, + current, + select: navigateTab, + toggleHome(route: LayoutRoute) { + if (!canToggleHome(route)) return + if (route.type === "home") { + const tab = tabs.find((tab) => tabKey(tab) === active.active) + if (tab) navigateTab(tab) + return + } + const tab = current(route) + if (tab) setActive("active", tabKey(tab)) + navigate("/") + }, } - return { ...actions, store, ready } + return { + ...actions, + get store() { + return tabs + }, + ready, + } }, }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 677738fde90a..d3902cd2b245 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ id: "view", label: "View", items: [ - { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } }, + { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, { type: "separator" }, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index e03d5c206fcc..55a03e36ef4e 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -13,7 +13,6 @@ import { Schema } from "effect" export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { const location = useLocation() const navigate = useNavigate() - const params = useParams() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) @@ -26,11 +25,6 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) - createResource( - () => params.id, - (id) => sync.session.sync(id).catch(() => {}), - ) - return ( layout.sidebar.toggle(), }, { diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c44d31bbdc27..352670cdc159 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -29,7 +29,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" -import { checksum } from "@opencode-ai/core/util/encode" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" @@ -44,6 +44,7 @@ import { useServerSDK } from "@/context/server-sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" +import { useTabs, type SessionTabTarget } from "@/context/tabs" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { @@ -57,7 +58,7 @@ import { import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { useServer } from "@/context/server" +import { ServerConnection, useServer } from "@/context/server" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" @@ -69,6 +70,7 @@ import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" +import { retry } from "@opencode-ai/core/util/retry" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" const emptyUserMessages: UserMessage[] = [] @@ -79,6 +81,21 @@ const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" +function SessionTabEntry(props: { session: SessionTabTarget }) { + const tabs = useTabs() + return ( + + + + ) +} + +function SessionTabCommit(props: { session: SessionTabTarget }) { + const tabs = useTabs() + onMount(() => tabs.enterSession(props.session)) + return null +} + type SessionHistoryWindowInput = { sessionID: () => string | undefined loaded: () => number @@ -632,8 +649,8 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs const [sessionSync] = createResource( - () => [sdk.directory, params.id] as const, - ([directory, id]) => { + () => [sdk.directory, params.id, server.key, settings.general.newLayoutDesigns()] as const, + async ([directory, id, server, v2]) => { if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) refreshFrame = undefined @@ -660,7 +677,32 @@ export default function Page() { }, 0) }) - return sync.session.sync(id) + await sync.session.sync(id) + if (!v2) return + const session = sync.session.get(id) + if (!session) return + + const seen = new Set([session.id]) + let root = session + while (root.parentID) { + const parentID = root.parentID + if (seen.has(parentID)) return + seen.add(parentID) + const parent = + sync.session.get(parentID) ?? + (await retry(() => sdk.client.session.get({ sessionID: parentID })) + .then((result) => result.data) + .catch(() => undefined)) + if (!parent) return + root = parent + } + + return { + server, + dirBase64: base64Encode(root.directory), + sessionID: id, + rootSessionID: root.id, + } }, ) @@ -1717,7 +1759,9 @@ export default function Page() { return (
- {sessionSync() ?? ""} + + {(session) => } +
Date: Sat, 13 Jun 2026 14:43:53 +0200 Subject: [PATCH 2/4] refactor(app): simplify v2 home toggle --- packages/app/src/components/titlebar.tsx | 87 ++++-- packages/app/src/context/command.tsx | 16 +- packages/app/src/context/layout.tsx | 16 +- packages/app/src/context/tabs.tsx | 299 ++++++++------------ packages/app/src/desktop-menu.ts | 2 +- packages/app/src/pages/directory-layout.tsx | 8 +- packages/app/src/pages/layout.tsx | 8 +- packages/app/src/pages/session.tsx | 56 +--- 8 files changed, 222 insertions(+), 270 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 744a3ae10fe9..d11545f61262 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -22,13 +22,14 @@ 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 { getProjectAvatarVariant, useLayout, type LocalProject } from "@/context/layout" +import { getProjectAvatarVariant, LayoutRoute, 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 { 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" @@ -39,7 +40,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp import { useGlobal } from "@/context/global" import { decode64 } from "@/utils/base64" import { ServerConnection, useServer } from "@/context/server" -import { tabHref, useTabs } from "@/context/tabs" +import { tabHref, useTabs, type Tab } from "@/context/tabs" import "./titlebar.css" type TauriDesktopWindow = { @@ -251,6 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { {(_) => { + const serverSync = useServerSync() const navigate = useNavigate() const layout = useLayout() @@ -264,17 +266,62 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { } const tabs = useTabs() - const currentTab = () => tabs.current(layout.route()) + const tabsStore = tabs.store + const tabsStoreActions = tabs + + 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 sync = serverSync.createDirSyncContext(route.dir) + const session = sync.session.get(route.sessionId) + if (session?.parentID) { + const parentID = session.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) return + + if (route.type === "session") { + const sync = serverSync.createDirSyncContext(route.dir) + const session = sync.session.get(route.sessionId) + if (!session) return + const sessionId = session.parentID ?? session.id + const next = { + server: route.server ?? server.key, + dirBase64: route.dirBase64, + sessionId, + } + tabsStoreActions.addSessionTab(next) + } + }) makeEventListener(window, SESSION_TABS_REMOVED_EVENT, (event) => { const detail = readSessionTabsRemovedDetail(event) if (!detail) return - tabs.removeSessions(detail) + tabsStoreActions.removeSessions(detail) }) const openNewTab = () => navigate(newSessionHref()) - const toggleHome = () => tabs.toggleHome(layout.route()) - const canToggleHome = () => tabs.canToggleHome(layout.route()) + const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() }) command.register("titlebar-home", () => [ { @@ -283,7 +330,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { category: language.t("command.category.view"), keybind: "mod+b", hidden: true, - disabled: !canToggleHome(), + disabled: !tabs.recentReady(), onSelect: toggleHome, }, ]) @@ -307,7 +354,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: "mod+w", hidden: true, onSelect: () => { - tabs.removeTab(tabs.store.findIndex((tab) => current === tab)) + tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab)) }, }, { @@ -317,13 +364,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: `mod+option+ArrowLeft`, hidden: true, onSelect: () => { - let index = tabs.store.findIndex((tab) => tab === currentTab()) + let index = tabsStore.findIndex((tab) => tab === currentTab()) if (index === -1) return index -= 1 - if (index === -1) index = tabs.store.length - 1 + if (index === -1) index = tabsStore.length - 1 - const next = tabs.store[index] + const next = tabsStore[index] if (next) tabs.select(next) }, }, @@ -334,13 +381,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { keybind: `mod+option+ArrowRight`, hidden: true, onSelect: () => { - let index = tabs.store.findIndex((tab) => tab === currentTab()) + let index = tabsStore.findIndex((tab) => tab === currentTab()) if (index === -1) return index += 1 - if (index === tabs.store.length) index = 0 + if (index === tabsStore.length) index = 0 - const next = tabs.store[index] + const next = tabsStore[index] if (next) tabs.select(next) }, }, @@ -355,7 +402,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { disabled: layout.projects.list().length <= index, hidden: true, onSelect: () => { - const tab = tabs.store[index] + const tab = tabsStore[index] if (tab) tabs.select(tab) }, } @@ -400,7 +447,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { icon={} state={layout.route().type === "home" ? "pressed" : undefined} onClick={toggleHome} - disabled={!canToggleHome()} + disabled={!tabs.recentReady()} aria-label={language.t("home.title")} aria-pressed={layout.route().type === "home"} /> @@ -419,7 +466,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { class="flex min-w-0 flex-row items-center gap-1.5" ref={(el) => createResizeObserver(el, refreshTabsAreOverflowing)} > - + {(tab, i) => { let ref!: HTMLDivElement @@ -441,7 +488,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { tabs.select(tab) ref.scrollIntoView({ behavior: "instant" }) }} - onClose={() => tabs.removeTab(i())} + onClose={() => tabsStoreActions.removeTab(i())} /> ) @@ -461,7 +508,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { ref.scrollIntoView({ behavior: "instant" }) }} - onClose={() => tabs.removeTab(i())} + onClose={() => tabsStoreActions.removeTab(i())} active={currentTab() === tab} activeServer={tab.server === server.key} forceTruncate={tabsAreOverflowing()} @@ -486,7 +533,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { href={`/${params.dir}/session`} title={language.t("command.session.new")} onClose={() => { - const tab = tabs.store.at(-1) + const tab = tabsStore.at(-1) if (tab) tabs.select(tab) else navigate("/") }} diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index ae6f4774dfbb..16483706b8e9 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -217,11 +217,8 @@ function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) { export function formatKeybindParts(config: string, t?: (key: KeyLabel) => string): string[] { if (!config || config === "none") return [] - - const keybinds = parseKeybind(config) - if (keybinds.length === 0) return [] - - return displayKeybindParts(keybinds[0], t) + const keybind = parseKeybind(config)[0] + return keybind ? displayKeybindParts(keybind, t) : [] } export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { @@ -413,12 +410,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const keybindConfig = (id: string) => { if (id === PALETTE_ID) return settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND - const base = actionId(id) - const option = options().find((x) => actionId(x.id) === base) - if (option?.keybind) return option.keybind - - return bind(base, catalog[base]?.keybind) + return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind) } return { @@ -433,8 +426,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }, keybindParts(id: string) { const config = keybindConfig(id) - if (!config) return [] - return formatKeybindParts(config, language.t) + return config ? formatKeybindParts(config, language.t) : [] }, show: showPalette, keybinds(enabled: boolean) { diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 8fa15d3a9d04..f9e954b98b11 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -77,15 +77,9 @@ export type ReviewDiffStyle = "unified" | "split" export type LayoutRoute = | { type: "home" } - | { type: "draft"; draftID: string; server: ServerConnection.Key } - | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server: ServerConnection.Key } - | { type: "session"; dir: string; dirBase64: string; sessionId: string; server: ServerConnection.Key } - -type ParsedLayoutRoute = - | { type: "home" } - | { type: "draft"; draftID: string } - | { type: "dir-new-sesssion"; dir: string; dirBase64: string } - | { type: "session"; dir: string; dirBase64: string; sessionId: string } + | { type: "draft"; draftID: string; server?: ServerConnection.Key } + | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } + | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { const all = current?.all ?? [] @@ -127,7 +121,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { } } -const currentRoute = (pathname: string, search: string): ParsedLayoutRoute => { +const currentRoute = (pathname: string, search: string): LayoutRoute => { const parts = pathname.split("/").filter(Boolean) if (parts.length === 0) return { type: "home" } @@ -157,7 +151,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const server = useServer() const platform = usePlatform() const location = useLocation() - const route = createMemo(() => { + const route = createMemo(() => { const value = currentRoute(location.pathname, location.search) if (value.type === "home") return value return { ...value, server: server.key } diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 034a7e856331..464269aafa44 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -4,12 +4,11 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" -import { batch, createEffect, startTransition } from "solid-js" +import { createEffect, startTransition } from "solid-js" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" -import type { LayoutRoute } from "./layout" export type SessionTab = { type: "session" @@ -28,15 +27,8 @@ export type DraftTab = { export type Tab = SessionTab | DraftTab -export type SessionTabTarget = { - server: ServerConnection.Key - dirBase64: string - sessionID: string - rootSessionID: string -} - -type ActiveState = { - active?: string +type RecentTab = { + key?: string } export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` @@ -46,9 +38,6 @@ export const tabHref = (tab: Tab) => export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) -const sessionRouteKey = (server: ServerConnection.Key, dirBase64: string, sessionID: string) => - `${server}\n${dirBase64}\n${sessionID}` - export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { const dirBase64 = base64Encode(session.directory) return tabs.some( @@ -63,140 +52,92 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ init: () => { const server = useServer() const platform = usePlatform() - const defaultServer = server.key - const [tabs, setTabs, , tabsReady] = persisted( + const fallback = server.key + const [store, setStore, _, ready] = persisted( { ...Persist.global("tabs"), migrate: (value: unknown) => { if (!Array.isArray(value)) return value return value.map((tab) => { if (!tab || typeof tab !== "object" || "server" in tab) return tab - return { ...tab, server: defaultServer } + return { ...tab, server: fallback } }) }, }, createStore([]), ) - const [active, setActive, , activeReady] = persisted(Persist.global("tabs.active"), createStore({})) - const [sessionRoutes, setSessionRoutes] = createStore>({}) - const ready = () => tabsReady() && activeReady() + const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore({})) const params = useParams() const navigate = useNavigate() const location = useLocation() const closing = new Set() + let recentWrite = 0 - const removeDraftPersisted = (draftID: string) => { - for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) - } - - const removeSessionRoutes = (tab: string) => { - setSessionRoutes( - produce((routes) => { - for (const [route, target] of Object.entries(routes)) { - if (target === tab) delete routes[route] - } - }), - ) - } - - const pruneSessionRoutes = (tabs: Set) => { - setSessionRoutes( - produce((routes) => { - for (const [route, tab] of Object.entries(routes)) { - if (!tabs.has(tab)) delete routes[route] - } - }), - ) + const setRecentKey = (key: string | undefined) => { + const write = ++recentWrite + if (recentReady()) { + setRecent("key", key) + return + } + void recentReady.promise?.then(() => { + if (write === recentWrite) setRecent("key", key) + }) } - const removeSessionRouteIDs = (input: SessionTabsRemovedDetail) => { - setSessionRoutes( - produce((routes) => { - for (const sessionID of input.sessionIDs) { - delete routes[sessionRouteKey(server.key, base64Encode(input.directory), sessionID)] - } - }), - ) + const removeDraftPersisted = (draftID: string) => { + for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) } createEffect(() => { - if (!ready()) return + if (!ready() || !recentReady()) return const servers = new Set(server.list.map(ServerConnection.key)) - if (tabs.every((tab) => servers.has(tab.server))) return - const next = tabs.filter((tab) => servers.has(tab.server)) - setTabs(() => next) - if (active.active && !next.some((tab) => tabKey(tab) === active.active)) setActive("active", undefined) - pruneSessionRoutes(new Set(next.map(tabKey))) + const next = store.filter((tab) => servers.has(tab.server)) + if (next.length !== store.length) setStore(() => next) + if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined) }) - const navigateTab = (tab: Tab, href = tabHref(tab)) => { + const navigateTab = (tab: Tab) => { + const href = tabHref(tab) + setRecentKey(tabKey(tab)) if (tab.server === server.key) { - batch(() => { - setActive("active", tabKey(tab)) - navigate(href) - }) + navigate(href) return } void startTransition(() => { - setActive("active", tabKey(tab)) server.setActive(tab.server) navigate(href) }) } - const current = (route: LayoutRoute) => { - if (route.type === "home" || route.type === "dir-new-sesssion") return - if (route.type === "draft") { - return tabs.find((tab) => tab.type === "draft" && tab.draftID === route.draftID) - } - const key = sessionRoutes[sessionRouteKey(route.server, route.dirBase64, route.sessionId)] - if (key) return tabs.find((tab) => tabKey(tab) === key) - return tabs.find( - (tab) => - tab.type === "session" && - tab.server === route.server && - tab.dirBase64 === route.dirBase64 && - tab.sessionId === route.sessionId, - ) - } - - const canToggleHome = (route: LayoutRoute) => { - if (!ready()) return false - if (route.type === "home") return true - return current(route) !== undefined - } - const actions = { - enterSession(input: SessionTabTarget) { - const tab = { - type: "session" as const, - server: input.server, - dirBase64: input.dirBase64, - sessionId: input.rootSessionID, - } - const key = tabKey(tab) - if (closing.has(key)) return - batch(() => { - if (!tabs.some((item) => tabKey(item) === key)) setTabs(tabs.length, tab) - setActive("active", key) - setSessionRoutes(sessionRouteKey(input.server, input.dirBase64, input.sessionID), key) - }) + addSessionTab: (tab: Omit) => { + const next = { type: "session" as const, ...tab } + if (closing.has(tabKey(next))) return + setStore( + produce((tabs) => { + if (tabs.some((item) => tabKey(item) === tabKey(next))) return + tabs.push(next) + }), + ) }, draft(draftID: string) { - const tab = tabs.find((item) => item.type === "draft" && item.draftID === draftID) + const tab = store.find((item) => item.type === "draft" && item.draftID === draftID) if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) return tab }, newDraft(draft: Omit, prompt?: string) { const draftID = uuid() - const tab = { type: "draft" as const, draftID, ...draft } - setTabs(tabs.length, tab) - navigateTab(tab, prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + setStore( + produce((tabs) => { + tabs.push({ type: "draft", draftID, ...draft }) + }), + ) + navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) }, updateDraft(draftID: string, draft: Partial>) { - setTabs( + setStore( (tab) => tab.type === "draft" && tab.draftID === draftID, produce((tab) => Object.assign(tab, draft)), ) @@ -206,108 +147,118 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ // replaces the draft tab with a session tab, so the draft route would stop resolving // and fall back home. Navigate to the new session first so we leave /new-session // before the draft is removed from the store. - const viewing = location.pathname === "/new-session" && location.query.draftId === draftID + const active = location.pathname === "/new-session" && location.query.draftId === draftID + const next = { type: "session" as const, ...session } startTransition(() => { - batch(() => { - setTabs( - produce((tabs) => { - const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) - if (index === -1) return - tabs[index] = { type: "session", ...session } - }), - ) - const previous = `draft:${draftID}` - const next = tabKey({ type: "session", ...session }) - if (active.active === previous) setActive("active", next) - }) - if (viewing) navigateTab({ type: "session", ...session }) + setStore( + produce((tabs) => { + const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) + if (index !== -1) tabs[index] = next + }), + ) + if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next)) + if (active) navigateTab(next) }) removeDraftPersisted(draftID) }, removeTab: (index: number) => { - const tab = tabs[index] + const tab = store[index] if (!tab) return const key = tabKey(tab) const draftID = tab.type === "draft" ? tab.draftID : undefined - const nextTab = tabs[index + 1] ?? tabs[index - 1] + const nextTab = store[index + 1] ?? store[index - 1] closing.add(key) void startTransition(() => { - setTabs(produce((tabs) => void tabs.splice(index, 1))) - if (active.active === key) setActive("active", nextTab && tabKey(nextTab)) - removeSessionRoutes(key) + setStore( + produce((tabs) => { + tabs.splice(index, 1) + }), + ) + if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab)) if (nextTab) navigateTab(nextTab) else navigate("/") }).finally(() => closing.delete(key)) if (draftID) removeDraftPersisted(draftID) }, removeServer(key: ServerConnection.Key) { - const drafts = tabs.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) - const next = tabs.filter((tab) => tab.server !== key) - setTabs(() => next) - if (active.active && !next.some((tab) => tabKey(tab) === active.active)) setActive("active", undefined) - pruneSessionRoutes(new Set(next.map(tabKey))) + const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) + const removed = store.filter((tab) => tab.server === key).map(tabKey) + setStore((tabs) => tabs.filter((tab) => tab.server !== key)) + if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { - const sessionIDs = new Set(input.sessionIDs) - const currentKey = - params.dir && params.id - ? (sessionRoutes[sessionRouteKey(server.key, params.dir, params.id)] ?? - tabKey({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id })) - : undefined - removeSessionRouteIDs(input) - const currentIndex = currentKey ? tabs.findIndex((tab) => tabKey(tab) === currentKey) : -1 - const removed = tabs.filter( - (tab) => - tab.type === "session" && - tab.server === server.key && - atob(tab.dirBase64) === input.directory && - sessionIDs.has(tab.sessionId), - ) - if (removed.length === 0) return + const removed = store + .filter( + (tab) => + tab.type === "session" && + tab.server === server.key && + atob(tab.dirBase64) === input.directory && + input.sessionIDs.includes(tab.sessionId), + ) + .map(tabKey) + void startTransition(() => { + setStore( + produce((tabs) => { + const sessionIDs = new Set(input.sessionIDs) + const currentHref = + params.dir && params.id + ? tabHref({ + type: "session", + server: server.key, + dirBase64: params.dir, + sessionId: params.id, + }) + : undefined + const currentIndex = currentHref + ? tabs.findIndex( + (tab) => tab.type === "session" && tab.server === server.key && tabHref(tab) === currentHref, + ) + : -1 + const currentTab = tabs[currentIndex] + const removedCurrent = + currentTab?.type === "session" && + currentTab.server === server.key && + atob(currentTab.dirBase64) === input.directory && + sessionIDs.has(currentTab.sessionId) - const removedKeys = new Set(removed.map(tabKey)) - const next = tabs.filter((tab) => !removedKeys.has(tabKey(tab))) - const removedCurrent = !!currentKey && removedKeys.has(currentKey) - const nextTab = removedCurrent - ? (tabs.slice(currentIndex + 1).find((tab) => !removedKeys.has(tabKey(tab))) ?? - tabs.slice(0, currentIndex).findLast((tab) => !removedKeys.has(tabKey(tab)))) - : undefined + for (let i = tabs.length - 1; i >= 0; i--) { + const tab = tabs[i] + if (!tab || tab.type !== "session") continue + if (tab.server !== server.key) continue + if (atob(tab.dirBase64) !== input.directory) continue + if (!sessionIDs.has(tab.sessionId)) continue + tabs.splice(i, 1) + } - void startTransition(() => { - batch(() => { - setTabs(() => next) - if (active.active && removedKeys.has(active.active)) - setActive("active", nextTab ? tabKey(nextTab) : undefined) - pruneSessionRoutes(new Set(next.map(tabKey))) - }) - if (nextTab) navigateTab(nextTab) - if (removedCurrent && !nextTab) navigate("/") + if (!removedCurrent) return + const nextTab = + tabs.slice(currentIndex).find((tab) => tab.type === "session") ?? + tabs.slice(0, currentIndex).findLast((tab) => tab.type === "session") + if (nextTab) navigateTab(nextTab) + else navigate("/") + }), + ) + if (recent.key && removed.includes(recent.key)) setRecentKey(undefined) }) }, - canToggleHome, - current, select: navigateTab, - toggleHome(route: LayoutRoute) { - if (!canToggleHome(route)) return - if (route.type === "home") { - const tab = tabs.find((tab) => tabKey(tab) === active.active) + toggleHome(input: { home: boolean; current?: Tab }) { + if (input.home) { + const tab = store.find((tab) => tabKey(tab) === recent.key) if (tab) navigateTab(tab) return } - const tab = current(route) - if (tab) setActive("active", tabKey(tab)) + if (input.current) { + setRecentKey(tabKey(input.current)) + navigate("/") + return + } navigate("/") }, } - return { - ...actions, - get store() { - return tabs - }, - ready, - } + return { ...actions, store, ready, recentReady } }, }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index d3902cd2b245..677738fde90a 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ id: "view", label: "View", items: [ - { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, + { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } }, { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, { type: "separator" }, diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 55a03e36ef4e..e03d5c206fcc 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, type ParentProps, Show } from "solid-js" +import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -13,6 +13,7 @@ import { Schema } from "effect" export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { const location = useLocation() const navigate = useNavigate() + const params = useParams() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) @@ -25,6 +26,11 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) + createResource( + () => params.id, + (id) => sync.session.sync(id).catch(() => {}), + ) + return ( layout.sidebar.toggle(), + onSelect: () => { + if (newDesign()) { + command.trigger("home.toggle") + return + } + layout.sidebar.toggle() + }, }, { id: "project.open", diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 352670cdc159..c44d31bbdc27 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -29,7 +29,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" import { showToast } from "@/utils/toast" -import { base64Encode, checksum } from "@opencode-ai/core/util/encode" +import { checksum } from "@opencode-ai/core/util/encode" import { useLocation, useSearchParams } from "@solidjs/router" import { NewSessionView, SessionHeader } from "@/components/session" import { useComments } from "@/context/comments" @@ -44,7 +44,6 @@ import { useServerSDK } from "@/context/server-sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" -import { useTabs, type SessionTabTarget } from "@/context/tabs" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { @@ -58,7 +57,7 @@ import { import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { ServerConnection, useServer } from "@/context/server" +import { useServer } from "@/context/server" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" @@ -70,7 +69,6 @@ import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { same } from "@/utils/same" import { formatServerError } from "@/utils/server-errors" -import { retry } from "@opencode-ai/core/util/retry" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" const emptyUserMessages: UserMessage[] = [] @@ -81,21 +79,6 @@ const emptyFollowups: FollowupItem[] = [] type ChangeMode = "git" | "branch" | "turn" type VcsMode = "git" | "branch" -function SessionTabEntry(props: { session: SessionTabTarget }) { - const tabs = useTabs() - return ( - - - - ) -} - -function SessionTabCommit(props: { session: SessionTabTarget }) { - const tabs = useTabs() - onMount(() => tabs.enterSession(props.session)) - return null -} - type SessionHistoryWindowInput = { sessionID: () => string | undefined loaded: () => number @@ -649,8 +632,8 @@ export default function Page() { const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs const [sessionSync] = createResource( - () => [sdk.directory, params.id, server.key, settings.general.newLayoutDesigns()] as const, - async ([directory, id, server, v2]) => { + () => [sdk.directory, params.id] as const, + ([directory, id]) => { if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) refreshFrame = undefined @@ -677,32 +660,7 @@ export default function Page() { }, 0) }) - await sync.session.sync(id) - if (!v2) return - const session = sync.session.get(id) - if (!session) return - - const seen = new Set([session.id]) - let root = session - while (root.parentID) { - const parentID = root.parentID - if (seen.has(parentID)) return - seen.add(parentID) - const parent = - sync.session.get(parentID) ?? - (await retry(() => sdk.client.session.get({ sessionID: parentID })) - .then((result) => result.data) - .catch(() => undefined)) - if (!parent) return - root = parent - } - - return { - server, - dirBase64: base64Encode(root.directory), - sessionID: id, - rootSessionID: root.id, - } + return sync.session.sync(id) }, ) @@ -1759,9 +1717,7 @@ export default function Page() { return (
- - {(session) => } - + {sessionSync() ?? ""}
Date: Sat, 13 Jun 2026 14:54:59 +0200 Subject: [PATCH 3/4] fix(app): preserve v2 home toggle behavior --- packages/app/src/components/titlebar.tsx | 7 ++++--- packages/app/src/context/tabs.tsx | 10 +++++++++- packages/app/src/pages/layout.tsx | 8 +------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index d11545f61262..bee54277796c 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -298,7 +298,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const route = layout.route() if (!tabs.ready()) return const tab = currentTab() - if (tab) return + if (tab) { + tabs.remember(tab) + return + } if (route.type === "session") { const sync = serverSync.createDirSyncContext(route.dir) @@ -330,7 +333,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { category: language.t("command.category.view"), keybind: "mod+b", hidden: true, - disabled: !tabs.recentReady(), onSelect: toggleHome, }, ]) @@ -447,7 +449,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { icon={} state={layout.route().type === "home" ? "pressed" : undefined} onClick={toggleHome} - disabled={!tabs.recentReady()} aria-label={language.t("home.title")} aria-pressed={layout.route().type === "home"} /> diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 464269aafa44..393875ff0915 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -74,9 +74,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const closing = new Set() let recentWrite = 0 + let recentValue: string | undefined + + const recentKey = () => (recentWrite ? recentValue : recent.key) const setRecentKey = (key: string | undefined) => { const write = ++recentWrite + recentValue = key if (recentReady()) { setRecent("key", key) return @@ -244,9 +248,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }) }, select: navigateTab, + remember(tab: Tab) { + const key = tabKey(tab) + if (recentKey() !== key) setRecentKey(key) + }, toggleHome(input: { home: boolean; current?: Tab }) { if (input.home) { - const tab = store.find((tab) => tabKey(tab) === recent.key) + const tab = store.find((tab) => tabKey(tab) === recentKey()) if (tab) navigateTab(tab) return } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b154203d1803..5eb01989cef3 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -997,13 +997,7 @@ export default function Layout(props: ParentProps) { title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), keybind: newDesign() ? undefined : "mod+b", - onSelect: () => { - if (newDesign()) { - command.trigger("home.toggle") - return - } - layout.sidebar.toggle() - }, + onSelect: () => layout.sidebar.toggle(), }, { id: "project.open", From b61a7bb5a658d4c76895d76e98a279d6a7930bf1 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:07:50 +0200 Subject: [PATCH 4/4] fix(app): let v2 handle home shortcut --- packages/app/src/desktop-menu.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 677738fde90a..d3902cd2b245 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [ id: "view", label: "View", items: [ - { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle", accelerator: { macos: "Cmd+B" } }, + { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, { type: "separator" },