diff --git a/docs/menus.md b/docs/menus.md index 590def2b33..3e29e13ac3 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -226,7 +226,8 @@ its own. inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes inactive, and that inactive commit is where the surface unmounts. -- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover - card, and are unit-tested. Fix positioning bugs there, not at a call site. +- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in + `components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a + surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site. - Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates, status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md). diff --git a/packages/app/e2e/browser/sidebar-footer-overflow.spec.ts b/packages/app/e2e/browser/sidebar-footer-overflow.spec.ts new file mode 100644 index 0000000000..da16dbb648 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-footer-overflow.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from "../support/fixtures"; +import { addProjectFlow } from "../support/helpers/add-project-flow"; +import { gotoAppShell } from "../support/helpers/app"; +import { + chooseFooterOverflowAction, + expectFooterLine, + expectFooterMenusInsideViewport, + expectFooterNeverClipped, + expectFooterOverflowActions, + footerOverflowMenu, + openFooterOverflow, + seedDockedSidebarWidth, + watchFooterClipping, +} from "../support/helpers/sidebar-footer-overflow"; +import { addOfflineHostsAndReload } from "../support/helpers/hosts"; +import { openCompactSidebar } from "../support/helpers/usage-sidebar-item"; + +const TOUCH_CONTROL = 44; +const MIN_SIDEBAR_WIDTH = 200; +const ALL_INLINE = [ + "sidebar-add-project", + "sidebar-usage-icon", + "sidebar-hosts-trigger", + "sidebar-help", + "sidebar-settings", +]; +// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret. +const NARROW_TOUCH_LINE = [ + "sidebar-add-project", + "sidebar-hosts-trigger", + "sidebar-footer-overflow", + "sidebar-settings", +]; + +test.describe("Sidebar footer on a touch screen", () => { + test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true }); + + test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({ + page, + }) => { + await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH); + await watchFooterClipping(page); + await gotoAppShell(page); + await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL }); + await expectFooterNeverClipped(page); + + await test.step("Help opens as a page of the overflow menu", async () => { + await openFooterOverflow(page); + await expectFooterOverflowActions(page, ["Usage", "Help and support"]); + await chooseFooterOverflowAction(page, "Help and support"); + await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible(); + await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(footerOverflowMenu(page)).toBeHidden(); + }); + + await test.step("Usage opens from the overflow menu", async () => { + await openFooterOverflow(page); + await chooseFooterOverflowAction(page, "Usage"); + await expect(footerOverflowMenu(page)).toBeHidden(); + await expect(page.getByTestId("usage-modal")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("usage-modal")).toBeHidden(); + }); + + await test.step("Add project and Settings shortcuts still work", async () => { + await page.keyboard.press("Control+o"); + await expect(addProjectFlow(page)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).toBeHidden(); + await page.keyboard.press("Control+,"); + await expect(page).toHaveURL(/\/settings\/general$/); + }); + }); + + test("the default sidebar width keeps all five actions inline", async ({ page }) => { + await gotoAppShell(page); + await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL }); + }); +}); + +test.describe("Sidebar footer in a compact overlay narrower than the footer", () => { + test.use({ viewport: { width: 220, height: 800 }, hasTouch: true }); + + test("the overflow menu reaches the hidden actions", async ({ page }) => { + await watchFooterClipping(page); + await gotoAppShell(page); + await openCompactSidebar(page); + await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL }); + await expectFooterNeverClipped(page); + await openFooterOverflow(page); + await expectFooterOverflowActions(page, ["Usage", "Help and support"]); + await expectFooterMenusInsideViewport(page); + }); +}); + +test.describe("Sidebar footer on a short, narrow phone", () => { + // 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it. + test.use({ viewport: { width: 180, height: 360 }, hasTouch: true }); + + test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => { + await gotoAppShell(page); + await addOfflineHostsAndReload( + page, + Array.from({ length: 10 }, (_, index) => ({ + serverId: `overflow-host-${index}`, + label: `Overflow host ${index}`, + })), + ); + await openCompactSidebar(page); + await expectFooterLine(page, { + testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"], + controlSize: TOUCH_CONTROL, + }); + await openFooterOverflow(page); + await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]); + await expectFooterMenusInsideViewport(page); + await chooseFooterOverflowAction(page, "Hosts"); + await expectFooterMenusInsideViewport(page); + const addHost = page.getByRole("menuitem", { name: "Add host" }); + await addHost.scrollIntoViewIfNeeded(); + await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible(); + await expect(addHost).toBeInViewport(); + await addHost.click(); + await expect(page.getByText("Add connection", { exact: true })).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/support/helpers/hosts.ts b/packages/app/e2e/support/helpers/hosts.ts index 69e0c4b9f5..0e0a9f56fe 100644 --- a/packages/app/e2e/support/helpers/hosts.ts +++ b/packages/app/e2e/support/helpers/hosts.ts @@ -22,15 +22,27 @@ export async function addOfflineHostAndReload( page: Page, input: { serverId: string; label: string; primaryLabel?: string }, ): Promise { - const offlineHost = buildSeededHost({ - serverId: input.serverId, - label: input.label, - endpoint: "127.0.0.1:59999", - nowIso: new Date().toISOString(), - }); + await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel }); +} + +/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */ +export async function addOfflineHostsAndReload( + page: Page, + hosts: Array<{ serverId: string; label: string }>, + options: { primaryLabel?: string } = {}, +): Promise { + const nowIso = new Date().toISOString(); + const offlineHosts = hosts.map((host) => + buildSeededHost({ + serverId: host.serverId, + label: host.label, + endpoint: "127.0.0.1:59999", + nowIso, + }), + ); await page.evaluate( - ({ host, keys, primaryLabel }) => { + ({ hosts: added, keys, primaryLabel }) => { const nonce = localStorage.getItem(keys.nonce); if (!nonce) { throw new Error("Expected the e2e seed nonce before overriding the host registry."); @@ -40,20 +52,22 @@ export async function addOfflineHostAndReload( if (primaryLabel && registry[0]) { registry[0].label = primaryLabel; } - if (!registry.some((entry) => entry.serverId === host.serverId)) { - registry.push(host); + for (const host of added) { + if (!registry.some((entry) => entry.serverId === host.serverId)) { + registry.push(host); + } } localStorage.setItem(keys.registry, JSON.stringify(registry)); localStorage.setItem(keys.disableSeedOnce, nonce); }, { - host: offlineHost, + hosts: offlineHosts, keys: { registry: REGISTRY_KEY, nonce: SEED_NONCE_KEY, disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY, }, - primaryLabel: input.primaryLabel, + primaryLabel: options.primaryLabel, }, ); diff --git a/packages/app/e2e/support/helpers/sidebar-footer-overflow.ts b/packages/app/e2e/support/helpers/sidebar-footer-overflow.ts new file mode 100644 index 0000000000..85983578df --- /dev/null +++ b/packages/app/e2e/support/helpers/sidebar-footer-overflow.ts @@ -0,0 +1,127 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** Every footer control that can sit in the icon line, caret included. */ +const FOOTER_LINE_TEST_IDS = [ + "sidebar-add-project", + "sidebar-usage-icon", + "sidebar-hosts-trigger", + "sidebar-help", + "sidebar-footer-overflow", + "sidebar-settings", +]; + +// Browser layout boxes include floating-point rounding, even for whole-pixel styles. +const GEOMETRY_TOLERANCE = 0.01; + +/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */ +export async function seedDockedSidebarWidth(page: Page, width: number): Promise { + await page.addInitScript((sidebarWidth) => { + localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } })); + }, width); +} + +/** + * From the first frame, record any frame in which a footer button reaches past its row, so a row + * laid out before it measured shows up even if it lasted one frame. + */ +export async function watchFooterClipping(page: Page): Promise { + await page.addInitScript((testIDs) => { + const clippedFrames: string[] = []; + Object.assign(window, { __footerClippedFrames: clippedFrames }); + const sample = () => { + for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) { + const rowRect = row.getBoundingClientRect(); + if (rowRect.width === 0) continue; + for (const testID of testIDs) { + const button = row.querySelector(`[data-testid="${testID}"]`); + if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) { + clippedFrames.push(testID); + } + } + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, FOOTER_LINE_TEST_IDS); +} + +export async function expectFooterNeverClipped(page: Page): Promise { + const clipped = await page.evaluate( + () => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames, + ); + expect(clipped).toEqual([]); +} + +function footerLine(page: Page): Locator { + return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible'); +} + +export function footerOverflowMenu(page: Page): Locator { + return page.getByTestId("sidebar-footer-overflow-menu"); +} + +/** + * The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the + * line's box, so no button is cut off by the sidebar's edge. + */ +export async function expectFooterLine( + page: Page, + input: { testIDs: string[]; controlSize: number }, +): Promise { + const line = footerLine(page); + await expect(line).toHaveCount(1); + await expect + .poll(async () => + line + .locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(",")) + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))), + ) + .toEqual(input.testIDs); + const { row, buttons } = await line.evaluate((element, testIDs) => { + const rect = (node: Element) => { + const { x, width, height } = node.getBoundingClientRect(); + return { x, width, height }; + }; + return { + row: rect(element), + buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)), + }; + }, input.testIDs); + for (const button of buttons) { + expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE); + expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE); + expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE); + expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE); + } +} + +export async function openFooterOverflow(page: Page): Promise { + await footerLine(page).getByTestId("sidebar-footer-overflow").click(); + await expect(footerOverflowMenu(page)).toBeVisible(); +} + +/** The overflow menu lists exactly these actions, in row order. */ +export async function expectFooterOverflowActions(page: Page, names: string[]): Promise { + await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names); +} + +export async function chooseFooterOverflowAction(page: Page, name: string): Promise { + await footerOverflowMenu(page).getByRole("menuitem", { name }).click(); +} + +/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */ +export async function expectFooterMenusInsideViewport(page: Page): Promise { + const viewport = page.viewportSize()!; + const rows = page.getByRole("menuitem"); + await expect(rows.first()).toBeVisible(); + const boxes = await rows.evaluateAll((elements) => + elements.map((element) => { + const { x, width } = element.getBoundingClientRect(); + return { x, width }; + }), + ); + for (const box of boxes) { + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + } +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b4b54db3db..1b2df08302 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -17,6 +17,7 @@ import { Text, useWindowDimensions, View, + type LayoutChangeEvent, } from "react-native"; import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; @@ -40,6 +41,17 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; 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"; +import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu"; +import { + resolveSidebarFooterLayout, + SIDEBAR_FOOTER_ACTIONS, + type SidebarFooterAction, +} from "@/components/sidebar-footer-layout"; +import { + controlSizingForLayout, + mapLayout, + TOOLBAR_CONTROL_SIZES, +} from "@/components/ui/control-geometry"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { DensityIcon } from "@/components/ui/density-icon"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; @@ -55,6 +67,7 @@ import { type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { density } from "@/styles/density"; +import { SPACING } from "@/styles/theme"; import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { RetainedPanelActivity } from "@/components/retained-panel"; @@ -79,6 +92,9 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list"; type SidebarTheme = ReturnType["theme"]; +/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */ +const FOOTER_ROW_PADDING = SPACING[2]; + const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; interface SidebarSharedProps { @@ -410,35 +426,72 @@ function SidebarFooter({ }) { const newAgentKeys = useShortcutKeys("new-agent"); const settingsKeys = useShortcutKeys("toggle-settings"); + const isCompactLayout = useIsCompactFormFactor(); + const [rowWidth, setRowWidth] = useState(0); + const handleRowLayout = useCallback((event: LayoutChangeEvent) => { + setRowWidth(event.nativeEvent.layout.width); + }, []); + const { inline, overflow } = useMemo( + () => + resolveSidebarFooterLayout({ + rowWidth, + controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)], + rowPadding: FOOTER_ROW_PADDING, + actions: SIDEBAR_FOOTER_ACTIONS, + }), + [rowWidth, isCompactLayout], + ); + const isInline = (action: SidebarFooterAction) => inline.includes(action); - // One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. + // One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that + // don't fit move behind a caret before Settings. return ( - - - - + + {isInline("add-project") ? ( + + ) : null} + {isInline("usage") ? : null} + {isInline("hosts") ? ( + + ) : null} - - + {isInline("help") ? : null} + {overflow.length > 0 ? ( + + ) : null} + {isInline("settings") ? ( + + ) : null} @@ -937,10 +990,12 @@ const styles = StyleSheet.create((theme) => ({ borderTopColor: theme.colors.border, }, // 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: { flexDirection: "row", alignItems: "center", - paddingHorizontal: theme.spacing[2], + minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2), + paddingHorizontal: FOOTER_ROW_PADDING, paddingVertical: theme.spacing[1.5], }, // Pushes Help and Settings to the end of the icon line. diff --git a/packages/app/src/components/sidebar-footer-layout.test.ts b/packages/app/src/components/sidebar-footer-layout.test.ts new file mode 100644 index 0000000000..b4ec0584f7 --- /dev/null +++ b/packages/app/src/components/sidebar-footer-layout.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + resolveSidebarFooterLayout, + SIDEBAR_FOOTER_ACTIONS, +} from "@/components/sidebar-footer-layout"; +import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store"; + +const TOUCH = 44; +const POINTER = 28; +const ROW_PADDING = 8; + +function layout(rowWidth: number, controlSize: number) { + return resolveSidebarFooterLayout({ + rowWidth, + controlSize, + rowPadding: ROW_PADDING, + actions: SIDEBAR_FOOTER_ACTIONS, + }); +} + +describe("sidebar footer layout", () => { + it("keeps every action inline at the default width with either sizing", () => { + for (const controlSize of [TOUCH, POINTER]) { + expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({ + inline: ["add-project", "usage", "hosts", "help", "settings"], + overflow: [], + }); + } + }); + + it("keeps every action inline at the minimum width with pointer sizing", () => { + expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]); + }); + + // The iPhone Duo report: the folded sidebar column is about the minimum width, and touch + // sizing makes five 44pt buttons plus padding 236pt wide. + it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => { + expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({ + inline: ["add-project", "hosts", "settings"], + overflow: ["usage", "help"], + }); + }); + + it("keeps Settings and Add project inline longest", () => { + // Three slots: two actions and the caret. + expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({ + inline: ["add-project", "settings"], + overflow: ["usage", "hosts", "help"], + }); + // Two slots: Settings and the caret. + expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({ + inline: ["settings"], + overflow: ["add-project", "usage", "hosts", "help"], + }); + }); + + it("puts everything behind the caret when only the caret fits", () => { + expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({ + inline: [], + overflow: ["add-project", "usage", "hosts", "help", "settings"], + }); + }); + + it("fits all five actions exactly at their combined width", () => { + const exact = 5 * TOUCH + 2 * ROW_PADDING; + expect(layout(exact, TOUCH).overflow).toEqual([]); + // Browser layout rounds a little below the exact width. + expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]); + expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]); + }); + + it("only places the actions it is given", () => { + expect( + resolveSidebarFooterLayout({ + rowWidth: 3 * TOUCH + 2 * ROW_PADDING, + controlSize: TOUCH, + rowPadding: ROW_PADDING, + actions: ["add-project", "hosts", "settings"], + }), + ).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] }); + }); + + // Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars. + it("places nothing before the row has been measured", () => { + for (const controlSize of [TOUCH, POINTER]) { + expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] }); + } + }); +}); diff --git a/packages/app/src/components/sidebar-footer-layout.ts b/packages/app/src/components/sidebar-footer-layout.ts new file mode 100644 index 0000000000..5513d59d09 --- /dev/null +++ b/packages/app/src/components/sidebar-footer-layout.ts @@ -0,0 +1,62 @@ +/** The sidebar footer's icon line, in row order. */ +export const SIDEBAR_FOOTER_ACTIONS = [ + "add-project", + "usage", + "hosts", + "help", + "settings", +] as const; + +export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number]; + +/** The order actions keep their place in a row too narrow for all of them. */ +const KEEP_ORDER: readonly SidebarFooterAction[] = [ + "settings", + "add-project", + "hosts", + "usage", + "help", +]; + +/** Measured widths land a hair under the exact sum of their parts. */ +const MEASUREMENT_TOLERANCE = 0.5; + +export interface SidebarFooterLayout { + /** Actions drawn in the row, in row order. */ + inline: SidebarFooterAction[]; + /** Actions behind the overflow caret, in row order. Empty when everything fits. */ + overflow: SidebarFooterAction[]; +} + +/** + * Splits the footer's actions between the row and the overflow menu. Buttons keep their full size; + * when they don't all fit, the caret takes one slot and the actions that lose their place move + * behind it, Help first and Settings last. Nothing is placed until the row has a measured width. + */ +export function resolveSidebarFooterLayout(input: { + rowWidth: number; + controlSize: number; + rowPadding: number; + actions: readonly SidebarFooterAction[]; +}): SidebarFooterLayout { + const { rowWidth, controlSize, rowPadding, actions } = input; + // An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow + // sidebar before the real width arrives. + if (rowWidth <= 0) { + return { inline: [], overflow: [] }; + } + const slots = Math.floor( + (rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1), + ); + if (slots >= actions.length) { + return { inline: [...actions], overflow: [] }; + } + const inlineCount = Math.max(slots - 1, 0); + const kept = new Set( + KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount), + ); + return { + inline: actions.filter((action) => kept.has(action)), + overflow: actions.filter((action) => !kept.has(action)), + }; +} diff --git a/packages/app/src/components/sidebar/sidebar-footer-menu-trigger.tsx b/packages/app/src/components/sidebar/sidebar-footer-menu-trigger.tsx new file mode 100644 index 0000000000..fd165efa79 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-footer-menu-trigger.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; +import type { LucideIcon } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { DensityIcon } from "@/components/ui/density-icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { density } from "@/styles/density"; +import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight"; + +/** + * A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the + * tooltip stays out of the way while the menu is open. + */ +export function SidebarFooterMenuTrigger({ + icon, + label, + menuOpen, + testID, +}: { + icon: LucideIcon; + label: string; + menuOpen: boolean; + testID: string; +}): ReactElement { + return ( + + + + + {({ hovered, pressed, open }) => ( + <> + + + + )} + + + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + width: density.iconButton, + height: density.iconButton, + alignItems: "center", + justifyContent: "center", + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[1], + }, + tooltipText: { + fontSize: theme.fontSize.base, + color: theme.colors.popoverForeground, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-footer-overflow-menu.tsx b/packages/app/src/components/sidebar/sidebar-footer-overflow-menu.tsx new file mode 100644 index 0000000000..aef13f7e28 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-footer-overflow-menu.tsx @@ -0,0 +1,245 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { + ChevronUp, + CircleGauge, + CircleHelp, + FolderPlus, + Plus, + Server, + Settings, +} from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { HostStatusDotSlot } from "@/components/hosts/host-picker"; +import type { SidebarFooterAction } from "@/components/sidebar-footer-layout"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSubTrigger, + type MenuPageDefinition, +} from "@/components/ui/dropdown-menu"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useHosts } from "@/runtime/host-runtime"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { orderHostsLocalFirst } from "@/types/host-connection"; +import { useOpenSidebarUsage } from "@/usage"; +import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger"; +import { SidebarHelpMenuItems } from "./sidebar-help-menu"; + +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const ThemedFolderPlus = withUnistyles(FolderPlus); +const ThemedCircleGauge = withUnistyles(CircleGauge); +const ThemedServer = withUnistyles(Server); +const ThemedCircleHelp = withUnistyles(CircleHelp); +const ThemedSettings = withUnistyles(Settings); +const ThemedPlus = withUnistyles(Plus); +const addProjectLeading = ; +const usageLeading = ; +const hostsLeading = ; +const helpLeading = ; +const settingsLeading = ; +const addHostLeading = ; + +const HOSTS_PAGE_ID = "hosts"; +const HELP_PAGE_ID = "help"; + +export interface SidebarFooterOverflowMenuProps { + /** The footer actions that did not fit in the row, in row order. */ + actions: readonly SidebarFooterAction[]; + labels: { addProject: string; hosts: string; settings: string; usage: string }; + onAddProject: () => void; + onSettings: () => void; + onAddHost: () => void; + onOpenHostSettings: (serverId: string) => void; +} + +/** + * The caret at the end of a footer row too narrow for every action. It lists the actions that + * lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their + * own in the row, open as pages of this menu. + */ +export function SidebarFooterOverflowMenu({ + actions, + labels, + onAddProject, + onSettings, + onAddHost, + onOpenHostSettings, +}: SidebarFooterOverflowMenuProps): ReactElement { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const openUsage = useOpenSidebarUsage(); + const newAgentKeys = useShortcutKeys("new-agent"); + const settingsKeys = useShortcutKeys("toggle-settings"); + const helpLabel = t("sidebar.help.trigger"); + const addProjectTrailing = useMemo( + () => (newAgentKeys ? : null), + [newAgentKeys], + ); + const settingsTrailing = useMemo( + () => (settingsKeys ? : null), + [settingsKeys], + ); + + const pages = useMemo((): MenuPageDefinition[] => { + const result: MenuPageDefinition[] = []; + if (actions.includes("hosts")) { + result.push({ + id: HOSTS_PAGE_ID, + title: labels.hosts, + content: , + }); + } + if (actions.includes("help")) { + result.push({ id: HELP_PAGE_ID, title: helpLabel, content: }); + } + return result; + }, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]); + + function renderAction(action: SidebarFooterAction): ReactElement { + switch (action) { + case "add-project": + return ( + + {labels.addProject} + + ); + case "usage": + return ( + + {labels.usage} + + ); + case "hosts": + return ( + + {labels.hosts} + + ); + case "help": + return ( + + {helpLabel} + + ); + case "settings": + return ( + + {labels.settings} + + ); + } + } + + return ( + + + + {actions.map(renderAction)} + + + ); +} + +/** The host picker's choices as a menu page: each host opens its settings, then Add host. */ +function HostsPage({ + onAddHost, + onOpenHostSettings, +}: { + onAddHost: () => void; + onOpenHostSettings: (serverId: string) => void; +}): ReactElement { + const { t } = useTranslation(); + const hosts = useHosts(); + const localServerId = useLocalDaemonServerId(); + const orderedHosts = useMemo( + () => orderHostsLocalFirst(hosts, localServerId), + [hosts, localServerId], + ); + + return ( + <> + {orderedHosts.map((host) => ( + + ))} + {orderedHosts.length > 0 ? : null} + + {t("settings.addHost")} + + + ); +} + +function HostRow({ + serverId, + label, + onOpenHostSettings, +}: { + serverId: string; + label: string; + onOpenHostSettings: (serverId: string) => void; +}): ReactElement { + const handleSelect = useCallback( + () => onOpenHostSettings(serverId), + [onOpenHostSettings, serverId], + ); + const leading = useMemo(() => , [serverId]); + return ( + + {label} + + ); +} diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index 3ba3011517..d6ba4e98e1 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import { Text, View } from "react-native"; +import { View } from "react-native"; import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -12,17 +12,13 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { DensityIcon } from "@/components/ui/density-icon"; -import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger"; import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useHardwareKeyboardPresent } from "@/keyboard"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; -import { density } from "@/styles/density"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -77,12 +73,32 @@ function HostVersionHint({ host }: { host: HostProfile }) { ); } +/** The Help menu's trigger, and the menu it opens. */ export function SidebarHelpMenu() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + + + + + + + ); +} + +/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */ +export function SidebarHelpMenuItems() { const { t } = useTranslation(); const shortcutsAvailable = useHardwareKeyboardPresent(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); - const [open, setOpen] = useState(false); const version = formatVersionWithPrefix(resolveAppVersion()); const hosts = useHosts(); @@ -99,105 +115,65 @@ export function SidebarHelpMenu() { }, []); return ( - - - - - - {({ hovered, pressed, open: menuOpen }) => ( - <> - - - - )} - - - - - {t("sidebar.help.trigger")} - - - - {t("sidebar.help.sectionHelp")} - {shortcutsAvailable ? ( - - {t("sidebar.help.shortcuts")} - - ) : null} + <> + {t("sidebar.help.sectionHelp")} + {shortcutsAvailable ? ( - {t("sidebar.help.whatsNew")} + {t("sidebar.help.shortcuts")} - + {t("sidebar.help.whatsNew")} + + + {t("sidebar.help.diagnostics")} + + + {t("sidebar.help.reportIssue")} + + {t("sidebar.help.discord")} + + + {t("sidebar.help.github")} + + + + - {t("sidebar.help.diagnostics")} - - - {t("sidebar.help.reportIssue")} - - {t("sidebar.help.discord")} - - - {t("sidebar.help.github")} - - - - - {t("sidebar.help.appName")} - - {hosts.map((host) => ( - - ))} - - - + {t("sidebar.help.appName")} + + {hosts.map((host) => ( + + ))} + + ); } const styles = StyleSheet.create((theme) => ({ - trigger: { - width: density.iconButton, - height: density.iconButton, - alignItems: "center", - justifyContent: "center", - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1], - }, - tooltipText: { - fontSize: theme.fontSize.base, - color: theme.colors.popoverForeground, - }, versionList: { gap: theme.spacing[1], paddingVertical: theme.spacing[2], diff --git a/packages/app/src/components/ui/anchor.test.ts b/packages/app/src/components/ui/anchor.test.ts index cb18a86271..2e1cd6f68f 100644 --- a/packages/app/src/components/ui/anchor.test.ts +++ b/packages/app/src/components/ui/anchor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computePosition, getTransformOrigin, type Rect } from "./anchor"; +import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor"; const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 }; const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 }; @@ -97,3 +97,26 @@ describe("getTransformOrigin", () => { expect(getTransformOrigin(placement, alignment)).toEqual(expected); }); }); + +describe("fitSurfaceWidth", () => { + it("leaves a surface that fits the display alone, capped at the display", () => { + expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({ + width: 240, + minWidth: 180, + maxWidth: 984, + }); + }); + + it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => { + expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({ + width: 204, + minWidth: 180, + maxWidth: 204, + }); + expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({ + width: undefined, + minWidth: 134, + maxWidth: 134, + }); + }); +}); diff --git a/packages/app/src/components/ui/anchor.ts b/packages/app/src/components/ui/anchor.ts index 51454a9ee5..96a7518cba 100644 --- a/packages/app/src/components/ui/anchor.ts +++ b/packages/app/src/components/ui/anchor.ts @@ -120,6 +120,27 @@ function clampToDisplayArea(input: { }; } +export interface SurfaceWidth { + width?: number; + minWidth?: number; + maxWidth?: number; +} + +/** + * Caps a surface's requested width to the display. Position clamping can move a surface but not + * shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge. + */ +export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth { + const available = Math.max(displayWidth - EDGE_PADDING * 2, 0); + const cap = (value: number | undefined) => + typeof value === "number" ? Math.min(value, available) : undefined; + return { + width: cap(requested.width), + minWidth: cap(requested.minWidth), + maxWidth: cap(requested.maxWidth) ?? available, + }; +} + export function computePosition({ triggerRect, contentSize, diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 5bfcd9b5bf..c48fda7156 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -29,6 +29,7 @@ import type { KeyboardFocusScope } from "@/keyboard/actions"; import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; import { computePosition, + fitSurfaceWidth, getTransformOrigin, measureElement, type Alignment, @@ -283,11 +284,7 @@ export function AnchoredSurface({ const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth ? { width: screenWidth - horizontalPadding * 2 } - : { - ...(typeof width === "number" ? { width } : null), - ...(typeof minWidth === "number" ? { minWidth } : null), - ...(typeof maxWidth === "number" ? { maxWidth } : null), - }; + : fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth); return [ resolvedWidthStyle, { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9d1ca6610d..f86d81b6e9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1236,6 +1236,7 @@ export const ar: TranslationResources = { hosts: "المضيفون", settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", + moreActions: "المزيد من الإجراءات", }, footer: { usage: "الاستخدام", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3f3091ae4a..103a981abc 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1244,6 +1244,7 @@ export const en = { hosts: "Hosts", settings: "Settings", closeSidebar: "Close sidebar", + moreActions: "More actions", }, footer: { usage: "Usage", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 73cc70a8ca..48737ee20e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1272,6 +1272,7 @@ export const es: TranslationResources = { hosts: "Hosts", settings: "Ajustes", closeSidebar: "Cerrar barra lateral", + moreActions: "Más acciones", }, footer: { usage: "Uso", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4257b799aa..0d2e4aabce 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1266,6 +1266,7 @@ export const fr: TranslationResources = { hosts: "Hôtes", settings: "Paramètres", closeSidebar: "Fermer la barre latérale", + moreActions: "Plus d’actions", }, footer: { usage: "Utilisation", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 073f164e6b..ca93aa3106 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1250,6 +1250,7 @@ export const ja: TranslationResources = { hosts: "ホスト", settings: "設定", closeSidebar: "サイドバーを閉じる", + moreActions: "その他のアクション", }, footer: { usage: "使用状況", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index cee2bb19bd..51ea225983 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1243,6 +1243,7 @@ export const ko: TranslationResources = { hosts: "호스트", settings: "설정", closeSidebar: "사이드바 닫기", + moreActions: "추가 작업", }, footer: { usage: "사용량", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a88a749709..5c2695f3bf 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1262,6 +1262,7 @@ export const ptBR: TranslationResources = { hosts: "Hosts", settings: "Configurações", closeSidebar: "Fechar barra lateral", + moreActions: "Mais ações", }, footer: { usage: "Uso", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3d8bd13196..ea8fb1eb25 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1254,6 +1254,7 @@ export const ru: TranslationResources = { hosts: "Хосты", settings: "Настройки", closeSidebar: "Закрыть боковую панель", + moreActions: "Дополнительные действия", }, footer: { usage: "Использование", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f0a0c9bc5c..54454d9bff 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1228,6 +1228,7 @@ export const zhCN: TranslationResources = { hosts: "Hosts", settings: "设置", closeSidebar: "关闭侧边栏", + moreActions: "更多操作", }, footer: { usage: "使用情况",