diff --git a/packages/app/e2e/browser/workspace-archive-undo.spec.ts b/packages/app/e2e/browser/workspace-archive-undo.spec.ts index 9bf8e4cafd..9f9567b75a 100644 --- a/packages/app/e2e/browser/workspace-archive-undo.spec.ts +++ b/packages/app/e2e/browser/workspace-archive-undo.spec.ts @@ -18,8 +18,11 @@ import { createGatedTeardown, expectDirectoryPresence, expectHostWorkspaceSettledActive, + holdNextArchiveRequest, outlastUndoWindow, + pressArchiveShortcut, pressUndoShortcut, + resizeSidebarToMinimum, rowArchiveButton, rowKebab, undoButton, @@ -84,7 +87,90 @@ test.describe("Workspace archive undo", () => { rowArchiveButton(page, worktree.workspaceId).boundingBox(), rowKebab(page, worktree.workspaceId).boundingBox(), ]); - expect(buttonBox && kebabBox && buttonBox.x + buttonBox.width <= kebabBox.x).toBe(true); + if (!buttonBox || !kebabBox) throw new Error("Row actions are not laid out"); + 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 ({ + 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(), + ]); + 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); + + 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.releaseForUnknownWorkspace("workspace-the-daemon-never-had"); + + await expect(page.getByTestId("app-toast-message")).toHaveText( + "Workspace not found: workspace-the-daemon-never-had", + ); + }); + + test("the archived toast keeps Undo inside the card at the narrowest sidebar", async ({ + page, + }) => { + const worktree = await seedWorktree(page, "archive-toast-narrow"); + await resizeSidebarToMinimum(page); + + await archiveFromRowButton(page, worktree.workspaceId); + + const [toastBox, undoBox] = await Promise.all([ + page.getByTestId("workspace-archived-toast").boundingBox(), + undoButton(page).boundingBox(), + ]); + if (!toastBox || !undoBox) 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); + }); + + test("archiving by shortcut with the sidebar collapsed shows the toast over the app", async ({ + page, + }) => { + const worktree = await seedWorktree(page, "archive-toast-collapsed"); + await workspaceRow(page, worktree.workspaceId).click(); + await page.keyboard.press("ControlOrMeta+b"); + await expect(workspaceRow(page, worktree.workspaceId)).not.toBeInViewport(); + + page.once("dialog", (dialog) => void dialog.accept()); + await pressArchiveShortcut(page); + + const toast = page.getByTestId("workspace-archived-toast"); + await expect(toast).toBeInViewport(); + const toastBox = await toast.boundingBox(); + if (!toastBox) throw new Error("Toast is not laid out"); + expect(toastBox.y).toBeLessThan(page.viewportSize()!.height / 2); }); test("Cmd/Ctrl+Z right after archiving keeps the worktree", async ({ page }) => { diff --git a/packages/app/e2e/support/helpers/workspace-archive-undo.ts b/packages/app/e2e/support/helpers/workspace-archive-undo.ts index 0eadc58644..7f6846b252 100644 --- a/packages/app/e2e/support/helpers/workspace-archive-undo.ts +++ b/packages/app/e2e/support/helpers/workspace-archive-undo.ts @@ -4,12 +4,15 @@ 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"; type DaemonClient = Awaited>; const UNDO_SHORTCUT = process.platform === "darwin" ? "Meta+Z" : "Control+Z"; +const ARCHIVE_SHORTCUT = + process.platform === "darwin" ? "Meta+Shift+Backspace" : "Control+Shift+Backspace"; /** The archived toast's lifetime, from `WORKSPACE_ARCHIVE_UNDO_WINDOW_MS`. */ const UNDO_WINDOW_MS = 6000; @@ -46,6 +49,25 @@ export async function archiveFromRowButton(page: Page, workspaceId: string): Pro await expect(undoButton(page)).toBeVisible(); } +/** Drags the sidebar's edge past its minimum, leaving it as narrow as it goes. */ +export async function resizeSidebarToMinimum(page: Page): Promise { + const handle = await page.getByTestId("left-sidebar-resize-handle").boundingBox(); + if (!handle) throw new Error("Sidebar resize handle is not laid out"); + const y = handle.y + handle.height / 2; + await page.mouse.move(handle.x + handle.width / 2, y); + await page.mouse.down(); + await page.mouse.move(handle.x - 30, y, { steps: 8 }); + await page.mouse.move(10, y, { steps: 16 }); + await page.mouse.up(); + await expect + .poll(async () => (await page.getByTestId("sidebar-footer").boundingBox())?.width) + .toBeLessThanOrEqual(200); +} + +export async function pressArchiveShortcut(page: Page): Promise { + await page.keyboard.press(ARCHIVE_SHORTCUT); +} + export async function pressUndoShortcut(page: Page): Promise { await page.keyboard.press(UNDO_SHORTCUT); } @@ -76,6 +98,51 @@ export async function expectDirectoryPresence(directory: string, present: boolea await expect.poll(() => existsSync(directory), { timeout: 30_000 }).toBe(present); } +export interface HeldArchiveRequest { + waitForHeld(): Promise; + /** Sends the held request to the daemon naming a workspace it has no record of, so it refuses. */ + releaseForUnknownWorkspace(workspaceId: string): void; +} + +/** Keeps the next archive request from reaching the daemon until the test releases it. */ +export async function holdNextArchiveRequest(page: Page): Promise { + let held: { request: SessionRequest; forward: (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 = { request, forward: (raw) => server.send(raw) }; + return; + } + server.send(message); + }); + server.onMessage((message) => browser.send(message)); + }); + return { + waitForHeld: () => expect.poll(() => held !== null).toBe(true), + releaseForUnknownWorkspace: (workspaceId) => { + if (!held) throw new Error("No archive request is held"); + held.forward(JSON.stringify({ type: "session", message: { ...held.request, workspaceId } })); + }, + }; +} + +interface SessionRequest { + type: string; + requestId: string; + workspaceId: string; +} + +function parseSessionRequest(message: string | Buffer): SessionRequest | 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; diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index 7504b175aa..2adc95e7f6 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -39,6 +39,7 @@ 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 { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; @@ -644,31 +645,33 @@ function MobileSidebar({ - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} + - {isInitialLoad && !hasActiveHostFilter ? ( - - ) : ( - - )} + + {isInitialLoad && !hasActiveHostFilter ? ( + + ) : ( + + )} + diff --git a/packages/app/src/components/sidebar/sidebar-toast-region.tsx b/packages/app/src/components/sidebar/sidebar-toast-region.tsx new file mode 100644 index 0000000000..852ce7302d --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-toast-region.tsx @@ -0,0 +1,52 @@ +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(() => { + 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 ( + + {children} + + + ); +} + +const styles = StyleSheet.create({ + region: { + position: "relative", + flex: 1, + minHeight: 0, + }, +}); diff --git a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx index f1c2fabcd1..ac872ffbd8 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-menu.tsx @@ -74,7 +74,11 @@ const pinLeadingIcon = ; /** Pointer-only, so it is always the kebab's pointer size. */ -export const ARCHIVE_BUTTON_SIZE = 18; +const ARCHIVE_BUTTON_SIZE = 18; +/** Keeps a press aimed at the kebab from landing on the one-click archive. */ +const ARCHIVE_BUTTON_KEBAB_GAP = 8; +/** The width the archive button and its gap take up left of the kebab. */ +export const ARCHIVE_BUTTON_FOOTPRINT = ARCHIVE_BUTTON_SIZE + ARCHIVE_BUTTON_KEBAB_GAP; const triggerIconProps = densityIconProps("sm", foregroundMutedColorMapping); const triggerIconHoveredProps = densityIconProps("sm", foregroundColorMapping); @@ -482,7 +486,6 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "center", borderRadius: 4, - marginLeft: 2, // Center the touch box on the 20px title line it is anchored to. marginTop: bySizing({ touch: -6, pointer: 0 }), // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but @@ -493,10 +496,11 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface2, }, // The kebab's box without its offsets: the archive glyph fills its view box, so it needs - // none of the nudging the three dots do. The kebab's marginLeft is the gap between them. + // none of the nudging the three dots do. archiveButton: { width: ARCHIVE_BUTTON_SIZE, height: ARCHIVE_BUTTON_SIZE, + marginRight: ARCHIVE_BUTTON_KEBAB_GAP, alignItems: "center", justifyContent: "center", borderRadius: 4, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index aef846c4a3..eda162d52c 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -31,7 +31,7 @@ import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sideba import { DensityIcon } from "@/components/ui/density-icon"; import { SCRIM_WIDTH, TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { - ARCHIVE_BUTTON_SIZE, + ARCHIVE_BUTTON_FOOTPRINT, SidebarWorkspaceArchiveButton, } from "@/components/sidebar/sidebar-workspace-menu"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; @@ -446,9 +446,6 @@ export function SidebarWorkspaceTrailingActionBase({ ); } -// The archive button plus the kebab's marginLeft, which is the gap between them. -const ARCHIVE_BUTTON_SCRIM_WIDTH = ARCHIVE_BUTTON_SIZE + 2; - export function SidebarWorkspaceTrailingActionOverlay({ visible, scrimBackdrop, @@ -473,7 +470,7 @@ export function SidebarWorkspaceTrailingActionOverlay({ {scrimBackdrop ? ( ) : null} diff --git a/packages/app/src/components/toast-host.tsx b/packages/app/src/components/toast-host.tsx index 81020210d9..7be8273a79 100644 --- a/packages/app/src/components/toast-host.tsx +++ b/packages/app/src/components/toast-host.tsx @@ -4,14 +4,7 @@ import { } from "@/components/floating-header"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { - Platform, - Pressable, - StyleSheet as NativeStyleSheet, - Text, - ToastAndroid, - View, -} from "react-native"; +import { Platform, StyleSheet as NativeStyleSheet, Text, ToastAndroid, View } from "react-native"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -59,7 +52,7 @@ export interface ToastApi { error: (message: string) => void; } -type ToastViewportPlacement = "app-shell" | "panel"; +type ToastViewportPlacement = "app-shell" | "panel" | "sidebar"; const DEFAULT_DURATION_MS = 2200; const noopDismiss: DismissToast = () => {}; @@ -221,11 +214,17 @@ export function ToastViewport({ const content = ( - + {toast ? ( - + ) : null} @@ -250,12 +249,12 @@ export function ToastViewport({ function ToastCard({ toast, - onHoverIn, - onHoverOut, + onPointerEnter, + onPointerLeave, }: { toast: ToastState; - onHoverIn: () => void; - onHoverOut: () => void; + onPointerEnter: () => void; + onPointerLeave: () => void; }) { let defaultIcon: ReactNode = null; if (toast.variant === "info") { @@ -270,10 +269,9 @@ function ToastCard({ const icon = toast.icon ?? defaultIcon; return ( - )} - + ); } @@ -300,7 +298,7 @@ const animatedStyles = NativeStyleSheet.create({ }); const styles = StyleSheet.create((theme) => ({ - container: (placement: ToastViewportPlacement, topOffset: number) => ({ + container: (placement: Exclude, topOffset: number) => ({ position: "absolute", left: theme.spacing[4], right: theme.spacing[4], @@ -308,11 +306,23 @@ const styles = StyleSheet.create((theme) => ({ zIndex: OVERLAY_Z.toast, alignItems: "center", }), + sidebarContainer: { + position: "absolute", + left: theme.spacing[2], + right: theme.spacing[2], + bottom: theme.spacing[2], + zIndex: OVERLAY_Z.toast, + alignItems: "center", + }, widthBoundary: { width: "92%", maxWidth: TOAST_MAX_WIDTH, alignItems: "center", }, + sidebarWidthBoundary: { + width: "100%", + alignItems: "center", + }, toast: { alignSelf: "center", maxWidth: "100%", diff --git a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx index 584b71dec9..4cca52fd48 100644 --- a/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx +++ b/packages/app/src/workspace/archive-undo/internal/workspace-archived-toast.tsx @@ -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 ? : null), [undoKeys]); + const undoShortcut = useMemo( + () => (undoKeys ? : null), + [undoKeys], + ); return ( @@ -29,9 +32,14 @@ export function WorkspaceArchivedToast({ onUndo }: { onUndo: () => void }) { const styles = StyleSheet.create((theme) => ({ row: { flexDirection: "row", + flexWrap: "wrap", 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,