From 6f91bc7415c3d5176f9abeda6a12efa24ceaa8c5 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Wed, 5 Aug 2026 19:31:42 -0400 Subject: [PATCH] fix(tui): load sidebar project names sooner (#40763) --- packages/tui/src/context/session-tabs.tsx | 15 ++--- .../tui/test/context/session-tabs.test.tsx | 55 ++++++++++++++++--- 2 files changed, 54 insertions(+), 16 deletions(-) diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 37e32b56432..f99e729e82f 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -157,13 +157,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }) }) - // Warm open tabs' session data so first switches render from cache instead of fetching inside - // the switch gesture. Uses only existing sync methods (each dedupes internally), so reruns on - // tab-set or connection changes are no-ops for already-warm sessions, and reconnects double as - // a cache refresh after an SSE gap. The delay lets the current session's own mount syncs get - // the first connection slots. The effect tracks only the id set: reorders, tab switches, and - // title updates neither restart the timer nor an in-flight warm pass; the timer callback - // itself runs untracked, where the current session is skipped. + // 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. const openTabSessions = createMemo(() => state() .tabs.map((tab) => tab.sessionID) @@ -173,7 +169,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp createEffect(() => { if (!enabled()) return if (client.connection.status() !== "connected") return - if (openTabSessions() === "") return + const sessionIDs = openTabSessions() + if (sessionIDs === "") return + void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID))) let stale = false const timer = setTimeout(async () => { const sessions = state() @@ -182,7 +180,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp for (const sessionID of sessions) { if (stale) return await Promise.allSettled([ - data.session.sync(sessionID), data.session.message.sync(sessionID), data.session.pending.sync(sessionID), data.session.permission.sync(sessionID), diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 330cb1443eb..471a3156d40 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -2,7 +2,7 @@ import { afterAll, expect, test } from "bun:test" import type { OpenCodeEvent } from "@opencode-ai/client" import { testRender } from "@opentui/solid" -import { mkdtempSync, readdirSync, rmSync, watch } from "fs" +import { mkdirSync, mkdtempSync, readdirSync, rmSync, watch } from "fs" import { tmpdir } from "os" import path from "path" import { ConfigProvider } from "../../src/config" @@ -49,15 +49,33 @@ function stateDir(prefix: string) { return dir } -async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) { +async function renderSessionTabs( + initialSessionID: string, + options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise }, +) { const state = options?.state ?? stateDir("opencode-session-tabs-") + if (options?.persisted) { + const file = path.join(state, "test", "tui", "tabs.json") + mkdirSync(path.dirname(file), { recursive: true }) + await Bun.write( + file, + JSON.stringify({ + global: { tabs: options.persisted.map((sessionID) => ({ sessionID })), unread: {} }, + cwd: {}, + }), + ) + } const events = createEventStream() - const calls = createFetch((url) => { - if (url.pathname !== `/api/session/${initialSessionID}`) return + const sessions: string[] = [] + const calls = createFetch(async (url) => { + const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1] + if (!sessionID) return undefined + sessions.push(sessionID) + await options?.sessionGate return json({ data: { - id: initialSessionID, - title: options?.title, + id: sessionID, + title: sessionID === initialSessionID ? options?.title : undefined, projectID: "project", location: { directory }, cost: 0, @@ -84,7 +102,9 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s - + @@ -104,6 +124,7 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s tabs, route, data, + sessions, state, emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), destroy() { @@ -112,6 +133,26 @@ async function renderSessionTabs(initialSessionID: string, options?: { state?: s } } +test("loads persisted tab metadata concurrently on connect", async () => { + let release!: () => void + const sessionGate = new Promise((resolve) => (release = resolve)) + const setup = await renderSessionTabs("first", { + home: true, + persisted: ["first", "second"], + sessionGate, + }) + + try { + await wait(() => setup.sessions.length === 2) + expect(setup.sessions.toSorted()).toEqual(["first", "second"]) + release() + await wait(() => setup.data.session.get("first") !== undefined && setup.data.session.get("second") !== undefined) + } finally { + release() + setup.destroy() + } +}) + test("stores session tabs globally by default", async () => { const setup = await renderSessionTabs("first")