fix(app): keep a title renamed elsewhere when an untouched inline rename closes

The unchanged check compared the field's text against the workspace's live title, while the
field keeps the text it opened with. A rename from another client during an untouched edit
made closing the field send the old title back. The inline session now owns its starting
text for both the field and the unchanged check.
This commit is contained in:
Mohamed Boudra
2026-11-10 09:55:48 +01:00
parent 0e1bb23b9e
commit d62116810a
2 changed files with 60 additions and 11 deletions
@@ -32,6 +32,15 @@ async function chooseRenameFromKebab(page: Page, workspaceId: string): Promise<v
await renameItem.click();
}
/** Lets the app render a server message the gate just forwarded. */
async function waitForTwoFrames(page: Page): Promise<void> {
await page.evaluate(async () => {
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
await nextFrame();
await nextFrame();
});
}
interface Box {
x: number;
y: number;
@@ -165,6 +174,38 @@ test.describe("Sidebar workspace rename", () => {
}
});
test("an untouched in-place rename keeps a title set elsewhere while it was open", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-" });
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 expect(input).toHaveValue("main");
// Another client renames the workspace while this field is open and untouched.
gate.holdNextServerMessage("workspace_update");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "External title");
await gate.waitForHeldServerMessage("workspace_update");
gate.releaseHeldServerMessage("workspace_update");
await waitForTwoFrames(page);
await page.mouse.click(900, 700);
await expect(input).toHaveCount(0);
await expect(row).toContainText("External title");
expect(gate.getClientRequestCount("workspace.title.set.request")).toBe(0);
} finally {
await workspace.cleanup();
}
});
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
@@ -14,14 +14,21 @@ export interface SidebarRenamableWorkspace extends RenamableWorkspace {
}
/**
* An inline session owns the text editing started from. The field keeps that text while it is
* open, so a rename arriving from elsewhere must not make an untouched field read as an edit.
*
* `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 editing started from.
*/
type RenameSession =
| { kind: "closed" }
| { kind: "modal" }
| { kind: "inline"; phase: "editing" | "saving" | "saved"; titleAtStart: string | null };
interface InlineRenameSession {
kind: "inline";
phase: "editing" | "saving" | "saved";
initialValue: string;
titleAtStart: string | null;
}
type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession;
const CLOSED: RenameSession = { kind: "closed" };
@@ -50,25 +57,26 @@ export function useSidebarWorkspaceRename(
setSession(CLOSED);
}
const initialValue = workspaceRenameInitialValue(workspace);
const begin = useCallback(() => {
setSession(
resolveWorkspaceRenameSurface() === "inline"
? { kind: "inline", phase: "editing", titleAtStart: currentTitle }
? { kind: "inline", phase: "editing", initialValue, titleAtStart: currentTitle }
: { kind: "modal" },
);
}, [currentTitle]);
}, [currentTitle, initialValue]);
const close = useCallback(() => setSession(CLOSED), []);
const initialValue = workspaceRenameInitialValue(workspace);
const commit = useCallback(
(text: string) => {
if (session.kind !== "inline") return;
const title = text.trim();
if (title.length === 0 || title === initialValue) {
if (title.length === 0 || title === session.initialValue) {
setSession(CLOSED);
return;
}
setSession({ kind: "inline", phase: "saving", titleAtStart: currentTitle });
setSession({ ...session, phase: "saving" });
setWorkspaceTitle(title).then(
() =>
setSession((previous) =>
@@ -82,13 +90,13 @@ export function useSidebarWorkspaceRename(
},
);
},
[currentTitle, initialValue, setWorkspaceTitle, toast],
[session, setWorkspaceTitle, toast],
);
const titleEditor =
session.kind === "inline" ? (
<InlineTitleField
initialValue={initialValue}
initialValue={session.initialValue}
placeholder={workspace.name}
readOnly={session.phase !== "editing"}
onCommit={commit}