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 =