fix(app): tighten the Undo shortcut and prove late archive errors in a browser

The button's gap and the shortcut badge's own inset added up to 12px between
the label and the keys.

Late archive failures are now covered by a Playwright journey that swaps the
sidebar while a real archive request is held, replacing the JSDOM component test.
This commit is contained in:
Mohamed Boudra
2026-11-08 12:44:17 +01:00
parent 1228a6c4a1
commit 31b730dc48
4 changed files with 88 additions and 72 deletions
@@ -18,6 +18,7 @@ import {
createGatedTeardown,
expectDirectoryPresence,
expectHostWorkspaceSettledActive,
holdNextArchiveRequest,
outlastUndoWindow,
pressArchiveShortcut,
pressUndoShortcut,
@@ -109,6 +110,30 @@ test.describe("Workspace archive undo", () => {
await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto");
await expect(undoButton(page)).toHaveCSS("cursor", "pointer");
const [labelBox, shortcutBox] = await Promise.all([
undoButton(page).getByText("Undo", { exact: true }).boundingBox(),
undoButton(page).getByText(/Z$/).boundingBox(),
]);
if (!labelBox || !shortcutBox) throw new Error("Undo button is not laid out");
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
});
test("an archive that fails after the sidebar was replaced still shows its error", async ({
page,
}) => {
const archive = await holdNextArchiveRequest(page);
const worktree = await seedWorktree(page, "archive-late-failure");
await archiveFromRowButton(page, worktree.workspaceId);
await archive.waitForHeld();
// Crossing the compact breakpoint swaps the sidebar, unmounting the toast's host.
await page.setViewportSize({ width: 400, height: 720 });
await expect(undoButton(page)).toHaveCount(0);
archive.fail("Injected archive failure.");
await expect(page.getByTestId("app-toast-message")).toHaveText("Injected archive failure.");
});
test("the archived toast keeps Undo inside the card at the narrowest sidebar", async ({
@@ -4,6 +4,7 @@ import path from "node:path";
import type { Page } from "@playwright/test";
import { expect } from "../fixtures";
import type { connectNewWorkspaceDaemonClient } from "./new-workspace";
import { daemonWsRoutePattern } from "./daemon-port";
import { getServerId } from "./server-id";
import { expectWorkspaceAbsentFromSidebar } from "./sidebar";
@@ -97,6 +98,60 @@ export async function expectDirectoryPresence(directory: string, present: boolea
await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present);
}
export interface HeldArchiveRequest {
waitForHeld(): Promise<void>;
fail(error: string): void;
}
/** Keeps the next archive request from the host and lets the test answer it with a failure. */
export async function holdNextArchiveRequest(page: Page): Promise<HeldArchiveRequest> {
let held: { requestId: string; workspaceId: string; respond: (raw: string) => void } | null =
null;
await page.routeWebSocket(daemonWsRoutePattern(), (browser) => {
const server = browser.connectToServer();
browser.onMessage((message) => {
const request = parseSessionRequest(message);
if (!held && request?.type === "archive_workspace_request") {
held = {
requestId: request.requestId,
workspaceId: request.workspaceId,
respond: (raw) => browser.send(raw),
};
return;
}
server.send(message);
});
server.onMessage((message) => browser.send(message));
});
return {
waitForHeld: () => expect.poll(() => held !== null).toBe(true),
fail: (error) => {
if (!held) throw new Error("No archive request is held");
const { requestId, workspaceId, respond } = held;
respond(
JSON.stringify({
type: "session",
message: {
type: "archive_workspace_response",
payload: { requestId, workspaceId, archivedAt: null, error },
},
}),
);
},
};
}
function parseSessionRequest(
message: string | Buffer,
): { type: string; requestId: string; workspaceId: string } | null {
try {
const envelope = JSON.parse(typeof message === "string" ? message : message.toString("utf8"));
return envelope?.type === "session" ? envelope.message : null;
} catch {
return null;
}
}
export interface GatedTeardown {
/** The `paseo.json` config whose worktree teardown parks until released. */
paseoConfig: Record<string, unknown>;
@@ -1,71 +0,0 @@
// @vitest-environment jsdom
import { render } from "@testing-library/react";
import { SafeAreaProvider } from "react-native-safe-area-context";
import { describe, expect, it, vi } from "vitest";
import type { ToastApi } from "@/components/toast-host";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
import { SidebarToastRegion } from "./sidebar-toast-region";
const safeAreaMetrics = {
frame: { x: 0, y: 0, width: 800, height: 600 },
insets: { top: 0, left: 0, right: 0, bottom: 0 },
};
function createFakeToast(): ToastApi {
return { show: vi.fn(() => () => {}), copied: vi.fn(), error: vi.fn() };
}
function renderRegion(active: boolean) {
const appToast = createFakeToast();
let regionToast: ToastApi | null = null;
function CaptureToast() {
regionToast = useToast();
return null;
}
const tree = (isActive: boolean) => (
<SafeAreaProvider initialMetrics={safeAreaMetrics}>
<ToastApiProvider api={appToast}>
<SidebarToastRegion active={isActive}>
<CaptureToast />
</SidebarToastRegion>
</ToastApiProvider>
</SafeAreaProvider>
);
const view = render(tree(active));
return {
appToast,
toast: () => regionToast as unknown as ToastApi,
setActive: (next: boolean) => view.rerender(tree(next)),
unmount: view.unmount,
};
}
describe("SidebarToastRegion", () => {
it("keeps toasts in the sidebar while it is visible", () => {
const { appToast, toast } = renderRegion(true);
toast().error("Archive failed");
expect(appToast.error).not.toHaveBeenCalled();
});
it("sends toasts to the app while the sidebar is hidden", () => {
const { appToast, toast, setActive } = renderRegion(true);
setActive(false);
toast().error("Archive failed");
expect(appToast.error).toHaveBeenCalledWith("Archive failed");
});
it("sends a late failure to the app after the sidebar unmounts", () => {
const { appToast, toast, unmount } = renderRegion(true);
const savedToast = toast();
unmount();
savedToast.error("Archive failed");
expect(appToast.error).toHaveBeenCalledWith("Archive failed");
});
});
@@ -9,7 +9,10 @@ import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) {
const { t } = useTranslation();
const undoKeys = useShortcutKeys("undo-archive-workspace");
const undoShortcut = useMemo(() => (undoKeys ? <Shortcut chord={undoKeys} /> : null), [undoKeys]);
const undoShortcut = useMemo(
() => (undoKeys ? <Shortcut chord={undoKeys} style={styles.shortcut} /> : null),
[undoKeys],
);
return (
<View style={styles.row}>
@@ -33,6 +36,10 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
gap: theme.spacing[2],
},
// Cancels the badge's own inset, which would otherwise add to the button's gap.
shortcut: {
marginLeft: -theme.spacing[1],
},
message: {
flexShrink: 1,
color: theme.colors.foreground,