diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx
index 9fc49c0a4196..bee54277796c 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,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 }) {
- }
- state={!!homeMatch() ? "pressed" : undefined}
- />
+
+ {language.t("home.title")}
+
+ >
+ }
+ class="shrink-0"
+ >
+ }
+ state={layout.route().type === "home" ? "pressed" : undefined}
+ onClick={toggleHome}
+ aria-label={language.t("home.title")}
+ aria-pressed={layout.route().type === "home"}
+ />
+
{
- 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("/")
}}
/>
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index e979ad6a0595..16483706b8e9 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,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 = {
- 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 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)))
diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx
index 7f6274cf9922..393875ff0915 100644
--- a/packages/app/src/context/tabs.tsx
+++ b/packages/app/src/context/tabs.tsx
@@ -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([]),
)
+ 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
+ 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 }
},
})
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/layout.tsx b/packages/app/src/pages/layout.tsx
index 199db2fda66c..5eb01989cef3 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -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(),
},
{
diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css
index e75e4c7aded1..c4a3df9115ec 100644
--- a/packages/ui/src/v2/components/icon-button-v2.css
+++ b/packages/ui/src/v2/components/icon-button-v2.css
@@ -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"]) {