fix(app): finish an inline rename when the row shows the accepted title

The saved phase treated any title change as confirmation of this save. A rename from
another client delivered before ours closed the field early and showed that title, and
saving a title the row already had never changed it, leaving the field read-only and the
row's handlers off. The daemon's response carries the accepted title; the field now closes
once the row shows it. That rule also covers a submit matching the current title, so the
special-case comparison for it is gone.
This commit is contained in:
Mohamed Boudra
2026-11-10 10:23:02 +01:00
parent 8901862465
commit 64958c84ed
4 changed files with 75 additions and 24 deletions
@@ -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-" });
@@ -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 (
<AdaptiveRenameModal
@@ -39,7 +46,7 @@ export function WorkspaceRenameModal({
placeholder={workspace.name}
submitLabel={t("sidebar.workspace.rename.submit")}
onClose={onClose}
onSubmit={setWorkspaceTitle}
onSubmit={submit}
testID={testID}
/>
);
@@ -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<RenamableWorkspace, "serverId" | "workspaceId">,
): (title: string) => Promise<void> {
): (title: string) => Promise<string | null> {
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;
@@ -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 =