Compare commits

...
Author SHA1 Message Date
Mohamed Boudra eb72872c5c test(app): satisfy lint in the sidebar toast browser test 2026-11-14 11:58:34 +01:00
Mohamed Boudra 3cd7690113 fix(app): show the sidebar's toast over its footer instead of the list
The archive undo toast floated over the bottom of the workspace list, so #9639
kept 96px of permanent padding under the last row to let it scroll clear. That
left 110px of empty space at the end of every long list, and mid-list the toast
still covered about a row.

The toast now covers the footer's icon line, which the user isn't using while
they work through the list. The list goes back to its 16px padding, no row is
ever covered, and nothing moves when the toast comes or goes.

The sidebar owns one toast host: the list raises toasts and the footer shows
them. Replacing the list, with a plugin view for example, still ends its toast
and so its undo window.
2026-11-14 11:46:19 +01:00
10 changed files with 323 additions and 100 deletions
@@ -14,6 +14,7 @@ import { connectSeedClient } from "../support/helpers/seed-client";
import { createTempGitRepo } from "../support/helpers/workspace";
import { getWorkspaceTabTestIds } from "../support/helpers/workspace-tabs";
import {
archivedToast,
archiveFromRowButton,
createGatedTeardown,
expectDirectoryPresence,
@@ -25,6 +26,7 @@ import {
resizeSidebarToMinimum,
rowArchiveButton,
rowKebab,
scrollSidebarListToEnd,
undoButton,
workspaceRow,
workspaceTitleWidth,
@@ -91,22 +93,24 @@ test.describe("Workspace archive undo", () => {
expect(kebabBox.x - (buttonBox.x + buttonBox.width)).toBeGreaterThanOrEqual(8);
});
test("the archived toast sits over the sidebar above its footer, and only Undo presses", async ({
test("the archived toast covers the footer's icon line instead of the list, and only Undo presses", async ({
page,
}) => {
const worktree = await seedWorktree(page, "archive-toast-sidebar");
await archiveFromRowButton(page, worktree.workspaceId);
const [toastBox, footerBox] = await Promise.all([
page.getByTestId("workspace-archived-toast").boundingBox(),
page.getByTestId("sidebar-footer").boundingBox(),
const [toastBox, lineBox, listBox] = await Promise.all([
archivedToast(page).boundingBox(),
page.getByTestId("sidebar-footer-bottom-line").boundingBox(),
page.getByTestId("sidebar-project-workspace-list-scroll").boundingBox(),
]);
if (!toastBox || !footerBox) throw new Error("Toast or footer is not laid out");
expect(toastBox.x).toBeGreaterThanOrEqual(footerBox.x);
expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(footerBox.x + footerBox.width);
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(footerBox.y);
expect(footerBox.y - (toastBox.y + toastBox.height)).toBeLessThanOrEqual(16);
if (!toastBox || !lineBox || !listBox) throw new Error("Toast, footer or list is not laid out");
expect(toastBox.y).toBeGreaterThanOrEqual(listBox.y + listBox.height);
expect(toastBox.y).toBeGreaterThanOrEqual(lineBox.y);
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(lineBox.y + lineBox.height);
expect(toastBox.x).toBeGreaterThanOrEqual(lineBox.x);
expect(toastBox.x + toastBox.width).toBeLessThanOrEqual(lineBox.x + lineBox.width);
await expect(page.getByTestId("app-toast-message")).toHaveCSS("cursor", "auto");
await expect(undoButton(page)).toHaveCSS("cursor", "pointer");
@@ -119,6 +123,41 @@ test.describe("Workspace archive undo", () => {
expect(shortcutBox.x - (labelBox.x + labelBox.width)).toBeLessThanOrEqual(8.5);
});
test("a long list ends just below its last row, and no row moves when the toast leaves", async ({
page,
}) => {
await page.clock.install({ time: Date.now() });
const fillers: Array<{ path: string; cleanup: () => Promise<void> }> = [];
const fillerProjectIds: string[] = [];
try {
for (let index = 0; index < 10; index += 1) {
const repo = await createTempGitRepo(`archive-toast-filler-${index}-`);
fillers.push(repo);
fillerProjectIds.push((await openProjectViaDaemon(client, repo.path)).projectId);
}
const worktree = await seedWorktree(page, "archive-toast-long-list");
const resting = await scrollSidebarListToEnd(page);
expect(resting.listBottom - resting.rowBottoms.at(-1)!).toBeLessThan(resting.rowHeight);
await archiveFromRowButton(page, worktree.workspaceId);
await page.mouse.move(0, 0);
const withToast = await scrollSidebarListToEnd(page);
const toastBox = await archivedToast(page).boundingBox();
if (!toastBox) throw new Error("Toast is not laid out");
expect(toastBox.y).toBeGreaterThanOrEqual(withToast.listBottom);
await outlastUndoWindow(page);
await expect(archivedToast(page)).toHaveCount(0);
expect(await scrollSidebarListToEnd(page)).toEqual(withToast);
} finally {
for (const projectId of fillerProjectIds) {
await client.removeProject(projectId).catch(() => undefined);
}
for (const repo of fillers) await repo.cleanup().catch(() => undefined);
}
});
test("an archive that fails after the sidebar was replaced still shows its error", async ({
page,
}) => {
@@ -146,13 +185,16 @@ test.describe("Workspace archive undo", () => {
await archiveFromRowButton(page, worktree.workspaceId);
const [toastBox, undoBox] = await Promise.all([
page.getByTestId("workspace-archived-toast").boundingBox(),
const [toastBox, undoBox, lineBox] = await Promise.all([
archivedToast(page).boundingBox(),
undoButton(page).boundingBox(),
page.getByTestId("sidebar-footer-bottom-line").boundingBox(),
]);
if (!toastBox || !undoBox) throw new Error("Toast is not laid out");
if (!toastBox || !undoBox || !lineBox) throw new Error("Toast is not laid out");
expect(undoBox.x + undoBox.width).toBeLessThanOrEqual(toastBox.x + toastBox.width);
expect(undoBox.y + undoBox.height).toBeLessThanOrEqual(toastBox.y + toastBox.height);
// The wrapped card grows upward from the footer's bottom edge, never past the window.
expect(toastBox.y + toastBox.height).toBeLessThanOrEqual(lineBox.y + lineBox.height);
});
test("archiving by shortcut with the sidebar collapsed shows the toast over the app", async ({
@@ -162,3 +162,38 @@ export function createGatedTeardown(): GatedTeardown {
release: () => writeFileSync(releasePath, "release\n"),
};
}
export function archivedToast(page: Page) {
return page.getByTestId("workspace-archived-toast");
}
export interface SidebarListLayout {
/** Bottom edge of the list's visible area. */
listBottom: number;
/** Bottom edges of the visible project and workspace rows, top to bottom. */
rowBottoms: number[];
rowHeight: number;
}
/** Scrolls the sidebar's workspace list to its end and reads where its rows sit. */
export async function scrollSidebarListToEnd(page: Page): Promise<SidebarListLayout> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
await list.evaluate((element) => {
element.scrollTop = element.scrollHeight;
});
return list.evaluate((element) => {
const visible = element.getBoundingClientRect();
const rows = Array.from(
element.querySelectorAll<HTMLElement>(
'[data-testid^="sidebar-workspace-row-"],[data-testid^="sidebar-project-row-"]',
),
)
.map((row) => row.getBoundingClientRect())
.filter((row) => row.height > 0 && row.bottom > visible.top && row.top < visible.bottom);
return {
listBottom: visible.bottom,
rowBottoms: rows.map((row) => row.bottom).sort((a, b) => a - b),
rowHeight: Math.min(...rows.map((row) => row.height)),
};
});
}
+30 -21
View File
@@ -38,7 +38,11 @@ import {
import { HostPicker } from "@/components/hosts/host-picker";
import { SidebarDisplayPreferencesMenu } from "@/components/sidebar/display-preferences/menu";
import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarToastRegion } from "@/components/sidebar/sidebar-toast-region";
import {
SidebarToastHost,
SidebarToastRegion,
SidebarToastSlot,
} from "@/components/sidebar/sidebar-toast";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
@@ -259,24 +263,26 @@ export const LeftSidebar = memo(function LeftSidebar({
return (
<>
<RetainedPanelActivity active={active}>
{isCompactLayout ? (
<MobileSidebar
{...sharedProps}
{...handlers}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
/>
) : (
<DesktopSidebar
{...sharedProps}
{...handlers}
insetsTop={insets.top}
active={active}
overlay={desktopOverlay}
/>
)}
<SidebarToastHost active={active}>
{isCompactLayout ? (
<MobileSidebar
{...sharedProps}
{...handlers}
active={active}
insetsTop={insets.top}
insetsBottom={insets.bottom}
closeSidebar={showMobileAgent}
/>
) : (
<DesktopSidebar
{...sharedProps}
{...handlers}
insetsTop={insets.top}
active={active}
overlay={desktopOverlay}
/>
)}
</SidebarToastHost>
</RetainedPanelActivity>
{importSessionSheet}
</>
@@ -496,6 +502,7 @@ function SidebarFooter({
shortcutKeys={settingsKeys}
/>
) : null}
<SidebarToastSlot />
</View>
</View>
</UsageSidebarRoot>
@@ -647,7 +654,7 @@ function MobileSidebar({
<SidebarSeparator />
<SidebarViewSlot dismiss={handleWorkspacePress}>
<SidebarToastRegion active={active}>
<SidebarToastRegion>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
@@ -849,7 +856,7 @@ function DesktopSidebar({
</View>
<SidebarViewSlot dismiss={closeSidebar}>
<SidebarToastRegion active={active}>
<SidebarToastRegion>
{isInitialLoad && !hasActiveHostFilter ? (
<SidebarAgentListSkeleton />
) : (
@@ -1006,6 +1013,8 @@ const styles = StyleSheet.create((theme) => ({
// Buttons sit edge to edge; their own inset spaces the glyphs.
// As tall as its buttons from the first frame, which places none until the row has measured.
sidebarFooter: {
// Anchors the sidebar's toast, which covers this line while it shows.
position: "relative",
flexDirection: "row",
alignItems: "center",
minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2),
@@ -2592,9 +2592,7 @@ const styles = StyleSheet.create((theme) => ({
// Optical inset: aligns the visible Pinned/Workspaces glyph edge with the
// Schedules icon across the divider; their layout boxes have different insets.
paddingTop: 2,
// Keep the last row scrollable above the sidebar toast, including a wrapped Undo row.
// This space stays present across toast changes so the scroll range never jumps.
paddingBottom: theme.spacing[24],
paddingBottom: theme.spacing[4],
},
projectListContainer: {
width: "100%",
@@ -1101,8 +1101,7 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2],
// Keep status mode's Pinned/Workspaces boundary identical to project mode.
paddingTop: 2,
// Match project grouping: permanent room to scroll the final row above sidebar toasts.
paddingBottom: theme.spacing[24],
paddingBottom: theme.spacing[4],
},
pinnedSection: {
marginBottom: theme.spacing[1],
@@ -1,52 +0,0 @@
import { useEffect, useMemo, useRef, type ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { ToastViewport, useToastHost, type ToastApi } from "@/components/toast-host";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
/**
* Shows toasts raised by the sidebar's list over its bottom edge, just above the footer.
*
* A collapsed sidebar stays mounted and its rows still handle shortcuts, so while it is hidden
* their toasts go to the app's host instead. The sidebar's API stays stable across open and close
* so that toggling the panel doesn't re-render every row.
*/
export function SidebarToastRegion({ active, children }: { active: boolean; children: ReactNode }) {
const appToast = useToast();
const { api: sidebarToast, toast, dismiss } = useToastHost();
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
return () => {
// An archive holds on to this API, so a failure that lands later needs a host on screen.
activeRef.current = false;
// A toast must not outlive its visibility: its dismissal is what ends an archive's undo.
dismiss();
};
}, [active, dismiss]);
const api = useMemo<ToastApi>(() => {
const visibleHost = () => (activeRef.current ? sidebarToast : appToast);
return {
show: (content, options) => visibleHost().show(content, options),
copied: (label) => visibleHost().copied(label),
error: (message) => visibleHost().error(message),
};
}, [appToast, sidebarToast]);
return (
<View style={styles.region}>
<ToastApiProvider api={api}>{children}</ToastApiProvider>
<ToastViewport toast={toast} onDismiss={dismiss} placement="sidebar" />
</View>
);
}
const styles = StyleSheet.create({
region: {
position: "relative",
flex: 1,
minHeight: 0,
},
});
@@ -0,0 +1,92 @@
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { View } from "react-native";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { i18n as testI18n } from "@/i18n/i18next";
import type { ToastApi } from "@/components/toast-host";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
import { SidebarToastHost, SidebarToastRegion, SidebarToastSlot } from "./sidebar-toast";
// Toasts read their copy through i18n, which this import initialises.
void testI18n;
// App sources compile against the classic JSX runtime, which expects React on the global.
beforeEach(() => vi.stubGlobal("React", React));
const footerLineStyle = { position: "relative", height: 40 } as const;
let root: Root | null = null;
let container: HTMLDivElement | null = null;
let listToast: ToastApi | null = null;
const appToast: ToastApi = {
show: () => () => {},
copied: () => {},
error: () => {},
};
afterEach(() => {
act(() => root?.unmount());
container?.remove();
root = null;
container = null;
listToast = null;
});
function WorkspaceList() {
listToast = useToast();
return <View testID="list" />;
}
function Sidebar({ showList }: { showList: boolean }) {
return (
<ToastApiProvider api={appToast}>
<SidebarToastHost active>
{showList ? (
<SidebarToastRegion>
<WorkspaceList />
</SidebarToastRegion>
) : null}
<View testID="footer-line" style={footerLineStyle}>
<SidebarToastSlot />
</View>
</SidebarToastHost>
</ToastApiProvider>
);
}
function renderSidebar(showList: boolean) {
if (!container) {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
}
act(() => root?.render(<Sidebar showList={showList} />));
}
function footerText(): string {
return container?.querySelector('[data-testid="footer-line"]')?.textContent ?? "";
}
it("shows a toast raised by the workspace list in the footer line", () => {
renderSidebar(true);
act(() => {
listToast?.show("Workspace archived", { durationMs: null });
});
expect(footerText()).toBe("Workspace archived");
});
it("ends the list's toast, and with it an archive's undo, when the list is replaced", async () => {
renderSidebar(true);
const onDismiss = vi.fn();
act(() => {
listToast?.show("Workspace archived", { durationMs: null, onDismiss });
});
renderSidebar(false);
expect(onDismiss).toHaveBeenCalledOnce();
// The card fades out before it leaves the footer.
await vi.waitFor(() => expect(footerText()).toBe(""));
});
@@ -0,0 +1,77 @@
import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from "react";
import {
ToastViewport,
useToastHost,
type ToastApi,
type ToastState,
} from "@/components/toast-host";
import { ToastApiProvider, useToast } from "@/contexts/toast-context";
/**
* The sidebar's toast. Toasts raised by the workspace list cover the footer's icon line, which the
* user isn't using while they work through the list, so no row is covered and the list keeps no
* room in reserve for them.
*
* `SidebarToastHost` wraps the whole sidebar. The list raises toasts inside `SidebarToastRegion`,
* and the footer shows them in `SidebarToastSlot`.
*/
interface SidebarToast {
api: ToastApi;
toast: ToastState | null;
dismiss: () => void;
}
const SidebarToastContext = createContext<SidebarToast | null>(null);
function useSidebarToast(): SidebarToast {
const value = useContext(SidebarToastContext);
if (!value) throw new Error("Sidebar toasts need a SidebarToastHost");
return value;
}
/**
* A collapsed sidebar stays mounted and its rows still handle shortcuts, so while it is hidden
* their toasts go to the app's host instead. The sidebar's API stays stable across open and close
* so that toggling the panel doesn't re-render every row.
*/
export function SidebarToastHost({ active, children }: { active: boolean; children: ReactNode }) {
const appToast = useToast();
const { api: sidebarToast, toast, dismiss } = useToastHost();
const activeRef = useRef(active);
useEffect(() => {
activeRef.current = active;
return () => {
// An archive holds on to this API, so a failure that lands later needs a host on screen.
activeRef.current = false;
// A toast must not outlive its visibility: its dismissal is what ends an archive's undo.
dismiss();
};
}, [active, dismiss]);
const api = useMemo<ToastApi>(() => {
const visibleHost = () => (activeRef.current ? sidebarToast : appToast);
return {
show: (content, options) => visibleHost().show(content, options),
copied: (label) => visibleHost().copied(label),
error: (message) => visibleHost().error(message),
};
}, [appToast, sidebarToast]);
const value = useMemo(() => ({ api, toast, dismiss }), [api, toast, dismiss]);
return <SidebarToastContext.Provider value={value}>{children}</SidebarToastContext.Provider>;
}
/** Sends toasts raised by the workspace list to the sidebar. */
export function SidebarToastRegion({ children }: { children: ReactNode }) {
const { api, dismiss } = useSidebarToast();
// Replacing the list, with a plugin view for example, ends its toast and so its undo.
useEffect(() => dismiss, [dismiss]);
return <ToastApiProvider api={api}>{children}</ToastApiProvider>;
}
/** Shows the sidebar's toast over the footer line it is rendered in. */
export function SidebarToastSlot() {
const { toast, dismiss } = useSidebarToast();
return <ToastViewport toast={toast} onDismiss={dismiss} placement="sidebar" />;
}
+31 -10
View File
@@ -229,16 +229,21 @@ export function ToastViewport({
collapsable={false}
>
<View
style={placement === "sidebar" ? styles.sidebarWidthBoundary : styles.widthBoundary}
style={placement === "sidebar" ? animatedStyles.sidebarLine : styles.widthBoundary}
pointerEvents="box-none"
collapsable={false}
>
<View
style={animatedStyles.surface}
style={placement === "sidebar" ? null : animatedStyles.surface}
testID={toast.testID ?? "app-toast"}
accessibilityRole="alert"
>
<ToastCard toast={toast} onPointerEnter={pauseDismiss} onPointerLeave={resumeDismiss} />
<ToastCard
toast={toast}
fillsLine={placement === "sidebar"}
onPointerEnter={pauseDismiss}
onPointerLeave={resumeDismiss}
/>
</View>
</View>
</Animated.View>
@@ -253,10 +258,12 @@ export function ToastViewport({
function ToastCard({
toast,
fillsLine,
onPointerEnter,
onPointerLeave,
}: {
toast: ToastState;
fillsLine: boolean;
onPointerEnter: () => void;
onPointerLeave: () => void;
}) {
@@ -278,6 +285,7 @@ function ToastCard({
onPointerLeave={onPointerLeave}
style={[
styles.toast,
fillsLine ? styles.toastFillsLine : null,
toast.variant === "info" ? styles.toastInfo : null,
toast.variant === "warning" ? styles.toastWarning : null,
toast.variant === "error" ? styles.toastError : null,
@@ -289,7 +297,10 @@ function ToastCard({
{toast.content}
</Text>
) : (
<View testID="app-toast-message" style={styles.contentSlot}>
<View
testID="app-toast-message"
style={[styles.contentSlot, fillsLine ? styles.contentFillsLine : null]}
>
{toast.content}
</View>
)}
@@ -305,14 +316,20 @@ const animatedStyles = NativeStyleSheet.create({
zIndex: OVERLAY_Z.toast,
alignItems: "center",
},
// Covers the line the slot is rendered in, so the toast hides that line's controls rather than
// anything above it.
sidebarContainer: {
position: "absolute",
top: 0,
bottom: 0,
left: SPACING[2],
right: SPACING[2],
bottom: SPACING[2],
zIndex: OVERLAY_Z.toast,
alignItems: "center",
justifyContent: "flex-end",
},
// Centred on the line while it fits. A taller card, wrapped at the narrowest sidebar, loses its
// auto margins and grows upward from the line's bottom edge rather than past the window.
sidebarLine: { marginVertical: "auto" },
surface: { alignSelf: "center", maxWidth: "100%" },
});
@@ -322,10 +339,6 @@ const styles = StyleSheet.create((theme) => ({
maxWidth: TOAST_MAX_WIDTH,
alignItems: "center",
},
sidebarWidthBoundary: {
width: "100%",
alignItems: "center",
},
toast: {
alignSelf: "center",
maxWidth: "100%",
@@ -340,6 +353,11 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[3],
...theme.shadow.md,
},
// Fits the footer's pointer-sized icon line.
toastFillsLine: {
alignSelf: "stretch",
paddingVertical: theme.spacing[1],
},
toastInfo: {
borderColor: theme.colors.palette.blue[300],
},
@@ -357,6 +375,9 @@ const styles = StyleSheet.create((theme) => ({
flexShrink: 1,
minWidth: 0,
},
contentFillsLine: {
flex: 1,
},
message: {
flexShrink: 1,
color: theme.colors.foreground,
@@ -34,6 +34,8 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
flexWrap: "wrap",
alignItems: "center",
// Undo sits at the far edge when the card spans the sidebar.
justifyContent: "space-between",
gap: theme.spacing[2],
},
// Cancels the badge's own inset, which would otherwise add to the button's gap.