diff --git a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts index b44435d7b26..b1d23f9a661 100644 --- a/packages/app/e2e/regression/new-session-workspace-pending.spec.ts +++ b/packages/app/e2e/regression/new-session-workspace-pending.spec.ts @@ -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[] = [] const creates: Record[] = [] const prompts: { sessionID: string; body: Record }[] = [] + 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>, prompt = text) { diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index 42486814b97..5806a654d8b 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -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(() => { diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index cb87cece33d..2e03bf3e8f5 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -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} diff --git a/packages/app/src/shell/titlebar/tab-title.test.ts b/packages/app/src/shell/titlebar/tab-title.test.ts new file mode 100644 index 00000000000..51ca3fae1ad --- /dev/null +++ b/packages/app/src/shell/titlebar/tab-title.test.ts @@ -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) + } + }) +}) diff --git a/packages/app/src/shell/titlebar/tab-title.ts b/packages/app/src/shell/titlebar/tab-title.ts new file mode 100644 index 00000000000..105fca2e675 --- /dev/null +++ b/packages/app/src/shell/titlebar/tab-title.ts @@ -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 +} diff --git a/packages/app/src/shell/titlebar/titlebar.tsx b/packages/app/src/shell/titlebar/titlebar.tsx index e5d7a9b58a5..373dd741078 100644 --- a/packages/app/src/shell/titlebar/titlebar.tsx +++ b/packages/app/src/shell/titlebar/titlebar.tsx @@ -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: { )} - + {currentTitle()} {tabsStore.length}