fix(app): close an inline rename that a later rename supersedes

The daemon builds each workspace update from its latest record, so a rename committed after
ours was accepted can be published in its place and ours never appears. Waiting for the
accepted title then left the field read-only and the row's handlers off. The saved phase now
ends on the first title change the row renders after the answer, or on the accepted title
if it is already showing. The session tracks the row's title while saving so a rename
delivered before the answer does not count.
This commit is contained in:
Mohamed Boudra
2026-11-10 10:38:18 +01:00
parent 64958c84ed
commit 5bfffd2e6f
2 changed files with 58 additions and 13 deletions
@@ -320,6 +320,41 @@ test.describe("Sidebar workspace rename", () => {
}
});
test("a later rename that replaces the saved title closes the field", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-superseded-" });
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");
// The daemon builds each update from its latest record, so a rename that lands after ours
// is accepted can be published in its place. Our update stays held: the page never sees it.
gate.holdNextServerMessage("workspace.title.set.response");
gate.holdNextServerMessage("workspace_update");
await page.keyboard.press("Enter");
await gate.waitForHeldServerMessage("workspace.title.set.response");
await gate.waitForHeldServerMessage("workspace_update");
gate.releaseHeldServerMessage("workspace.title.set.response");
await waitForTwoFrames(page);
await expect(input).toHaveValue("Submitted title");
await workspace.client.setWorkspaceTitle(workspace.workspaceId, "Later title");
await expect(input).toHaveCount(0);
await expect(row).toContainText("Later title");
await chooseRenameFromKebab(page, workspace.workspaceId);
await expect(input).toBeFocused();
} finally {
await workspace.cleanup();
}
});
test("below the lg breakpoint renaming opens the modal", async ({ page }) => {
const workspace = await seedWorkspace({ repoPrefix: "sidebar-rename-modal-" });
@@ -19,12 +19,15 @@ 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 shows the title the daemon accepted. Any other
* title change is a different client's rename, delivered before ours.
* old title for a beat. `saved` ends on the first title the row shows after the answer: ours, or
* a later rename the daemon published in its place. The daemon builds each update from its
* latest record, so a later rename can mean ours never appears. `titleSeen` is the row's title as
* of the last render, so a rename delivered before the answer does not end `saved`.
*/
type InlineRenameSession = { kind: "inline"; initialValue: string } & (
| { phase: "editing" | "saving" }
| { phase: "saved"; acceptedTitle: string | null }
| { phase: "editing" }
| { phase: "saving"; titleSeen: string | null }
| { phase: "saved"; titleSeen: string | null; acceptedTitle: string | null }
);
type RenameSession = { kind: "closed" } | { kind: "modal" } | InlineRenameSession;
@@ -48,10 +51,17 @@ export function useSidebarWorkspaceRename(
const [session, setSession] = useState<RenameSession>(CLOSED);
const currentTitle = workspace.title ?? null;
if (
session.kind === "inline" &&
session.phase === "saving" &&
currentTitle !== session.titleSeen
) {
setSession({ ...session, titleSeen: currentTitle });
}
if (
session.kind === "inline" &&
session.phase === "saved" &&
currentTitle === session.acceptedTitle
(currentTitle === session.acceptedTitle || currentTitle !== session.titleSeen)
) {
setSession(CLOSED);
}
@@ -75,17 +85,17 @@ export function useSidebarWorkspaceRename(
setSession(CLOSED);
return;
}
setSession({ kind: "inline", initialValue: session.initialValue, phase: "saving" });
setSession({
kind: "inline",
initialValue: session.initialValue,
phase: "saving",
titleSeen: currentTitle,
});
setWorkspaceTitle(title).then(
(acceptedTitle) =>
setSession((previous) =>
previous.kind === "inline" && previous.phase === "saving"
? {
kind: "inline",
initialValue: previous.initialValue,
phase: "saved",
acceptedTitle,
}
? { ...previous, phase: "saved", acceptedTitle }
: previous,
),
(error: unknown) => {
@@ -94,7 +104,7 @@ export function useSidebarWorkspaceRename(
},
);
},
[session, setWorkspaceTitle, toast],
[currentTitle, session, setWorkspaceTitle, toast],
);
const titleEditor =