From d62116810a35e3bb276273d32527bf57948846c0 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Nov 2026 09:55:48 +0100 Subject: [PATCH] fix(app): keep a title renamed elsewhere when an untouched inline rename closes The unchanged check compared the field's text against the workspace's live title, while the field keeps the text it opened with. A rename from another client during an untouched edit made closing the field send the old title back. The inline session now owns its starting text for both the field and the unchanged check. --- .../browser/sidebar-workspace-rename.spec.ts | 41 +++++++++++++++++++ .../internal/use-sidebar-workspace-rename.tsx | 30 +++++++++----- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts index 8a857281e7..e733f8004d 100644 --- a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts @@ -32,6 +32,15 @@ async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise { + await page.evaluate(async () => { + const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + await nextFrame(); + await nextFrame(); + }); +} + interface Box { x: number; y: number; @@ -165,6 +174,38 @@ test.describe("Sidebar workspace rename", () => { } }); + test("an untouched in-place rename keeps a title set elsewhere while it was open", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-" }); + + try { + const gate = await installDaemonWebSocketGate(page); + await page.setViewportSize({ width: 1280, height: 800 }); + await gotoAppShell(page); + const row = page.getByTestId(workspaceRowTestId(workspace.workspaceId)); + const input = page.getByTestId(workspaceRenameInputTestId(workspace.workspaceId)); + + await chooseRenameFromKebab(page, workspace.workspaceId); + await expect(input).toBeFocused(); + await expect(input).toHaveValue("main"); + + // Another client renames the workspace while this field is open and untouched. + gate.holdNextServerMessage("workspace_update"); + await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title"); + await gate.waitForHeldServerMessage("workspace_update"); + gate.releaseHeldServerMessage("workspace_update"); + await waitForTwoFrames(page); + + await page.mouse.click(900, 700); + await expect(input).toHaveCount(0); + await expect(row).toContainText("External title"); + expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0); + } finally { + await workspace.cleanup(); + } + }); + test("below the lg breakpoint renaming opens the modal", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" }); diff --git a/packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx b/packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx index 88e05d5ce3..496f79c4af 100644 --- a/packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx +++ b/packages/app/src/workspace-rename/internal/use-sidebar-workspace-rename.tsx @@ -14,14 +14,21 @@ export interface SidebarRenamableWorkspace extends RenamableWorkspace { } /** + * An inline session owns the text editing started from. The field keeps that text while it is + * open, so a rename arriving from elsewhere must not make an untouched field read as an edit. + * * `saving` and `saved` keep the field on screen holding the submitted text. The daemon answers * the request before it pushes the updated workspace, so closing on the answer would show the * old title for a beat. `saved` ends when the row's title moves off the one editing started from. */ -type RenameSession = - | { kind: "closed" } - | { kind: "modal" } - | { kind: "inline"; phase: "editing" | "saving" | "saved"; titleAtStart: string | null }; +interface InlineRenameSession { + kind: "inline"; + phase: "editing" | "saving" | "saved"; + initialValue: string; + titleAtStart: string | null; +} + +type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession; const CLOSED: RenameSession = { kind: "closed" }; @@ -50,25 +57,26 @@ export function useSidebarWorkspaceRename( setSession(CLOSED); } + const initialValue = workspaceRenameInitialValue(workspace); const begin = useCallback(() => { setSession( resolveWorkspaceRenameSurface() === "inline" - ? { kind: "inline", phase: "editing", titleAtStart: currentTitle } + ? { kind: "inline", phase: "editing", initialValue, titleAtStart: currentTitle } : { kind: "modal" }, ); - }, [currentTitle]); + }, [currentTitle, initialValue]); const close = useCallback(() => setSession(CLOSED), []); - const initialValue = workspaceRenameInitialValue(workspace); const commit = useCallback( (text: string) => { + if (session.kind !== "inline") return; const title = text.trim(); - if (title.length === 0 || title === initialValue) { + if (title.length === 0 || title === session.initialValue) { setSession(CLOSED); return; } - setSession({ kind: "inline", phase: "saving", titleAtStart: currentTitle }); + setSession({ ...session, phase: "saving" }); setWorkspaceTitle(title).then( () => setSession((previous) => @@ -82,13 +90,13 @@ export function useSidebarWorkspaceRename( }, ); }, - [currentTitle, initialValue, setWorkspaceTitle, toast], + [session, setWorkspaceTitle, toast], ); const titleEditor = session.kind === "inline" ? (