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")