From b1bfe84d62ca55b40f40aafe474f1680a7d3133f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 20 Oct 2026 00:04:23 -0700 Subject: [PATCH] fix(app): tabs can be renamed again after a rename (#57322) --- packages/app/e2e/regression/tabs.spec.ts | 57 +++++++++++-------- packages/app/src/shell/titlebar/tab-nav.tsx | 10 ++-- packages/app/src/shell/titlebar/tab-strip.tsx | 4 +- 3 files changed, 41 insertions(+), 30 deletions(-) diff --git a/packages/app/e2e/regression/tabs.spec.ts b/packages/app/e2e/regression/tabs.spec.ts index 592532737fc..dc58e3f76e9 100644 --- a/packages/app/e2e/regression/tabs.spec.ts +++ b/packages/app/e2e/regression/tabs.spec.ts @@ -124,31 +124,42 @@ test("inactive session attention dots share the close control without marking th await expectPath(page, sessionHref(a.id)) }) -test("a tab does not reopen its title editor while a rename is saving", async ({ page }) => { - await mockWorkspace(page, { name: "Tabs", sessions: [a, b] }) - await page.goto(sessionHref(a.id)) - const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"]) [data-slot="tab-title"]`) - const editor = page.locator('[data-slot="tab-title"][contenteditable="true"]') - await expect(title).toHaveText(b.title) - const save = await holdRoute(page, (url) => url.pathname === `/api/session/${b.id}`, { method: "PATCH" }) +for (const tabLayout of ["horizontal", "vertical"] as const) { + test(`a ${tabLayout} tab can be renamed again while its previous rename is still saving`, async ({ page }) => { + await mockWorkspace(page, { name: "Tabs", sessions: [a, b], seed: { settings: { appearance: { tabLayout } } } }) + await page.goto(sessionHref(a.id)) + const title = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"]) [data-slot="tab-title"]`) + const editor = page.locator('[data-slot="tab-title"][contenteditable="true"]') + await expect(title).toHaveText(a.title) + const save = await holdRoute(page, (url) => url.pathname === `/api/session/${a.id}`, { method: "PATCH" }) - await title.dblclick() - await expect(editor).toBeFocused() - await editor.fill("Renamed tab") - await editor.press("Enter") - expect((await save.arrived).postDataJSON()).toEqual({ title: "Renamed tab" }) - await expect(editor).toHaveCount(0) - await title.dblclick() - await expect(editor).toHaveCount(0) + await title.dblclick() + await expect(editor).toBeFocused() + await editor.fill("First name") + await editor.press("Enter") + expect((await save.arrived).postDataJSON()).toEqual({ title: "First name" }) + await expect(editor).toHaveCount(0) - // The context menu's Rename item is enabled again once the save settles. - save.release() - await title.click({ button: "right" }) - await expect(page.getByRole("menuitem", { name: "Rename", exact: true })).toBeEnabled() - await page.keyboard.press("Escape") - await title.dblclick() - await expect(editor).toBeFocused() -}) + await title.dblclick() + await expect(editor).toBeFocused() + await editor.fill("Second name") + await editor.press("Enter") + await expect(title).toHaveText("Second name") + + await title.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Rename", exact: true }).click() + await expect(editor).toBeFocused() + await editor.fill("Third name") + await editor.press("Enter") + await expect(title).toHaveText("Third name") + + save.release() + await expect + .poll(() => save.requests.map((request) => request.postDataJSON())) + .toEqual([{ title: "First name" }, { title: "Second name" }, { title: "Third name" }]) + await expect(title).toHaveText("Third name") + }) +} test("keyboard navigation follows the visible tab order and skips unresolved tabs", async ({ page }) => { await mockWorkspace(page, { name: "Tabs", sessions: [a, c], seed: { tabs: [a.id, "ses_tab_unresolved", c.id] } }) diff --git a/packages/app/src/shell/titlebar/tab-nav.tsx b/packages/app/src/shell/titlebar/tab-nav.tsx index 5384e301a2c..17ef6e05bb8 100644 --- a/packages/app/src/shell/titlebar/tab-nav.tsx +++ b/packages/app/src/shell/titlebar/tab-nav.tsx @@ -3,7 +3,6 @@ import { createStore } from "solid-js/store" import { Predicate } from "effect" import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" -import { createMutation } from "@tanstack/solid-query" import { IconButton } from "@opencode/ui/icon-button" import { Icon } from "@opencode/ui/icon" import { Menu } from "@opencode/ui/menu" @@ -48,7 +47,6 @@ export function TabNavItem(props: { let tabRoot!: HTMLDivElement let titleEl!: HTMLSpanElement let measureFrame: number | undefined - const rename = createMutation(() => ({ mutationFn: props.onRename })) const closeTab = (event: MouseEvent) => { event.preventDefault() @@ -162,7 +160,7 @@ export function TabNavItem(props: { } const closeRename = async (save: boolean) => { - if (rename.isPending || !editing()) return + if (!editing()) return const original = props.session?.title ?? "" const next = (titleEl.textContent ?? "").trim() @@ -174,7 +172,7 @@ export function TabNavItem(props: { return } - await rename.mutateAsync(next) + await props.onRename(next) } createEffect(() => { @@ -191,7 +189,7 @@ export function TabNavItem(props: { event?.preventDefault() event?.stopPropagation() - if (props.dragging || editing() || rename.isPending) return + if (props.dragging || editing()) return const session = props.session if (!session) return @@ -433,7 +431,7 @@ export function TabNavItem(props: { openRename() }} > - setMenu("rename", true)}> + setMenu("rename", true)}> {language.t("common.rename")} {language.t("common.closeTab")} diff --git a/packages/app/src/shell/titlebar/tab-strip.tsx b/packages/app/src/shell/titlebar/tab-strip.tsx index 7896b0e555d..094c49ed8bb 100644 --- a/packages/app/src/shell/titlebar/tab-strip.tsx +++ b/packages/app/src/shell/titlebar/tab-strip.tsx @@ -146,7 +146,9 @@ function SessionTabEntry(props: { const current = session() const currentCtx = props.serverCtx - if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title }) + // A newer rename may have replaced this title while the save was in flight. + if (current && currentCtx && current.title === title) + currentCtx.data.session.remember({ ...current, title: value.title }) showToast({ title: language.t("common.requestFailed"), description: err instanceof Error ? err.message : undefined,