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
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 169 additions and 88 deletions

View File

@ -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,6 +19,8 @@ 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 { usePlatform } from "@/context/platform"
@ -253,7 +254,6 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
{(_) => {
const serverSync = useServerSync()
const navigate = useNavigate()
const homeMatch = useMatch(() => "/")
const layout = useLayout()
const newSessionHref = () => {
@ -268,17 +268,6 @@ 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
@ -309,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)
@ -332,6 +324,18 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
})
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", () => {
const current = currentTab()
@ -369,7 +373,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
if (index === -1) index = tabsStore.length - 1
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
const next = tabsStore[index]
if (next) navigateTab(next)
if (next) tabs.select(next)
},
},
...Array.from({ length: 9 }, (_, i) => {
@ -401,7 +405,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
hidden: true,
onSelect: () => {
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()}>
<WindowsAppMenu command={command} platform={platform} variant="v2" />
</Show>
<IconButtonV2
variant="ghost-muted"
size="large"
as="a"
href="/"
class="!w-9 shrink-0"
icon={<IconV2 name="grid-plus" />}
state={!!homeMatch() ? "pressed" : undefined}
/>
<TooltipV2
placement="bottom"
value={
<>
{language.t("home.title")}
<KeybindV2 keys={command.keybindParts("home.toggle")} variant="neutral" />
</>
}
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
@ -469,7 +486,7 @@ 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())}
@ -488,7 +505,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
directory={decode64(tab.dirBase64)!}
sessionId={tab.sessionId}
onNavigate={() => {
navigateTab(tab)
tabs.select(tab)
ref.scrollIntoView({ behavior: "instant" })
}}
@ -518,7 +535,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
title={language.t("command.session.new")}
onClose={() => {
const tab = tabsStore.at(-1)
if (tab) navigateTab(tab)
if (tab) tabs.select(tab)
else navigate("/")
}}
/>

View File

@ -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,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.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t))
if (kb.key) {
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)
}
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)
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("+")
}
@ -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 {
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)
return config ? formatKeybindParts(config, language.t) : []
},
show: showPalette,
keybinds(enabled: boolean) {
setStore("suspendCount", (count) => Math.max(0, count + (enabled ? -1 : 1)))

View File

@ -27,6 +27,10 @@ export type DraftTab = {
export type Tab = SessionTab | DraftTab
type RecentTab = {
key?: string
}
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
export const tabHref = (tab: Tab) =>
@ -62,26 +66,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
createStore<Tab[]>([]),
)
const [recent, setRecent, , recentReady] = persisted(Persist.global("tabs.recent"), createStore<RecentTab>({}))
const params = useParams()
const navigate = useNavigate()
const location = useLocation()
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) => {
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 (store.every((tab) => servers.has(tab.server))) return
setStore((tabs) => tabs.filter((tab) => servers.has(tab.server)))
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) => {
const href = tabHref(tab)
setRecentKey(tabKey(tab))
if (tab.server === server.key) {
navigate(href)
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
// before the draft is removed from the store.
const active = location.pathname === "/new-session" && location.query.draftId === draftID
const next = { type: "session" as const, ...session }
startTransition(() => {
setStore(
produce((tabs) => {
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)
},
@ -153,6 +178,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
tabs.splice(index, 1)
}),
)
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
if (nextTab) navigateTab(nextTab)
else navigate("/")
}).finally(() => closing.delete(key))
@ -160,11 +186,22 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
},
removeServer(key: ServerConnection.Key) {
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 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) => {
@ -207,10 +244,29 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
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 }
},
})

View File

@ -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" },

View File

@ -996,7 +996,7 @@ export default function Layout(props: ParentProps) {
id: "sidebar.toggle",
title: language.t("command.sidebar.toggle"),
category: language.t("command.category.view"),
keybind: "mod+b",
keybind: newDesign() ? undefined : "mod+b",
onSelect: () => layout.sidebar.toggle(),
},
{

View File

@ -137,6 +137,7 @@
[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:active, [data-state="pressed"]):not(:disabled) {
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"]) {