fix(app): keep session tab labels stable during creation (#47099)

Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
This commit is contained in:
David Hill 2026-09-03 18:33:51 -06:00 committed by GitHub
parent 726107729e
commit c76e602ba4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 93 additions and 11 deletions

View file

@ -26,6 +26,53 @@ for (const viewport of [
{ name: "desktop", width: 1280, height: 900 },
{ name: "mobile", width: 390, height: 844 },
]) {
test(`keeps Session in the tab until the generated title arrives on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page, { untitled: true })
const label = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tab-title"]'
: '[data-titlebar-tab-slot][data-active="true"] [data-titlebar-tab-title]',
)
await expect(label).toHaveText("Session")
const pending = await submitPending(page, mock)
const spinner = page.locator(
viewport.name === "mobile"
? '[data-slot="mobile-tabs-trigger"] [data-component="session-progress-indicator-v2"]'
: `[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"] [data-component="session-progress-indicator-v2"]`,
)
await expect(spinner).toBeVisible()
await testInfo.attach("pending-tab-title", {
body: await page.screenshot(),
contentType: "image/png",
})
await expect(label).toHaveText("Session")
mock.worktree.resolve({ status: 200, json: { directory: workspace } })
await expect(pending.shimmer).toHaveCount(0)
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
await expect(label).toHaveText("Session")
if (viewport.name === "mobile") {
await label.click()
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
const tab = drawer.locator(`[data-titlebar-tab-link][href="${sessionPath}${pending.sessionID}"]`)
await expect(tab.locator("[data-titlebar-tab-title]")).toHaveText("Session")
await tab.click()
await expect(drawer).toBeHidden()
}
mock.events.push({
id: "evt_generated_title",
type: "session.renamed",
created: Date.now(),
location: { directory: workspace },
durable: { aggregateID: pending.sessionID, seq: 1, version: 1 },
data: { sessionID: pending.sessionID, title: "Generated session title" },
})
await expect(label).toHaveText("Generated session title")
})
test(`shows a pending workspace session immediately on ${viewport.name}`, async ({ page }, testInfo) => {
await page.setViewportSize(viewport)
const mock = await openDraft(page)
@ -404,13 +451,14 @@ async function draftFollowUp(page: Page) {
async function openDraft(
page: Page,
options?: { failSessionCreate?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
options?: { failSessionCreate?: boolean; untitled?: boolean; command?: boolean; events?: () => OpenCodeEvent[] },
) {
const worktree = Promise.withResolvers<{ status: number; json: { directory?: string; message?: string } }>()
const calls: string[] = []
const worktreeRequests: Record<string, unknown>[] = []
const creates: Record<string, unknown>[] = []
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
const events: OpenCodeEvent[] = []
const project = {
id: projectID,
worktree: directory,
@ -437,7 +485,7 @@ async function openDraft(
sessions,
pageMessages: () => ({ items: [] }),
onPrompt: (input) => prompts.push(input),
events: options?.events,
events: options?.events ?? (() => events.splice(0)),
})
page.on("request", (request) => {
if (request.method() !== "POST") return
@ -464,7 +512,10 @@ async function openDraft(
return route.fulfill({ status: 500, json: { message: "Session creation failed in the fixture" }, headers })
}
if (typeof body.id !== "string") throw new Error("Session creation must use the client-reserved ID")
const session = currentSession({ ...body, id: body.id, projectID, title: "Created workspace session" }, workspace)
const session = currentSession(
{ ...body, id: body.id, projectID, title: options?.untitled ? "" : "Created workspace session" },
workspace,
)
sessions.push(session)
return route.fulfill({ json: { data: session }, headers })
})
@ -532,7 +583,7 @@ async function openDraft(
await page.getByRole("menuitem", { name: "New worktree", exact: true }).click()
await expect(page.getByRole("button", { name: "New worktree", exact: true })).toBeVisible()
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
return { worktree, worktreeRequests, calls, creates, prompts }
return { worktree, worktreeRequests, calls, creates, prompts, events }
}
async function submitPending(page: Page, mock: Awaited<ReturnType<typeof openDraft>>, prompt = text) {

View file

@ -13,7 +13,7 @@ import { displayName, projectForSession } from "@/shell/layout/helpers"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { sessionLabel } from "@/session/title"
import { sessionTabTitle } from "./tab-title"
import { useSettings } from "@/settings/model"
import { canOpenTabRename, forwardTabRef } from "./tab-gesture"
import { TabPreviewPopover } from "./tab-popover"
@ -63,7 +63,7 @@ export function TabNavItem(props: {
})
const title = createMemo(() => {
const session = props.session
return session ? sessionLabel(session) : props.fallbackTitle
return sessionTabTitle(session ? session.title : props.fallbackTitle, language.t("session.tab.session"))
})
const projectName = createMemo(() => {

View file

@ -171,7 +171,7 @@ function SessionTabEntry(props: {
preparing={!!pending()}
fallbackTitle={
pending()
? language.t("command.session.new")
? language.t("session.tab.session")
: (persisted()?.title ?? (missingSession() ? language.t("session.tab.unknown") : undefined))
}
onRename={rename}

View file

@ -0,0 +1,22 @@
import { describe, expect, test } from "bun:test"
import { sessionTabTitle } from "./tab-title"
describe("session tab titles", () => {
test("uses the same localized label before a title arrives", () => {
for (const title of [
undefined,
"",
"New session - 2026-07-30T18:45:03.662Z",
"Child session - 2026-07-30T18:45:03.662Z",
]) {
expect(sessionTabTitle(title, "Session")).toBe("Session")
expect(sessionTabTitle(title, "Sitzung")).toBe("Sitzung")
}
})
test("preserves generated and user-supplied titles", () => {
for (const title of ["Generated title", "New session", "New session - custom"]) {
expect(sessionTabTitle(title, "Session")).toBe(title)
}
})
})

View file

@ -0,0 +1,6 @@
import { isFallbackTitle } from "@opencode-ai/util/session-title-fallback"
// Draft, preparing, and untitled session tabs share one localized label.
export function sessionTabTitle(title: string | undefined, fallback: string) {
return !title || isFallbackTitle(title) ? fallback : title
}

View file

@ -26,7 +26,7 @@ import "./titlebar.css"
import { newTabTooltipKeybind } from "@/shell/commands/tooltip-keybind"
import { TitlebarRightMount } from "@/shell/titlebar/right-slot"
import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigger } from "@/shell/mobile-drawer"
import { sessionLabel } from "@/session/title"
import { sessionTabTitle } from "./tab-title"
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
import { SessionProgressIndicatorV2 } from "@opencode-ai/session-ui/v2/session-progress-indicator-v2"
import { projectForSession } from "@/shell/layout/helpers"
@ -422,9 +422,12 @@ export function Titlebar(props: {
const currentTitle = () => {
const tab = currentTab()
if (!tab) return language.t("home.title")
if (tab.type === "draft") return language.t("command.session.new")
if (tab.type === "draft") return language.t("session.tab.session")
const value = session()
return value ? sessionLabel(value) : (tabs.info[tabKey(tab)]?.title ?? language.t("command.session.new"))
return sessionTabTitle(
value ? value.title : tabs.info[tabKey(tab)]?.title,
language.t("session.tab.session"),
)
}
createEffect(() => {
path()
@ -505,7 +508,7 @@ export function Titlebar(props: {
</span>
)}
</Show>
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
<span data-slot="mobile-tab-title" dir="auto" class="min-w-0 flex-1 truncate text-start">
{currentTitle()}
</span>
<span class="shrink-0 text-v2-text-text-muted">{tabsStore.length}</span>