fix(app): hold a saved inline title against the title it replaced

The field closed once the row's title moved off the one editing started from. When another
client renamed the workspace while the field was open, that title had already moved, so a
save closed the field on the daemon's answer and showed the other client's title until the
saved one arrived. The session now records the title at submit and waits for it to change.
This commit is contained in:
Mohamed Boudra
2026-11-10 10:04:51 +01:00
parent d62116810a
commit d274dc5431
2 changed files with 65 additions and 18 deletions
@@ -41,6 +41,22 @@ async function waitForTwoFrames(page: Page): Promise<void> {
});
}
type DaemonWebSocketGate = Awaited<ReturnType<typeof installDaemonWebSocketGate>>;
/** Sets the title from a second daemon client and waits until this page has rendered it. */
async function renameFromAnotherClient(
page: Page,
gate: DaemonWebSocketGate,
workspace: Awaited<ReturnType<typeof seedWorkspace>>,
title: string,
): Promise<void> {
gate.holdNextServerMessage("workspace_update");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, title);
await gate.waitForHeldServerMessage("workspace_update");
gate.releaseHeldServerMessage("workspace_update");
await waitForTwoFrames(page);
}
interface Box {
x: number;
y: number;
@@ -190,12 +206,7 @@ test.describe("Sidebar workspace rename", () => {
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 renameFromAnotherClient(page, gate, workspace, "External title");
await page.mouse.click(900, 700);
await expect(input).toHaveCount(0);
@@ -206,6 +217,38 @@ test.describe("Sidebar workspace rename", () => {
}
});
test("saving after a rename elsewhere holds the submitted title until it arrives", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-external-save-" });
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 renameFromAnotherClient(page, gate, workspace, "External title");
await input.fill("Submitted title");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace_update");
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-" });
@@ -19,14 +19,13 @@ 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 editing started from.
* 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.
*/
interface InlineRenameSession {
kind: "inline";
phase: "editing" | "saving" | "saved";
initialValue: string;
titleAtStart: string | null;
}
type InlineRenameSession = { kind: "inline"; initialValue: string } & (
| { phase: "editing" }
| { phase: "saving" | "saved"; titleBeforeSave: string | null }
);
type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession;
@@ -52,7 +51,7 @@ export function useSidebarWorkspaceRename(
if (
session.kind === "inline" &&
session.phase === "saved" &&
currentTitle !== session.titleAtStart
currentTitle !== session.titleBeforeSave
) {
setSession(CLOSED);
}
@@ -61,10 +60,10 @@ export function useSidebarWorkspaceRename(
const begin = useCallback(() => {
setSession(
resolveWorkspaceRenameSurface() === "inline"
? { kind: "inline", phase: "editing", initialValue, titleAtStart: currentTitle }
? { kind: "inline", phase: "editing", initialValue }
: { kind: "modal" },
);
}, [currentTitle, initialValue]);
}, [initialValue]);
const close = useCallback(() => setSession(CLOSED), []);
@@ -76,7 +75,12 @@ export function useSidebarWorkspaceRename(
setSession(CLOSED);
return;
}
setSession({ ...session, phase: "saving" });
setSession({
kind: "inline",
initialValue: session.initialValue,
phase: "saving",
titleBeforeSave: currentTitle,
});
setWorkspaceTitle(title).then(
() =>
setSession((previous) =>
@@ -90,7 +94,7 @@ export function useSidebarWorkspaceRename(
},
);
},
[session, setWorkspaceTitle, toast],
[currentTitle, session, setWorkspaceTitle, toast],
);
const titleEditor =