diff --git a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts index 97c06bf36f..a2436353ed 100644 --- a/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts +++ b/packages/app/e2e/browser/sidebar-workspace-rename.spec.ts @@ -249,9 +249,7 @@ test.describe("Sidebar workspace rename", () => { } }); - test("submitting the title another client just set closes the field without saving", async ({ - page, - }) => { + test("submitting the title another client just set closes the field", async ({ page }) => { const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-same-" }); try { @@ -265,11 +263,11 @@ test.describe("Sidebar workspace rename", () => { await expect(input).toBeFocused(); await renameFromAnotherClient(page, gate, workspace, "External title"); + // The daemon accepts it, but the title does not change, so no renamed workspace follows. await input.fill("External title"); await page.keyboard.press("Enter"); await expect(input).toHaveCount(0); await expect(row).toContainText("External title"); - expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0); // The row is a row again: its menu opens and renames. await chooseRenameFromKebab(page, workspace.workspaceId); @@ -279,6 +277,49 @@ test.describe("Sidebar workspace rename", () => { } }); + test("a rename elsewhere delivered while saving does not close the field", async ({ page }) => { + const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-pending-" }); + + 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"); + + // Another client's rename lands just before ours. The page receives, in daemon order: that + // rename, our response, our renamed workspace. Each is held until the previous is rendered. + gate.holdNextServerMessage("workspace_update"); + await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title"); + await gate.waitForHeldServerMessage("workspace_update"); + gate.holdNextServerMessage("workspace.title.set.response"); + gate.holdNextServerMessage("workspace_update"); + await page.keyboard.press("Enter"); + await gate.waitForHeldServerMessage("workspace.title.set.response"); + + // Releases the oldest held update: the other client's. Ours stays held behind it. + gate.releaseHeldServerMessage("workspace_update"); + await gate.waitForHeldServerMessage("workspace_update"); + await waitForTwoFrames(page); + await expect(input).toHaveValue("Submitted title"); + + gate.releaseHeldServerMessage("workspace.title.set.response"); + await waitForTwoFrames(page); + await expect(input).toHaveValue("Submitted title"); + await expect(row).not.toContainText("External title"); + + gate.releaseHeldServerMessage("workspace_update"); + await expect(input).toHaveCount(0); + await expect(row).toContainText("Submitted title"); + } 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/modal.tsx b/packages/app/src/workspace-rename/internal/modal.tsx index 7c4ebb58ef..58aafe8afc 100644 --- a/packages/app/src/workspace-rename/internal/modal.tsx +++ b/packages/app/src/workspace-rename/internal/modal.tsx @@ -1,3 +1,4 @@ +import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { AdaptiveRenameModal } from "@/components/rename-modal"; import { @@ -30,6 +31,12 @@ export function WorkspaceRenameModal({ }: WorkspaceRenameModalProps) { const { t } = useTranslation(); const setWorkspaceTitle = useSetWorkspaceTitle(workspace); + const submit = useCallback( + async (title: string) => { + await setWorkspaceTitle(title); + }, + [setWorkspaceTitle], + ); return ( ); diff --git a/packages/app/src/workspace-rename/internal/set-workspace-title.ts b/packages/app/src/workspace-rename/internal/set-workspace-title.ts index c177a8319e..d8de6772e6 100644 --- a/packages/app/src/workspace-rename/internal/set-workspace-title.ts +++ b/packages/app/src/workspace-rename/internal/set-workspace-title.ts @@ -16,10 +16,10 @@ export function workspaceRenameInitialValue(workspace: RenamableWorkspace): stri return workspace.title ?? workspace.name; } -/** Resolves once the daemon accepts the title; rejects with a displayable message. */ +/** Resolves to the title the daemon accepted; rejects with a displayable message. */ export function useSetWorkspaceTitle( workspace: Pick, -): (title: string) => Promise { +): (title: string) => Promise { const { t } = useTranslation(); const mutation = useMutation({ mutationFn: async (title: string) => { @@ -28,7 +28,11 @@ export function useSetWorkspaceTitle( throw new Error(t("sidebar.workspace.toasts.hostDisconnected")); } const trimmed = title.trim(); - await client.setWorkspaceTitle(workspace.workspaceId, trimmed.length === 0 ? null : trimmed); + const accepted = await client.setWorkspaceTitle( + workspace.workspaceId, + trimmed.length === 0 ? null : trimmed, + ); + return accepted.title; }, }); return mutation.mutateAsync; 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 f06ccbea0e..27c7c4f11c 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,12 @@ 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's title moves off the one it had at submit, - * which is not the one editing started from when another client renamed it in between. + * 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. */ type InlineRenameSession = { kind: "inline"; initialValue: string } & ( - | { phase: "editing" } - | { phase: "saving" | "saved"; titleBeforeSave: string | null } + | { phase: "editing" | "saving" } + | { phase: "saved"; acceptedTitle: string | null } ); type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession; @@ -51,7 +51,7 @@ export function useSidebarWorkspaceRename( if ( session.kind === "inline" && session.phase === "saved" && - currentTitle !== session.titleBeforeSave + currentTitle === session.acceptedTitle ) { setSession(CLOSED); } @@ -71,22 +71,21 @@ export function useSidebarWorkspaceRename( (text: string) => { if (session.kind !== "inline") return; const title = text.trim(); - // Saving the title the row already has would never move it, so `saved` would never end. - if (title.length === 0 || title === session.initialValue || title === currentTitle) { + if (title.length === 0 || title === session.initialValue) { setSession(CLOSED); return; } - setSession({ - kind: "inline", - initialValue: session.initialValue, - phase: "saving", - titleBeforeSave: currentTitle, - }); + setSession({ kind: "inline", initialValue: session.initialValue, phase: "saving" }); setWorkspaceTitle(title).then( - () => + (acceptedTitle) => setSession((previous) => previous.kind === "inline" && previous.phase === "saving" - ? { ...previous, phase: "saved" } + ? { + kind: "inline", + initialValue: previous.initialValue, + phase: "saved", + acceptedTitle, + } : previous, ), (error: unknown) => { @@ -95,7 +94,7 @@ export function useSidebarWorkspaceRename( }, ); }, - [currentTitle, session, setWorkspaceTitle, toast], + [session, setWorkspaceTitle, toast], ); const titleEditor =