diff --git a/.oxlintrc.json b/.oxlintrc.json index d1566195e9..4582602a97 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -252,7 +252,6 @@ "packages/app/src/components/download-toast.tsx", "packages/app/src/components/draggable-list.native.tsx", "packages/app/src/components/headers/back-header.tsx", - "packages/app/src/components/headers/menu-header.tsx", "packages/app/src/components/headers/screen-header.tsx", "packages/app/src/components/host-status-dot.tsx", "packages/app/src/components/hosts/host-picker.tsx", diff --git a/packages/app/e2e/browser/appearance-theme-picker.spec.ts b/packages/app/e2e/browser/appearance-theme-picker.spec.ts index 8df71f0911..b0d86e164e 100644 --- a/packages/app/e2e/browser/appearance-theme-picker.spec.ts +++ b/packages/app/e2e/browser/appearance-theme-picker.spec.ts @@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo) await row.click(); await expect(row).toHaveAttribute("aria-selected", "true"); - await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)"); + await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)"); await page.screenshot({ path: testInfo.outputPath("light-selected-workspace.png"), fullPage: true, diff --git a/packages/app/e2e/browser/compact-workspace-header.spec.ts b/packages/app/e2e/browser/compact-workspace-header.spec.ts new file mode 100644 index 0000000000..c245d2ae10 --- /dev/null +++ b/packages/app/e2e/browser/compact-workspace-header.spec.ts @@ -0,0 +1,96 @@ +import type { Page, TestInfo } from "@playwright/test"; +import { test, expect } from "../support/fixtures"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { openAgentRoute } from "../support/helpers/mock-agent"; +import { getServerId } from "../support/helpers/server-id"; + +const labels = { + long: { + title: "workspace-with-a-very-long-release-and-investigation-name", + project: "project-with-a-very-long-customer-integration-name", + }, + short: { title: "main", project: "Paseo" }, +}; + +async function openNamedWorkspace(page: Page, names: typeof labels.long) { + const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title }); + try { + await workspace.client.renameProject(workspace.projectId, names.project); + const agent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: "Header layout check", + modeId: "load-test", + model: "e2e-fast-stream", + }); + await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id }); + await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title); + await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project); + await expect( + page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }), + ).toHaveCount(1); + return workspace; + } catch (error) { + await workspace.cleanup(); + throw error; + } +} + +async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) { + const title = page.getByTestId("workspace-header-title"); + const project = page.getByTestId("workspace-header-subtitle"); + const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }); + const trigger = page.getByRole("button", { name: /Switch tabs/ }); + const measure = async () => ({ + title: await title.boundingBox(), + project: await project.boundingBox(), + badge: await badge.boundingBox(), + icon: await badge.locator("svg").boundingBox(), + trigger: await trigger.boundingBox(), + titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth), + projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth), + }); + await page.screenshot({ path: info.outputPath("header.png") }); + await expect(async () => { + const bounds = await measure(); + expect(bounds.title).not.toBeNull(); + expect(bounds.project).not.toBeNull(); + expect(bounds.badge).not.toBeNull(); + expect(bounds.icon).not.toBeNull(); + expect(bounds.trigger).not.toBeNull(); + // Approved geometry: 12px outside margin, 46px leading island, 8px title gap. + expect(bounds.title!.x).toBe(66); + expect(bounds.project!.x).toBe(bounds.title!.x); + expect(bounds.trigger!.width).toBe(32); + expect(bounds.trigger!.height).toBe(32); + // The action frame starts 7px inside its island; preserve the 8px gap before it. + const contentRight = bounds.trigger!.x - 15; + for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) { + expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1); + } + expect(bounds.project!.width).toBeGreaterThan(20); + expect(bounds.icon!.width).toBe(12); + expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width); + expect(bounds.titleTruncated).toBe(long); + expect(bounds.projectTruncated).toBe(long); + }).toPass({ timeout: 5_000 }); + await info.attach("header-bounds", { + body: JSON.stringify(await measure(), null, 2), + contentType: "application/json", + }); +} + +for (const width of [320, 390]) { + for (const kind of ["long", "short"] as const) { + test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => { + await page.setViewportSize({ width, height: 844 }); + const workspace = await openNamedWorkspace(page, labels[kind]); + try { + await expectHeaderContentsFit(page, info, kind === "long"); + } finally { + await workspace.cleanup(); + } + }); + } +} diff --git a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts index 07e62131b0..72b5908446 100644 --- a/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts +++ b/packages/app/e2e/browser/workspace-tab-switcher-rename.spec.ts @@ -1,3 +1,4 @@ +import type { Page, TestInfo } from "@playwright/test"; import { randomUUID } from "node:crypto"; import { test, expect } from "../support/fixtures"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; @@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client"; const MOBILE_VIEWPORT = { width: 390, height: 844 }; +test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 }); + +async function openTabSwitcher(page: Page) { + await page.getByRole("button", { name: /Switch tabs/ }).click(); + const footer = page.getByRole("button", { name: "New terminal", exact: true }); + await expect(footer).toBeVisible(); + // Visibility alone passes while the sheet is still travelling up from below the viewport. + await expect + .poll(async () => { + const bounds = await footer.boundingBox(); + return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY; + }) + .toBeLessThanOrEqual(MOBILE_VIEWPORT.height); +} + +async function captureScreen(page: Page, testInfo: TestInfo, name: string) { + await page.screenshot({ path: testInfo.outputPath(name + ".png") }); +} + +async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") { + await openTabSwitcher(page); + await page.getByRole("button", { name: kind, exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); +} + async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise { const result = await client.fetchAgents({ scope: "active" }); return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null; @@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi test.describe("Workspace session rename (compact tab switcher)", () => { test("renames an agent session from the switcher's per-session actions sheet", async ({ page, - }) => { + }, testInfo) => { test.setTimeout(120_000); await page.setViewportSize(MOBILE_VIEWPORT); @@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => { timeout: 30_000, }); + await captureScreen(page, testInfo, "compact-chat-header"); + // Open the sessions dropdown (a bottom sheet on compact). - await page.getByRole("button", { name: /Switch tabs/ }).click(); + await openTabSwitcher(page); await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible( { timeout: 10_000, @@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => { await renameModalSubmit(page, modalPrefix).click(); await expect(input).toHaveCount(0, { timeout: 15_000 }); - await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, { + await expect(page.getByText(renamed, { exact: true })).toBeVisible({ timeout: 15_000, }); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1"); await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1); + await expect(page.getByText("Rename agent", { exact: true })).toBeHidden(); + await captureScreen(page, testInfo, "renamed-tab-in-switcher"); } finally { await session.cleanup(); } }); }); + +test("switcher footer creates tabs and returns to the existing chat", async ({ + page, +}, testInfo) => { + const session = await seedMockAgentWorkspace({ + repoPrefix: "compact-header-actions-", + title: "Existing chat", + }); + try { + await openAgentRoute(page, session); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1"); + + await createTabFromSwitcher(page, "New terminal"); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2"); + await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1); + await captureScreen(page, testInfo, "compact-terminal-header"); + + await createTabFromSwitcher(page, "New agent"); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3"); + await expect( + page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }), + ).toHaveCount(1); + await captureScreen(page, testInfo, "compact-draft-header"); + + await openTabSwitcher(page); + await expect(page.getByText("Existing chat", { exact: true })).toBeVisible(); + await captureScreen(page, testInfo, "switcher-three-tabs"); + await page.getByText("Existing chat", { exact: true }).click(); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3"); + + await openTabSwitcher(page); + await expect(page.getByText("Existing chat", { exact: true })).toBeVisible(); + await page + .getByRole("button", { name: "Bottom sheet backdrop" }) + .click({ position: { x: 10, y: 10 } }); + await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0); + await captureScreen(page, testInfo, "returned-chat-header"); + } finally { + await session.cleanup(); + } +}); diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad index 63f5fb0728..335f269b13 100644 --- a/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-terminal-basic.ios.ad @@ -2,9 +2,9 @@ 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-header-menu-trigger\"" -press "id=\"workspace-header-new-terminal\"" +wait "id=\"workspace-tab-switcher-trigger\"" 20000 +press "id=\"workspace-tab-switcher-trigger\"" +press "id=\"workspace-tab-switcher-new-terminal\"" wait "id=\"terminal-keyboard-toggle\"" 10000 press "id=\"terminal-keyboard-toggle\"" type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad index 4988c9afdd..2b5e5fcfdb 100644 --- a/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-terminal-scroll.ios.ad @@ -2,9 +2,9 @@ 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-header-menu-trigger\"" -press "id=\"workspace-header-new-terminal\"" +wait "id=\"workspace-tab-switcher-trigger\"" 20000 +press "id=\"workspace-tab-switcher-trigger\"" +press "id=\"workspace-tab-switcher-new-terminal\"" wait "id=\"terminal-surface\"" 10000 press "id=\"terminal-surface\"" type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0 diff --git a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad index 9ef099b5a7..11f75b62b3 100644 --- a/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad +++ b/packages/app/e2e/mobile/agent-device/native-terminal-selection-panels.ios.ad @@ -2,9 +2,9 @@ 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-header-menu-trigger\"" -press "id=\"workspace-header-new-terminal\"" +wait "id=\"workspace-tab-switcher-trigger\"" 20000 +press "id=\"workspace-tab-switcher-trigger\"" +press "id=\"workspace-tab-switcher-new-terminal\"" wait "id=\"terminal-keyboard-toggle\"" 10000 press "id=\"terminal-keyboard-toggle\"" type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0 diff --git a/packages/app/e2e/mobile/composer-keyboard/android.sh b/packages/app/e2e/mobile/composer-keyboard/android.sh index ba86400132..49c907dba9 100755 --- a/packages/app/e2e/mobile/composer-keyboard/android.sh +++ b/packages/app/e2e/mobile/composer-keyboard/android.sh @@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen ad wait "text=\"${AGENT_TITLE}\"" 10000 run_host_scenario chat ad keyboard dismiss || true -ad press 'id="workspace-header-menu-trigger"' --settle -ad wait 'id="workspace-header-new-agent"' 10000 -ad press 'id="workspace-header-new-agent"' --settle +ad press 'id="workspace-tab-switcher-trigger"' --settle +ad wait 'id="workspace-tab-switcher-new-agent"' 10000 +ad press 'id="workspace-tab-switcher-new-agent"' --settle ad wait 'text="New Agent"' 10000 run_host_scenario workspace-draft ad keyboard dismiss || true diff --git a/packages/app/e2e/support/helpers/plugin-button-example.ts b/packages/app/e2e/support/helpers/plugin-button-example.ts index 94018c4415..ebe868ee2c 100644 --- a/packages/app/e2e/support/helpers/plugin-button-example.ts +++ b/packages/app/e2e/support/helpers/plugin-button-example.ts @@ -30,7 +30,6 @@ async function capture( name: string, subject: Locator = headerButtons(page), ) { - await expect(headerButtons(page)).toHaveCount(1); await expect(subject).toBeInViewport(); await waitForSettledPosition(subject); const file = info.outputPath(`${name}.png`); @@ -80,6 +79,7 @@ export async function withButtonAuthorExample( refreshFromHeaderAndComposer: () => test.step("icon-only header action and labeled composer action update in place", async () => { await exampleCommand(page, "action"); + await expect(headerButtons(page)).toHaveCount(1); await headerButtons(page).click(); await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)"); await page.getByRole("button", { name: "Refresh context", exact: true }).click(); @@ -88,7 +88,13 @@ export async function withButtonAuthorExample( ).toContainText("Refreshed · 2"); await capture(page, info, "01-actions"); await page.setViewportSize(COMPACT); - await capture(page, info, "02-compact-actions"); + await expect(headerButtons(page)).toHaveCount(0); + await capture( + page, + info, + "02-compact-actions", + page.getByRole("button", { name: "Refresh context", exact: true }), + ); }), useMenusAndToggleButtons: () => test.step("named menus include separators, nested actions, disabled items, and custom content", async () => { @@ -128,7 +134,8 @@ export async function withButtonAuthorExample( await capture(page, info, "05-disabled"); await exampleCommand(page, "enable"); await page.setViewportSize(COMPACT); - await headerButtons(page).click(); + await expect(headerButtons(page)).toHaveCount(0); + await page.getByRole("button", { name: "Composer tools", exact: true }).click(); await expect( page.getByRole("menuitem", { name: "Refresh workspace", exact: true }), ).toBeInViewport(); @@ -143,6 +150,7 @@ export async function withButtonAuthorExample( inspectLiveWorkspaceDetails: () => test.step("custom icons and content react to workspace changes and adapt to sheets", async () => { await exampleCommand(page, "popover"); + await expect(headerButtons(page)).toHaveCount(1); await headerButtons(page).click(); await expect( page.getByRole("button", { name: "Close workspace details", exact: true }), diff --git a/packages/app/e2e/support/helpers/plugin-buttons.ts b/packages/app/e2e/support/helpers/plugin-buttons.ts index 7404173fc8..f3279ce7ef 100644 --- a/packages/app/e2e/support/helpers/plugin-buttons.ts +++ b/packages/app/e2e/support/helpers/plugin-buttons.ts @@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) { await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12); } -async function holdButton(page: Page, button: Locator) { - await button.hover(); - await page.mouse.down(); -} - -async function openCompactOverflowWithHeaderChrome(page: Page) { - const hamburger = page.getByRole("button", { name: "Open menu", exact: true }); - const action = page.getByRole("button", { name: "Deploy application", exact: true }); - const overflow = page - .getByRole("toolbar", { name: "Workspace actions", exact: true }) - .getByRole("button", { name: "More actions", exact: true }); - await expect(action).toHaveCSS("border-top-width", "0px"); - await expect(overflow).toHaveCSS("border-top-width", "0px"); - await hamburger.hover(); - const highlight = await hamburger.evaluate( - (element) => getComputedStyle(element).backgroundColor, - ); - await action.hover(); - await expect(action).toHaveCSS("background-color", highlight); - await holdButton(page, overflow); - await expect(overflow).toHaveCSS("background-color", highlight); - await expectSurfaceVisible(page); - await page.mouse.up(); -} - export async function withButtonShowcase( page: Page, run: (buttons: { @@ -408,7 +383,7 @@ export async function withButtonShowcase( ); }), openCompactSheets: () => - test.step("compact triggers and overflow open sheets with working plugin context", async () => { + test.step("compact header omits plugin actions while composer triggers open sheets", async () => { await page.setViewportSize(COMPACT); await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0); await expect( @@ -426,39 +401,12 @@ export async function withButtonShowcase( "Button showcase with a long workspace title", ); await expectHeaderTextTruncated(page); - await expectSurfaceVisible(page); - await openCompactOverflowWithHeaderChrome(page); await expect( - page.getByRole("menuitem", { name: "Production locked", exact: true }), - ).toBeDisabled(); - await expectSurfaceVisible( - page, - page.getByRole("menuitem", { name: "Production locked", exact: true }), - ); - await choose(page, "Header checks"); - await expectSurfaceVisible( - page, - page.getByRole("menuitem", { name: "Run checks", exact: true }), - ); - await choose(page, "Deployment details"); - await expectDetails(page); - await expectSurfaceVisible( - page, - page.getByRole("button", { name: "Close deployment details", exact: true }), - ); - await press(page, "Close deployment details"); - await showcase.setTitle("Compact checks"); - await press(page, "Header checks"); - await expect( - page - .getByRole("button", { name: "Header checks", exact: true }) - .getByTestId("plugin-button-chevron"), + page.getByRole("toolbar", { name: "Workspace actions", exact: true }), ).toHaveCount(0); - await expectSurfaceVisible( - page, - page.getByRole("menuitem", { name: "Run checks", exact: true }), - ); - await choose(page, "Run checks"); + await expect( + page.getByRole("button", { name: "Open menu", exact: true }), + ).toBeInViewport(); await showcase.setTitle("Button showcase"); await press(page, "Composer status"); await expectDetails(page); diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index 47f2766a30..65f535db11 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -42,6 +42,7 @@ import { type HistoryStartPaginationInput, type HistoryStartPaginationTransition, } from "./history-start-pagination"; +import { useStreamTopOverlayInset } from "./top-overlay"; import { createHistoryStartSettleScheduler, type HistoryStartSettleScheduler, @@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom" ? undefined : DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION; + const topOverlayInset = useStreamTopOverlayInset(); + // The list is inverted, so its content's bottom padding is the visual top. const listContentContainerStyle = useMemo( () => [ baseListContentContainerStyle, - { paddingBottom: streamKeyboardInset.contentContainerPaddingBottom }, + { + paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset, + }, + ], + [ + baseListContentContainerStyle, + streamKeyboardInset.contentContainerPaddingBottom, + topOverlayInset, ], - [baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom], ); const listInsetProps = useMemo( () => diff --git a/packages/app/src/agent-stream/top-overlay.ts b/packages/app/src/agent-stream/top-overlay.ts new file mode 100644 index 0000000000..24cd7f9592 --- /dev/null +++ b/packages/app/src/agent-stream/top-overlay.ts @@ -0,0 +1,14 @@ +import { createContext, useContext } from "react"; +import { isNative } from "@/constants/platform"; + +/** The native inverted viewport reserves visual-top clearance; web stays in flow. */ +export const supportsStreamTopOverlay = isNative; + +const StreamTopOverlayContext = createContext(0); + +/** Visual-top clearance supplied by the containing layout, in points. */ +export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider; + +export function useStreamTopOverlayInset(): number { + return useContext(StreamTopOverlayContext); +} diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index 6e9b506b55..aa740a1d52 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -35,7 +35,7 @@ import { ChangelogHost } from "@/changelog"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { LeftSidebar } from "@/components/left-sidebar"; -import { WindowSidebarMenuToggle } from "@/components/headers/menu-header"; +import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; import { DesktopWindowControls } from "@/components/desktop/window-controls"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts index fbd37c15e7..cc8ad8fc11 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.test.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.test.ts @@ -1,8 +1,5 @@ import { describe, expect, it } from "vitest"; -import { - getBottomSheetVisibleContentHeight, - getCompactSheetSafeAreaPadding, -} from "@/components/adaptive-modal-sheet-layout"; +import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; describe("getCompactSheetSafeAreaPadding", () => { it("assigns safe clearance to the footer independently of decorative padding", () => { @@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => { ).toEqual({}); }); }); - -describe("getBottomSheetVisibleContentHeight", () => { - it("stops subtracting the retained keyboard height after the keyboard hides", () => { - const layout = { - containerHeight: 874, - contentPosition: 88, - handleHeight: 24, - keyboardHeight: 344, - }; - - expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418); - expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762); - }); -}); diff --git a/packages/app/src/components/adaptive-modal-sheet-layout.ts b/packages/app/src/components/adaptive-modal-sheet-layout.ts index 91fc62274e..b38db5750c 100644 --- a/packages/app/src/components/adaptive-modal-sheet-layout.ts +++ b/packages/app/src/components/adaptive-modal-sheet-layout.ts @@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding { footerPaddingBottom?: number; } -interface BottomSheetVisibleContentHeightInput { - containerHeight: number; - contentPosition: number; - handleHeight: number; - keyboardHeight: number; - isKeyboardVisible: boolean; -} - -export function getBottomSheetVisibleContentHeight({ - containerHeight, - contentPosition, - handleHeight, - keyboardHeight, - isKeyboardVisible, -}: BottomSheetVisibleContentHeightInput): number { - "worklet"; - return Math.max( - 0, - containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0), - ); -} - export function getCompactSheetSafeAreaPadding({ isCompact, isKeyboardVisible, diff --git a/packages/app/src/components/adaptive-modal-sheet.tsx b/packages/app/src/components/adaptive-modal-sheet.tsx index 4ceb43d917..707c0e8622 100644 --- a/packages/app/src/components/adaptive-modal-sheet.tsx +++ b/packages/app/src/components/adaptive-modal-sheet.tsx @@ -12,22 +12,16 @@ import { useGlobalWebOverlayLayer, useWebOverlayRegistration, } from "../lib/overlay-root"; -import { - KEYBOARD_STATUS, - useBottomSheetInternal, - type BottomSheetBackgroundProps, -} from "@gorhom/bottom-sheet"; -import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import Animated from "react-native-reanimated"; import { ArrowLeft, Search, X } from "lucide-react-native"; import { IsolatedBottomSheetModal, + SheetVisibleFrame, type ContextBridge, useIsolatedBottomSheetVisibility, } from "@/components/ui/isolated-bottom-sheet-modal"; -import { - getBottomSheetVisibleContentHeight, - getCompactSheetSafeAreaPadding, -} from "@/components/adaptive-modal-sheet-layout"; +import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout"; import { ScrollView } from "@/components/ui/scroll-view"; import { isWeb } from "@/constants/platform"; import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; @@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({ flex: 1, minHeight: 0, }, - bottomSheetVisibleContent: { - minHeight: 0, - overflow: "hidden", - }, bottomSheetVisibleScroll: { flex: 1, minHeight: 0, @@ -263,40 +253,6 @@ function SheetContent({ style, children }: { style: StyleProp; childr return {children}; } -function BottomSheetVisibleContent({ children }: { children: ReactNode }) { - const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = - useBottomSheetInternal(); - const visibleContentStyle = useAnimatedStyle(() => { - const { containerHeight, handleHeight } = animatedLayoutState.get(); - if (containerHeight < 0 || handleHeight < 0) { - return { height: 0 }; - } - - const initialDetentPosition = animatedDetentsState.get().detents?.[0]; - const contentPosition = - initialDetentPosition == null - ? animatedPosition.get() - : Math.min(animatedPosition.get(), initialDetentPosition); - - const keyboardState = animatedKeyboardState.get(); - return { - height: getBottomSheetVisibleContentHeight({ - containerHeight, - contentPosition, - handleHeight, - keyboardHeight: keyboardState.heightWithinContainer, - isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN, - }), - }; - }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); - - return ( - - {children} - - ); -} - export function SheetHeaderView({ header, onClose, @@ -471,8 +427,6 @@ export interface AdaptiveModalSheetProps { bodyStyle?: StyleProp; /** Layout intent for the sheet body, composed over the sheet's own content inset. */ contentStyle?: StyleProp; - /** Size compact sheet content to the live snap height instead of its largest snap point. */ - sizeContentToCurrentSnapPoint?: boolean; /** Re-establishes caller-owned contexts inside the native or compact sheet portal. */ contextBridge?: ContextBridge | null; } @@ -493,7 +447,6 @@ export function AdaptiveModalSheet({ presentation, contentStyle, bodyStyle, - sizeContentToCurrentSnapPoint = true, contextBridge = null, }: AdaptiveModalSheetProps) { const { theme } = useUnistyles(); @@ -519,11 +472,15 @@ export function AdaptiveModalSheet({ () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), [compactSafeAreaPadding.footerPaddingBottom], ); - const footerView = footer ? ( - - {footer} - - ) : null; + const footerView = useMemo( + () => + footer ? ( + + {footer} + + ) : null, + [footer, footerClearanceStyle, footerContainerStyle], + ); const handleIndicatorStyle = useMemo( () => ({ backgroundColor: theme.colors.palette.zinc[600] }), [theme.colors.palette.zinc], @@ -628,7 +585,6 @@ export function AdaptiveModalSheet({ )} - {footerView} ); @@ -650,11 +606,7 @@ export function AdaptiveModalSheet({ accessible={false} presentation={presentation} > - {sizeContentToCurrentSnapPoint ? ( - {sheetContent} - ) : ( - sheetContent - )} + {sheetContent} ); } diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx new file mode 100644 index 0000000000..b18a8731b0 --- /dev/null +++ b/packages/app/src/components/floating-header/index.tsx @@ -0,0 +1,99 @@ +import { useCallback, useId, useState, type ReactNode } from "react"; +import { View, type LayoutChangeEvent } from "react-native"; +import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import type { Theme } from "@/styles/theme"; + +/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ +const FADE_OVERHANG = 24; + +interface FloatingHeaderLayoutProps { + header: ReactNode; + /** Float only when the content supports visual-top clearance. */ + floating: boolean; + children: (topOverlayInset: number) => ReactNode; +} + +/** + * Stacks a header above content, or floats it over the content behind a fade. Children keep the + * same tree position in both modes, so toggling `floating` never remounts them. + */ +export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) { + const [headerHeight, setHeaderHeight] = useState(0); + const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => { + setHeaderHeight(Math.round(event.nativeEvent.layout.height)); + }, []); + + return ( + + {floating ? null : header} + {children(floating ? headerHeight : 0)} + {floating ? ( + + + {header} + + ) : null} + + ); +} + +function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) { + return ( + + + + {/* Vary opacity rather than interpolating toward `transparent`, which crosses black in + some engines and leaves a grey fringe. */} + + + + + + + + + ); +} + +const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg); +const workspaceSurfaceColorMapping = (theme: Theme) => ({ + color: theme.colors.surfaceWorkspace, +}); + +function FloatingHeaderFade() { + // React-generated ids contain characters that are invalid inside SVG fragment references. + const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`; + return ( + + + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + minHeight: 0, + }, + content: { + flex: 1, + minHeight: 0, + }, + overlay: { + position: "absolute", + top: 0, + left: 0, + right: 0, + }, + fade: { + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: -FADE_OVERHANG, + }, +}); diff --git a/packages/app/src/components/headers/back-header.tsx b/packages/app/src/components/headers/back-header.tsx index 6532ee561a..a9398da0cf 100644 --- a/packages/app/src/components/headers/back-header.tsx +++ b/packages/app/src/components/headers/back-header.tsx @@ -6,10 +6,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ArrowLeft } from "lucide-react-native"; import { ScreenHeader } from "./screen-header"; import { ScreenTitle } from "./screen-title"; +import { CompactHeader } from "./compact-header"; +import { useIsCompactFormFactor } from "@/constants/layout"; interface BackHeaderProps { title?: string; - titleAccessory?: ReactNode; rightContent?: ReactNode; onBack?: () => void; } @@ -18,7 +19,15 @@ function goBack(): void { router.back(); } -export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) { +export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) { + const isCompact = useIsCompactFormFactor(); + if (isCompact) { + return ; + } + return ; +} + +function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) { const { theme } = useUnistyles(); const { t } = useTranslation(); const handleBack = useCallback(() => { @@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back {title && {title}} - {titleAccessory} } right={rightContent} @@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, backButton: { - padding: { - xs: theme.spacing[3], - md: theme.spacing[2], - }, + padding: theme.spacing[2], borderRadius: theme.borderRadius.lg, }, })); diff --git a/packages/app/src/components/headers/compact-header.tsx b/packages/app/src/components/headers/compact-header.tsx new file mode 100644 index 0000000000..aa66580fce --- /dev/null +++ b/packages/app/src/components/headers/compact-header.tsx @@ -0,0 +1,168 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Text, View } from "react-native"; +import { router } from "expo-router"; +import { ArrowLeft } from "lucide-react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; +import { + IconButtonChromeProvider, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; +import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout"; +import type { ShortcutKey } from "@/utils/format-shortcut"; + +const ThemedArrowLeft = withUnistyles(ArrowLeft); + +const ISLAND_PADDING = 7; +const NO_SHORTCUT: ShortcutKey[] = []; + +interface CompactHeaderProps { + /** The leading island: the sidebar menu, or a back button. */ + navigation: "menu" | "back"; + /** Back button action. Defaults to navigating back. */ + onBack?: () => void; + title?: string; + titleTestID?: string; + /** A line under the title. */ + subtitle?: ReactNode; + /** Show a placeholder instead of the title while it loads. */ + loading?: boolean; + /** Icon actions inherit the island chrome through the shared button primitives. */ + actions?: ReactNode; +} + +/** + * The phone header: the navigation button and the screen's actions sit in islands, with the title + * left-aligned between them. It has no background of its own, so it reads the same in flow and floating + * over content (see `FloatingHeaderLayout`). Its height matches the old flat header, so anything + * offset by the header height still lines up. + */ +export function CompactHeader({ + navigation, + onBack, + title, + titleTestID, + subtitle, + loading = false, + actions, +}: CompactHeaderProps) { + const insets = useSafeAreaInsets(); + const rootStyle = useMemo( + () => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }], + [insets.top], + ); + + return ( + + + + + {navigation === "menu" ? : } + + + + + {actions ? {actions} : null} + + + + ); +} + +function CompactHeaderTitle({ + loading, + title, + titleTestID, + subtitle, +}: Pick & { loading: boolean }) { + if (loading) { + return ; + } + return ( + <> + {title ? ( + + {title} + + ) : null} + {subtitle} + + ); +} + +function BackButton({ onBack }: { onBack?: () => void }) { + const { t } = useTranslation(); + const handlePress = useCallback(() => { + if (onBack) { + onBack(); + return; + } + router.back(); + }, [onBack]); + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + root: { + paddingHorizontal: theme.spacing[3], + }, + row: { + height: HEADER_INNER_HEIGHT_MOBILE, + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + }, + titleGroup: { + flex: 1, + minWidth: 0, + // Both title lines receive the remaining width; text alignment does not size the slot. + alignItems: "stretch", + }, + island: { + flexDirection: "row", + alignItems: "center", + padding: ISLAND_PADDING, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface2, + ...theme.shadow.sm, + }, + title: { + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.medium, + color: theme.colors.foreground, + textAlign: "left", + }, + titleSkeleton: { + width: 140, + maxWidth: "100%", + height: 22, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.surface3, + opacity: 0.25, + }, +})); diff --git a/packages/app/src/components/headers/header-toggle-button.tsx b/packages/app/src/components/headers/header-toggle-button.tsx index b11394c46d..137d5d483d 100644 --- a/packages/app/src/components/headers/header-toggle-button.tsx +++ b/packages/app/src/components/headers/header-toggle-button.tsx @@ -1,4 +1,4 @@ -import { useMemo, type ReactElement, type ReactNode } from "react"; +import { type ReactElement, type ReactNode } from "react"; import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; @@ -7,7 +7,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; import { isWeb } from "@/constants/platform"; import { iconButtonChromeFrameStyle, - iconButtonChromeStyle, + useIconButtonChromeStyle, } from "@/components/ui/icon-button-chrome"; interface HeaderToggleButtonState { @@ -46,17 +46,11 @@ export function HeaderToggleButton({ ? ({ "aria-expanded": expandedState } as Record) : null; - const combinedStyle = useMemo( - () => - ({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => - iconButtonChromeStyle({ - size: "large", - state: { hovered: Boolean(hovered), pressed }, - disabled: Boolean(disabled), - style, - }), - [disabled, style], - ); + const combinedStyle = useIconButtonChromeStyle({ + size: "large", + disabled: Boolean(disabled), + style, + }); return ( diff --git a/packages/app/src/components/headers/menu-header.tsx b/packages/app/src/components/headers/menu-header.tsx index 37f39ca0ba..fc4f2442ff 100644 --- a/packages/app/src/components/headers/menu-header.tsx +++ b/packages/app/src/components/headers/menu-header.tsx @@ -1,16 +1,10 @@ -import { useCallback, useMemo, type ReactNode } from "react"; -import { useTranslation } from "react-i18next"; -import { View, type StyleProp, type ViewStyle } from "react-native"; -import { StyleSheet, useUnistyles } from "react-native-unistyles"; -import { PanelLeft } from "lucide-react-native"; +import type { ReactNode } from "react"; +import { StyleSheet } from "react-native-unistyles"; import { ScreenHeader } from "./screen-header"; import { ScreenTitle } from "./screen-title"; -import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button"; -import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { CompactHeader } from "./compact-header"; +import { SidebarMenuToggle } from "./sidebar-menu-toggle"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { getShortcutOs } from "@/utils/shortcut-platform"; -import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window"; -import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome"; interface MenuHeaderProps { title?: string; @@ -18,130 +12,11 @@ interface MenuHeaderProps { borderless?: boolean; } -interface SidebarMenuToggleProps { - style?: StyleProp; - tooltipSide?: "left" | "right" | "top" | "bottom"; - testID?: string; - nativeID?: string; -} - -const MOBILE_MENU_LINE_WIDTH = 16; -const MOBILE_MENU_LINE_SHORT_WIDTH = 8; -const MOBILE_MENU_LINE_HEIGHT = 1.5; - -function MobileMenuIcon({ color }: { color: string }) { - const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]); - const shortLineStyle = useMemo( - () => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }], - [color], - ); - return ( - - - - - - ); -} - -function SidebarMenuToggleButton({ - isMobile, - extraMutedIdleIcon = false, - resolvedStyle, - tooltipSide = "right", - testID = "menu-button", - nativeID = "menu-button", -}: Omit & { - isMobile: boolean; - extraMutedIdleIcon?: boolean; - resolvedStyle: StyleProp; -}) { - const { theme } = useUnistyles(); - const { t } = useTranslation(); - const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); - const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); - const toggleShortcutKeys = useMemo( - () => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]), - [], - ); - - const handlePress = useCallback(() => { - toggleAgentListForLayout({ isCompact: isMobile }); - }, [toggleAgentListForLayout, isMobile]); - - const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]); - - return ( - - {isMobile ? ( - - ) : ( - - )} - - ); -} - -export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { - const isMobile = useIsCompactFormFactor(); - const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); - const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); - const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); - const placeholderStyle = useMemo( - () => [headerIconSlotStyle.slot, resolvedStyle], - [resolvedStyle], - ); - - if (!isMobile && !ownsTopLeft) { - return null; - } - - if (!isMobile && hasTopLeftWindowControls) { - return ( - - - - ); - } - - return ; -} - -export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { - const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); - return ( - - ); -} - export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) { + const isCompact = useIsCompactFormFactor(); + if (isCompact) { + return ; + } return ( ({ - leadingToggle: { - marginLeft: { - xs: 0, - md: -theme.spacing[2], - }, - }, left: { gap: theme.spacing[2], }, - mobileMenuIcon: { - width: MOBILE_MENU_LINE_WIDTH, - height: 12, - justifyContent: "space-between", - alignItems: "flex-start", - }, - desktopMenuIconSpace: { - width: theme.iconSize.md, - height: theme.iconSize.md, - }, - mobileMenuLine: { - width: MOBILE_MENU_LINE_WIDTH, - height: MOBILE_MENU_LINE_HEIGHT, - borderRadius: theme.borderRadius.full, - }, - mobileMenuLineShort: { - width: MOBILE_MENU_LINE_SHORT_WIDTH, - }, })); diff --git a/packages/app/src/components/headers/sidebar-menu-toggle.tsx b/packages/app/src/components/headers/sidebar-menu-toggle.tsx new file mode 100644 index 0000000000..445b7baa96 --- /dev/null +++ b/packages/app/src/components/headers/sidebar-menu-toggle.tsx @@ -0,0 +1,167 @@ +import { useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { View, type StyleProp, type ViewStyle } from "react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { PanelLeft } from "lucide-react-native"; +import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button"; +import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { getShortcutOs } from "@/utils/shortcut-platform"; +import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window"; +import { + extraMutedIconColorMapping, + iconButtonChromeGlyphSize, + mutedIconColorMapping, +} from "@/components/ui/icon-button-chrome"; + +const ThemedPanelLeft = withUnistyles(PanelLeft); + +interface SidebarMenuToggleProps { + style?: StyleProp; + tooltipSide?: "left" | "right" | "top" | "bottom"; + testID?: string; + nativeID?: string; +} + +const MOBILE_MENU_LINE_WIDTH = 16; +const MOBILE_MENU_LINE_SHORT_WIDTH = 8; +const MOBILE_MENU_LINE_HEIGHT = 1.5; + +function MobileMenuIcon({ extraMuted }: { extraMuted: boolean }) { + const color = extraMuted ? styles.mobileMenuLineExtraMuted : styles.mobileMenuLineMuted; + const lineStyle = useMemo(() => [styles.mobileMenuLine, color], [color]); + const shortLineStyle = useMemo( + () => [styles.mobileMenuLine, styles.mobileMenuLineShort, color], + [color], + ); + return ( + + + + + + ); +} + +function SidebarMenuToggleButton({ + isMobile, + extraMutedIdleIcon = false, + resolvedStyle, + tooltipSide = "right", + testID = "menu-button", + nativeID = "menu-button", +}: Omit & { + isMobile: boolean; + extraMutedIdleIcon?: boolean; + resolvedStyle: StyleProp; +}) { + const { t } = useTranslation(); + const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile })); + const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout); + const toggleShortcutKeys = useMemo( + () => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]), + [], + ); + + const handlePress = useCallback(() => { + toggleAgentListForLayout({ isCompact: isMobile }); + }, [toggleAgentListForLayout, isMobile]); + + const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]); + + return ( + + {isMobile ? ( + + ) : ( + + )} + + ); +} + +export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const isMobile = useIsCompactFormFactor(); + const ownsTopLeft = useOwnsWindowChromeCorner("top-left"); + const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left"); + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + const placeholderStyle = useMemo( + () => [headerIconSlotStyle.slot, resolvedStyle], + [resolvedStyle], + ); + + if (!isMobile && !ownsTopLeft) { + return null; + } + + if (!isMobile && hasTopLeftWindowControls) { + return ( + + + + ); + } + + return ; +} + +export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) { + const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]); + return ( + + ); +} + +const styles = StyleSheet.create((theme) => ({ + leadingToggle: { + marginLeft: { + xs: 0, + md: -theme.spacing[2], + }, + }, + mobileMenuIcon: { + width: MOBILE_MENU_LINE_WIDTH, + height: 12, + justifyContent: "space-between", + alignItems: "flex-start", + }, + desktopMenuIconSpace: { + width: theme.iconSize.md, + height: theme.iconSize.md, + }, + mobileMenuLine: { + width: MOBILE_MENU_LINE_WIDTH, + height: MOBILE_MENU_LINE_HEIGHT, + borderRadius: theme.borderRadius.full, + }, + mobileMenuLineMuted: { + backgroundColor: theme.colors.foregroundMuted, + }, + mobileMenuLineExtraMuted: { + backgroundColor: theme.colors.foregroundExtraMuted, + }, + mobileMenuLineShort: { + width: MOBILE_MENU_LINE_SHORT_WIDTH, + }, +})); diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index ddfc9e219f..087a3ffca8 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -1,5 +1,13 @@ import { router } from "expo-router"; -import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native"; +import { + CircleGauge, + FolderPlus, + GitBranch, + Server, + Settings, + X, + type LucideIcon, +} from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { @@ -26,7 +34,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; -import { buttonControlHeight } from "@/components/ui/control-geometry"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; @@ -37,6 +45,7 @@ import { type SidebarProjectEntry, type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; +import { density } from "@/styles/density"; import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { RetainedPanelActivity } from "@/components/retained-panel"; @@ -280,30 +289,25 @@ function FooterIconButton({ onPress, testID, label, - icon: Icon, + icon, iconSizeAdjustment = 0, shortcutKeys, - theme, }: { onPress: () => void; testID: string; label: string; - icon: typeof FolderPlus; + icon: LucideIcon; /** Only for a glyph that reads larger than the others at the same size. */ iconSizeAdjustment?: number; shortcutKeys?: ReturnType; - theme: SidebarTheme; buttonRef?: RefObject; }) { - const isCompact = useIsCompactFormFactor(); - const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md; - return ( {({ hovered }) => ( - )} @@ -328,12 +334,10 @@ function FooterIconButton({ } function SidebarHostPicker({ - theme, label, onAddHost, onOpenHostSettings, }: { - theme: SidebarTheme; label: string; onAddHost: () => void; onOpenHostSettings: (serverId: string) => void; @@ -377,7 +381,6 @@ function SidebarHostPicker({ icon={Server} // Server's two boxes fill more of the square than the other glyphs. iconSizeAdjustment={-1} - theme={theme} /> ); @@ -399,7 +402,6 @@ function IconTooltipContent({ } function SidebarFooter({ - theme, handleOpenProject, handleSettings, labels, @@ -407,7 +409,6 @@ function SidebarFooter({ handleOpenHostSettings, onBeforeNavigate, }: { - theme: SidebarTheme; handleOpenProject: () => void; handleSettings: () => void; labels: { @@ -436,11 +437,9 @@ function SidebarFooter({ label={labels.addProject} icon={FolderPlus} shortcutKeys={newAgentKeys} - theme={theme} /> - + @@ -461,7 +459,7 @@ function SidebarFooter({ ); } -function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) { +function SidebarUsageIcon({ label }: { label: string }) { const openUsage = useOpenSidebarUsage(); return ( ); } @@ -612,7 +609,6 @@ function MobileSidebar({ )} ({ paddingVertical: theme.spacing[1.5], gap: 2, }, - footerIconButton: (isCompact: boolean) => ({ - width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, - height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, + footerIconButton: { + width: density.iconButton, + height: density.iconButton, alignItems: "center", justifyContent: "center", paddingVertical: theme.spacing[1], paddingHorizontal: theme.spacing[1], - }), + }, tooltipRow: { flexDirection: "row", alignItems: "center", diff --git a/packages/app/src/components/project-edit-sheet.tsx b/packages/app/src/components/project-edit-sheet.tsx index 179d1140b8..d80fa2bad0 100644 --- a/packages/app/src/components/project-edit-sheet.tsx +++ b/packages/app/src/components/project-edit-sheet.tsx @@ -126,9 +126,6 @@ export function ProjectEditSheet({ onClose={handleClose} footer={footer} desktopMaxWidth={440} - // Bound the compact scroller to the live snap height so the footer stays - // on screen instead of being pushed past the bottom of the sheet. - sizeContentToCurrentSnapPoint testID="project-edit-sheet" > ( <> - ({ // the step in reads as belonging to that project. Padding rather than margin, so the hover and // pressed fills stay the same box as every other row in the sidebar. newWorkspaceGhostRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[4], @@ -2539,27 +2542,27 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it. newWorkspaceGhostIconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, }, newWorkspaceGhostText: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, newWorkspaceGhostTextHovered: { - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, color: theme.colors.foreground, }, projectRow: { position: "relative", - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -2600,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({ }, projectTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -2681,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({ right: theme.spacing[2], }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/pinned-section-header.tsx b/packages/app/src/components/sidebar/pinned-section-header.tsx index 9dc2c9fdba..2169492a08 100644 --- a/packages/app/src/components/sidebar/pinned-section-header.tsx +++ b/packages/app/src/components/sidebar/pinned-section-header.tsx @@ -2,16 +2,11 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight } from "lucide-react-native"; import { Pressable, Text } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; +import { DensityIcon } from "@/components/ui/density-icon"; import { useIsCompactFormFactor } from "@/constants/layout"; import { isNative } from "@/constants/platform"; -import type { Theme } from "@/styles/theme"; - -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronRight = withUnistyles(ChevronRight); -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); +import { density, densityFontSize } from "@/styles/density"; export function PinnedSectionHeader({ collapsed, @@ -23,7 +18,6 @@ export function PinnedSectionHeader({ const { t } = useTranslation(); const isCompact = useIsCompactFormFactor(); const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); - const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown; return ( {t("sidebar.pinned.title")} {hovered || isNative || isCompact ? ( - + ) : null} )} @@ -47,7 +45,7 @@ export function PinnedSectionHeader({ const styles = StyleSheet.create((theme) => ({ header: { - minHeight: 36, + minHeight: density.rowHeight, flexDirection: "row", alignItems: "center", alignSelf: "flex-start", @@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({ }, title: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.sm, + fontSize: densityFontSize(theme, "sm"), fontWeight: theme.fontWeight.normal, }, })); diff --git a/packages/app/src/components/sidebar/project-leading-visual.tsx b/packages/app/src/components/sidebar/project-leading-visual.tsx index e8c1e3a5d6..3f31181220 100644 --- a/packages/app/src/components/sidebar/project-leading-visual.tsx +++ b/packages/app/src/components/sidebar/project-leading-visual.tsx @@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ProjectIconView } from "@/components/project-icon-view"; import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; -import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { density, densityIconBox, densityIconProps } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getProjectStatusBadgeContent, @@ -35,9 +36,8 @@ const STATUS_BADGE_OFFSET = -4; // // The filled alert occupies the full badge shell so needs-input remains more prominent than // the passive status dots. -// Matches the workspace title's lineHeight (sidebar-workspace-row-content's -// workspaceBranchText) so the icon centers on the title rather than floating above it. -const LEADING_SLOT_HEIGHT = 20; +const DensityProjectIconView = withUnistyles(ProjectIconView); +const projectIconProps = densityIconProps("md", () => ({})); const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedCircleAlert = withUnistyles(CircleAlert); @@ -230,12 +230,12 @@ function ProjectIcon({ projectViewKey: string; }) { return ( - ); } @@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => { return { // The slot is as tall as the title's line box, not as tall as the icon, and centers the - // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a - // 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab — - // which is why the workspace status indicator is also 20 tall. Keep the two in step. + // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot + // only as tall as the icon puts it above the title and the kebab — which is why the + // workspace status indicator shares the same `rowLineHeight`. projectLeadingVisualSlot: { - width: theme.iconSize.md, - height: LEADING_SLOT_HEIGHT, + width: densityIconBox("md"), + height: density.rowLineHeight, flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -284,8 +284,8 @@ const styles = StyleSheet.create((theme) => { // Anchors the corner badge to the icon rather than to the taller slot. projectIconBox: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), }, projectIconFallbackText: { fontSize: 9, diff --git a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx index f67bd738ba..3d437ae0ca 100644 --- a/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-group-toggle-row.tsx @@ -2,19 +2,11 @@ import { useCallback } from "react"; import { useTranslation } from "react-i18next"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { ChevronDown, ChevronUp } from "lucide-react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { isWeb } from "@/constants/platform"; import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content"; -import type { Theme } from "@/styles/theme"; - -const foregroundMutedColorMapping = (theme: Theme) => ({ - color: theme.colors.foregroundMuted, -}); -const foregroundColorMapping = (theme: Theme) => ({ - color: theme.colors.foreground, -}); -const ThemedChevronDown = withUnistyles(ChevronDown); -const ThemedChevronUp = withUnistyles(ChevronUp); +import { DensityIcon } from "@/components/ui/density-icon"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; /** * The row that ends a truncated group. It is a workspace row that happens to say "Show more", so @@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({ {({ hovered, pressed }) => ( <> - {expanded ? ( - - ) : ( - - )} + {label} @@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({ const styles = StyleSheet.create((theme) => ({ // Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx. row: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], @@ -106,21 +92,21 @@ const styles = StyleSheet.create((theme) => ({ // The width of a workspace row's status slot, so the label lands on the same rail as the // titles above it rather than two points to their left. iconSlot: { - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), alignItems: "center", justifyContent: "center", flexShrink: 0, }, text: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, textHovered: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), minWidth: 0, flexShrink: 1, }, diff --git a/packages/app/src/components/sidebar/sidebar-header-row.tsx b/packages/app/src/components/sidebar/sidebar-header-row.tsx index 4519f6d4ff..7001bf66a6 100644 --- a/packages/app/src/components/sidebar/sidebar-header-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-header-row.tsx @@ -1,22 +1,12 @@ -import { - useCallback, - useMemo, - useState, - type ComponentType, - type ReactNode, - type Ref, -} from "react"; +import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react"; import { Pressable, Text, View } from "react-native"; -import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { StyleSheet } from "react-native-unistyles"; import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout"; -import { ICON_SIZE } from "@/styles/theme"; -import type { Theme } from "@/styles/theme"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Shortcut } from "@/components/ui/shortcut"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { ShortcutKey } from "@/utils/format-shortcut"; -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); - type SidebarHeaderRowVariant = "header" | "compact" | "inline"; export type SidebarRowIcon = ComponentType<{ size: number; color: string }>; @@ -49,7 +39,7 @@ interface SidebarHeaderRowProps { } export function SidebarHeaderRow({ - icon: Icon, + icon, label, onPress, isActive = false, @@ -64,9 +54,8 @@ export function SidebarHeaderRow({ const [isHovered, setIsHovered] = useState(false); const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []); - const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]); const isHighlighted = isHovered || isActive; - const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm; + const iconSize = variant === "header" ? "md" : "sm"; let right = trailing ?? null; if (right === null && shortcutKeys && isHovered) { @@ -91,10 +80,11 @@ export function SidebarHeaderRow({ aria-selected={isActive} style={styles.button} > - {ThemedIcon ? ( - ) : ( @@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({ alignItems: "center", // Same row geometry as the settings sidebar items. Shorter than the header // strip so the hover highlight clears the strip's bottom separator. - minHeight: 28, + minHeight: density.tightRowHeight, borderRadius: theme.borderRadius.lg, }, rowHighlighted: { @@ -152,17 +142,17 @@ const styles = StyleSheet.create((theme) => ({ flexDirection: "row", alignItems: "center", gap: theme.spacing[2], - minHeight: 28, + minHeight: density.tightRowHeight, paddingVertical: theme.spacing[1], // Match the project rows' inner padding so the icons align on one vertical // edge with the list below. paddingHorizontal: theme.spacing[2], }, - iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md }, - iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm }, + iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") }, + iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") }, label: { flexShrink: 1, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: theme.fontWeight.normal, color: theme.colors.foregroundMuted, }, diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index 5f603392f2..97ff3d2772 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -14,14 +14,14 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { buttonControlHeight } from "@/components/ui/control-geometry"; -import { useIsCompactFormFactor } from "@/constants/layout"; +import { DensityIcon } from "@/components/ui/density-icon"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; 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"; +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"; @@ -32,12 +32,10 @@ import { openExternalUrl } from "@/utils/open-external-url"; const DISCORD_URL = "https://discord.gg/jz8T2uahpH"; const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; const ThemedActivity = withUnistyles(Activity); -const ThemedCircleHelp = withUnistyles(CircleHelp); const ThemedGift = withUnistyles(Gift); const ThemedKeyboard = withUnistyles(Keyboard); const ThemedDiscordIcon = withUnistyles(DiscordIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon); -const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted, }); @@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) { } export function SidebarHelpMenu() { - const isCompact = useIsCompactFormFactor(); const { t } = useTranslation(); const shortcutsAvailable = useKeyboardShortcutsAvailable(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); @@ -106,15 +103,16 @@ export function SidebarHelpMenu() { {({ hovered }) => ( - )} @@ -184,14 +182,14 @@ export function SidebarHelpMenu() { } const styles = StyleSheet.create((theme) => ({ - trigger: (isCompact: boolean) => ({ - width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, - height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, + 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-status-list.tsx b/packages/app/src/components/sidebar/sidebar-status-list.tsx index c299d07987..85c0d524f8 100644 --- a/packages/app/src/components/sidebar/sidebar-status-list.tsx +++ b/packages/app/src/components/sidebar/sidebar-status-list.tsx @@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { useIsCompactFormFactor } from "@/constants/layout"; import { StyleSheet } from "react-native-unistyles"; -import type { Theme } from "@/styles/theme"; +import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import { withUnistyles } from "react-native-unistyles"; import { @@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; // Themed icon wrappers -const foregroundMutedColorMapping = (theme: Theme) => ({ +const mutedGroupIconProps = densityIconProps("sm", (theme) => ({ color: theme.colors.foregroundMuted, -}); +})); // One mapping per bucket, resolved through the status-dot producer so a group header and // the rows under it cannot disagree about what "failed" looks like. -const needsInputColorMapping = (theme: Theme) => ({ +const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, -}); -const failedColorMapping = (theme: Theme) => ({ +})); +const failedGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, -}); -const attentionColorMapping = (theme: Theme) => ({ +})); +const attentionGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, -}); -const runningColorMapping = (theme: Theme) => ({ +})); +const runningGroupIconProps = densityIconProps("sm", (theme) => ({ color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, -}); +})); const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronRight = withUnistyles(ChevronRight); @@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({ return ; } if (collapsed) { - return ; + return ; } - return ; + return ; } function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) { switch (bucket) { case "needs_input": - return ; + return ; case "failed": - return ; + return ; case "attention": - return ; + return ; case "running": - return ; + return ; case "done": - return ; + return ; } } @@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({ }, statusWorkspaceListContainer: {}, statusGroupRow: { - minHeight: 36, + minHeight: density.rowHeight, paddingVertical: theme.spacing[2], paddingHorizontal: theme.spacing[2], borderRadius: theme.borderRadius.lg, @@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupLeadingVisualSlot: { position: "relative", - width: theme.iconSize.md, - height: theme.iconSize.md, + width: densityIconBox("md"), + height: densityIconBox("md"), flexShrink: 0, alignItems: "center", justifyContent: "center", @@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({ }, statusGroupTitle: { color: theme.colors.foregroundMuted, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", minWidth: 0, flexShrink: 1, @@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[0.5], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx index 7b48f0dd6c..33f14afcb5 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row-content.tsx @@ -16,6 +16,7 @@ import { type SidebarWorkspaceTrailing, } from "@/components/sidebar/workspace-trailing"; import { useAppSettings } from "@/hooks/use-settings"; +import { density, densityFontSize, densityIconBox } from "@/styles/density"; import type { Theme } from "@/styles/theme"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { getStatusDotColor } from "@/utils/status-dot-color"; @@ -27,19 +28,16 @@ import { import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { StatusRing } from "@/components/status-ring"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; +import { DensityIcon } from "@/components/ui/density-icon"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; -const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const needsInputColorMapping = (theme: Theme) => ({ color: theme.colors.surface0, fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, }); const ThemedCircleAlert = withUnistyles(CircleAlert); -const ThemedMonitor = withUnistyles(Monitor); -const ThemedFolder = withUnistyles(Folder); -const ThemedFolderGit2 = withUnistyles(FolderGit2); export function SidebarWorkspaceRowFrame({ workspace, @@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({ ) : null; } - let KindIcon: typeof ThemedMonitor; - if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; - else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; - else KindIcon = ThemedFolder; + let KindIcon: typeof Monitor; + if (workspaceKind === "local_checkout") KindIcon = Monitor; + else if (workspaceKind === "worktree") KindIcon = FolderGit2; + else KindIcon = Folder; const dotColorStyle = getStatusDotColorStyle(bucket); return ( - + {dotColorStyle ? : null} ); @@ -482,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({ }, workspaceStatusDot: { position: "relative", - width: theme.iconSize.md, - height: 20, + width: densityIconBox("md"), + height: density.rowLineHeight, borderRadius: theme.borderRadius.full, flexShrink: 0, alignItems: "center", @@ -515,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({ // to the meta row, so it takes the full width the trailing slot leaves behind. workspaceBranchText: { color: theme.colors.foreground, - fontSize: theme.fontSize.base, + fontSize: densityFontSize(theme, "base"), fontWeight: "400", - lineHeight: 20, + lineHeight: density.rowLineHeight, opacity: 0.76, flex: 1, minWidth: 0, diff --git a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx index f8dcdebaf5..c5f8a278bd 100644 --- a/packages/app/src/components/sidebar/sidebar-workspace-row.tsx +++ b/packages/app/src/components/sidebar/sidebar-workspace-row.tsx @@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import type { HostBadgeModel } from "@/hosts/appearance"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; +import { density } from "@/styles/density"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { ShortcutKey } from "@/utils/format-shortcut"; @@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({ position: "relative", }, workspaceRow: { - minHeight: 36, + minHeight: density.rowHeight, marginBottom: theme.spacing[1], paddingVertical: theme.spacing[2], paddingLeft: theme.spacing[2], diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index 736ca3bfa5..caf73d1bbc 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import type { EditingTextInputHandle } from "@/components/ui/text-input"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useIsCompactFormFactor } from "@/constants/layout"; -import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; +import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet"; import Animated, { FadeIn, FadeOut } from "react-native-reanimated"; import { GestureHandlerRootView } from "react-native-gesture-handler"; import { Check, File, Folder, Search } from "lucide-react-native"; @@ -50,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options"; import { isWeb } from "@/constants/platform"; import { IsolatedBottomSheetModal, + SheetVisibleFrame, useIsolatedBottomSheetVisibility, } from "./isolated-bottom-sheet-modal"; import { @@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { () => [styles.comboboxTitle, { color: props.titleColor }], [props.titleColor], ); + const { footer, safeAreaBottom } = props; const frameStyle = useMemo( - () => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }], - [props.safeAreaBottom], + () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }], + [footer, safeAreaBottom], + ); + const footerStyle = useMemo( + () => [styles.footer, { paddingBottom: safeAreaBottom }], + [safeAreaBottom], + ); + + const footerView = useMemo( + () => (footer ? {footer} : undefined), + [footer, footerStyle], ); const body = props.hasChildren ? ( @@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement { keyboardBlurBehavior="none" presentation={props.presentation} > - - {props.header ? ( - - ) : ( - <> - - - {props.title} - - - {props.stickyHeader} - {!props.hasChildren && props.searchable ? ( - - ) : null} - - )} - {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( - body - ) : ( - - {body} - - )} - {props.footer ? {props.footer} : null} - + + + {props.header ? ( + + ) : ( + <> + + + {props.title} + + + {props.stickyHeader} + {!props.hasChildren && props.searchable ? ( + + ) : null} + + )} + {props.hasChildren && !props.mobileChildrenScrollEnabled ? ( + body + ) : ( + + {body} + + )} + + ); } diff --git a/packages/app/src/components/ui/density-icon.tsx b/packages/app/src/components/ui/density-icon.tsx new file mode 100644 index 0000000000..ee24ee3588 --- /dev/null +++ b/packages/app/src/components/ui/density-icon.tsx @@ -0,0 +1,51 @@ +import { useMemo, type ComponentType } from "react"; +import { withUnistyles } from "react-native-unistyles"; +import { densityIconSize, type DensityIconSize } from "@/styles/density"; +import type { Theme } from "@/styles/theme"; + +type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>; + +type IconColor = { + [K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never; +}[keyof Theme["colors"]]; + +function IconGlyph({ + icon: Icon, + size, + color, + strokeWidth, +}: { + icon: IconComponent; + size: number; + color: string; + strokeWidth?: number; +}) { + return ; +} + +const ThemedIconGlyph = withUnistyles(IconGlyph); + +/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */ +export function DensityIcon({ + icon, + size, + sizeAdjustment = 0, + color, + strokeWidth, +}: { + icon: IconComponent; + size: DensityIconSize; + /** Only for a glyph that reads larger than others at the same size. */ + sizeAdjustment?: number; + color: IconColor; + strokeWidth?: number; +}) { + const uniProps = useMemo( + () => (theme: Theme, rt: { breakpoint?: string }) => ({ + size: densityIconSize(rt, size) + sizeAdjustment, + color: theme.colors[color], + }), + [size, sizeAdjustment, color], + ); + return ; +} diff --git a/packages/app/src/components/ui/icon-button-chrome.ts b/packages/app/src/components/ui/icon-button-chrome.ts index 394a744e64..75735ac986 100644 --- a/packages/app/src/components/ui/icon-button-chrome.ts +++ b/packages/app/src/components/ui/icon-button-chrome.ts @@ -1,3 +1,4 @@ +import { createContext, useCallback, useContext } from "react"; import type { StyleProp, ViewStyle } from "react-native"; import { StyleSheet } from "react-native-unistyles"; import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; @@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/ export type IconButtonChromeSize = "large" | "small"; const SMALL_ICON_BUTTON_SIZE = 20; +/** Width and height of a large icon button on compact layouts. */ +export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32; const COMPACT_SMALL_ICON_BUTTON_SIZE = 32; export interface IconButtonChromeState { @@ -27,20 +30,53 @@ interface IconButtonChromeOptions { state?: IconButtonChromeState; compact?: boolean; disabled?: boolean; + /** A circular hitbox and highlight, for buttons inside a pill-shaped container. */ + round?: boolean; style?: StyleProp; } +const IconButtonChromeContext = createContext<"default" | "header-island">("default"); + +/** A container owns the chrome of its icon actions, including custom menu and badge triggers. */ +export const IconButtonChromeProvider = IconButtonChromeContext.Provider; + +/** The same state callback works with Pressable, TooltipTrigger, and DropdownMenuTrigger. */ +export function useIconButtonChromeStyle({ + size, + compact, + disabled, + round, + style, + active, +}: Omit & { active?: boolean }) { + const scope = useContext(IconButtonChromeContext); + return useCallback( + (state: IconButtonChromeState) => + iconButtonChromeStyle({ + size: scope === "header-island" ? "large" : size, + compact, + disabled, + round: scope === "header-island" || round, + style, + state: { ...state, active: active || state.active }, + }), + [scope, size, compact, disabled, round, style, active], + ); +} + /** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */ export function iconButtonChromeStyle({ size, state, compact = false, disabled = false, + round = false, style, }: IconButtonChromeOptions): StyleProp { const highlighted = state?.active || state?.hovered || state?.pressed || state?.open; return [ resolveIconButtonFrame(size, compact), + round ? styles.round : null, style, highlighted ? styles.highlighted : null, disabled ? styles.disabled : null, @@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number { const styles = StyleSheet.create((theme) => ({ large: { width: { - xs: 32, + xs: COMPACT_LARGE_ICON_BUTTON_SIZE, md: HEADER_CONTROL_HEIGHT, }, height: { - xs: 32, + xs: COMPACT_LARGE_ICON_BUTTON_SIZE, md: HEADER_CONTROL_HEIGHT, }, padding: 0, @@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({ outlineWidth: 0, outlineColor: "transparent", }, + round: { + borderRadius: theme.borderRadius.full, + }, highlighted: { backgroundColor: theme.colors.interactionHighlight, }, 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 3f9c9113d0..8dd55e5678 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 @@ -152,3 +152,5 @@ export function useIsolatedBottomSheetVisibility({ handleSheetDismiss, }; } + +export { SheetVisibleFrame } from "./visible-frame"; diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts new file mode 100644 index 0000000000..9e98bd359f --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +describe("getBottomSheetVisibleContentHeight", () => { + it("stops subtracting the retained keyboard height after the keyboard hides", () => { + const layout = { + containerHeight: 874, + contentPosition: 88, + handleHeight: 24, + keyboardHeight: 344, + }; + + expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418); + expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762); + }); +}); + +// Below the lowest snap the frame must travel with the sheet, not shrink to the screen edge. +describe("sheet footer motion", () => { + it("keeps the frame height fixed while opening and panning below the lowest snap", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + const heights = [1000, 800, 600, 400].map((contentPosition) => + getBottomSheetVisibleContentHeight({ ...layout, contentPosition }), + ); + expect(heights).toEqual([576, 576, 576, 576]); + }); +}); + +it("resizes above the lowest snap and never returns negative or unmeasured heights", () => { + const layout = { + containerHeight: 1000, + handleHeight: 24, + keyboardHeight: 0, + isKeyboardVisible: false, + lowestDetentPosition: 400, + }; + expect(getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100 })).toBe(876); + expect( + getBottomSheetVisibleContentHeight({ + ...layout, + contentPosition: 100, + keyboardHeight: 950, + isKeyboardVisible: true, + }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, handleHeight: -999 }), + ).toBe(0); + expect( + getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, containerHeight: -999 }), + ).toBe(0); +}); diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts new file mode 100644 index 0000000000..316f56048a --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame-layout.ts @@ -0,0 +1,25 @@ +interface BottomSheetVisibleContentHeightInput { + containerHeight: number; + contentPosition: number; + lowestDetentPosition?: number; + handleHeight: number; + keyboardHeight: number; + isKeyboardVisible: boolean; +} + +export function getBottomSheetVisibleContentHeight({ + containerHeight, + contentPosition, + lowestDetentPosition, + handleHeight, + keyboardHeight, + isKeyboardVisible, +}: BottomSheetVisibleContentHeightInput): number { + "worklet"; + if (containerHeight < 0 || handleHeight < 0) return 0; + const position = Math.min(contentPosition, lowestDetentPosition ?? contentPosition); + return Math.max( + 0, + containerHeight - position - handleHeight - (isKeyboardVisible ? keyboardHeight : 0), + ); +} diff --git a/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx new file mode 100644 index 0000000000..15ead99edf --- /dev/null +++ b/packages/app/src/components/ui/isolated-bottom-sheet-modal/visible-frame.tsx @@ -0,0 +1,42 @@ +import type { ReactNode } from "react"; +import { View, StyleSheet } from "react-native"; +import { KEYBOARD_STATUS, useBottomSheetInternal } from "@gorhom/bottom-sheet"; +import Animated, { useAnimatedStyle } from "react-native-reanimated"; +import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout"; + +/** Fixed-detent sheet content. The footer shares the sheet's translation below its lowest snap. */ +export function SheetVisibleFrame({ + children, + footer, +}: { + children: ReactNode; + footer?: ReactNode; +}) { + const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } = + useBottomSheetInternal(); + const visibleStyle = useAnimatedStyle(() => { + const { containerHeight, handleHeight } = animatedLayoutState.get(); + const keyboard = animatedKeyboardState.get(); + return { + height: getBottomSheetVisibleContentHeight({ + containerHeight, + handleHeight, + contentPosition: animatedPosition.get(), + lowestDetentPosition: animatedDetentsState.get().detents?.[0], + keyboardHeight: keyboard.heightWithinContainer, + isKeyboardVisible: keyboard.status === KEYBOARD_STATUS.SHOWN, + }), + }; + }, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]); + return ( + + {children} + {footer} + + ); +} + +const styles = StyleSheet.create({ + frame: { minHeight: 0, overflow: "hidden" }, + body: { flex: 1, minHeight: 0 }, +}); diff --git a/packages/app/src/components/ui/pane-content-toolbar.tsx b/packages/app/src/components/ui/pane-content-toolbar.tsx index 03634670d1..4ff947afc9 100644 --- a/packages/app/src/components/ui/pane-content-toolbar.tsx +++ b/packages/app/src/components/ui/pane-content-toolbar.tsx @@ -16,6 +16,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; import { iconButtonChromeGlyphSize, iconButtonChromeStyle, + useIconButtonChromeStyle, smallIconButtonChromeFrameSize, } from "@/components/ui/icon-button-chrome"; import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives"; @@ -60,6 +61,8 @@ interface ToolbarButtonCommonProps extends Omit< tooltip?: ReactNode; selected?: boolean; compact?: boolean; + /** A circular highlight, for buttons inside a pill-shaped container. */ + round?: boolean; shortcut?: ShortcutKey[][] | null; tooltipSide?: "left" | "right" | "top" | "bottom"; style?: StyleProp; @@ -81,6 +84,7 @@ export function ToolbarButton({ tooltip: tooltipBody, selected = false, compact = false, + round = false, shortcut, tooltipSide = "bottom", style, @@ -90,17 +94,14 @@ export function ToolbarButton({ testID, ...props }: ToolbarButtonProps) { - const buttonStyle = useMemo( - () => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) => - iconButtonChromeStyle({ - size: "small", - compact, - state: { ...state, active: selected }, - disabled: Boolean(disabled), - style, - }), - [compact, disabled, selected, style], - ); + const buttonStyle = useIconButtonChromeStyle({ + size: "small", + compact, + active: selected, + disabled: Boolean(disabled), + round, + style, + }); const accessibilityState = useMemo( () => ({ disabled: Boolean(disabled), selected }), [disabled, selected], diff --git a/packages/app/src/composer/agent-controls/model-sheet.tsx b/packages/app/src/composer/agent-controls/model-sheet.tsx index bca5a250bf..6929b447ea 100644 --- a/packages/app/src/composer/agent-controls/model-sheet.tsx +++ b/packages/app/src/composer/agent-controls/model-sheet.tsx @@ -298,7 +298,6 @@ export function CompactModelSheet({ onClose={close} snapPoints={SNAP_POINTS} scrollable={false} - sizeContentToCurrentSnapPoint={usesBottomSheet} contentStyle={styles.sheetBody} testID="agent-controls-model-sheet" > @@ -346,7 +345,6 @@ export function CompactModelSheet({ onClose={closeModelBrowser} snapPoints={SNAP_POINTS} scrollable={false} - sizeContentToCurrentSnapPoint presentation="push" contentStyle={styles.sheetBody} testID="agent-controls-model-browser-sheet" diff --git a/packages/app/src/constants/layout.ts b/packages/app/src/constants/layout.ts index 721708715c..9e9a15f6b4 100644 --- a/packages/app/src/constants/layout.ts +++ b/packages/app/src/constants/layout.ts @@ -33,13 +33,18 @@ export { getIsElectronMac as getIsElectronRuntimeMac, } from "./platform"; +/** Compact layouts are the breakpoints below `md`. */ +export function isCompactBreakpoint(breakpoint: string | undefined): boolean { + return breakpoint === "xs" || breakpoint === "sm"; +} + /** * Reactive hook — re-renders the component when the breakpoint changes. * Always use this instead of reading UnistylesRuntime.breakpoint directly. */ export function useIsCompactFormFactor(): boolean { const { rt } = useUnistyles(); - return rt.breakpoint === "xs" || rt.breakpoint === "sm"; + return isCompactBreakpoint(rt.breakpoint); } // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. diff --git a/packages/app/src/plugins/surface-screen.tsx b/packages/app/src/plugins/surface-screen.tsx index f2f9997749..378965cd8f 100644 --- a/packages/app/src/plugins/surface-screen.tsx +++ b/packages/app/src/plugins/surface-screen.tsx @@ -7,9 +7,11 @@ import { Text, View } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; +import { CompactHeader } from "@/components/headers/compact-header"; import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenTitle } from "@/components/headers/screen-title"; import { HostFilter } from "@/components/hosts/host-filter"; +import type { HostPickerHost } from "@/components/hosts/host-picker"; import { useIsCompactFormFactor } from "@/constants/layout"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import type { Theme } from "@/styles/theme"; @@ -87,19 +89,14 @@ function PluginHostFilter({ pluginId, identity, params, - serverIds, + hosts, }: { serverId: string; pluginId: string; identity: PluginSurfaceContributionIdentity; params: PluginScreenParams; - serverIds: string[]; + hosts: HostPickerHost[]; }) { - const allHosts = useHosts(); - const hosts = useMemo( - () => allHosts.filter((host) => serverIds.includes(host.serverId)), - [allHosts, serverIds], - ); const selectHost = useCallback( (nextServerId: string) => { // A legacy row remembers its own host; a screen's host carries to all the plugin's items. @@ -113,9 +110,6 @@ function PluginHostFilter({ }, [identity, params, pluginId], ); - const show = serverIds.length > 1 && hosts.length > 1; - if (!show) return null; - return ( host.serverId === serverId)?.label ?? serverId; - const contributionServerIds = useMemo( - () => - identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [], - [identity, installations, pluginId], - ); + // The hosts this contribution is installed on; the filter shows when there is a choice. + const filterHosts = useMemo(() => { + if (!identity) return []; + const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity); + return hosts.filter((candidate) => serverIds.includes(candidate.serverId)); + }, [hosts, identity, installations, pluginId]); const title = useMemo( () => surface @@ -187,18 +182,23 @@ export function PluginSurfaceScreen() { ), [Icon, title], ); + const hostFilter = useMemo( + () => + identity && filterHosts.length > 1 ? ( + + ) : null, + [filterHosts, identity, params, pluginId, serverId], + ); const headerRight = useMemo( () => ( <> - {identity ? ( - - ) : null} + {hostFilter} ), - [close, contributionServerIds, identity, params, pluginId, serverId], + [close, hostFilter], ); return ( - + {compact ? ( + + ) : ( + + )} {plugin && surface && client ? ( , []); const importSession = useImportSession({ serverId: selectedServerId }); const composer = isTerminalLaunch ? ( @@ -2431,7 +2429,7 @@ export function NewWorkspaceScreen({ ); return ( - + void; + explorerToggleLabel: string; + explorerToggleAccessibilityState: { expanded: boolean }; + /** Tab switcher and workspace menu, before the explorer toggle. */ + children: ReactNode; +} + +/** The compact header with the workspace's project row and controls. */ +export function CompactWorkspaceHeader({ + isLoading, + title, + subtitle, + isSubtitleDistinct, + onToggleExplorer, + explorerToggleLabel, + explorerToggleAccessibilityState, + children, + serverId, +}: CompactWorkspaceHeaderProps) { + const { t } = useTranslation(); + const projectRow = useMemo( + () => ( + + ), + [subtitle, isSubtitleDistinct, serverId], + ); + const actions = ( + <> + {children} + + + ); + + return ( + + ); +} diff --git a/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx new file mode 100644 index 0000000000..970e77aaa9 --- /dev/null +++ b/packages/app/src/screens/workspace/compact-workspace-header/tab-switcher.tsx @@ -0,0 +1,501 @@ +import { memo, useCallback, useMemo, useRef, useState } from "react"; +import { Keyboard, Pressable, Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { SquarePen, SquareTerminal } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { useIconButtonChromeStyle } from "@/components/ui/icon-button-chrome"; +import { + WorkspaceTabPresentationResolver, + WorkspaceTabOptionRow, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; +import { + buildWorkspaceTabMenuEntries, + type WorkspaceTabMenuLabels, +} from "@/screens/workspace/workspace-tab-menu"; +import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; + +// The trigger draws at 32pt beside its island neighbours, so only vertical slop is free. +const TAB_SWITCHER_TRIGGER_HIT_SLOP = { top: 8, bottom: 8 } as const; + +function getFallbackTabOptionLabel( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + setup: string; + terminal: string; + browser: string; + agent: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.setup; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "file") { + return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + return labels.agent; +} + +function getFallbackTabOptionDescription( + tab: WorkspaceTabDescriptor, + labels: { + newTab: string; + newAgent: string; + workspaceSetup: string; + agent: string; + terminal: string; + browser: string; + changes: string; + files: string; + pullRequest: string; + }, +): string { + if (tab.target.kind === "new_tab") { + return labels.newTab; + } + if (tab.target.kind === "draft") { + return labels.newAgent; + } + if (tab.target.kind === "setup") { + return labels.workspaceSetup; + } + if (tab.target.kind === "agent") { + return labels.agent; + } + if (tab.target.kind === "terminal") { + return labels.terminal; + } + if (tab.target.kind === "browser") { + return labels.browser; + } + if (tab.target.kind === "provider_subagent") { + return labels.agent; + } + if (tab.target.kind === "commit_diff") { + return tab.target.sha.slice(0, 7); + } + if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { + return labels.changes; + } + if (tab.target.kind === "files") { + return labels.files; + } + if (tab.target.kind === "pull_request") { + return labels.pullRequest; + } + if (tab.target.kind === "plugin") { + return tab.target.panelId; + } + return tab.target.path; +} + +interface MobileWorkspaceTabSwitcherProps { + tabs: WorkspaceTabDescriptor[]; + activeTabKey: string; + normalizedServerId: string; + normalizedWorkspaceId: string; + onSelectSwitcherTab: (key: string) => void; + createTerminalDisabled: boolean; + onCreateDraftTab: () => void; + onCreateTerminal: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +} + +function MobileWorkspaceTabOption({ + tab, + tabIndex, + tabCount, + normalizedServerId, + normalizedWorkspaceId, + selected, + active, + onPress, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: { + tab: WorkspaceTabDescriptor; + tabIndex: number; + tabCount: number; + normalizedServerId: string; + normalizedWorkspaceId: string; + selected: boolean; + active: boolean; + onPress: () => void; + onCopyResumeCommand: (agentId: string) => Promise | void; + onCopyAgentId: (agentId: string) => Promise | void; + onCopyTerminalId: (terminalId: string) => Promise | void; + onCopyFilePath: (path: string) => Promise | void; + onReloadAgent: (agentId: string) => Promise | void; + onRenameTab: (tab: WorkspaceTabDescriptor) => void; + onCloseTab: (tabId: string) => Promise | void; + onCloseTabsAbove: (tabId: string) => Promise | void; + onCloseTabsBelow: (tabId: string) => Promise | void; + onCloseOtherTabs: (tabId: string) => Promise | void; +}) { + const { t } = useTranslation(); + const tabMenuLabels = useMemo( + () => ({ + copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), + copyAgentId: t("workspace.tabs.menu.copyAgentId"), + copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), + copyFilePath: t("workspace.tabs.menu.copyFilePath"), + rename: t("workspace.tabs.menu.rename"), + closeAbove: t("workspace.tabs.menu.closeAbove"), + closeBelow: t("workspace.tabs.menu.closeBelow"), + closeLeft: t("workspace.tabs.menu.closeLeft"), + closeRight: t("workspace.tabs.menu.closeRight"), + closeOthers: t("workspace.tabs.menu.closeOthers"), + reloadAgent: t("workspace.tabs.menu.reloadAgent"), + reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), + close: t("workspace.tabs.menu.close"), + }), + [t], + ); + const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; + const menuEntries = buildWorkspaceTabMenuEntries({ + surface: "mobile", + tab, + index: tabIndex, + tabCount, + menuTestIDBase, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsBefore: onCloseTabsAbove, + onCloseTabsAfter: onCloseTabsBelow, + onCloseOtherTabs, + labels: tabMenuLabels, + }); + + const fallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); + const trailingAccessory = useMemo( + () => ( + + ), + [menuTestIDBase, fallbackLabel, menuEntries], + ); + + const renderPresentation = useCallback( + (presentation: WorkspaceTabPresentation) => ( + + ), + [selected, active, onPress, trailingAccessory], + ); + + return ( + + {renderPresentation} + + ); +} + +export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ + tabs, + activeTabKey, + normalizedServerId, + normalizedWorkspaceId, + onSelectSwitcherTab, + createTerminalDisabled, + onCreateDraftTab, + onCreateTerminal, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, +}: MobileWorkspaceTabSwitcherProps) { + const { t } = useTranslation(); + const tabFallbackLabels = useMemo( + () => ({ + newTab: t("workspace.tabs.actions.newTab"), + newAgent: t("workspace.tabs.fallback.newAgent"), + setup: t("workspace.tabs.fallback.setup"), + workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), + terminal: t("workspace.tabs.fallback.terminal"), + browser: t("workspace.tabs.fallback.browser"), + agent: t("workspace.tabs.fallback.agent"), + changes: t("panels.diff.changesLabel"), + files: t("panels.files.label"), + pullRequest: t("panels.pullRequest.label"), + }), + [t], + ); + + const tabSwitcherOptions = useMemo( + () => + tabs.map((tab) => ({ + id: tab.key, + label: getFallbackTabOptionLabel(tab, tabFallbackLabels), + description: getFallbackTabOptionDescription(tab, tabFallbackLabels), + })), + [tabFallbackLabels, tabs], + ); + + const tabByKey = useMemo(() => new Map(tabs.map((tab) => [tab.key, tab])), [tabs]); + const triggerStyle = useIconButtonChromeStyle({ size: "large" }); + const [isOpen, setIsOpen] = useState(false); + const anchorRef = useRef(null); + const tabIndexByKey = useMemo(() => { + const map = new Map(); + tabs.forEach((tab, index) => { + map.set(tab.key, index); + }); + return map; + }, [tabs]); + + const handleOpenSwitcher = useCallback(() => { + Keyboard.dismiss(); + setIsOpen(true); + }, []); + + const handleNewAgent = useCallback(() => { + setIsOpen(false); + onCreateDraftTab(); + }, [onCreateDraftTab]); + + const handleNewTerminal = useCallback(() => { + setIsOpen(false); + onCreateTerminal(); + }, [onCreateTerminal]); + + const footer = useMemo( + () => ( + + + + + ), + [createTerminalDisabled, handleNewAgent, handleNewTerminal, t], + ); + + const renderTabOption = useCallback( + ({ + option, + selected, + active, + onPress, + }: { + option: ComboboxOption; + selected: boolean; + active: boolean; + onPress: () => void; + }) => { + const tab = tabByKey.get(option.id); + if (!tab) { + return ; + } + const tabIndex = tabIndexByKey.get(tab.key) ?? -1; + if (tabIndex < 0) { + return ; + } + return ( + + ); + }, + [ + tabByKey, + tabIndexByKey, + tabs.length, + normalizedServerId, + normalizedWorkspaceId, + onCopyResumeCommand, + onCopyAgentId, + onCopyTerminalId, + onCopyFilePath, + onReloadAgent, + onRenameTab, + onCloseTab, + onCloseTabsAbove, + onCloseTabsBelow, + onCloseOtherTabs, + ], + ); + + return ( + <> + + + {tabs.length} + + + + + + ); +}); + +const styles = StyleSheet.create((theme) => ({ + tabSwitcherFooter: { + flexDirection: "row", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + paddingVertical: theme.spacing[3], + }, + tabSwitcherFooterButton: { + flex: 1, + }, + // Sized and stroked like the 16px header glyphs beside it (1.5 stroke on a 24 viewbox = 1px). + tabCountBadge: { + minWidth: 16, + height: 16, + paddingHorizontal: 2, + alignItems: "center", + justifyContent: "center", + borderRadius: theme.borderRadius.sm, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.foregroundMuted, + }, + // Android pads text above the ascender by default, which pushes the digit below center. + tabCountText: { + fontSize: 10, + lineHeight: 12, + includeFontPadding: false, + textAlign: "center", + textAlignVertical: "center", + fontWeight: theme.fontWeight.medium, + color: theme.colors.foregroundMuted, + fontVariant: ["tabular-nums"], + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx index 041aa6954b..5e8c3eea78 100644 --- a/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx +++ b/packages/app/src/screens/workspace/workspace-explorer-toggle.tsx @@ -11,6 +11,8 @@ import type { ShortcutKey } from "@/utils/format-shortcut"; const ThemedPanelRight = withUnistyles(PanelRight); +export const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; + interface WorkspaceExplorerToggleProps { onPress: () => void; label: string; diff --git a/packages/app/src/screens/workspace/workspace-header-menu.tsx b/packages/app/src/screens/workspace/workspace-header-menu.tsx index 1ffd99425f..2fb7176e10 100644 --- a/packages/app/src/screens/workspace/workspace-header-menu.tsx +++ b/packages/app/src/screens/workspace/workspace-header-menu.tsx @@ -2,14 +2,7 @@ import React, { useCallback, useMemo } from "react"; import { View } from "react-native"; import { useRouter, type Href } from "expo-router"; import { useTranslation } from "react-i18next"; -import { - Copy, - Ellipsis, - Globe, - Import as ImportIcon, - Settings, - SquarePen, -} from "lucide-react-native"; +import { Copy, Ellipsis, Globe, Import as ImportIcon, Settings } from "lucide-react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; import { @@ -24,6 +17,7 @@ import { extraMutedIconColorMapping, iconButtonChromeGlyphSize, iconButtonChromeStyle, + useIconButtonChromeStyle, } from "@/components/ui/icon-button-chrome"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; import { useDaemonConfig } from "@/hooks/use-daemon-config"; @@ -36,16 +30,13 @@ import type { Theme } from "@/styles/theme"; const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedCopy = withUnistyles(Copy); -const ThemedSquarePen = withUnistyles(SquarePen); const ThemedGlobe = withUnistyles(Globe); const ThemedImport = withUnistyles(ImportIcon); const ThemedSettings = withUnistyles(Settings); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const MENU_NEW_AGENT_ICON = ; const MENU_NEW_BROWSER_ICON = ; -const MENU_NEW_TERMINAL_ICON = ; const MENU_IMPORT_ICON = ; const MENU_COPY_ICON = ; const MENU_SETTINGS_ICON = ; @@ -203,28 +194,25 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace normalizedServerId: string; showCreateBrowserTab: boolean; createTerminalDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void; onCreateBrowser: () => void; } /** - * Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace - * actions. + * Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer; + * the rarer launches stay here alongside the workspace actions. */ export function WorkspaceHeaderMenuMobile({ normalizedServerId, showCreateBrowserTab, createTerminalDisabled, - onCreateDraftTab, - onCreateTerminal, onCreateTerminalWithProfile, onCreateBrowser, ...workspaceActions }: WorkspaceHeaderMenuMobileProps) { const { t } = useTranslation(); const router = useRouter(); + const triggerStyle = useIconButtonChromeStyle({ size: "large" }); const { config } = useDaemonConfig(normalizedServerId); const profiles = useMemo( () => resolveTerminalProfiles(config?.terminalProfiles), @@ -239,7 +227,7 @@ export function WorkspaceHeaderMenuMobile({ - - {t("workspace.header.actions.newAgent")} - {showCreateBrowserTab ? ( {t("workspace.tabs.actions.terminalProfilesMenu")} - - {t("workspace.header.actions.newTerminal")} - {profiles.map((profile) => ( + {showProject ? ( + + {subtitle} + + ) : null} + {showProject && hostBadge ? · : null} + {hostBadge ? : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + // The compact header stretches this row to its available title width. Within that width, + // the project truncates and HostBadge yields its label while retaining its fixed icon. + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[1.5], + minWidth: 0, + flexShrink: 1, + }, + project: { + color: theme.colors.foregroundMuted, + fontSize: { + xs: theme.fontSize.sm, + md: theme.fontSize.base, + }, + flexShrink: 1, + minWidth: 0, + }, + separator: { + color: theme.colors.foregroundExtraMuted, + fontSize: theme.fontSize.sm, + flexShrink: 0, + }, +})); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index d94b7e5e82..dacc17fd3b 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -14,23 +14,26 @@ import { } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useIsFocused } from "@react-navigation/native"; -import { BackHandler, Keyboard, Pressable, Text, View } from "react-native"; +import { BackHandler, Text, View } from "react-native"; import { useQueryClient } from "@tanstack/react-query"; import { useRouter, type Href } from "expo-router"; import * as Clipboard from "expo-clipboard"; import { useTranslation } from "react-i18next"; -import { ChevronDown } from "lucide-react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import type { Theme } from "@/styles/theme"; import invariant from "tiny-invariant"; -import { SidebarMenuToggle } from "@/components/headers/menu-header"; +import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle"; import { ScreenHeader } from "@/components/headers/screen-header"; +import { MenuHeader } from "@/components/headers/menu-header"; +import { FloatingHeaderLayout } from "@/components/floating-header"; +import { StreamTopOverlayProvider, supportsStreamTopOverlay } from "@/agent-stream/top-overlay"; +import { + CompactWorkspaceHeader, + MobileWorkspaceTabSwitcher, +} from "@/screens/workspace/compact-workspace-header"; +import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row"; import { ScreenTitle } from "@/components/headers/screen-title"; -import { HostBadge } from "@/hosts/host-badge"; -import { useHostBadges } from "@/hosts/use-host-badges"; -import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; -import type { ShortcutKey } from "@/utils/format-shortcut"; import { FloatingPanelPortalHost, FloatingPanelPortalHostNameProvider, @@ -115,30 +118,20 @@ import { buildProviderCommand } from "@/utils/provider-command-templates"; import { generateDraftId } from "@/stores/draft-keys"; import { resolveWorkspaceRouteId } from "@/utils/workspace-identity"; import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels"; -import { - WorkspaceTabPresentationResolver, - WorkspaceTabIcon, - WorkspaceTabOptionRow, - type WorkspaceTabPresentation, -} from "@/screens/workspace/workspace-tab-presentation"; +import { WorkspaceTabPresentationResolver } from "@/screens/workspace/workspace-tab-presentation"; import { useWorkspaceTabRename, WorkspaceTabRenameModal, } from "@/screens/workspace/use-workspace-tab-rename"; -import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory"; import { WorkspaceDesktopTabsRow, type WorkspaceDesktopTabRowItem, } from "@/screens/workspace/workspace-desktop-tabs-row"; -import { - buildWorkspaceTabMenuEntries, - type WorkspaceTabMenuLabels, -} from "@/screens/workspace/workspace-tab-menu"; import { useDesktopBrowserNewTabRequests } from "@/desktop/browser/new-tab-requests"; import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types"; import { resolveWorkspaceExplorerToggleOwner, - WorkspaceExplorerToggle, + EXPLORER_TOGGLE_KEYS, WorkspaceExplorerSidebarToggle, WorkspaceHeaderExplorerToggle, } from "@/screens/workspace/workspace-explorer-toggle"; @@ -189,7 +182,6 @@ import { import { findAdjacentPane } from "@/utils/split-navigation"; import { supportsDesktopPaneSplits, useIsCompactFormFactor } from "@/constants/layout"; import { getIsElectron, isNative, isWeb } from "@/constants/platform"; -import type { SurfaceBackdrop } from "@/styles/surface-backdrop"; import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; import { useWorkspaceTerminals } from "@/screens/workspace/terminals/use-workspace-terminals"; import type { TerminalProfile } from "@getpaseo/protocol/messages"; @@ -246,12 +238,9 @@ function buildWorkspaceFileLocation( } const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -const ThemedChevronDown = withUnistyles(ChevronDown); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); -const GATED_WORKSPACE_HEADER_LEFT = ; - interface WorkspaceScreenProps { serverId: string; workspaceId: string; @@ -301,187 +290,6 @@ function useSyncWorkspaceActiveBrowser(input: { }, [focusedBrowserId, input.workspaceId]); } -function getFallbackTabOptionLabel( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - setup: string; - terminal: string; - browser: string; - agent: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.setup; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "file") { - return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path; - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - return labels.agent; -} - -function getFallbackTabOptionDescription( - tab: WorkspaceTabDescriptor, - labels: { - newTab: string; - newAgent: string; - workspaceSetup: string; - agent: string; - terminal: string; - browser: string; - changes: string; - files: string; - pullRequest: string; - }, -): string { - if (tab.target.kind === "new_tab") { - return labels.newTab; - } - if (tab.target.kind === "draft") { - return labels.newAgent; - } - if (tab.target.kind === "setup") { - return labels.workspaceSetup; - } - if (tab.target.kind === "agent") { - return labels.agent; - } - if (tab.target.kind === "terminal") { - return labels.terminal; - } - if (tab.target.kind === "browser") { - return labels.browser; - } - if (tab.target.kind === "provider_subagent") { - return labels.agent; - } - if (tab.target.kind === "commit_diff") { - return tab.target.sha.slice(0, 7); - } - if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") { - return labels.changes; - } - if (tab.target.kind === "files") { - return labels.files; - } - if (tab.target.kind === "pull_request") { - return labels.pullRequest; - } - if (tab.target.kind === "plugin") { - return tab.target.panelId; - } - return tab.target.path; -} - -interface MobileWorkspaceTabSwitcherProps { - tabs: WorkspaceTabDescriptor[]; - activeTabKey: string; - activeTab: WorkspaceTabDescriptor | null; - tabSwitcherOptions: ComboboxOption[]; - tabByKey: Map; - normalizedServerId: string; - normalizedWorkspaceId: string; - onSelectSwitcherTab: (key: string) => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -} - -function MobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor | null; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - if (!activeTab) { - return null; - } - - return ( - - ); -} - -function ResolvedMobileActiveTabTrigger({ - activeTab, - normalizedServerId, - normalizedWorkspaceId, - backdrop, -}: { - activeTab: WorkspaceTabDescriptor; - normalizedServerId: string; - normalizedWorkspaceId: string; - backdrop: SurfaceBackdrop; -}) { - const { t } = useTranslation(); - return ( - - {(presentation) => ( - <> - - - - - - {presentation.titleState === "loading" - ? t("workspace.tabs.loading") - : presentation.label} - - - )} - - ); -} - function WorkspaceDocumentTitleEffect({ label, titleState, @@ -504,277 +312,6 @@ function WorkspaceDocumentTitleEffect({ return null; } -function switcherTriggerStyle({ pressed }: { pressed?: boolean }) { - return [styles.switcherTrigger, Boolean(pressed) && styles.switcherTriggerPressed]; -} - -function MobileWorkspaceTabOption({ - tab, - tabIndex, - tabCount, - normalizedServerId, - normalizedWorkspaceId, - selected, - active, - onPress, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: { - tab: WorkspaceTabDescriptor; - tabIndex: number; - tabCount: number; - normalizedServerId: string; - normalizedWorkspaceId: string; - selected: boolean; - active: boolean; - onPress: () => void; - onCopyResumeCommand: (agentId: string) => Promise | void; - onCopyAgentId: (agentId: string) => Promise | void; - onCopyTerminalId: (terminalId: string) => Promise | void; - onCopyFilePath: (path: string) => Promise | void; - onReloadAgent: (agentId: string) => Promise | void; - onRenameTab: (tab: WorkspaceTabDescriptor) => void; - onCloseTab: (tabId: string) => Promise | void; - onCloseTabsAbove: (tabId: string) => Promise | void; - onCloseTabsBelow: (tabId: string) => Promise | void; - onCloseOtherTabs: (tabId: string) => Promise | void; -}) { - const { t } = useTranslation(); - const tabMenuLabels = useMemo( - () => ({ - copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"), - copyAgentId: t("workspace.tabs.menu.copyAgentId"), - copyTerminalId: t("workspace.tabs.menu.copyTerminalId"), - copyFilePath: t("workspace.tabs.menu.copyFilePath"), - rename: t("workspace.tabs.menu.rename"), - closeAbove: t("workspace.tabs.menu.closeAbove"), - closeBelow: t("workspace.tabs.menu.closeBelow"), - closeLeft: t("workspace.tabs.menu.closeLeft"), - closeRight: t("workspace.tabs.menu.closeRight"), - closeOthers: t("workspace.tabs.menu.closeOthers"), - reloadAgent: t("workspace.tabs.menu.reloadAgent"), - reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"), - close: t("workspace.tabs.menu.close"), - }), - [t], - ); - const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`; - const menuEntries = buildWorkspaceTabMenuEntries({ - surface: "mobile", - tab, - index: tabIndex, - tabCount, - menuTestIDBase, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsBefore: onCloseTabsAbove, - onCloseTabsAfter: onCloseTabsBelow, - onCloseOtherTabs, - labels: tabMenuLabels, - }); - - const fallbackLabels = useMemo( - () => ({ - newTab: t("workspace.tabs.actions.newTab"), - newAgent: t("workspace.tabs.fallback.newAgent"), - setup: t("workspace.tabs.fallback.setup"), - terminal: t("workspace.tabs.fallback.terminal"), - browser: t("workspace.tabs.fallback.browser"), - agent: t("workspace.tabs.fallback.agent"), - changes: t("panels.diff.changesLabel"), - files: t("panels.files.label"), - pullRequest: t("panels.pullRequest.label"), - }), - [t], - ); - const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels); - const trailingAccessory = useMemo( - () => ( - - ), - [menuTestIDBase, fallbackLabel, menuEntries], - ); - - const renderPresentation = useCallback( - (presentation: WorkspaceTabPresentation) => ( - - ), - [selected, active, onPress, trailingAccessory], - ); - - return ( - - {renderPresentation} - - ); -} - -const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({ - tabs, - activeTabKey, - activeTab, - tabSwitcherOptions, - tabByKey, - normalizedServerId, - normalizedWorkspaceId, - onSelectSwitcherTab, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, -}: MobileWorkspaceTabSwitcherProps) { - const { t } = useTranslation(); - const [isOpen, setIsOpen] = useState(false); - const anchorRef = useRef(null); - const tabIndexByKey = useMemo(() => { - const map = new Map(); - tabs.forEach((tab, index) => { - map.set(tab.key, index); - }); - return map; - }, [tabs]); - - const handleOpenSwitcher = useCallback(() => { - Keyboard.dismiss(); - setIsOpen(true); - }, []); - - const renderTabOption = useCallback( - ({ - option, - selected, - active, - onPress, - }: { - option: ComboboxOption; - selected: boolean; - active: boolean; - onPress: () => void; - }) => { - const tab = tabByKey.get(option.id); - if (!tab) { - return ; - } - const tabIndex = tabIndexByKey.get(tab.key) ?? -1; - if (tabIndex < 0) { - return ; - } - return ( - - ); - }, - [ - tabByKey, - tabIndexByKey, - tabs.length, - normalizedServerId, - normalizedWorkspaceId, - onCopyResumeCommand, - onCopyAgentId, - onCopyTerminalId, - onCopyFilePath, - onReloadAgent, - onRenameTab, - onCloseTab, - onCloseTabsAbove, - onCloseTabsBelow, - onCloseOtherTabs, - ], - ); - - return ( - - - {({ pressed }) => ( - <> - - - - - - )} - - - - - ); -}); - interface MobileMountedTabSlotProps { tabDescriptor: WorkspaceTabDescriptor; isVisible: boolean; @@ -902,47 +439,6 @@ function useCloseTabs(): UseCloseTabsResult { return { closingTabIds, closeTab }; } -/** - * Which project the workspace belongs to, and which machine it runs on. - * - * Compact gets both, on their own line under the workspace name: this header is the only thing on - * screen that says where the workspace lives, because the sidebar that normally carries the host - * badge is closed. It still follows the host's own badge setting, so a purely local setup stays - * quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit - * side by side, and kept on compact, where the line exists for the host anyway. - */ -function WorkspaceHeaderProjectRow({ - subtitle, - isSubtitleDistinct, - serverId, -}: { - subtitle: string; - isSubtitleDistinct: boolean; - serverId: string; -}) { - const isCompact = useIsCompactFormFactor(); - const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null; - const showProject = isSubtitleDistinct || isCompact; - if (!showProject && !hostBadge) { - return null; - } - return ( - - {showProject ? ( - - {subtitle} - - ) : null} - {showProject && hostBadge ? · : null} - {hostBadge ? : null} - - ); -} - interface WorkspaceHeaderTitleBarProps { isLoading: boolean; title: string; @@ -950,26 +446,13 @@ interface WorkspaceHeaderTitleBarProps { isSubtitleDistinct: boolean; currentBranchName: string | null; normalizedServerId: string; - normalizedWorkspaceId: string; - workspaceScripts: WorkspaceDescriptor["scripts"]; - liveTerminalIds: string[]; showWorkspaceSetup: boolean; - showCreateBrowserTab: boolean; - isMobile: boolean; - createTerminalDisabled: boolean; importAgentDisabled: boolean; copyPathDisabled: boolean; - onCreateDraftTab: () => void; - onCreateTerminal: () => void; - onCreateTerminalWithProfile: (profile: TerminalProfile) => void; - onCreateBrowser: () => void; onOpenImportSheet: () => void; onCopyWorkspacePath: () => void; onCopyBranchName: () => void; onOpenSetupTab: () => void; - onScriptTerminalStarted: (terminalId: string) => void; - onViewScriptTerminal: (terminalId: string) => void; - onOpenUrlInBrowserTab: (url: string) => void; } function WorkspaceHeaderTitleBar({ @@ -979,26 +462,13 @@ function WorkspaceHeaderTitleBar({ isSubtitleDistinct, currentBranchName, normalizedServerId, - normalizedWorkspaceId, - workspaceScripts, - liveTerminalIds, showWorkspaceSetup, - showCreateBrowserTab, - isMobile, - createTerminalDisabled, importAgentDisabled, copyPathDisabled, - onCreateDraftTab, - onCreateTerminal, - onCreateTerminalWithProfile, - onCreateBrowser, onOpenImportSheet, onCopyWorkspacePath, onCopyBranchName, onOpenSetupTab, - onScriptTerminalStarted, - onViewScriptTerminal, - onOpenUrlInBrowserTab, }: WorkspaceHeaderTitleBarProps) { return ( @@ -1017,49 +487,16 @@ function WorkspaceHeaderTitleBar({ )} - {isMobile ? ( - - ) : ( - - )} - {isMobile && workspaceScripts.length > 0 ? ( - - ) : null} + ); @@ -1293,7 +730,7 @@ function shouldInspectWorkspaceRecovery( function WorkspaceScreenGateFrame({ children }: { children: ReactNode }) { return ( <> - + {children} ); @@ -1375,6 +812,10 @@ function WorkspaceDocumentTitleEffectSlot({ ); } +function floatsHeaderOverContent(activeTab: WorkspaceTabDescriptor | null): boolean { + return supportsStreamTopOverlay && activeTab?.kind === "agent"; +} + function shouldShowWorkspaceScreenHeader(input: { isFocusModeEnabled: boolean; isMobile: boolean; @@ -2300,14 +1741,6 @@ function WorkspaceScreenContent({ terminalsQueryKey, }); - const tabByKey = useMemo(() => { - const map = new Map(); - for (const tab of tabs) { - map.set(tab.key, tab); - } - return map; - }, [tabs]); - const allTabDescriptorsById = useMemo(() => { const map = new Map(); for (const tab of uiTabs) { @@ -2353,32 +1786,6 @@ function WorkspaceScreenContent({ : t("workspace.tabs.explorerSidebar.open"); const activeTabKey = useMemo(() => activeTabId ?? "", [activeTabId]); - const tabFallbackLabels = useMemo( - () => ({ - newTab: t("workspace.tabs.actions.newTab"), - newAgent: t("workspace.tabs.fallback.newAgent"), - setup: t("workspace.tabs.fallback.setup"), - workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"), - terminal: t("workspace.tabs.fallback.terminal"), - browser: t("workspace.tabs.fallback.browser"), - agent: t("workspace.tabs.fallback.agent"), - changes: t("panels.diff.changesLabel"), - files: t("panels.files.label"), - pullRequest: t("panels.pullRequest.label"), - }), - [t], - ); - - const tabSwitcherOptions = useMemo( - () => - tabs.map((tab) => ({ - id: tab.key, - label: getFallbackTabOptionLabel(tab, tabFallbackLabels), - description: getFallbackTabOptionDescription(tab, tabFallbackLabels), - })), - [tabFallbackLabels, tabs], - ); - const handleCreateDraftTab = useCallback( (input?: { paneId?: string }) => { openWorkspaceDraftTab({ paneId: input?.paneId }); @@ -3759,7 +3166,7 @@ function WorkspaceScreenContent({ () => ( - {!isMobile && workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( + {workspaceDescriptor && workspaceDescriptor.scripts.length > 0 ? ( ) : null} - {!isMobile && workspaceDirectory ? ( + {workspaceDirectory ? ( ) : null} - {!isMobile && workspaceDirectory ? ( + {workspaceDirectory ? ( <> ) : null} - {isMobile ? ( - - ) : null} ), [ - isMobile, workspaceDescriptor, normalizedServerId, normalizedWorkspaceId, @@ -3892,26 +3288,13 @@ function WorkspaceScreenContent({ isSubtitleDistinct={isWorkspaceHeaderSubtitleDistinct} currentBranchName={currentBranchName} normalizedServerId={normalizedServerId} - normalizedWorkspaceId={normalizedWorkspaceId} - workspaceScripts={workspaceScripts} - liveTerminalIds={liveTerminalIds} showWorkspaceSetup={showWorkspaceSetup} - showCreateBrowserTab={showCreateBrowserTab} - isMobile={isMobile} - createTerminalDisabled={createTerminalDisabled} importAgentDisabled={!canOpenImportSheet} copyPathDisabled={!workspaceDirectory} - onCreateDraftTab={handleCreateDraftTab} - onCreateTerminal={handleCreateTerminal} - onCreateTerminalWithProfile={handleCreateTerminalWithProfile} - onCreateBrowser={handleCreateBrowserTab} onOpenImportSheet={openImportSheet} onCopyWorkspacePath={handleCopyWorkspacePath} onCopyBranchName={handleCopyBranchName} onOpenSetupTab={handleOpenSetupTab} - onScriptTerminalStarted={handleScriptTerminalStarted} - onViewScriptTerminal={handleViewScriptTerminal} - onOpenUrlInBrowserTab={handleOpenUrlInBrowserTab} /> } @@ -3920,33 +3303,110 @@ function WorkspaceScreenContent({ ) : null, [ canOpenImportSheet, - createTerminalDisabled, currentBranchName, handleCopyBranchName, handleCopyWorkspacePath, - handleCreateBrowserTab, - handleCreateDraftTab, - handleCreateTerminal, - handleCreateTerminalWithProfile, handleOpenSetupTab, - handleOpenUrlInBrowserTab, - handleScriptTerminalStarted, - handleViewScriptTerminal, headerRight, - isMobile, isWorkspaceHeaderLoading, - liveTerminalIds, normalizedServerId, - normalizedWorkspaceId, openImportSheet, - showCreateBrowserTab, showScreenHeader, showWorkspaceSetup, workspaceDirectory, workspaceHeaderSubtitle, workspaceHeaderTitle, isWorkspaceHeaderSubtitleDistinct, - workspaceScripts, + ], + ); + const compactWorkspaceHeader = useMemo( + () => + isMobile ? ( + + + + + ) : null, + [ + activeTabKey, + canOpenImportSheet, + createTerminalDisabled, + currentBranchName, + explorerSidebarToggleAccessibilityState, + explorerSidebarToggleLabel, + handleCloseOtherTabs, + handleCloseTabById, + handleCloseTabsToLeft, + handleCloseTabsToRight, + handleCopyAgentId, + handleCopyBranchName, + handleCopyFilePath, + handleCopyResumeCommand, + handleCopyTerminalId, + handleCopyWorkspacePath, + handleCreateBrowserTab, + handleCreateDraftTab, + handleCreateTerminal, + handleCreateTerminalWithProfile, + handleOpenSetupTab, + handleReloadAgent, + handleRenameTab, + handleSelectSwitcherTab, + handleToggleExplorerSidebar, + isMobile, + isWorkspaceHeaderLoading, + isWorkspaceHeaderSubtitleDistinct, + normalizedServerId, + normalizedWorkspaceId, + openImportSheet, + showCreateBrowserTab, + showWorkspaceSetup, + tabs, + workspaceDirectory, + workspaceHeaderSubtitle, + workspaceHeaderTitle, ], ); const desktopSplitContent = useMemo(() => { @@ -4035,33 +3495,23 @@ function WorkspaceScreenContent({ /> ); - const workspaceCenterColumn = ( + const workspaceCenterColumn = isMobile ? ( + + + {(topOverlayInset) => ( + + {workspacePanelContent} + + )} + + + ) : ( {rendersDesktopSplitContent ? null : renderWorkspaceScreenHeader()} - {isMobile ? ( - - ) : null} - {shouldRenderDesktopPaneFallback ? ( ({ md: theme.spacing[2], }, }, - // No width cap. A percentage cap resolves against the title group, whose own width comes from - // this row's content, so it clips the project name while there is still room beside it. - // `flexShrink` on both this row and the title already gives up space only when there is none. - headerProjectRow: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1.5], - minWidth: 0, - flexShrink: 1, - }, - headerProjectTitle: { - color: theme.colors.foregroundMuted, - fontSize: { - xs: theme.fontSize.sm, - md: theme.fontSize.base, - }, - flexShrink: 1, - minWidth: 0, - }, - headerProjectSeparator: { - color: theme.colors.foregroundExtraMuted, - fontSize: theme.fontSize.sm, - flexShrink: 0, - }, headerTitleSkeleton: { width: 220, maxWidth: "100%", @@ -4272,37 +3698,6 @@ const styles = StyleSheet.create((theme) => ({ gap: theme.spacing[2], }, newTabTooltipShortcut: {}, - mobileTabsRow: { - backgroundColor: theme.colors.surface0, - borderBottomWidth: theme.borderWidth[1], - borderBottomColor: theme.colors.border, - }, - switcherTrigger: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[2], - paddingHorizontal: theme.spacing[2] + theme.spacing[3], - paddingVertical: theme.spacing[2], - }, - switcherTriggerPressed: { - backgroundColor: theme.colors.surface1, - }, - switcherTriggerLeft: { - flexDirection: "row", - alignItems: "center", - gap: theme.spacing[1], - flex: 1, - minWidth: 0, - }, - switcherTriggerIcon: { - flexShrink: 0, - }, - switcherTriggerText: { - minWidth: 0, - flex: 1, - color: theme.colors.foreground, - fontSize: theme.fontSize.base, - }, headerMenuProfileIconWrapper: { width: 16, height: 16, @@ -4417,5 +3812,3 @@ const styles = StyleSheet.create((theme) => ({ textAlign: "center", }, })); - -const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"]; diff --git a/packages/app/src/styles/density.ts b/packages/app/src/styles/density.ts new file mode 100644 index 0000000000..5b452bcb93 --- /dev/null +++ b/packages/app/src/styles/density.ts @@ -0,0 +1,57 @@ +import { isCompactBreakpoint } from "@/constants/layout"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; + +/** + * Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact + * layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay + * desktop-sized everywhere, so a surface migrates by swapping its sizes for these. + * + * Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes + * are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping. + */ + +export type DensityIconSize = keyof typeof ICON_SIZE; +type TextSize = "sm" | "base"; + +const COMPACT_ICON_SIZE: Record = { xs: 14, sm: 18, md: 20, lg: 24 }; +const COMPACT_TEXT_SIZE: Record = { sm: "base", base: "lg" }; + +// Compact layouts end where `md` begins; see `isCompactBreakpoint`. +function byLayout(compact: T, desktop: T) { + return { xs: compact, md: desktop }; +} + +export const density = { + /** A list row such as a sidebar entry. */ + rowHeight: byLayout(44, 36), + /** A row in a tight group, such as the sidebar's navigation rows. */ + tightRowHeight: byLayout(40, 28), + /** The line height of a row's `base` text; slots that align to its first line share it. */ + rowLineHeight: byLayout(22, 20), + /** A square icon-only button that sits in a row or toolbar. */ + iconButton: byLayout(44, 28), +}; + +/** The width or height of a slot that holds an icon of `size`. */ +export function densityIconBox(size: DensityIconSize) { + return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]); +} + +export function densityFontSize(theme: Theme, size: TextSize) { + return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]); +} + +export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number { + return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size]; +} + +/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */ +export function densityIconProps

( + size: DensityIconSize, + mapping: (theme: Theme) => P, +) { + return (theme: Theme, rt: { breakpoint?: string }) => ({ + ...mapping(theme), + size: densityIconSize(rt, size), + }); +} diff --git a/packages/app/src/styles/theme.test.ts b/packages/app/src/styles/theme.test.ts index 707085e7c9..461d980ecd 100644 --- a/packages/app/src/styles/theme.test.ts +++ b/packages/app/src/styles/theme.test.ts @@ -68,9 +68,9 @@ describe("Pure black theme", () => { }); describe("Sidebar interaction surfaces", () => { - it("keeps Light selection distinct from the sidebar surface", () => { - expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1); - expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3); + it("keeps Light hover and selection darker than the sidebar surface", () => { + expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3); + expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4); expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar); }); diff --git a/packages/app/src/styles/theme.ts b/packages/app/src/styles/theme.ts index 04b1e8f6ae..8e1e77aac7 100644 --- a/packages/app/src/styles/theme.ts +++ b/packages/app/src/styles/theme.ts @@ -267,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) { surface4: tint.surface4, surfaceDiffEmpty: tint.surfaceDiffEmpty, surfaceSidebar: tint.surfaceSidebar, - surfaceSidebarHover: tint.surface1, - surfaceSidebarSelected: tint.surface3, + surfaceSidebarHover: tint.surface3, + surfaceSidebarSelected: tint.surface4, surfaceWorkspace: tint.surface0, interactionHighlight: "rgba(0, 0, 0, 0.06)", diff --git a/packages/app/src/usage/controls.tsx b/packages/app/src/usage/controls.tsx index 020faf61ec..20297c9df0 100644 --- a/packages/app/src/usage/controls.tsx +++ b/packages/app/src/usage/controls.tsx @@ -31,7 +31,7 @@ export function UsageControls({ const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); return ( - {hostSelection && hostSelection.hosts.length > 1 ? ( + {hasHostChoice(hostSelection) ? ( 1; +} + function usageHostOptionTestID(serverId: string): string { return `usage-host-filter-item-${serverId}`; } @@ -65,17 +71,22 @@ const styles = StyleSheet.create((theme) => ({ })); /** - * One host's usage and the title-row controls that go with it, for the Usage modal. + * One host’s usage and title-row controls for the Usage modal. + * Returns null when there is nothing to control, so headers leave no empty island. */ export function useHostUsageWithControls(hostSelection: UsageHostSelection): { view: UsageView; refresh: () => void; - controls: ReactElement; + controls: ReactElement | null; } { const { view, refresh } = useHostUsage(hostSelection.serverId); + const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable"; const controls = useMemo( - () => , - [hostSelection, refresh, view], + () => + hasControls ? ( + + ) : null, + [hasControls, hostSelection, refresh, view], ); return { view, refresh, controls }; } diff --git a/packages/app/src/usage/refresh-button.tsx b/packages/app/src/usage/refresh-button.tsx index bf6dc787b2..48754c4e19 100644 --- a/packages/app/src/usage/refresh-button.tsx +++ b/packages/app/src/usage/refresh-button.tsx @@ -9,7 +9,10 @@ import { usageCopy } from "./copy"; const ThemedRotateCw = withUnistyles(RotateCw); const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); -/** Refreshes every report on the host; spins while a refresh is in flight. */ +/** + * Refreshes every report on the host; spins while a refresh is in flight. On compact it sits in the + * header's island, so its highlight is round. + */ export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) { const compact = useIsCompactFormFactor(); const iconSize = paneContentToolbarIconSize(compact); diff --git a/packages/app/src/workspace-labels/manager-modal.tsx b/packages/app/src/workspace-labels/manager-modal.tsx index 0dc8f1c53f..a23e7e0925 100644 --- a/packages/app/src/workspace-labels/manager-modal.tsx +++ b/packages/app/src/workspace-labels/manager-modal.tsx @@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({ onClose={onClose} header={header} footer={footer} - // Bound the compact scroller to the live snap height, so the edit view's footer stays on - // screen instead of being pushed past the bottom of the sheet. - sizeContentToCurrentSnapPoint testID="workspace-label-manager" > {draft && editing ? (