From 1b587823b6bef247c3bba3e846142a4bb4e199e4 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 13 Aug 2026 13:19:13 -0400 Subject: [PATCH] feat(tui): label worktree session tabs (#41342) --- packages/tui/src/component/session-tabs.tsx | 23 +++++++-- packages/tui/src/context/data.tsx | 9 ++-- .../tui/src/context/session-tabs-model.ts | 10 ++++ packages/tui/src/context/session-tabs.tsx | 29 +++++++++--- .../test/context/session-tabs-model.test.ts | 9 ++++ .../tui/test/context/session-tabs.test.tsx | 47 ++++++++++++++++++- 6 files changed, 112 insertions(+), 15 deletions(-) diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index be5c5be2d4..2c5bc2908b 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -21,6 +21,7 @@ import { moveSessionTab, NEW_SESSION_TAB_TITLE, sessionTabComplete, + sessionTabDetail, sessionTabShortcutLabel, seedSessionTabMotion, sessionTabOverflowWidth, @@ -408,10 +409,18 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const titleFades = createMemo(() => marqueeOverflows(title(), titleWidth()) && titleWidth() > FADE_WIDTH) const detail = createMemo(() => { const fixture = tabs.detail?.(tab.sessionID) - if (fixture !== undefined) return Locale.takeWidth(fixture, titleWidth()) + if (fixture !== undefined) return fixture const value = session() - return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth()) + const currentProject = project() + const projectLabel = projectName(currentProject, value?.location.directory) ?? "" + const vcs = value ? data.location.vcs.info(value.location) : undefined + const location = value ? data.location.info(value.location) : undefined + const worktree = !!location && location.project.directory !== location.project.canonical + return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default, worktree) }) + const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth())) + const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail())) + const detailFades = createMemo(() => marqueeOverflows(detail(), titleWidth()) && titleWidth() > FADE_WIDTH) const background = createMemo(() => { if (selected()) return theme.background.action.primary.selected if (hovered() === tab.sessionID || dragging() === tab.sessionID) @@ -453,6 +462,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const detailFlashColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.42)) const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25)) const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35)) + const detailTextColor = (index: number) => + detailFades() + ? fadeTitleColor(detailColor(), pulseBackground(), index, visibleDetailParts().length, 0) + : detailColor() const glows = () => status().glows const previous = createMemo(() => items()[index() - 1]) const previousStatus = createMemo(() => { @@ -670,7 +683,11 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat /> - {detail()} + + + {(character, index) => {character}} + + diff --git a/packages/tui/src/context/data.tsx b/packages/tui/src/context/data.tsx index dc15939930..384fce9c0e 100644 --- a/packages/tui/src/context/data.tsx +++ b/packages/tui/src/context/data.tsx @@ -94,7 +94,7 @@ type Store = { location: Record } -function locationKey(location: LocationRef) { +export function locationKey(location: LocationRef) { return JSON.stringify([location.directory, location.workspaceID]) } @@ -1214,9 +1214,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ default() { return defaultLocation() }, - async sync(ref?: LocationRef) { + syncInfo(ref?: LocationRef) { const current = ref ?? defaultLocation() - await sync.run(`location:${locationKey(current)}`, async () => { + return sync.run(`location:${locationKey(current)}`, async () => { const location = await client.api.location.get({ location: locationQuery(current) }) const key = locationKey(location) if (!store.location[key]) setStore("location", key, {}) @@ -1225,6 +1225,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ setDefaultLocation({ directory: location.directory, workspaceID: location.workspaceID }) } }) + }, + async sync(ref?: LocationRef) { + await result.location.syncInfo(ref) const location = ref ?? defaultLocation() await Promise.all([ result.location.vcs.sync(location), diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index dd49fe790b..7288c4b238 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -13,6 +13,16 @@ export function sessionTabShortcutLabel(index: number) { return "ยท" } +export function sessionTabDetail( + project: string, + current: string | undefined, + defaultBranch: string | undefined, + worktree: boolean, +) { + const branch = worktree && current !== defaultBranch ? current : undefined + return branch && project ? `${project} โއ ${branch}` : (branch ?? project) +} + 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 c62c6bc757..3503030add 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -2,7 +2,7 @@ import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" import { useClient } from "./client" -import { useData } from "./data" +import { locationKey, useData } from "./data" import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallback" import { useEvent } from "./event" import { useRoute } from "./route" @@ -159,9 +159,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,10 +171,25 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp createEffect(() => { if (!enabled()) return if (client.connection.status() !== "connected") return - const sessionIDs = openTabSessions() - if (sessionIDs === "") return - void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID))) + const signature = openTabSessions() + if (signature === "") return + const sessionIDs = signature.split("\n") let stale = false + void (async () => { + await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID))) + if (stale) return + const locations = new Map( + sessionIDs + .map((sessionID) => data.session.get(sessionID)?.location) + .filter((location) => location !== undefined) + .map((location) => [locationKey(location), location]), + ) + await Promise.allSettled( + Array.from(locations.values(), (location) => + Promise.all([data.location.syncInfo(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 442df1abfc..b49eb38917 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -11,11 +11,20 @@ import { reopenSessionTab, seedSessionTabMotion, sessionTabComplete, + sessionTabDetail, sessionTabOverflowWidth, sessionTabShortcutLabel, } from "../../src/context/session-tabs-model" describe("session tabs", () => { + test("appends the branch to the project detail", () => { + expect(sessionTabDetail("opencode", "feature/sidebar", "main", true)).toBe("opencode โއ feature/sidebar") + expect(sessionTabDetail("opencode", "feature/sidebar", undefined, true)).toBe("opencode โއ feature/sidebar") + expect(sessionTabDetail("opencode", "feature/sidebar", "main", false)).toBe("opencode") + expect(sessionTabDetail("opencode", "main", "main", true)).toBe("opencode") + expect(sessionTabDetail("opencode", undefined, "main", true)).toBe("opencode") + }) + 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 d6d1765ff4..b07fcc2113 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -28,7 +28,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 @@ -45,7 +52,25 @@ async function renderSessionTabs( } const events = createEventStream() const sessions: string[] = [] + const locations: string[] = [] + const vcsLocations: string[] = [] const calls = createFetch(async (url) => { + if (url.pathname === "/api/location") { + const requested = url.searchParams.get("location[directory]") ?? directory + locations.push(requested) + return json({ + directory: requested, + project: { id: "project", directory: requested, canonical: directory }, + }) + } + 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) @@ -55,7 +80,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 }, @@ -107,6 +132,8 @@ async function renderSessionTabs( route, data, sessions, + locations, + vcsLocations, state, emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), async destroy() { @@ -137,6 +164,22 @@ 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.locations.includes(other)) + 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")