diff --git a/docs/floating-panels.md b/docs/floating-panels.md index 1141d11c6..aa2dedf48 100644 --- a/docs/floating-panels.md +++ b/docs/floating-panels.md @@ -124,8 +124,13 @@ quietly relying on: renders inside its host, not necessarily at window origin. Position anchored content relative to the host: `anchorRect - hostRect`. This is what `measureFloatingPanelPortalHost()` is for. +- **React context.** `@gorhom/portal` is not a React portal — a real one keeps + context, this one does not. It stores the element and the host renders it, so + context resolves at the _host's_ position. Everything provided between + `PortalProvider` in `app/_layout.tsx` and your sheet is invisible inside it. + This is why app-wide providers wrap `PortalProvider` rather than the reverse. -The fix for transforms is Gotcha 3. +The fix for transforms is Gotcha 3. The fix for context is Gotcha 7. ## Gotcha 3 — Reanimated transforms vs `measureInWindow` @@ -236,6 +241,28 @@ Do not treat `onChange(-1)` as a close by itself. In a stacked another pushed sheet. Close React state from `onDismiss`; use `onChange` only to track phase. +## Gotcha 7 — A sheet cannot read context from its call site + +React cannot copy contexts reflectively, so the only way across the teleport in +Gotcha 2 is to render the providers a second time, with values captured on the +near side where they are still readable. `IsolatedBottomSheetModal` takes a +`contextBridge` for exactly that: + +```tsx +const contextBridge = useCallback( + (content) => {content}, + [thing], +); +``` + +The prop is **required**, and `null` is a real answer. A sheet whose content +needs nothing local should have to say so, because the failure mode is silent +until someone adds a `useContext` deep inside and it throws on device only — +never on web, where the desktop path uses a real portal. `menu-surface.tsx` +bridges the menu's two contexts; the rest pass `null`. + +Wrapping providers _around_ the modal does nothing. They land on the wrong side. + ## Recipe for a new anchored panel Before you write a new one, ask: diff --git a/docs/menus.md b/docs/menus.md index 68ffb6350..1b0af48af 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -23,6 +23,9 @@ sheets the same way a phone does. flipping every menu in the app to sheets at once is not a change anyone can review. Opt a menu in when you have actually looked at it on a phone. +`ContextMenu` is the exception: it defaults to `compactMode="sheet"` and enables native long press. +Disable mobile triggering explicitly on draggable rows, where long press belongs to drag instead. + ## Pages A submenu is a page, declared as data on the surface and reached by a `MenuSubTrigger` whose `id` @@ -114,6 +117,10 @@ its own. identity changes — a pushed page taller than the one it replaced is clipped without that. - **Sheets size to content.** `enableDynamicSizing`, not fixed snap points. A pushed page is rarely the height of the page before it. +- **The sheet's content is teleported out of the menu's subtree**, so `MenuSheetSurface` rebuilds + both menu contexts through the sheet's `contextBridge`. Providing them around the modal puts + them on the wrong side of the portal and every item inside throws. Gotcha 7 in + [floating-panels.md](floating-panels.md). - **One overlay per menu.** Submenus render inside their parent's layer and paint no second backdrop, so there is exactly one `Modal` on native no matter how deep the menu goes. - Anchoring, flipping, and edge clamping live in `menu-anchor.ts` and are unit-tested. Fix diff --git a/packages/app/e2e/browser/diff-row-alignment.spec.ts b/packages/app/e2e/browser/diff-row-alignment.spec.ts index ddeb77c5c..840be71a9 100644 --- a/packages/app/e2e/browser/diff-row-alignment.spec.ts +++ b/packages/app/e2e/browser/diff-row-alignment.spec.ts @@ -1,6 +1,6 @@ import { unlink, writeFile } from "node:fs/promises"; import path from "node:path"; -import { type Page } from "@playwright/test"; +import { type Locator, type Page } from "@playwright/test"; import { buildHostWorkspaceRoute, buildSettingsSectionRoute } from "../../src/utils/host-routes"; import { test, expect } from "../support/fixtures"; import { getServerId } from "../support/helpers/server-id"; @@ -25,6 +25,103 @@ const cleanupTasks: CleanupTask[] = []; const APP_SETTINGS_KEY = "@paseo:app-settings"; const CHANGES_PREFERENCES_KEY = "@paseo:changes-preferences"; +interface HorizontalInkBounds { + left: number; + right: number; +} + +async function readSvgInkBounds(svgLocator: Locator): Promise { + return svgLocator.evaluate((svg) => { + const graphics = Array.from(svg.querySelectorAll("path, line, polyline")); + const bounds = graphics.map((graphic) => { + const box = graphic.getBBox(); + const matrix = graphic.getScreenCTM(); + if (!matrix) { + throw new Error("SVG glyph has no screen transform"); + } + const strokeInset = Number.parseFloat(getComputedStyle(graphic).strokeWidth) / 2 || 0; + const corners = [ + new DOMPoint(box.x - strokeInset, box.y - strokeInset), + new DOMPoint(box.x + box.width + strokeInset, box.y - strokeInset), + new DOMPoint(box.x - strokeInset, box.y + box.height + strokeInset), + new DOMPoint(box.x + box.width + strokeInset, box.y + box.height + strokeInset), + ].map((point) => point.matrixTransform(matrix)); + return { + left: Math.min(...corners.map((point) => point.x)), + right: Math.max(...corners.map((point) => point.x)), + }; + }); + return { + left: Math.min(...bounds.map((bound) => bound.left)), + right: Math.max(...bounds.map((bound) => bound.right)), + }; + }); +} + +async function readTextInkBounds( + container: Locator, + edge: "first" | "last" = "first", +): Promise { + return container.evaluate((root, requestedEdge) => { + const textElements = [root, ...Array.from(root.querySelectorAll("*"))].filter((element) => + Array.from(element.childNodes).some( + (node) => node.nodeType === Node.TEXT_NODE && Boolean(node.textContent?.trim()), + ), + ); + const element = textElements[requestedEdge === "first" ? 0 : textElements.length - 1]; + const text = Array.from(element.childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent ?? "") + .join("") + .trim(); + const style = getComputedStyle(element); + const canvas = document.createElement("canvas"); + const context = canvas.getContext("2d"); + if (!context || !text) { + throw new Error("Text glyph could not be measured"); + } + context.font = style.font; + const metrics = context.measureText(text); + const origin = element.getBoundingClientRect().left; + return { + left: origin - metrics.actualBoundingBoxLeft, + right: origin + metrics.actualBoundingBoxRight, + }; + }, edge); +} + +async function readScrollbarGutter(scrollContainer: Locator): Promise { + return scrollContainer.evaluate((element) => { + const htmlElement = element as HTMLElement; + return htmlElement.offsetWidth - htmlElement.clientWidth; + }); +} + +async function dragOverlayScrollbarDown(page: Page, scrollContainer: Locator): Promise { + const thumb = page.getByTestId("workspace-overlay-scrollbar-grab"); + const thumbBounds = await thumb.boundingBox(); + expect(thumbBounds).not.toBeNull(); + const initialOffset = await scrollContainer.evaluate((element) => element.scrollTop); + await page.mouse.move( + thumbBounds!.x + thumbBounds!.width / 2, + thumbBounds!.y + thumbBounds!.height / 2, + ); + await page.mouse.down(); + await page.mouse.move( + thumbBounds!.x + thumbBounds!.width / 2, + thumbBounds!.y + thumbBounds!.height / 2 + 40, + { steps: 4 }, + ); + await page.mouse.up(); + await expect + .poll(() => scrollContainer.evaluate((element) => element.scrollTop)) + .toBeGreaterThan(initialOffset); +} + +function expectSameRail(actual: number, expected: number): void { + expect(Math.abs(actual - expected)).toBeLessThanOrEqual(1); +} + const BEFORE = `import { useLayoutEffect, useMemo, useRef, useState } from "react"; interface UseMountedTabSetInput { @@ -370,21 +467,62 @@ test("workspace file panes keep their controls on shared alignment rails", async const workspace = await createWorkspaceWithMountedTabDiff(); await openWorkspaceChanges(page, workspace); + const diffScroll = page.getByTestId("git-diff-scroll"); + await diffScroll.evaluate((element) => { + element.style.scrollbarGutter = "stable"; + }); + expect(await readScrollbarGutter(diffScroll)).toBe(0); + const overlayScrollbarBounds = await page + .getByTestId("workspace-overlay-scrollbar") + .boundingBox(); + const overlayThumbBounds = await page + .getByTestId("workspace-overlay-scrollbar-thumb") + .boundingBox(); + expect(overlayScrollbarBounds?.width).toBe(8); + expect(overlayThumbBounds?.width).toBe(4); + const flatFileStat = await readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"); await page.getByTestId("changes-toggle-view-mode").click(); await expect(page.getByTestId("diff-folder-src")).toBeVisible(); await expect(page.getByTestId("diff-file-0-actions")).toHaveCount(0); - const [folderStat, fileStat] = await Promise.all([ - page.getByTestId("diff-folder-src-stat").boundingBox(), - page.getByTestId("diff-file-0-stat").boundingBox(), - ]); - expect(folderStat).not.toBeNull(); - expect(fileStat).not.toBeNull(); - expect(folderStat!.x + folderStat!.width).toBeCloseTo(fileStat!.x + fileStat!.width, 0); + const folderRow = page.getByTestId("diff-folder-src-toggle"); + const fileRow = page.getByTestId("diff-file-0-toggle"); + await folderRow.hover(); + const folderHoverColor = await folderRow.evaluate((row) => getComputedStyle(row).backgroundColor); + await fileRow.hover(); + const fileHoverColor = await fileRow.evaluate((row) => getComputedStyle(row).backgroundColor); + expect(fileHoverColor).toBe(folderHoverColor); + + const [folderStat, fileStat, optionsChevron, explorerCloseIcon, diffModeLabel] = + await Promise.all([ + readTextInkBounds(page.getByTestId("diff-folder-src-stat"), "last"), + readTextInkBounds(page.getByTestId("diff-file-0-stat"), "last"), + readSvgInkBounds(page.getByTestId("changes-options-menu").locator("svg")), + readSvgInkBounds(page.getByTestId("explorer-close").locator("svg")), + readTextInkBounds(page.getByTestId("changes-diff-status-trigger")), + ]); + const expandedFolderChevron = await readSvgInkBounds(folderRow.locator("svg")); + expectSameRail(folderStat.right, fileStat.right); + expectSameRail(flatFileStat.right, fileStat.right); + expectSameRail(fileStat.right, explorerCloseIcon.right); + expectSameRail(fileStat.right, optionsChevron.right); + expectSameRail(optionsChevron.right, explorerCloseIcon.right); + expectSameRail(expandedFolderChevron.left, diffModeLabel.left); + + await folderRow.click(); + const collapsedFolderChevron = await readSvgInkBounds(folderRow.locator("svg")); + expectSameRail(collapsedFolderChevron.left, diffModeLabel.left); + await folderRow.click(); + await dragOverlayScrollbarDown(page, diffScroll); await page.getByTestId("explorer-tab-files").click(); await expect(page.getByTestId("file-explorer-row-0")).toBeVisible(); + const filesScroll = page.getByTestId("file-explorer-tree-scroll"); + await filesScroll.evaluate((element) => { + element.style.scrollbarGutter = "stable"; + }); + expect(await readScrollbarGutter(filesScroll)).toBe(0); await expect(page.getByTestId("file-explorer-row-0-actions")).toHaveCount(0); const fileExplorerRow = page.getByTestId("file-explorer-row-0"); @@ -399,16 +537,14 @@ test("workspace file panes keep their controls on shared alignment rails", async await page.keyboard.press("Escape"); const [sortLabel, firstRowIcon, treeBounds, rowBounds] = await Promise.all([ - page.getByTestId("files-sort-label").boundingBox(), - page.getByTestId("file-explorer-row-0").locator("svg").first().boundingBox(), + readTextInkBounds(page.getByTestId("files-sort-label")), + readSvgInkBounds(page.getByTestId("file-explorer-row-0").locator("svg").first()), page.getByTestId("file-explorer-tree-scroll").boundingBox(), page.getByTestId("file-explorer-row-0").boundingBox(), ]); - expect(sortLabel).not.toBeNull(); - expect(firstRowIcon).not.toBeNull(); expect(treeBounds).not.toBeNull(); expect(rowBounds).not.toBeNull(); - expect(sortLabel!.x).toBeCloseTo(firstRowIcon!.x, 0); + expectSameRail(firstRowIcon.left, sortLabel.left); expect(rowBounds!.x).toBeCloseTo(treeBounds!.x, 0); expect(rowBounds!.x + rowBounds!.width).toBeCloseTo(treeBounds!.x + treeBounds!.width, 0); }); diff --git a/packages/app/e2e/browser/host-appearance.spec.ts b/packages/app/e2e/browser/host-appearance.spec.ts index d97189b60..a3677b358 100644 --- a/packages/app/e2e/browser/host-appearance.spec.ts +++ b/packages/app/e2e/browser/host-appearance.spec.ts @@ -99,11 +99,8 @@ test.describe("Host appearance", () => { await expectHostBadgeName(page, { ...badge, hostName: "Build Box" }); }); - test("long host names leave room for the workspace title and actions", async ({ - page, - twoHostSidebar, - }) => { - const hostName = "A very long remote build host name that must not take over the sidebar"; + test("host names use the available metadata width", async ({ page, twoHostSidebar }) => { + const hostName = "Developer MacBook Pro.local"; const workspaceKey = `${twoHostSidebar.secondaryServerId}:${twoHostSidebar.secondaryWorkspaceId}`; await openHostAppearanceSettings(page, twoHostSidebar.secondaryServerId); @@ -113,25 +110,18 @@ test.describe("Host appearance", () => { const row = page.getByTestId(`sidebar-workspace-row-${workspaceKey}`); const badge = row.getByTestId(`sidebar-host-badge-${twoHostSidebar.secondaryServerId}`); await expect(badge).toHaveText(hostName); - await row.hover(); - - const title = row.getByText("Secondary workspace", { exact: true }); - const kebab = page.getByTestId(`sidebar-workspace-kebab-${workspaceKey}`); - await expect(kebab).toBeVisible(); - const [badgeBox, titleBox, rowBox, kebabBox] = await Promise.all([ - badge.boundingBox(), - title.boundingBox(), - row.boundingBox(), - kebab.boundingBox(), - ]); + const [badgeBox, rowBox] = await Promise.all([badge.boundingBox(), row.boundingBox()]); expect(badgeBox).not.toBeNull(); - expect(titleBox).not.toBeNull(); expect(rowBox).not.toBeNull(); - expect(kebabBox).not.toBeNull(); - await expect(badge).toHaveCSS("flex-shrink", "0"); - expect(titleBox!.width).toBeGreaterThan(0); - expect(kebabBox!.x + kebabBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width); + await expect(badge).toHaveCSS("flex-shrink", "1"); + expect(badgeBox!.width).toBeGreaterThan(96); + expect(badgeBox!.x + badgeBox!.width).toBeLessThanOrEqual(rowBox!.x + rowBox!.width); + const labelWidths = await badge.getByText(hostName, { exact: true }).evaluate((label) => ({ + clientWidth: label.clientWidth, + scrollWidth: label.scrollWidth, + })); + expect(labelWidths.scrollWidth).toBeLessThanOrEqual(labelWidths.clientWidth); }); test("picking a color identifies the badge", async ({ page, twoHostSidebar }) => { diff --git a/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts new file mode 100644 index 000000000..6cd35902e --- /dev/null +++ b/packages/app/e2e/browser/sidebar-mobile-menu-sheets.spec.ts @@ -0,0 +1,79 @@ +import type { Locator } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; +import { gotoAppShell } from "../support/helpers/app"; +import { projectEquivalenceViewKey } from "../support/helpers/project-view-key"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { getServerId } from "../support/helpers/server-id"; +import { waitForSidebarHydration } from "../support/helpers/workspace-ui"; + +test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true }); + +async function paintedSvgRight(locator: Locator): Promise { + return locator.evaluateAll((svgs) => { + let right = Number.NEGATIVE_INFINITY; + for (const element of svgs) { + const svg = element as SVGSVGElement; + const svgRect = svg.getBoundingClientRect(); + const viewBox = svg.viewBox.baseVal; + if (svgRect.right <= 0 || svgRect.left >= window.innerWidth || viewBox.width === 0) continue; + const scaleX = svgRect.width / viewBox.width; + for (const child of svg.querySelectorAll( + "path, circle, ellipse, line, polyline, polygon, rect", + )) { + const box = child.getBBox(); + const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0; + const localRight = box.x + box.width + strokeWidth / 2; + right = Math.max(right, svgRect.left + (localRight - viewBox.x) * scaleX); + } + } + if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children"); + return right; + }); +} + +async function closeMenuSheet(page: Page): Promise { + const backdrop = page.getByRole("button", { name: "Bottom sheet backdrop" }).first(); + await backdrop.click({ position: { x: 12, y: 12 } }); + await expect(backdrop).not.toBeVisible({ timeout: 10_000 }); +} + +test("project and workspace kebabs open action sheets on compact layouts", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "sidebar-mobile-menu-sheet-" }); + + try { + await gotoAppShell(page); + await page.getByRole("button", { name: "Open menu", exact: true }).click(); + await waitForSidebarHydration(page); + + const closeGlyphRight = await paintedSvgRight(page.getByTestId("sidebar-close").locator("svg")); + const trailingRail = await paintedSvgRight( + page.getByTestId("sidebar-display-preferences-menu").locator("svg"), + ); + expect(closeGlyphRight).toBeCloseTo(trailingRail, 0); + + await page + .getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`) + .click(); + + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByText("Project actions", { exact: true })).toBeVisible(); + await closeMenuSheet(page); + + const workspaceRow = page.getByTestId( + `sidebar-workspace-row-${getServerId()}:${seeded.workspaceId}`, + ); + await workspaceRow.hover(); + await workspaceRow + .getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`) + .click(); + + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible({ + timeout: 10_000, + }); + await expect(page.getByText("Workspace actions", { exact: true })).toBeVisible(); + } finally { + await seeded.cleanup(); + } +}); diff --git a/packages/app/e2e/browser/sidebar-model-b.spec.ts b/packages/app/e2e/browser/sidebar-model-b.spec.ts index a11d2480b..ed724c686 100644 --- a/packages/app/e2e/browser/sidebar-model-b.spec.ts +++ b/packages/app/e2e/browser/sidebar-model-b.spec.ts @@ -1,3 +1,4 @@ +import type { Locator } from "@playwright/test"; import { test, expect, type Page } from "../support/fixtures"; import { gotoAppShell } from "../support/helpers/app"; import { gotoWorkspace, clickNewTerminal } from "../support/helpers/launcher"; @@ -25,6 +26,35 @@ function projectNewWorktreeIcon(page: Page, projectKey: string) { return page.getByTestId(`sidebar-project-new-worktree-${projectEquivalenceViewKey(projectKey)}`); } +async function paintedSvgRight(locator: Locator): Promise { + return locator.evaluate((svg) => { + let right = Number.NEGATIVE_INFINITY; + for (const child of svg.querySelectorAll( + "path, circle, ellipse, line, polyline, polygon, rect", + )) { + const box = child.getBBox(); + const matrix = child.getScreenCTM(); + if (!matrix) continue; + const geometryRight = new DOMPoint(box.x + box.width, box.y).matrixTransform(matrix).x; + const strokeWidth = Number.parseFloat(getComputedStyle(child).strokeWidth) || 0; + const scaleX = Math.hypot(matrix.a, matrix.b); + right = Math.max(right, geometryRight + (strokeWidth * scaleX) / 2); + } + if (!Number.isFinite(right)) throw new Error("SVG has no measurable painted children"); + return right; + }); +} + +async function paintedTextRight(locator: Locator): Promise { + return locator.evaluate((label) => { + const context = document.createElement("canvas").getContext("2d"); + if (!context) throw new Error("Canvas text measurement is unavailable"); + context.font = getComputedStyle(label).font; + const metrics = context.measureText(label.textContent ?? ""); + return label.getBoundingClientRect().left + metrics.actualBoundingBoxRight; + }); +} + async function seedSecondWorkspace(seeded: SeededWorkspace, title: string): Promise { const created = await seeded.client.createWorkspace({ source: { kind: "directory", path: seeded.repoPath, projectId: seeded.projectId }, @@ -86,6 +116,53 @@ test.describe("Model B sidebar shape", () => { } }); + test("sidebar trailing glyphs share the workspace content rail", async ({ page }) => { + const seeded = await seedWorkspace({ repoPrefix: "model-b-trailing-rail-" }); + + try { + await gotoAppShell(page); + await waitForSidebarHydration(page); + + await page.getByTestId("sidebar-display-preferences-menu").click(); + await page.getByTestId("sidebar-display-show").click(); + await page.getByTestId("sidebar-workspace-trailing-timestamp").click(); + await page.keyboard.press("Escape"); + await page.keyboard.press("Escape"); + + const project = projectRow(page, seeded.projectKey); + const workspace = workspaceRow(page, seeded.workspaceId); + const displayPreferencesGlyph = page + .getByTestId("sidebar-display-preferences-menu") + .locator("svg"); + + await project.hover(); + const projectKebabGlyph = page + .getByTestId(`sidebar-project-kebab-${projectEquivalenceViewKey(seeded.projectKey)}`) + .locator("svg"); + await expect(projectKebabGlyph).toBeVisible(); + const projectKebabRight = await paintedSvgRight(projectKebabGlyph); + + await workspace.hover(); + const workspaceKebabGlyph = page + .getByTestId(`sidebar-workspace-kebab-${getServerId()}:${seeded.workspaceId}`) + .locator("svg"); + const timestamp = workspace.getByTestId("sidebar-workspace-timestamp"); + await expect(workspaceKebabGlyph).toBeVisible(); + await expect(timestamp).toBeVisible(); + + const rail = await paintedTextRight(timestamp); + for (const glyphRight of await Promise.all([ + paintedSvgRight(displayPreferencesGlyph), + Promise.resolve(projectKebabRight), + paintedSvgRight(workspaceKebabGlyph), + ])) { + expect(glyphRight).toBeCloseTo(rail, 0); + } + } finally { + await seeded.cleanup(); + } + }); + test("no tab, agent, or terminal ever renders as a sidebar row", async ({ page }) => { const mock = await seedMockAgentWorkspace({ repoPrefix: "model-b-leaf-", diff --git a/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad b/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad new file mode 100644 index 000000000..9f758a280 --- /dev/null +++ b/packages/app/e2e/mobile/agent-device/file-context-menu.ios.ad @@ -0,0 +1,13 @@ +context platform=ios timeout=60000 retries=1 +env APP_ID=sh.paseo.debug + +open "${APP_ID}" +wait "id=\"workspace-header-menu-trigger\"" 20000 +press "id=\"workspace-explorer-toggle\"" +wait "id=\"explorer-tab-files\"" 10000 +press "id=\"explorer-tab-files\"" +wait "id=\"file-explorer-row-0\"" 10000 +longpress "id=\"file-explorer-row-0\"" 600 +wait "id=\"file-explorer-row-0-context-menu-content\"" 10000 +is visible "text=\"Copy path\"" +close diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index ec8496aa3..1e123959d 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -940,9 +940,7 @@ function RuntimeProviders({ children }: { children: ReactNode }) { - - {children} - + {children} ); @@ -960,9 +958,11 @@ function RootProviders({ children }: { children: ReactNode }) { - - {children} - + + + {children} + + diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index e67b6a6de..051655bc5 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -592,6 +592,7 @@ export function CommandCenter() { return ( (() => buildSettingsRoute(), []); const homeRoute = useMemo(() => buildOpenProjectRoute(), []); @@ -87,8 +89,8 @@ export function CommandCenterRootActions() { () => ({ isMac: getShortcutOs() === "mac", isDesktop: getIsElectronRuntime() }), [], ); - const actions = useMemo( - () => [ + const actions = useMemo(() => { + const availableActions: CommandCenterContribution[] = [ { id: "add-project", group: "actions", @@ -204,7 +206,10 @@ export function CommandCenterRootActions() { undefined, }, }, - { + ]; + + if (shortcutsAvailable) { + availableActions.push({ id: "keyboard-shortcuts", group: "actions", groupRank: 0, @@ -221,20 +226,22 @@ export function CommandCenterRootActions() { resolveShortcutKeysForAction("show-shortcuts", overrides, shortcutPlatform) ?? undefined, }, - }, - ], - [ - homeRoute, - openAddProject, - overrides, - schedulesRoute, - sessionsRoute, - setShortcutsDialogOpen, - settingsRoute, - shortcutPlatform, - t, - ], - ); + }); + } + + return availableActions; + }, [ + homeRoute, + openAddProject, + overrides, + schedulesRoute, + sessionsRoute, + setShortcutsDialogOpen, + settingsRoute, + shortcutPlatform, + shortcutsAvailable, + t, + ]); useCommandCenterActions({ sourceId: "root", enabled: true, actions }); return null; diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 90f871833..b689bdc6a 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -674,6 +674,7 @@ export function AdaptiveModalSheet({ return ( diff --git a/packages/app/src/components/file-explorer-pane.tsx b/packages/app/src/components/file-explorer-pane.tsx index 4c9d7517c..798c28456 100644 --- a/packages/app/src/components/file-explorer-pane.tsx +++ b/packages/app/src/components/file-explorer-pane.tsx @@ -12,7 +12,7 @@ import { type ViewStyle, } from "react-native"; import { StyleSheet, useUnistyles, withUnistyles } from "react-native-unistyles"; -import { WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; +import { useIsCompactFormFactor, WORKSPACE_SECONDARY_HEADER_HEIGHT } from "@/constants/layout"; import * as Clipboard from "expo-clipboard"; import { ChevronDown, Eye, EyeOff, RotateCw } from "lucide-react-native"; import { MaterialFileIcon } from "@/components/material-file-icon"; @@ -20,9 +20,14 @@ import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft, + WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING, } from "@/components/tree-primitives"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { + useOverlayFlatListScrollbar, + type OverlayFlatListScrollbar, +} from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar"; import type { Theme } from "@/styles/theme"; import type { AgentFileExplorerState, @@ -174,12 +179,7 @@ function TreeRowItem({ return ( - + @@ -226,6 +226,7 @@ export function FileExplorerPane({ onAddToChat, }: FileExplorerPaneProps) { const { t } = useTranslation(); + const isCompact = useIsCompactFormFactor(); const normalizedWorkspaceRoot = useMemo(() => workspaceRoot.trim(), [workspaceRoot]); const workspaceStateKey = useMemo( @@ -278,6 +279,7 @@ export function FileExplorerPane({ ); const treeListRef = useRef>(null); + const scrollbar = useOverlayFlatListScrollbar(treeListRef, { enabled: !isCompact }); const hasInitializedRef = useRef(false); @@ -518,6 +520,7 @@ export function FileExplorerPane({ currentSortLabel={currentSortLabel} isRefreshFetching={isRefreshFetching} treeListRef={treeListRef} + scrollbar={scrollbar} renderTreeRow={renderTreeRow} handleSortCycle={handleSortCycle} handleToggleHiddenFiles={handleToggleHiddenFiles} @@ -539,6 +542,7 @@ interface FileExplorerPaneContentProps { currentSortLabel: string; isRefreshFetching: boolean; treeListRef: RefObject | null>; + scrollbar: OverlayFlatListScrollbar; renderTreeRow: (info: ListRenderItemInfo) => ReactElement; handleSortCycle: () => void; handleToggleHiddenFiles: () => void; @@ -560,6 +564,7 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { currentSortLabel, isRefreshFetching, treeListRef, + scrollbar, renderTreeRow, handleSortCycle, handleToggleHiddenFiles, @@ -682,12 +687,17 @@ function FileExplorerPaneContent(props: FileExplorerPaneContentProps) { keyExtractor={treeRowKeyExtractor} testID="file-explorer-tree-scroll" contentContainerStyle={styles.entriesContent} - showsVerticalScrollIndicator + onLayout={scrollbar.onLayout} + onScroll={scrollbar.onScroll} + onContentSizeChange={scrollbar.onContentSizeChange} + scrollEventThrottle={16} + showsVerticalScrollIndicator={!scrollbar.enabled} initialNumToRender={24} maxToRenderPerBatch={40} windowSize={12} /> )} + {treeRows.length > 0 ? scrollbar.overlay : null} ); } @@ -1055,7 +1065,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "space-between", paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, - paddingRight: theme.spacing[3], + paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING, }, entryRowActive: { backgroundColor: theme.colors.surfaceSidebarHover, diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index fa3038d59..7564469b9 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -988,11 +988,11 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", justifyContent: "space-between", gap: theme.spacing[2], - // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]), so the - // title lands at spacing[2] left to align with project icons, and the trailing - // pill sits flush with the list edge on the right. + // Rendered inside the scroll's listContent (paddingHorizontal spacing[2]). The title + // lands at spacing[2] left to align with project icons. Settings2's painted path stops + // inside its 14px SVG, so 4px aligns the ink rather than the SVG box to the row rail. paddingLeft: theme.spacing[2], - paddingRight: 0, + paddingRight: 4, paddingTop: theme.spacing[1], paddingBottom: theme.spacing[1], }, @@ -1030,7 +1030,9 @@ const styles = StyleSheet.create((theme) => ({ pointerEvents: "box-none", }, mobileCloseButton: { - marginRight: theme.spacing[4], + // The 16px X paints farther inside its 32px hit target than the 14px Settings2 glyph. + // This optical inset puts their painted right edges on the same sidebar rail. + marginRight: theme.spacing[2] + 1.5, width: 32, height: 32, alignItems: "center", diff --git a/packages/app/src/components/sidebar-workspace-list.tsx b/packages/app/src/components/sidebar-workspace-list.tsx index a156eab41..38395dc55 100644 --- a/packages/app/src/components/sidebar-workspace-list.tsx +++ b/packages/app/src/components/sidebar-workspace-list.tsx @@ -495,7 +495,7 @@ function ProjectKebabMenu({ }) { const { t } = useTranslation(); return ( - + {renderKebabTriggerIcon} - + ({ alignItems: "center", gap: 2, flexShrink: 0, + // MoreVertical paints only around the center of its 14px SVG. Keep the 24px controls, + // but pull their painted edge through the unused view-box space onto the row rail. + marginRight: -6, }, projectKebabButton: { width: 24, diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index 6168d5d39..538b61749 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -15,9 +15,8 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { isNative } from "@/constants/platform"; import { useAppDiagnosticStore } from "@/diagnostics/store"; +import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; @@ -78,11 +77,10 @@ function HostVersionHint({ host }: { host: HostProfile }) { export function SidebarHelpMenu() { const { t } = useTranslation(); - const isCompactLayout = useIsCompactFormFactor(); + const shortcutsAvailable = useKeyboardShortcutsAvailable(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); const [open, setOpen] = useState(false); - const showKeyboardShortcuts = !isNative && !isCompactLayout; const version = formatVersionWithPrefix(resolveAppVersion()); const hosts = useHosts(); @@ -128,7 +126,7 @@ export function SidebarHelpMenu() { {t("sidebar.help.sectionHelp")} - {showKeyboardShortcuts ? ( + {shortcutsAvailable ? ( (archiveShortcutKeys && !isNative ? : null), + () => (archiveShortcutKeys ? : null), [archiveShortcutKeys], ); @@ -205,7 +205,7 @@ export function SidebarWorkspaceMenu({ }: SidebarWorkspaceMenuProps) { const { t } = useTranslation(); return ( - + {renderTriggerIcon} - + ({ padding: 2, borderRadius: 4, marginLeft: 2, + // MoreVertical paints only around the center of its SVG. Keep the padded hit box, but + // pull the painted dots through that unused view-box space onto the trailing-content rail. + marginRight: -7, }, triggerHovered: { backgroundColor: theme.colors.surface2, diff --git a/packages/app/src/components/sidebar/workspace-meta-row/index.tsx b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx index dc44d8d98..cf62ff8cd 100644 --- a/packages/app/src/components/sidebar/workspace-meta-row/index.tsx +++ b/packages/app/src/components/sidebar/workspace-meta-row/index.tsx @@ -31,9 +31,6 @@ export { selectWorkspaceScriptSummary, type WorkspaceScriptSummary } from "./scr */ const META_ICON_SIZE = 12; -/** Carried over from the host pill: a host can carry an arbitrary custom label. */ -const MAX_HOST_LABEL_WIDTH = 96; - const ThemedServer = withUnistyles(Server); const ThemedExternalLink = withUnistyles(ExternalLink); const ThemedGitPullRequest = withUnistyles(GitPullRequest); @@ -283,16 +280,14 @@ const styles = StyleSheet.create((theme) => ({ minWidth: 0, flexShrink: 0, }, - // A host label is arbitrary user text and the only item on the line that can be long, so - // it is the one that gets capped and truncated. The change request, CI and service items - // are bounded by their own content and must never be the thing that gives way. + // The host is the only unbounded item, so it consumes the remaining width and gives way + // before the bounded change request, CI, and service items. hostItem: { flexDirection: "row", alignItems: "center", gap: 3, minWidth: 0, - flexShrink: 0, - maxWidth: MAX_HOST_LABEL_WIDTH, + flexShrink: 1, }, itemPressed: { opacity: 0.82, diff --git a/packages/app/src/components/sidebar/workspace-trailing/index.tsx b/packages/app/src/components/sidebar/workspace-trailing/index.tsx index 5895a71ac..c9eae8f92 100644 --- a/packages/app/src/components/sidebar/workspace-trailing/index.tsx +++ b/packages/app/src/components/sidebar/workspace-trailing/index.tsx @@ -62,7 +62,7 @@ export function SidebarWorkspaceTrailingContent({ function WorkspaceTimestamp({ enteredAt }: { enteredAt: Date }) { const label = useCompactTimeAgo(enteredAt); return ( - + {label} ); diff --git a/packages/app/src/components/tool-call-sheet.tsx b/packages/app/src/components/tool-call-sheet.tsx index 2d9f98a5c..788da80ec 100644 --- a/packages/app/src/components/tool-call-sheet.tsx +++ b/packages/app/src/components/tool-call-sheet.tsx @@ -114,6 +114,7 @@ export function ToolCallSheetProvider({ children }: ToolCallSheetProviderProps) {children} + ); @@ -73,7 +85,13 @@ const styles = StyleSheet.create((theme: Theme) => ({ justifyContent: "center", flexShrink: 0, }, + // Lucide's right-pointing path occupies the middle of its square view box. + // Move the painted edge—not the 16px layout slot—onto the tree's glyph rail. + chevronCollapsed: { + left: -4, + }, chevronExpanded: { + left: -2, transform: [{ rotate: "90deg" }], }, })); diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 4940b27bb..b756ecc9c 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -994,6 +994,7 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { return ( ): ReactElement { + return ; +} + export function ContextMenuContent(props: MenuSurfaceProps): ReactElement | null { return ; } @@ -85,7 +101,7 @@ export function ContextMenuTrigger({ disabled, style, enabled = true, - enabledOnMobile = false, + enabledOnMobile = true, enabledOnWeb = true, longPressDelayMs, triggerRef, diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx index 5b15bc96d..e0ed8e6e2 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.test.tsx @@ -1,34 +1,75 @@ /** * @vitest-environment jsdom */ -import React, { act } from "react"; +import React, { act, createContext, useContext, useSyncExternalStore } from "react"; import { createRoot, type Root } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +/** + * A faithful stand-in for `@gorhom/portal`: it renders *nothing* where it sits and hands its + * element to a host somewhere else in the tree, which is what makes the real thing lose context. + * A passthrough fake would keep context and quietly pass every test in this file — which is + * exactly what happened, and why the menu sheet shipped broken. + */ +const portalStore = vi.hoisted(() => { + const listeners = new Set<() => void>(); + let node: React.ReactNode = null; + return { + put(next: React.ReactNode) { + node = next; + for (const listener of listeners) listener(); + }, + read: () => node, + subscribe(listener: () => void) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + reset() { + node = null; + listeners.clear(); + }, + }; +}); + vi.mock("@gorhom/portal", () => ({ - Portal: ({ children, hostName }: { children?: React.ReactNode; hostName?: string }) => - React.createElement("div", { "data-portal-host": hostName }, children), - PortalHost: ({ name }: { name?: string }) => React.createElement("div", { "data-host": name }), + Portal: ({ children }: { children?: React.ReactNode }) => { + React.useEffect(() => { + portalStore.put(children); + }, [children]); + return null; + }, + PortalHost: () => React.createElement("div", { "data-host": true }), })); -vi.mock("@gorhom/bottom-sheet", () => ({ - BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) => - React.createElement("div", { "data-bottom-sheet-provider": true }, children), - BottomSheetModal: React.forwardRef( - ( - { - children, - stackBehavior, - }: { - children?: React.ReactNode; - stackBehavior?: string; - }, - _ref, - ) => React.createElement("div", { "data-stack-behavior": stackBehavior }, children), - ), -})); +vi.mock("@gorhom/bottom-sheet", async () => { + const { Portal } = await import("@gorhom/portal"); + return { + BottomSheetModalProvider: ({ children }: { children?: React.ReactNode }) => + React.createElement("div", { "data-bottom-sheet-provider": true }, children), + BottomSheetModal: React.forwardRef( + ({ children, stackBehavior }: { children?: React.ReactNode; stackBehavior?: string }, _ref) => + React.createElement( + Portal, + null, + React.createElement("div", { "data-stack-behavior": stackBehavior }, children), + ), + ), + }; +}); -import { IsolatedBottomSheetModal } from "."; +import { IsolatedBottomSheetModal, type ContextBridge } from "."; + +/** Renders whatever the portal is currently holding, from its own place in the tree. */ +function PortalHostProbe() { + const node = useSyncExternalStore(portalStore.subscribe, portalStore.read, portalStore.read); + return
{node}
; +} + +const LabelContext = createContext("unbridged"); + +function Label() { + return
; +} describe("IsolatedBottomSheetModal presentation", () => { let container: HTMLDivElement; @@ -43,34 +84,94 @@ describe("IsolatedBottomSheetModal presentation", () => { afterEach(() => { act(() => root.unmount()); container.remove(); + portalStore.reset(); }); + function stackBehaviors(): (string | null)[] { + return Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) => + node.getAttribute("data-stack-behavior"), + ); + } + it("defaults sibling top-level sheets to push instead of replacing by React ancestry", () => { act(() => { root.render( <> - Settings - Diagnostic + Settings + , ); }); + expect(stackBehaviors()).toEqual(["push"]); - expect( - Array.from(container.querySelectorAll("[data-stack-behavior]")).map((node) => - node.getAttribute("data-stack-behavior"), - ), - ).toEqual(["push", "push"]); + act(() => { + root.render( + <> + Diagnostic + + , + ); + }); + expect(stackBehaviors()).toEqual(["push"]); }); it("only replaces when the callsite asks for replacement", () => { act(() => { root.render( - Selector, + <> + + Selector + + + , ); }); - expect( - container.querySelector("[data-stack-behavior]")?.getAttribute("data-stack-behavior"), - ).toBe("replace"); + expect(stackBehaviors()).toEqual(["replace"]); + }); +}); + +describe("IsolatedBottomSheetModal context bridging", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + portalStore.reset(); + }); + + function renderSheet(contextBridge: ContextBridge | null) { + act(() => { + root.render( + <> + + + + + {/* Outside the provider, standing in for the app-root portal host. */} + + , + ); + }); + return container.querySelector("[data-label]")?.getAttribute("data-label"); + } + + it("loses callsite context without a bridge", () => { + expect(renderSheet(null)).toBe("unbridged"); + }); + + it("carries callsite context across the portal when bridged", () => { + const bridge: ContextBridge = (children) => ( + {children} + ); + expect(renderSheet(bridge)).toBe("from callsite"); }); }); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx index 3f5585936..b1bee58c3 100644 --- a/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/index.tsx @@ -4,7 +4,7 @@ import { } from "@gorhom/bottom-sheet"; import React from "react"; import { forwardRef, useCallback, useEffect, useMemo, useRef } from "react"; -import type { ElementRef } from "react"; +import type { ElementRef, ReactNode } from "react"; import { type BottomSheetController, createBottomSheetVisibilityTracker, @@ -12,11 +12,42 @@ import { type GorhomBottomSheetModalMethods = ElementRef; +/** + * Re-establishes React context on the far side of the portal. + * + * `@gorhom/portal` is not a React portal. It stores the element and a host elsewhere in the tree + * renders it, so context resolves at the *host's* position: everything provided between + * `PortalProvider` (see `app/_layout.tsx`) and this sheet is invisible to its content. React + * cannot copy contexts reflectively, so the only way across is to render the providers again — + * with values captured out here, where they are still readable. + * + * Write it as a closure over what you already have: + * + * ```tsx + * const contextBridge = useCallback( + * (content) => {content}, + * [thing], + * ); + * ``` + */ +export type ContextBridge = (children: ReactNode) => ReactNode; + type IsolatedBottomSheetModalProps = Omit< BottomSheetModalProps, - "enableDismissOnClose" | "stackBehavior" + "enableDismissOnClose" | "stackBehavior" | "children" > & { + /** + * Nodes only. Gorhom also accepts a render function, but nothing here uses it and a bridge + * would have to reach around it. + */ + children?: ReactNode; presentation?: "push" | "replace"; + /** + * Required, and `null` is a real answer: a sheet that needs nothing from its call site should + * have to say so. The failure it prevents is invisible until someone adds a `useContext` deep + * inside the sheet and it throws on device only. + */ + contextBridge: ContextBridge | null; }; export type IsolatedBottomSheetModalRef = GorhomBottomSheetModalMethods; @@ -25,7 +56,7 @@ export const IsolatedBottomSheetModal = forwardRef< IsolatedBottomSheetModalRef, IsolatedBottomSheetModalProps >(function IsolatedBottomSheetModal(props, ref) { - const { children, presentation = "push", ...bottomSheetProps } = props; + const { children, presentation = "push", contextBridge, ...bottomSheetProps } = props; const modal = ( - {children} + {contextBridge ? contextBridge(children) : children} ); diff --git a/packages/app/src/components/ui/menu/menu-surface.tsx b/packages/app/src/components/ui/menu/menu-surface.tsx index df062eddc..74d439c3e 100644 --- a/packages/app/src/components/ui/menu/menu-surface.tsx +++ b/packages/app/src/components/ui/menu/menu-surface.tsx @@ -17,9 +17,10 @@ import { ChevronLeft } from "lucide-react-native"; import { IsolatedBottomSheetModal, useIsolatedBottomSheetVisibility, + type ContextBridge, } from "@/components/ui/isolated-bottom-sheet-modal"; import type { Theme } from "@/styles/theme"; -import { useMenuContext, MenuDepthProvider } from "./menu-context"; +import { useMenuContext, MenuContextProvider, MenuDepthProvider } from "./menu-context"; import { currentPageId, isSubPageOpen } from "./menu-navigation"; import { AnchoredSurface, MenuOverlay } from "./menu-overlay"; import type { Alignment, Placement } from "./menu-anchor"; @@ -327,40 +328,51 @@ function MenuSheetSurface({ const openPage = openPageId ? pages.find((page) => page.id === openPageId) : null; const depth = menu.path.length; + // The sheet's content is teleported out of this subtree, so both menu contexts have to be + // rebuilt on the other side. Providing them around the modal instead would put them on the + // wrong side of the portal and every item inside would throw. See `ContextBridge`. + const contextBridge = useCallback( + (content) => ( + + {content} + + ), + [menu, surfaceValue], + ); + return ( - - + - - {openPage ? ( - <> - - {openPage.content} - - ) : ( - <> - {sheetTitle ? : null} - {children} - - )} - - - + {openPage ? ( + <> + + {openPage.content} + + ) : ( + <> + {sheetTitle ? : null} + {children} + + )} + + ); } diff --git a/packages/app/src/components/ui/overlay-scrollbar/geometry.test.ts b/packages/app/src/components/ui/overlay-scrollbar/geometry.test.ts new file mode 100644 index 000000000..793edf520 --- /dev/null +++ b/packages/app/src/components/ui/overlay-scrollbar/geometry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry"; + +describe("computeOverlayScrollbarGeometry", () => { + it("hides the thumb when the content fits", () => { + expect( + computeOverlayScrollbarGeometry({ viewportSize: 500, contentSize: 500, offset: 0 }), + ).toEqual({ + isVisible: false, + maxScrollOffset: 0, + thumbSize: 0, + thumbOffset: 0, + maxThumbOffset: 0, + }); + }); + + it("places a proportional thumb for overflowing content", () => { + expect( + computeOverlayScrollbarGeometry({ viewportSize: 500, contentSize: 2000, offset: 375 }), + ).toEqual({ + isVisible: true, + maxScrollOffset: 1500, + thumbSize: 125, + thumbOffset: 93.75, + maxThumbOffset: 375, + }); + }); +}); + +describe("scrollOffsetFromThumbDrag", () => { + it("maps thumb travel to the scroll range and clamps it", () => { + expect( + scrollOffsetFromThumbDrag({ + startOffset: 250, + dragDelta: 50, + maxScrollOffset: 1000, + maxThumbOffset: 200, + }), + ).toBe(500); + expect( + scrollOffsetFromThumbDrag({ + startOffset: 900, + dragDelta: 1000, + maxScrollOffset: 1000, + maxThumbOffset: 200, + }), + ).toBe(1000); + }); +}); diff --git a/packages/app/src/components/ui/overlay-scrollbar/geometry.ts b/packages/app/src/components/ui/overlay-scrollbar/geometry.ts new file mode 100644 index 000000000..cf6f75684 --- /dev/null +++ b/packages/app/src/components/ui/overlay-scrollbar/geometry.ts @@ -0,0 +1,63 @@ +const DEFAULT_MIN_THUMB_SIZE = 36; + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export interface OverlayScrollbarGeometryInput { + viewportSize: number; + contentSize: number; + offset: number; + minThumbSize?: number; +} + +export interface OverlayScrollbarGeometry { + isVisible: boolean; + maxScrollOffset: number; + thumbSize: number; + thumbOffset: number; + maxThumbOffset: number; +} + +export function computeOverlayScrollbarGeometry( + input: OverlayScrollbarGeometryInput, +): OverlayScrollbarGeometry { + const viewportSize = Number.isFinite(input.viewportSize) ? Math.max(0, input.viewportSize) : 0; + const contentSize = Number.isFinite(input.contentSize) ? Math.max(0, input.contentSize) : 0; + const minThumbSize = Number.isFinite(input.minThumbSize) + ? Math.max(0, input.minThumbSize ?? DEFAULT_MIN_THUMB_SIZE) + : DEFAULT_MIN_THUMB_SIZE; + const maxScrollOffset = Math.max(0, contentSize - viewportSize); + + if (maxScrollOffset <= 0 || viewportSize <= 0 || contentSize <= 0) { + return { + isVisible: false, + maxScrollOffset: 0, + thumbSize: 0, + thumbOffset: 0, + maxThumbOffset: 0, + }; + } + + const thumbSize = clamp((viewportSize * viewportSize) / contentSize, minThumbSize, viewportSize); + const maxThumbOffset = Math.max(0, viewportSize - thumbSize); + const thumbOffset = (clamp(input.offset, 0, maxScrollOffset) / maxScrollOffset) * maxThumbOffset; + + return { isVisible: true, maxScrollOffset, thumbSize, thumbOffset, maxThumbOffset }; +} + +export function scrollOffsetFromThumbDrag(input: { + startOffset: number; + dragDelta: number; + maxScrollOffset: number; + maxThumbOffset: number; +}): number { + if (input.maxScrollOffset <= 0 || input.maxThumbOffset <= 0) { + return clamp(input.startOffset, 0, Math.max(0, input.maxScrollOffset)); + } + return clamp( + input.startOffset + input.dragDelta * (input.maxScrollOffset / input.maxThumbOffset), + 0, + input.maxScrollOffset, + ); +} diff --git a/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.tsx b/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.tsx new file mode 100644 index 000000000..e73e580c1 --- /dev/null +++ b/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.tsx @@ -0,0 +1,32 @@ +import type { ReactNode, RefObject } from "react"; +import type { + FlatList, + LayoutChangeEvent, + NativeScrollEvent, + NativeSyntheticEvent, +} from "react-native"; + +export interface OverlayFlatListScrollbar { + enabled: boolean; + onContentSizeChange: (width: number, height: number) => void; + onLayout: (event: LayoutChangeEvent) => void; + onScroll: (event: NativeSyntheticEvent) => void; + overlay: ReactNode; +} + +const ignoreContentSize = () => {}; +const ignoreLayout = (_event: LayoutChangeEvent) => {}; +const ignoreScroll = (_event: NativeSyntheticEvent) => {}; + +export function useOverlayFlatListScrollbar( + _listRef: RefObject | null>, + _options: { enabled: boolean }, +): OverlayFlatListScrollbar { + return { + enabled: false, + onContentSizeChange: ignoreContentSize, + onLayout: ignoreLayout, + onScroll: ignoreScroll, + overlay: null, + }; +} diff --git a/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.web.tsx b/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.web.tsx new file mode 100644 index 000000000..e74d3d6c6 --- /dev/null +++ b/packages/app/src/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar.web.tsx @@ -0,0 +1,200 @@ +import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; +import { + FlatList, + View, + type LayoutChangeEvent, + type NativeScrollEvent, + type NativeSyntheticEvent, + type ViewStyle, +} from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { WEB_SCROLLBAR_SIZE_PX } from "@/styles/web-scrollbar"; +import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; +import { computeOverlayScrollbarGeometry, scrollOffsetFromThumbDrag } from "./geometry"; +import type { OverlayFlatListScrollbar } from "./use-overlay-flat-list-scrollbar"; + +const THUMB_WIDTH = WEB_SCROLLBAR_SIZE_PX - 4; +const THUMB_OPACITY = 0.62; +const GRAB_WIDTH = 12; + +interface ScrollbarMetrics { + offset: number; + viewportSize: number; + contentSize: number; +} + +interface PointerLikeEvent { + clientY?: number; + nativeEvent?: { clientY?: number; preventDefault?: () => void }; + preventDefault?: () => void; + stopPropagation?: () => void; +} + +function readClientY(event: PointerLikeEvent): number | null { + const value = event.nativeEvent?.clientY ?? event.clientY; + return typeof value === "number" ? value : null; +} + +function OverlayScrollbar({ + listRef, + metrics, +}: { + listRef: RefObject | null>; + metrics: ScrollbarMetrics; +}) { + const geometry = useMemo(() => computeOverlayScrollbarGeometry(metrics), [metrics]); + const geometryRef = useRef(geometry); + const dragStartClientYRef = useRef(0); + const dragStartOffsetRef = useRef(0); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + geometryRef.current = geometry; + }, [geometry]); + + const scrollFromDrag = useCallback( + (dragDelta: number) => { + const current = geometryRef.current; + listRef.current?.scrollToOffset({ + offset: scrollOffsetFromThumbDrag({ + startOffset: dragStartOffsetRef.current, + dragDelta, + maxScrollOffset: current.maxScrollOffset, + maxThumbOffset: current.maxThumbOffset, + }), + animated: false, + }); + }, + [listRef], + ); + + const startDrag = useCallback( + (event: PointerLikeEvent) => { + const clientY = readClientY(event); + if (clientY === null) return; + event.preventDefault?.(); + event.stopPropagation?.(); + event.nativeEvent?.preventDefault?.(); + dragStartClientYRef.current = clientY; + dragStartOffsetRef.current = metrics.offset; + setIsDragging(true); + }, + [metrics.offset], + ); + + useEffect(() => { + if (!isDragging) return; + const handlePointerMove = (event: PointerEvent) => { + scrollFromDrag(event.clientY - dragStartClientYRef.current); + }; + const stopDragging = () => setIsDragging(false); + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", stopDragging); + window.addEventListener("pointercancel", stopDragging); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", stopDragging); + window.removeEventListener("pointercancel", stopDragging); + }; + }, [isDragging, scrollFromDrag]); + + const thumbRegionStyle = useMemo( + () => [ + styles.thumbRegion, + inlineUnistylesStyle({ + height: geometry.thumbSize, + transform: [{ translateY: geometry.thumbOffset }], + cursor: isDragging ? "grabbing" : "grab", + } as unknown as ViewStyle), + ], + [geometry.thumbOffset, geometry.thumbSize, isDragging], + ); + + if (!geometry.isVisible) return null; + + return ( + + + + + + ); +} + +export function useOverlayFlatListScrollbar( + listRef: RefObject | null>, + options: { enabled: boolean }, +): OverlayFlatListScrollbar { + const [metrics, setMetrics] = useState({ + offset: 0, + viewportSize: 0, + contentSize: 0, + }); + + const onScroll = useCallback((event: NativeSyntheticEvent) => { + setMetrics((previous) => ({ + ...previous, + offset: Math.max(0, event.nativeEvent.contentOffset.y), + viewportSize: Math.max(0, event.nativeEvent.layoutMeasurement.height), + contentSize: Math.max(0, event.nativeEvent.contentSize.height), + })); + }, []); + const onLayout = useCallback((event: LayoutChangeEvent) => { + setMetrics((previous) => ({ + ...previous, + viewportSize: Math.max(0, event.nativeEvent.layout.height), + })); + }, []); + const onContentSizeChange = useCallback((_width: number, height: number) => { + setMetrics((previous) => ({ ...previous, contentSize: Math.max(0, height) })); + }, []); + + return { + enabled: options.enabled, + onContentSizeChange, + onLayout, + onScroll, + overlay: options.enabled ? ( + | null>} + metrics={metrics} + /> + ) : null, + }; +} + +const styles = StyleSheet.create((theme) => ({ + overlay: { + position: "absolute", + top: 0, + right: 0, + bottom: 0, + width: WEB_SCROLLBAR_SIZE_PX, + zIndex: 10, + }, + thumbRegion: { + position: "absolute", + top: 0, + right: -(GRAB_WIDTH - WEB_SCROLLBAR_SIZE_PX) / 2, + width: GRAB_WIDTH, + alignItems: "center", + justifyContent: "center", + touchAction: "none", + userSelect: "none", + }, + thumb: { + width: THUMB_WIDTH, + height: "100%", + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.scrollbarHandle, + opacity: THUMB_OPACITY, + }, +})); diff --git a/packages/app/src/components/ui/shortcut.tsx b/packages/app/src/components/ui/shortcut.tsx index 2b9b33f9a..5d4eb90e4 100644 --- a/packages/app/src/components/ui/shortcut.tsx +++ b/packages/app/src/components/ui/shortcut.tsx @@ -1,6 +1,7 @@ import React, { useMemo, type ReactElement } from "react"; import { Text, View, type StyleProp, type TextStyle, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { formatShortcut, type ShortcutKey } from "@/utils/format-shortcut"; import { getShortcutOs } from "@/utils/shortcut-platform"; @@ -14,7 +15,8 @@ export function Shortcut({ chord?: ShortcutKey[][]; style?: StyleProp; textStyle?: StyleProp; -}): ReactElement { +}): ReactElement | null { + const shortcutsAvailable = useKeyboardShortcutsAvailable(); const displayChord = chord ?? (keys ? [keys] : []); const shortcutOs = getShortcutOs(); const singleCombo = displayChord[0]; @@ -23,6 +25,10 @@ export function Shortcut({ const textCombinedStyle = useMemo(() => [styles.text, textStyle], [textStyle]); const sequenceStyle = useMemo(() => [styles.sequence, style], [style]); + if (!shortcutsAvailable) { + return null; + } + if (!singleCombo) { return ; } diff --git a/packages/app/src/git/diff-folder-row.tsx b/packages/app/src/git/diff-folder-row.tsx index 203fe5f00..337fc9a9b 100644 --- a/packages/app/src/git/diff-folder-row.tsx +++ b/packages/app/src/git/diff-folder-row.tsx @@ -12,6 +12,7 @@ import { TreeChevron, TreeIndentGuides, treeRowPaddingLeft, + WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING, } from "@/components/tree-primitives"; import { type Theme } from "@/styles/theme"; @@ -103,7 +104,7 @@ const styles = StyleSheet.create((theme: Theme) => ({ folderRow: { flexDirection: "row", alignItems: "center", - paddingRight: theme.spacing[3], + paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING, paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, gap: theme.spacing[1], minWidth: 0, diff --git a/packages/app/src/git/diff-pane.tsx b/packages/app/src/git/diff-pane.tsx index 0d9c4534d..a3ffbd9b0 100644 --- a/packages/app/src/git/diff-pane.tsx +++ b/packages/app/src/git/diff-pane.tsx @@ -48,6 +48,7 @@ import { DiffFolderRow } from "@/git/diff-folder-row"; import { TreeIndentGuides, treeRowPaddingLeft, + WORKSPACE_FILE_ROW_TRAILING_PADDING, WORKSPACE_FILE_ROW_VERTICAL_PADDING, } from "@/components/tree-primitives"; import { MaterialFileIcon } from "@/components/material-file-icon"; @@ -92,6 +93,7 @@ import { useCheckoutGitActionsStore } from "@/git/actions-store"; import { useToast } from "@/contexts/toast-context"; import { useSessionStore } from "@/stores/session-store"; import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { useOverlayFlatListScrollbar } from "@/components/ui/overlay-scrollbar/use-overlay-flat-list-scrollbar"; import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style"; import { usePanelStore } from "@/stores/panel-store"; import { collectAllTabs, useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; @@ -125,8 +127,11 @@ export function resolveDiffLayout( return canUseSplitLayout ? layout : "unified"; } -function fileHeaderPressableStyle({ pressed }: PressableStateCallbackType) { - return [styles.fileHeader, pressed && styles.fileHeaderPressed]; +function fileHeaderPressableStyle({ + hovered, + pressed, +}: PressableStateCallbackType & { hovered?: boolean }) { + return [styles.fileHeader, (Boolean(hovered) || pressed) && styles.fileHeaderActive]; } interface HighlightedTextProps { @@ -972,6 +977,10 @@ const DiffFileHeader = memo(function DiffFileHeader({ }; }, []); + const handleLongPress = useCallback(() => { + pressHandledRef.current = true; + }, []); + const handlePressOut = useCallback( (event: { nativeEvent: { pageX: number; pageY: number } }) => { if ( @@ -1051,13 +1060,13 @@ const DiffFileHeader = memo(function DiffFileHeader({ } else { trigger = ( {headerContent} @@ -1831,6 +1840,7 @@ interface SharedDiffViewProps { } export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffViewProps) { + const isCompact = useIsCompactFormFactor(); const { layout, wrapLines, codeFontSize, monoFontFamily } = displayPreferences; const diffBodyLineHeight = Math.round(codeFontSize * 1.5); const typographyKey = [monoFontFamily, codeFontSize, diffBodyLineHeight].join(":"); @@ -1876,6 +1886,8 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [allFolderPathSet, collapsedFolders], ); const diffListRef = useRef>(null); + const scrollbar = useOverlayFlatListScrollbar(diffListRef, { enabled: !isCompact }); + const { onLayout: updateScrollbarLayout, onScroll: updateScrollbarOffset } = scrollbar; const consumedFocusRequestRef = useRef(null); const pendingFocusRequestRef = useRef(null); const diffListScrollOffsetRef = useRef(0); @@ -2025,17 +2037,25 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi [getBodyHeightKey, scheduleHeightVersionUpdate], ); - const handleDiffListScroll = useCallback((event: NativeSyntheticEvent) => { - diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; - }, []); + const handleDiffListScroll = useCallback( + (event: NativeSyntheticEvent) => { + diffListScrollOffsetRef.current = event.nativeEvent.contentOffset.y; + updateScrollbarOffset(event); + }, + [updateScrollbarOffset], + ); - const handleDiffListLayout = useCallback((event: LayoutChangeEvent) => { - const height = event.nativeEvent.layout.height; - if (!Number.isFinite(height) || height <= 0) { - return; - } - diffListViewportHeightRef.current = height; - }, []); + const handleDiffListLayout = useCallback( + (event: LayoutChangeEvent) => { + const height = event.nativeEvent.layout.height; + if (!Number.isFinite(height) || height <= 0) { + return; + } + diffListViewportHeightRef.current = height; + updateScrollbarLayout(event); + }, + [updateScrollbarLayout], + ); const computeItemOffset = useCallback( (predicate: (item: DiffFlatItem) => boolean): number | null => { @@ -2269,26 +2289,30 @@ export function SharedDiffView({ files, displayPreferences, mode }: SharedDiffVi ); return ( - + + + {scrollbar.overlay} + ); } @@ -3050,6 +3074,11 @@ const styles = StyleSheet.create((theme) => ({ scrollView: { flex: 1, }, + scrollContainer: { + flex: 1, + minHeight: 0, + position: "relative", + }, contentContainer: { paddingBottom: theme.spacing[8], }, @@ -3110,15 +3139,15 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", paddingLeft: theme.spacing[3], - paddingRight: theme.spacing[3], + paddingRight: WORKSPACE_FILE_ROW_TRAILING_PADDING, paddingVertical: WORKSPACE_FILE_ROW_VERTICAL_PADDING, gap: theme.spacing[1], minWidth: 0, zIndex: 2, elevation: 2, }, - fileHeaderPressed: { - opacity: 0.7, + fileHeaderActive: { + backgroundColor: theme.colors.surfaceSidebarHover, }, fileHeaderLeft: { flexDirection: "row", diff --git a/packages/app/src/hooks/use-keyboard-shortcuts.ts b/packages/app/src/hooks/use-keyboard-shortcuts.ts index 607b90119..243c366af 100644 --- a/packages/app/src/hooks/use-keyboard-shortcuts.ts +++ b/packages/app/src/hooks/use-keyboard-shortcuts.ts @@ -29,6 +29,7 @@ import { getShortcutOs } from "@/utils/shortcut-platform"; import { useOpenAddProject } from "@/hooks/use-open-add-project"; import { useKeyboardShortcutOverrides } from "@/hooks/use-keyboard-shortcut-overrides"; import { isNative } from "@/constants/platform"; +import { keyboardShortcutsAvailable } from "@/keyboard/availability"; import { getDesktopHost, isElectronRuntime } from "@/desktop/host"; import { isImeComposingKeyboardEvent } from "@/utils/keyboard-ime"; import { @@ -59,6 +60,7 @@ export function useKeyboardShortcuts({ const resetModifiers = useKeyboardShortcutsStore((s) => s.resetModifiers); const { overrides } = useKeyboardShortcutOverrides(); const bindings = useMemo(() => buildEffectiveBindings(overrides), [overrides]); + const shortcutsAvailable = keyboardShortcutsAvailable({ isNative, isCompact: isMobile }); const isDesktopApp = getIsElectronRuntime(); const isMac = getShortcutOs() === "mac"; const chordStateRef = useRef({ @@ -73,7 +75,7 @@ export function useKeyboardShortcuts({ const publishBrowserShortcutPolicy = useCallback( (chordState?: ChordState) => { const policy = - enabled && !isMobile + enabled && shortcutsAvailable ? buildBrowserKeyboardPolicy({ bindings, chordState, @@ -83,7 +85,7 @@ export function useKeyboardShortcuts({ : { menuPrefixes: [], prefixes: [] }; void getDesktopHost()?.browser?.setShortcutPolicy?.(policy); }, - [bindings, enabled, isDesktopApp, isMac, isMobile], + [bindings, enabled, isDesktopApp, isMac, shortcutsAvailable], ); useEffect(() => { @@ -102,8 +104,7 @@ export function useKeyboardShortcuts({ useEffect(() => { if (!enabled) return; - if (isNative) return; - if (isMobile) return; + if (!shortcutsAvailable) return; // Only the modifier that actually performs the workspace-index jump on this // runtime should reveal the sidebar number badges (Alt on web, Cmd on @@ -390,6 +391,7 @@ export function useKeyboardShortcuts({ publishBrowserShortcutPolicy, resetModifiers, router, + shortcutsAvailable, toggleAgentList, toggleBothSidebars, ]); diff --git a/packages/app/src/keyboard/availability.test.ts b/packages/app/src/keyboard/availability.test.ts new file mode 100644 index 000000000..282a87900 --- /dev/null +++ b/packages/app/src/keyboard/availability.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { keyboardShortcutsAvailable } from "./availability"; + +describe("keyboardShortcutsAvailable", () => { + it("matches the environments where the shortcut dispatcher runs", () => { + expect(keyboardShortcutsAvailable({ isNative: false, isCompact: false })).toBe(true); + expect(keyboardShortcutsAvailable({ isNative: false, isCompact: true })).toBe(false); + expect(keyboardShortcutsAvailable({ isNative: true, isCompact: false })).toBe(false); + expect(keyboardShortcutsAvailable({ isNative: true, isCompact: true })).toBe(false); + }); +}); diff --git a/packages/app/src/keyboard/availability.ts b/packages/app/src/keyboard/availability.ts new file mode 100644 index 000000000..ec3957f58 --- /dev/null +++ b/packages/app/src/keyboard/availability.ts @@ -0,0 +1,19 @@ +import { useIsCompactFormFactor } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; + +interface KeyboardShortcutEnvironment { + isNative: boolean; + isCompact: boolean; +} + +export function keyboardShortcutsAvailable({ + isNative: native, + isCompact, +}: KeyboardShortcutEnvironment): boolean { + return !native && !isCompact; +} + +export function useKeyboardShortcutsAvailable(): boolean { + const isCompact = useIsCompactFormFactor(); + return keyboardShortcutsAvailable({ isNative, isCompact }); +} diff --git a/packages/app/src/review/surface.test.tsx b/packages/app/src/review/surface.test.tsx index bc1636804..86a8fb7eb 100644 --- a/packages/app/src/review/surface.test.tsx +++ b/packages/app/src/review/surface.test.tsx @@ -93,7 +93,7 @@ vi.mock("react-native-unistyles", () => ({ create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), }, withUnistyles: (component: T) => component, - useUnistyles: () => ({ theme }), + useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }), })); vi.mock("@/constants/platform", () => ({