From 5fa0a4238647dc25df137cb3df7e0d7eb8454d67 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:35:52 +0800 Subject: [PATCH] feat(app): restore option arrow session tab navigation (#54277) Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com> --- packages/app/e2e/regression/tabs.spec.ts | 26 ++++++++++ packages/app/src/runtime/i18n/en.ts | 4 ++ packages/app/src/shell/titlebar/tab-nav.tsx | 13 ++--- .../app/src/shell/titlebar/tab-order.test.ts | 13 ++++- packages/app/src/shell/titlebar/tab-order.ts | 15 ++++-- packages/app/src/shell/titlebar/tab-strip.tsx | 52 ++++++++++++++++++- 6 files changed, 108 insertions(+), 15 deletions(-) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index bb97cef7935..b118b1d67e7 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -162,6 +162,32 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab await expectPath(page, sessionHref(c.id)) }) +test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => { + await mockWorkspace(page, { + name: "Tabs", + sessions: [a, b, c], + sessionPermissions: { + [c.id]: [ + { id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] }, + ], + }, + }) + await page.goto(sessionHref(a.id)) + await page.mouse.move(0, 0) + await expect( + page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`), + ).toBeVisible() + + await page.keyboard.press("Alt+ArrowDown") + await expectPath(page, sessionHref(b.id)) + + await page.keyboard.press("Alt+ArrowUp") + await expectPath(page, sessionHref(a.id)) + + await page.keyboard.press("Shift+Alt+ArrowDown") + await expectPath(page, sessionHref(c.id)) +}) + for (const row of [ { tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" }, { tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" }, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index bc964c7378d..69487c2a244 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -42,6 +42,10 @@ export const dict = { "command.palette": "Command palette", "command.session.new": "New session", + "command.session.previous": "Previous session", + "command.session.next": "Next session", + "command.session.previous.unseen": "Previous unread session", + "command.session.next.unseen": "Next unread session", "command.file.open": "Open file", "command.tab.close": "Close tab", "command.tab.reopenClosed": "Reopen closed tab", diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index 4ebea9da13e..77088bf90de 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -12,7 +12,6 @@ import { useLanguage } from "@/runtime/i18n/language" import { ServerConnection, serverName, useServers } from "@/runtime/server/registry" import { displayName } from "@opencode/ui/project-avatar" import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar" -import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state" import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2" import type { SessionInfo } from "@opencode/client/promise" import { sessionTabTitle } from "./tab-title" @@ -28,6 +27,8 @@ export function TabNavItem(props: { server: ServerConnection.Key session: SessionInfo | undefined preparing: boolean + unread: boolean + loading: boolean fallbackTitle?: string onRename: (title: string) => Promise onClose: () => void @@ -57,13 +58,7 @@ export function TabNavItem(props: { const servers = useServers() const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server)) - const avatar = useSessionTabAvatarState( - () => props.server, - () => props.session?.id ?? "", - () => true, - ) - - const unread = () => !!props.session && !props.active && avatar.unread() && !avatar.loading() + const unread = () => !!props.session && !props.active && props.unread const project = createMemo(() => { const session = props.session @@ -312,7 +307,7 @@ export function TabNavItem(props: { project={project()} directory={session.location.directory} unread={false} - loading={avatar.loading()} + loading={props.loading} /> )} diff --git a/packages/app/src/shell/titlebar/tab-order.test.ts b/packages/app/src/shell/titlebar/tab-order.test.ts index 23765a5f081..f4e5e349b4c 100644 --- a/packages/app/src/shell/titlebar/tab-order.test.ts +++ b/packages/app/src/shell/titlebar/tab-order.test.ts @@ -1,5 +1,16 @@ import { expect, test } from "bun:test" -import { mergeVisibleTabOrder } from "./tab-order" +import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order" + +test("finds the nearest accepted tab in either direction, wrapping around", () => { + const order = ["a", "b", "c", "d", "e"] + const unread = new Set(["a", "d"]) + + expect(adjacentTabKey(order, "b", 1, (key) => unread.has(key))).toBe("d") + expect(adjacentTabKey(order, "b", -1, (key) => unread.has(key))).toBe("a") + expect(adjacentTabKey(order, "e", 1, (key) => unread.has(key))).toBe("a") + expect(adjacentTabKey(order, "a", -1, (key) => unread.has(key))).toBe("d") + expect(adjacentTabKey(order, "a", 1, (key) => key === "a")).toBeUndefined() +}) test("merges reordered visible tabs around hidden tabs", () => { expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([ diff --git a/packages/app/src/shell/titlebar/tab-order.ts b/packages/app/src/shell/titlebar/tab-order.ts index e597d11d284..c4510719eff 100644 --- a/packages/app/src/shell/titlebar/tab-order.ts +++ b/packages/app/src/shell/titlebar/tab-order.ts @@ -1,10 +1,19 @@ -export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) { - if (!current || order.length === 0) return +/** The nearest other tab in the offset's direction, wrapping around, that `include` accepts. */ +export function adjacentTabKey( + order: string[], + current: string | undefined, + offset: -1 | 1, + include: (key: string) => boolean = () => true, +) { + if (!current) return const index = order.indexOf(current) if (index === -1) return - return order[(index + offset + order.length) % order.length] + return Array.from( + { length: order.length - 1 }, + (_, step) => order[(index + offset * (step + 1) + order.length) % order.length], + ).find(include) } export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) { diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 2f440573d8d..884918efb44 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -13,6 +13,7 @@ import { useGlobal, useServerCtx, type ServerCtx } from "@/runtime/server/runtim import { useLanguage } from "@/runtime/i18n/language" import { useCommand } from "@/shell/commands/command" import { useTabs } from "@/shell/tabs/tabs" +import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state" import { createTabComposerState } from "@/composer/persistence" import { base64Encode } from "@opencode/util/encode" import { showToast } from "@/shell/notifications/toast" @@ -27,6 +28,8 @@ function SessionTabSlot(props: { orientation: "horizontal" | "vertical" session: SessionInfo | undefined preparing: boolean + unread: boolean + loading: boolean fallbackTitle?: string onRename: (title: string) => Promise onNavigate: (element: HTMLDivElement) => void @@ -64,6 +67,8 @@ function SessionTabSlot(props: { server={props.tab.server} session={props.session} preparing={props.preparing} + unread={props.unread} + loading={props.loading} fallbackTitle={props.fallbackTitle} onRename={props.onRename} onNavigate={() => props.onNavigate(ref)} @@ -84,6 +89,7 @@ function SessionTabEntry(props: { orientation: "horizontal" | "vertical" serverCtx: ServerCtx | undefined onVisibleChange: (visible: boolean) => void + onUnreadChange: (unread: boolean) => void onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { @@ -94,6 +100,15 @@ function SessionTabEntry(props: { const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId)) const persisted = createMemo(() => tabs.info[props.id]) + const status = useSessionTabAvatarState( + () => props.tab.server, + () => props.tab.sessionId, + () => true, + ) + + // A busy session shows its progress indicator instead of the unread dot. + const unread = createMemo(() => status.unread() && !status.loading()) + const [loadedSession] = createResource( () => { if (pending()) return null @@ -135,6 +150,7 @@ function SessionTabEntry(props: { } createEffect(() => props.onVisibleChange(visible())) + createEffect(() => props.onUnreadChange(unread())) createEffect(() => { const ctx = props.serverCtx @@ -186,6 +202,8 @@ function SessionTabEntry(props: { orientation={props.orientation} session={session()} preparing={!!pending()} + unread={unread()} + loading={status.loading()} fallbackTitle={ pending() ? language.t("session.tab.session") @@ -263,6 +281,7 @@ export function TitlebarTabStrip(props: { const vertical = () => props.orientation === "vertical" let listRef!: HTMLDivElement const [visibility, setVisibility] = createStore>({}) + const [unread, setUnread] = createStore>({}) const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) const visibleTabIds = () => visibleTabs().map(tabKey) @@ -283,11 +302,39 @@ export function TitlebarTabStrip(props: { hidden: true, onSelect: () => selectAdjacentTab(1), }, + { + id: "session.previous", + category: language.t("command.category.session"), + title: language.t("command.session.previous"), + keybind: "alt+arrowup", + onSelect: () => selectAdjacentTab(-1), + }, + { + id: "session.next", + category: language.t("command.category.session"), + title: language.t("command.session.next"), + keybind: "alt+arrowdown", + onSelect: () => selectAdjacentTab(1), + }, + { + id: "session.previous.unseen", + category: language.t("command.category.session"), + title: language.t("command.session.previous.unseen"), + keybind: "shift+alt+arrowup", + onSelect: () => selectAdjacentTab(-1, (key) => !!unread[key]), + }, + { + id: "session.next.unseen", + category: language.t("command.category.session"), + title: language.t("command.session.next.unseen"), + keybind: "shift+alt+arrowdown", + onSelect: () => selectAdjacentTab(1, (key) => !!unread[key]), + }, ]) - function selectAdjacentTab(offset: -1 | 1) { + function selectAdjacentTab(offset: -1 | 1, include?: (key: string) => boolean) { const current = props.currentTab - const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) + const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset, include) const next = props.tabs.find((tab) => tabKey(tab) === key) if (next) props.onNavigate(next) @@ -390,6 +437,7 @@ export function TitlebarTabStrip(props: { orientation={vertical() ? "vertical" : "horizontal"} serverCtx={serverCtx()} onVisibleChange={(visible) => setVisibility(id, visible)} + onUnreadChange={(value) => setUnread(id, value)} onNavigate={(element) => { ref = element props.onNavigate(tab, element)