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 =