feat(app): add v2 home tab toggle (#32191)

This commit is contained in:
Luke Parker
2026-06-17 12:37:48 +02:00
committed by GitHub
parent 417ad240c7
commit 5c9e4ff21b
6 changed files with 169 additions and 88 deletions
+47 -30
View File
@@ -7,12 +7,11 @@ import {
Match, Match,
onMount, onMount,
Show, Show,
startTransition,
Switch, Switch,
untrack, untrack,
} from "solid-js" } from "solid-js"
import { createStore } from "solid-js/store" 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 { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -20,6 +19,8 @@ import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme/context" import { useTheme } from "@opencode-ai/ui/theme/context"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" 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, LayoutRoute, useLayout, type LocalProject } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -253,7 +254,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
{(_) => { {(_) => {
const serverSync = useServerSync() const serverSync = useServerSync()
const navigate = useNavigate() const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const layout = useLayout() const layout = useLayout()
const newSessionHref = () => { const newSessionHref = () => {
@@ -268,17 +268,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const tabs = useTabs() const tabs = useTabs()
const tabsStore = tabs.store const tabsStore = tabs.store
const tabsStoreActions = tabs 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) => { const matchRoute = (route: LayoutRoute) => {
if (route.type === "home") return if (route.type === "home") return
@@ -309,7 +298,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route() const route = layout.route()
if (!tabs.ready()) return if (!tabs.ready()) return
const tab = currentTab() const tab = currentTab()
if (tab) return if (tab) {
tabs.remember(tab)
return
}
if (route.type === "session") { if (route.type === "session") {
const sync = serverSync().createDirSyncContext(route.dir) const sync = serverSync().createDirSyncContext(route.dir)
@@ -332,6 +324,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
}) })
const openNewTab = () => navigate(newSessionHref()) const openNewTab = () => navigate(newSessionHref())
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", () => { command.register("tabs", () => {
const current = currentTab() const current = currentTab()
@@ -369,7 +373,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (index === -1) index = tabsStore.length - 1 if (index === -1) index = tabsStore.length - 1
const next = tabsStore[index] const next = tabsStore[index]
if (next) navigateTab(next) if (next) tabs.select(next)
}, },
}, },
{ {
@@ -386,7 +390,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (index === tabsStore.length) index = 0 if (index === tabsStore.length) index = 0
const next = tabsStore[index] const next = tabsStore[index]
if (next) navigateTab(next) if (next) tabs.select(next)
}, },
}, },
...Array.from({ length: 9 }, (_, i) => { ...Array.from({ length: 9 }, (_, i) => {
@@ -401,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true, hidden: true,
onSelect: () => { onSelect: () => {
const tab = tabsStore[index] const tab = tabsStore[index]
if (tab) navigateTab(tab) if (tab) tabs.select(tab)
}, },
} }
}), }),
@@ -427,15 +431,28 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
<Show when={windows() || linux()}> <Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" /> <WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show> </Show>
<IconButtonV2 <TooltipV2
variant="ghost-muted" placement="bottom"
size="large" value={
as="a" <>
href="/" {language.t("home.title")}
class="!w-9 shrink-0" <KeybindV2 keys={command.keybindParts("home.toggle")} variant="neutral" />
icon={<IconV2 name="grid-plus" />} </>
state={!!homeMatch() ? "pressed" : undefined} }
/> class="shrink-0"
>
<IconButtonV2
type="button"
variant="ghost-muted"
size="large"
class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />}
state={layout.route().type === "home" ? "pressed" : undefined}
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
/>
</TooltipV2>
<div data-slot="titlebar-tabs" class="relative min-w-0"> <div data-slot="titlebar-tabs" class="relative min-w-0">
<div <div
@@ -469,7 +486,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
title={language.t("command.session.new")} title={language.t("command.session.new")}
active={currentTab() === tab} active={currentTab() === tab}
onNavigate={() => { onNavigate={() => {
navigateTab(tab) tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" }) ref.scrollIntoView({ behavior: "instant" })
}} }}
onClose={() => tabsStoreActions.removeTab(i())} onClose={() => tabsStoreActions.removeTab(i())}
@@ -488,7 +505,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
directory={decode64(tab.dirBase64)!} directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId} sessionId={tab.sessionId}
onNavigate={() => { onNavigate={() => {
navigateTab(tab) tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" }) ref.scrollIntoView({ behavior: "instant" })
}} }}
@@ -518,7 +535,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
title={language.t("command.session.new")} title={language.t("command.session.new")}
onClose={() => { onClose={() => {
const tab = tabsStore.at(-1) const tab = tabsStore.at(-1)
if (tab) navigateTab(tab) if (tab) tabs.select(tab)
else navigate("/") else navigate("/")
}} }}
/> />
+57 -50
View File
@@ -170,13 +170,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean
return false return false
} }
export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { function displayKeybindParts(kb: Keybind, t?: (key: KeyLabel) => string) {
if (!config || config === "none") return ""
const keybinds = parseKeybind(config)
if (keybinds.length === 0) return ""
const kb = keybinds[0]
const parts: string[] = [] const parts: string[] = []
if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t)) if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t))
@@ -184,40 +178,52 @@ export function formatKeybind(config: string, t?: (key: KeyLabel) => string): st
if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t)) 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.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
if (kb.key) { if (!kb.key) return parts
const keys: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
comma: ",",
plus: "+",
}
const named: Record<string, KeyLabel> = {
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)
}
const keys: Record<string, string> = {
arrowup: "↑",
arrowdown: "↓",
arrowleft: "←",
arrowright: "→",
comma: ",",
plus: "+",
}
const named: Record<string, KeyLabel> = {
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 keybind = parseKeybind(config)[0]
return keybind ? displayKeybindParts(keybind, 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("+") return IS_MAC ? parts.join("") : parts.join("+")
} }
@@ -402,25 +408,26 @@ 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)
return options().find((x) => actionId(x.id) === base)?.keybind ?? bind(base, catalog[base]?.keybind)
}
return { return {
register, register,
trigger(id: string, source?: CommandSource) { trigger(id: string, source?: CommandSource) {
run(id, source) run(id, source)
}, },
keybind(id: string) { keybind(id: string) {
if (id === PALETTE_ID) { const config = keybindConfig(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)
if (!config) return "" if (!config) return ""
return formatKeybind(config, language.t) return formatKeybind(config, language.t)
}, },
keybindParts(id: string) {
const config = keybindConfig(id)
return config ? formatKeybindParts(config, language.t) : []
},
show: showPalette, show: showPalette,
keybinds(enabled: boolean) { keybinds(enabled: boolean) {
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1))) setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))
+62 -6
View File
@@ -27,6 +27,10 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab export type Tab = SessionTab | DraftTab
type RecentTab = {
key?: string
}
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) => export const tabHref = (tab: Tab) =>
@@ -62,26 +66,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}, },
createStore<Tab[]>([]), createStore<Tab[]>([]),
) )
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
const params = useParams() const params = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const closing = new Set<string>() const closing = new Set<string>()
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
}
void recentReady.promise?.then(() => {
if (write === recentWrite) setRecent("key", key)
})
}
const removeDraftPersisted = (draftID: string) => { const removeDraftPersisted = (draftID: string) => {
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
} }
createEffect(() => { createEffect(() => {
if (!ready()) return if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key)) const servers = new Set(server.list.map(ServerConnection.key))
if (store.every((tab) => servers.has(tab.server))) return const next = store.filter((tab) => servers.has(tab.server))
setStore((tabs) => tabs.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) => { const navigateTab = (tab: Tab) => {
const href = tabHref(tab) const href = tabHref(tab)
setRecentKey(tabKey(tab))
if (tab.server === server.key) { if (tab.server === server.key) {
navigate(href) navigate(href)
return return
@@ -129,14 +152,16 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
// and fall back home. Navigate to the new session first so we leave /new-session // and fall back home. Navigate to the new session first so we leave /new-session
// before the draft is removed from the store. // before the draft is removed from the store.
const active = 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(() => { startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID)
if (index !== -1) tabs[index] = { type: "session", ...session } if (index !== -1) tabs[index] = next
}), }),
) )
if (active) navigateTab({ type: "session", ...session }) if (recent.key === `draft:${draftID}`) setRecentKey(tabKey(next))
if (active) navigateTab(next)
}) })
removeDraftPersisted(draftID) removeDraftPersisted(draftID)
}, },
@@ -153,6 +178,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
tabs.splice(index, 1) tabs.splice(index, 1)
}), }),
) )
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab) navigateTab(nextTab) if (nextTab) navigateTab(nextTab)
else navigate("/") else navigate("/")
}).finally(() => closing.delete(key)) }).finally(() => closing.delete(key))
@@ -160,11 +186,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
}, },
removeServer(key: ServerConnection.Key) { removeServer(key: ServerConnection.Key) {
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) 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)) setStore((tabs) => tabs.filter((tab) => tab.server !== key))
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID) for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/") if (server.key === key) navigate("/")
}, },
removeSessions: (input: SessionTabsRemovedDetail) => { removeSessions: (input: SessionTabsRemovedDetail) => {
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(() => { void startTransition(() => {
setStore( setStore(
produce((tabs) => { produce((tabs) => {
@@ -207,10 +244,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
else navigate("/") else navigate("/")
}), }),
) )
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
}) })
}, },
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) === recentKey())
if (tab) navigateTab(tab)
return
}
if (input.current) {
setRecentKey(tabKey(input.current))
navigate("/")
return
}
navigate("/")
},
} }
return { ...actions, store, ready } return { ...actions, store, ready, recentReady }
}, },
}) })
+1 -1
View File
@@ -141,7 +141,7 @@ export const DESKTOP_MENU: DesktopMenu[] = [
id: "view", id: "view",
label: "View", label: "View",
items: [ 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 Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } },
{ type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" },
{ type: "separator" }, { type: "separator" },
+1 -1
View File
@@ -996,7 +996,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle", id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"), title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"), category: language.t("command.category.view"),
keybind: "mod+b", keybind: newDesign() ? undefined : "mod+b",
onSelect: () => layout.sidebar.toggle(), onSelect: () => layout.sidebar.toggle(),
}, },
{ {
@@ -137,6 +137,7 @@
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) { [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {
background-color: var(--v2-overlay-simple-overlay-pressed); background-color: var(--v2-overlay-simple-overlay-pressed);
color: var(--v2-icon-icon-base);
} }
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) { [data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:disabled, [data-state="disabled"]) {