diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index e9c0977a76c..03077484a26 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -9,6 +9,7 @@ import { adaptiveSessionTabLayout, moveSessionTab, NEW_SESSION_TAB_TITLE, + sessionTabBranch, sessionTabComplete, sessionTabShortcutLabel, seedSessionTabMotion, @@ -148,9 +149,24 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH) const detail = createMemo(() => { - if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth()) + if (tab === NEW_SESSION_TAB) + return { project: Locale.takeWidth("Start a new session", titleWidth()), branch: undefined } const value = session() - return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth()) + const projectLabel = projectName(project(), value?.location.directory) ?? "" + const vcs = value ? data.location.vcs.info(value.location) : undefined + const branch = sessionTabBranch(vcs?.branch.current, vcs?.branch.default) + if (!branch) return { project: Locale.takeWidth(projectLabel, titleWidth()), branch: undefined } + + const separatorWidth = projectLabel ? 1 : 0 + const branchWidth = Math.min( + branch.length, + Math.max(1, titleWidth() - Math.min(projectLabel.length, 12) - separatorWidth), + ) + const projectWidth = Math.max(0, titleWidth() - branchWidth - separatorWidth) + return { + project: Locale.takeWidth(projectLabel, projectWidth), + branch: Locale.truncateLeft(branch, branchWidth), + } }) const background = createMemo(() => { if (selected()) return theme.background.action.primary.selected @@ -184,6 +200,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13)) const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25)) const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35)) + const branchColor = createMemo(() => tint(detailColor(), accent(), 0.45)) const glows = () => status().glows const previous = createMemo(() => items()[index() - 1]) const previousStatus = createMemo(() => { @@ -359,9 +376,21 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat backgroundColor={pulseBackground()} /> - - {detail()} - + + {(project) => ( + + {project()} + + )} + + + {(branch) => ( + + {detail().project ? " " : ""} + {branch()} + + )} + diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index dd49fe790bd..21e9a5792e0 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -13,6 +13,11 @@ export function sessionTabShortcutLabel(index: number) { return "ยท" } +export function sessionTabBranch(current: string | undefined, defaultBranch: string | undefined) { + if (!current || current === defaultBranch) return undefined + return current +} + export type SessionTabHistory = { entries: readonly string[] index: number diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index c980c4d93b1..3ffe8499929 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -157,9 +157,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }) }) - // Load lightweight session metadata concurrently so persisted tabs can resolve their project - // labels immediately. Delay the heavier per-tab data so the visible session keeps the first - // connection slots and switches still render from a warm cache. + // Load lightweight session and location metadata concurrently so persisted tabs can resolve + // their project and branch labels. Delay the heavier per-tab data so the visible session keeps + // the first connection slots and switches still render from a warm cache. const openTabSessions = createMemo(() => state() .tabs.map((tab) => tab.sessionID) @@ -171,8 +171,19 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (client.connection.status() !== "connected") return const sessionIDs = openTabSessions() if (sessionIDs === "") return - void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID))) let stale = false + void (async () => { + await Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID))) + if (stale) return + const locations = new Map( + sessionIDs + .split("\n") + .map((sessionID) => data.session.get(sessionID)?.location) + .filter((location) => location !== undefined) + .map((location) => [`${location.directory}\n${location.workspaceID ?? ""}`, location]), + ) + await Promise.allSettled(Array.from(locations.values(), (location) => data.location.vcs.sync(location))) + })() const timer = setTimeout(async () => { const sessions = state() .tabs.map((tab) => tab.sessionID) diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index 442df1abfc0..787d44ad184 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -11,11 +11,19 @@ import { reopenSessionTab, seedSessionTabMotion, sessionTabComplete, + sessionTabBranch, sessionTabOverflowWidth, sessionTabShortcutLabel, } from "../../src/context/session-tabs-model" describe("session tabs", () => { + test("shows only non-default session branches", () => { + expect(sessionTabBranch("main", "main")).toBeUndefined() + expect(sessionTabBranch("feature/sidebar", "main")).toBe("feature/sidebar") + expect(sessionTabBranch("feature/sidebar", undefined)).toBe("feature/sidebar") + expect(sessionTabBranch(undefined, "main")).toBeUndefined() + }) + test("labels direct shortcut tabs and marks unbound tabs with a dot", () => { expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([ "1", diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 66ef608e7e7..4af52254cad 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -27,7 +27,14 @@ async function wait(fn: () => boolean | Promise, timeout = 2_000) { async function renderSessionTabs( initialSessionID: string, - options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise }, + options?: { + state?: string + title?: string + home?: boolean + persisted?: string[] + sessionGate?: Promise + sessionDirectories?: Record + }, ) { const temporary = options?.state ? undefined : await tmpdir() const state = options?.state ?? temporary!.path @@ -44,7 +51,16 @@ async function renderSessionTabs( } const events = createEventStream() const sessions: string[] = [] + const vcsLocations: string[] = [] const calls = createFetch(async (url) => { + if (url.pathname === "/api/vcs") { + const requested = url.searchParams.get("location[directory]") ?? directory + vcsLocations.push(requested) + return json({ + location: { directory: requested }, + data: { branch: { current: "main", default: "main" } }, + }) + } const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1] if (!sessionID) return undefined sessions.push(sessionID) @@ -54,7 +70,7 @@ async function renderSessionTabs( id: sessionID, title: sessionID === initialSessionID ? options?.title : undefined, projectID: "project", - location: { directory }, + location: { directory: options?.sessionDirectories?.[sessionID] ?? directory }, cost: 0, tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { created: 0, updated: 0 }, @@ -104,6 +120,7 @@ async function renderSessionTabs( route, data, sessions, + vcsLocations, state, emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), async destroy() { @@ -134,6 +151,21 @@ test("loads persisted tab metadata concurrently on connect", async () => { } }) +test("loads VCS metadata for each persisted tab location", async () => { + const other = `${directory}/other-worktree` + const setup = await renderSessionTabs("first", { + home: true, + persisted: ["first", "second"], + sessionDirectories: { second: other }, + }) + + try { + await wait(() => setup.vcsLocations.includes(other)) + } finally { + await setup.destroy() + } +}) + test("stores session tabs for the current working directory by default", async () => { const setup = await renderSessionTabs("first")