Răsfoiți Sursa

feat(tui): label worktree session tabs (#41342)

Kit Langton 4 zile în urmă
părinte
comite
1b587823b6

+ 20 - 3
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
                     />
                     <box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
                       <text fg={detailColor()} wrapMode="none" selectable={false}>
-                        {detail()}
+                        <Show when={detailFades()} fallback={visibleDetail()}>
+                          <For each={visibleDetailParts()}>
+                            {(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
+                          </For>
+                        </Show>
                       </text>
                     </box>
                   </box>

+ 6 - 3
packages/tui/src/context/data.tsx

@@ -94,7 +94,7 @@ type Store = {
   location: Record<string, LocationData>
 }
 
-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),

+ 10 - 0
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

+ 22 - 7
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)

+ 9 - 0
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",

+ 45 - 2
packages/tui/test/context/session-tabs.test.tsx

@@ -28,7 +28,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
@@ -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")