feat(tui): show session branches in vertical tabs

This commit is contained in:
Kit Langton
2026-08-08 20:39:23 -04:00
parent e8f215bfbc
commit 433fb4711f
5 changed files with 96 additions and 11 deletions
+34 -5
View File
@@ -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()}
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
{detail()}
</text>
<Show when={detail().project}>
{(project) => (
<text fg={detailColor()} wrapMode="none" selectable={false}>
{project()}
</text>
)}
</Show>
<Show when={detail().branch}>
{(branch) => (
<text fg={branchColor()} wrapMode="none" selectable={false}>
{detail().project ? " " : ""}
{branch()}
</text>
)}
</Show>
</box>
</box>
</box>
@@ -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
+15 -4
View File
@@ -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)
@@ -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",
@@ -27,7 +27,14 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
) {
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")