From 5bfffd2e6fc0a19502b222e034db1aa695daf7c7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 10 Nov 2026 10:38:18 +0100 Subject: [PATCH] fix(app): close an inline rename that a later rename supersedes The daemon builds each workspace update from its latest record, so a rename committed after ours was accepted can be published in its place and ours never appears. Waiting for the accepted title then left the field read-only and the row's handlers off. The saved phase now ends on the first title change the row renders after the answer, or on the accepted title if it is already showing. The session tracks the row's title while saving so a rename delivered before the answer does not count. --- .../browser/sidebar-workspace-rename.spec.ts | 35 ++++++++++++++++++ .../internal/use-sidebar-workspace-rename.tsx | 36 ++++++++++++------- 2 files changed, 58 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts index a2436353ed..469fd9c72f 100644 --- a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts @@ -320,6 +320,41 @@ test.describe("Sidebar workspace rename", () => { } }); + test("a later rename that replaces the saved title closes the field", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-superseded-" }); + + 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 input.fill("Submitted title"); + + // The daemon builds each update from its latest record, so a rename that lands after ours + // is accepted can be published in its place. Our update stays held: the page never sees it. + gate.holdNextServerMessage("workspace.title.set.response"); + gate.holdNextServerMessage("workspace_update"); + await page.keyboard.press("Enter"); + await gate.waitForHeldServerMessage("workspace.title.set.response"); + await gate.waitForHeldServerMessage("workspace_update"); + gate.releaseHeldServerMessage("workspace.title.set.response"); + await waitForTwoFrames(page); + await expect(input).toHaveValue("Submitted title"); + + await workspace.client.setWorkspaceTitle(workspace.workspaceId, "Later title"); + await expect(input).toHaveCount(0); + await expect(row).toContainText("Later title"); + await chooseRenameFromKebab(page, workspace.workspaceId); + await expect(input).toBeFocused(); + } 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 27c7c4f11c..4a3bdc676a 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 @@ -19,12 +19,15 @@ export interface SidebarRenamableWorkspace extends RenamableWorkspace { * * `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 shows the title the daemon accepted. Any other - * title change is a different client's rename, delivered before ours. + * old title for a beat. `saved` ends on the first title the row shows after the answer: ours, or + * a later rename the daemon published in its place. The daemon builds each update from its + * latest record, so a later rename can mean ours never appears. `titleSeen` is the row's title as + * of the last render, so a rename delivered before the answer does not end `saved`. */ type InlineRenameSession = { kind: "inline"; initialValue: string } & ( - | { phase: "editing" | "saving" } - | { phase: "saved"; acceptedTitle: string | null } + | { phase: "editing" } + | { phase: "saving"; titleSeen: string | null } + | { phase: "saved"; titleSeen: string | null; acceptedTitle: string | null } ); type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession; @@ -48,10 +51,17 @@ export function useSidebarWorkspaceRename( const [session, setSession] = useState(CLOSED); const currentTitle = workspace.title ?? null; + if ( + session.kind === "inline" && + session.phase === "saving" && + currentTitle !== session.titleSeen + ) { + setSession({ ...session, titleSeen: currentTitle }); + } if ( session.kind === "inline" && session.phase === "saved" && - currentTitle === session.acceptedTitle + (currentTitle === session.acceptedTitle || currentTitle !== session.titleSeen) ) { setSession(CLOSED); } @@ -75,17 +85,17 @@ export function useSidebarWorkspaceRename( setSession(CLOSED); return; } - setSession({ kind: "inline", initialValue: session.initialValue, phase: "saving" }); + setSession({ + kind: "inline", + initialValue: session.initialValue, + phase: "saving", + titleSeen: currentTitle, + }); setWorkspaceTitle(title).then( (acceptedTitle) => setSession((previous) => previous.kind === "inline" && previous.phase === "saving" - ? { - kind: "inline", - initialValue: previous.initialValue, - phase: "saved", - acceptedTitle, - } + ? { ...previous, phase: "saved", acceptedTitle } : previous, ), (error: unknown) => { @@ -94,7 +104,7 @@ export function useSidebarWorkspaceRename( }, ); }, - [session, setWorkspaceTitle, toast], + [currentTitle, session, setWorkspaceTitle, toast], ); const titleEditor =