Modernize compact headers and sidebar affordances (#6431)

* feat(app): modernize compact headers and sidebar affordances

Use a shared island header with quick tab creation and keep sheet footers inside their visible layout. Opt sidebar rows into touch sizing without changing global appearance settings.

* test(app): match darker light-theme workspace selection

* test(app): align plugin journeys with compact header
This commit is contained in:
Mohamed Boudra
2026-10-10 20:53:09 +01:00
committed by GitHub
parent d0728cd1ed
commit 01f9bc8291
57 changed files with 2036 additions and 1407 deletions
-1
View File
@@ -252,7 +252,6 @@
"packages/app/src/components/download-toast.tsx", "packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx", "packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/headers/back-header.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/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx", "packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx", "packages/app/src/components/hosts/host-picker.tsx",
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click(); await row.click();
await expect(row).toHaveAttribute("aria-selected", "true"); 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({ await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"), path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true, fullPage: true,
@@ -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();
}
});
}
}
@@ -1,3 +1,4 @@
import type { Page, TestInfo } from "@playwright/test";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { test, expect } from "../support/fixtures"; import { test, expect } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; 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 }; 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<string | null> { async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" }); const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null; 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.describe("Workspace session rename (compact tab switcher)", () => {
test("renames an agent session from the switcher's per-session actions sheet", async ({ test("renames an agent session from the switcher's per-session actions sheet", async ({
page, page,
}) => { }, testInfo) => {
test.setTimeout(120_000); test.setTimeout(120_000);
await page.setViewportSize(MOBILE_VIEWPORT); await page.setViewportSize(MOBILE_VIEWPORT);
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
timeout: 30_000, timeout: 30_000,
}); });
await captureScreen(page, testInfo, "compact-chat-header");
// Open the sessions dropdown (a bottom sheet on compact). // 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( await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{ {
timeout: 10_000, timeout: 10_000,
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click(); await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 }); 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, 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.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 { } finally {
await session.cleanup(); 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();
}
});
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug env APP_ID=sh.paseo.debug
open "${APP_ID}" open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000 wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\"" press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-header-new-terminal\"" press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000 wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\"" press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0 type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug env APP_ID=sh.paseo.debug
open "${APP_ID}" open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000 wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\"" press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-header-new-terminal\"" press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-surface\"" 10000 wait "id=\"terminal-surface\"" 10000
press "id=\"terminal-surface\"" press "id=\"terminal-surface\""
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0 type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug env APP_ID=sh.paseo.debug
open "${APP_ID}" open "${APP_ID}"
wait "id=\"workspace-header-menu-trigger\"" 20000 wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\"" press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-header-new-terminal\"" press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000 wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\"" press "id=\"terminal-keyboard-toggle\""
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0 type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
@@ -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 ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat run_host_scenario chat
ad keyboard dismiss || true ad keyboard dismiss || true
ad press 'id="workspace-header-menu-trigger"' --settle ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-header-new-agent"' 10000 ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-header-new-agent"' --settle ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad wait 'text="New Agent"' 10000 ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft run_host_scenario workspace-draft
ad keyboard dismiss || true ad keyboard dismiss || true
@@ -30,7 +30,6 @@ async function capture(
name: string, name: string,
subject: Locator = headerButtons(page), subject: Locator = headerButtons(page),
) { ) {
await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport(); await expect(subject).toBeInViewport();
await waitForSettledPosition(subject); await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`); const file = info.outputPath(`${name}.png`);
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
refreshFromHeaderAndComposer: () => refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => { test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action"); await exampleCommand(page, "action");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click(); await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)"); await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click(); await page.getByRole("button", { name: "Refresh context", exact: true }).click();
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2"); ).toContainText("Refreshed · 2");
await capture(page, info, "01-actions"); await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT); 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: () => useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => { 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 capture(page, info, "05-disabled");
await exampleCommand(page, "enable"); await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT); 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( await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }), page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport(); ).toBeInViewport();
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
inspectLiveWorkspaceDetails: () => inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => { test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover"); await exampleCommand(page, "popover");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click(); await headerButtons(page).click();
await expect( await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }), page.getByRole("button", { name: "Close workspace details", exact: true }),
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12); 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( export async function withButtonShowcase(
page: Page, page: Page,
run: (buttons: { run: (buttons: {
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
); );
}), }),
openCompactSheets: () => 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 page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0); await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect( await expect(
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title", "Button showcase with a long workspace title",
); );
await expectHeaderTextTruncated(page); await expectHeaderTextTruncated(page);
await expectSurfaceVisible(page);
await openCompactOverflowWithHeaderChrome(page);
await expect( await expect(
page.getByRole("menuitem", { name: "Production locked", exact: true }), page.getByRole("toolbar", { name: "Workspace actions", 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"),
).toHaveCount(0); ).toHaveCount(0);
await expectSurfaceVisible( await expect(
page, page.getByRole("button", { name: "Open menu", exact: true }),
page.getByRole("menuitem", { name: "Run checks", exact: true }), ).toBeInViewport();
);
await choose(page, "Run checks");
await showcase.setTitle("Button showcase"); await showcase.setTitle("Button showcase");
await press(page, "Composer status"); await press(page, "Composer status");
await expectDetails(page); await expectDetails(page);
@@ -42,6 +42,7 @@ import {
type HistoryStartPaginationInput, type HistoryStartPaginationInput,
type HistoryStartPaginationTransition, type HistoryStartPaginationTransition,
} from "./history-start-pagination"; } from "./history-start-pagination";
import { useStreamTopOverlayInset } from "./top-overlay";
import { import {
createHistoryStartSettleScheduler, createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler, type HistoryStartSettleScheduler,
@@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom" Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined ? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION; : 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( const listContentContainerStyle = useMemo(
() => [ () => [
baseListContentContainerStyle, baseListContentContainerStyle,
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom }, {
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
},
],
[
baseListContentContainerStyle,
streamKeyboardInset.contentContainerPaddingBottom,
topOverlayInset,
], ],
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
); );
const listInsetProps = useMemo( const listInsetProps = useMemo(
() => () =>
@@ -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);
}
+1 -1
View File
@@ -35,7 +35,7 @@ import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host"; import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary"; import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar"; 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 { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model"; import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler"; import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
@@ -1,8 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => { describe("getCompactSheetSafeAreaPadding", () => {
it("assigns safe clearance to the footer independently of decorative padding", () => { it("assigns safe clearance to the footer independently of decorative padding", () => {
@@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => {
).toEqual({}); ).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);
});
});
@@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding {
footerPaddingBottom?: number; 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({ export function getCompactSheetSafeAreaPadding({
isCompact, isCompact,
isKeyboardVisible, isKeyboardVisible,
@@ -12,22 +12,16 @@ import {
useGlobalWebOverlayLayer, useGlobalWebOverlayLayer,
useWebOverlayRegistration, useWebOverlayRegistration,
} from "../lib/overlay-root"; } from "../lib/overlay-root";
import { import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
KEYBOARD_STATUS, import Animated from "react-native-reanimated";
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native"; import { ArrowLeft, Search, X } from "lucide-react-native";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
SheetVisibleFrame,
type ContextBridge, type ContextBridge,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal"; } from "@/components/ui/isolated-bottom-sheet-modal";
import { import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { ScrollView } from "@/components/ui/scroll-view"; import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility"; import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
@@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
}, },
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: { bottomSheetVisibleScroll: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
@@ -263,40 +253,6 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>; return <View style={[styles.sheetContent, style]}>{children}</View>;
} }
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 (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export function SheetHeaderView({ export function SheetHeaderView({
header, header,
onClose, onClose,
@@ -471,8 +427,6 @@ export interface AdaptiveModalSheetProps {
bodyStyle?: StyleProp<ViewStyle>; bodyStyle?: StyleProp<ViewStyle>;
/** Layout intent for the sheet body, composed over the sheet's own content inset. */ /** Layout intent for the sheet body, composed over the sheet's own content inset. */
contentStyle?: StyleProp<ViewStyle>; contentStyle?: StyleProp<ViewStyle>;
/** 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. */ /** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
contextBridge?: ContextBridge | null; contextBridge?: ContextBridge | null;
} }
@@ -493,7 +447,6 @@ export function AdaptiveModalSheet({
presentation, presentation,
contentStyle, contentStyle,
bodyStyle, bodyStyle,
sizeContentToCurrentSnapPoint = true,
contextBridge = null, contextBridge = null,
}: AdaptiveModalSheetProps) { }: AdaptiveModalSheetProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -519,11 +472,15 @@ export function AdaptiveModalSheet({
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }), () => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom], [compactSafeAreaPadding.footerPaddingBottom],
); );
const footerView = footer ? ( const footerView = useMemo(
<View style={footerClearanceStyle}> () =>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View> footer ? (
</View> <View style={footerClearanceStyle}>
) : null; <View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const handleIndicatorStyle = useMemo( const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }), () => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc], [theme.colors.palette.zinc],
@@ -628,7 +585,6 @@ export function AdaptiveModalSheet({
</View> </View>
)} )}
</View> </View>
{footerView}
</> </>
); );
@@ -650,11 +606,7 @@ export function AdaptiveModalSheet({
accessible={false} accessible={false}
presentation={presentation} presentation={presentation}
> >
{sizeContentToCurrentSnapPoint ? ( <SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
) : (
sheetContent
)}
</IsolatedBottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -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 (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none" onLayout={handleHeaderLayout}>
<FloatingHeaderFade />
{header}
</View>
) : null}
</View>
);
}
function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={1} />
<Stop offset="50%" stopColor={color} stopOpacity={0.97} />
<Stop offset="75%" stopColor={color} stopOpacity={0.8} />
<Stop offset="100%" stopColor={color} stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
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 (
<View style={styles.fade} pointerEvents="none">
<ThemedFloatingHeaderFadeSvg
gradientId={gradientId}
uniProps={workspaceSurfaceColorMapping}
/>
</View>
);
}
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,
},
});
@@ -6,10 +6,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native"; import { ArrowLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header"; import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title"; import { ScreenTitle } from "./screen-title";
import { CompactHeader } from "./compact-header";
import { useIsCompactFormFactor } from "@/constants/layout";
interface BackHeaderProps { interface BackHeaderProps {
title?: string; title?: string;
titleAccessory?: ReactNode;
rightContent?: ReactNode; rightContent?: ReactNode;
onBack?: () => void; onBack?: () => void;
} }
@@ -18,7 +19,15 @@ function goBack(): void {
router.back(); router.back();
} }
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) { export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="back" title={title} onBack={onBack} actions={rightContent} />;
}
return <DesktopBackHeader title={title} rightContent={rightContent} onBack={onBack} />;
}
function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
@@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} /> <ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable> </Pressable>
{title && <ScreenTitle>{title}</ScreenTitle>} {title && <ScreenTitle>{title}</ScreenTitle>}
{titleAccessory}
</> </>
} }
right={rightContent} right={rightContent}
@@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
backButton: { backButton: {
padding: { padding: theme.spacing[2],
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}, },
})); }));
@@ -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 (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
<View style={styles.titleGroup}>
<CompactHeaderTitle
loading={loading}
title={title}
titleTestID={titleTestID}
subtitle={subtitle}
/>
</View>
{actions ? <View style={styles.island}>{actions}</View> : null}
</View>
</View>
</IconButtonChromeProvider>
);
}
function CompactHeaderTitle({
loading,
title,
titleTestID,
subtitle,
}: Pick<CompactHeaderProps, "title" | "titleTestID" | "subtitle"> & { loading: boolean }) {
if (loading) {
return <View style={styles.titleSkeleton} />;
}
return (
<>
{title ? (
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
{title}
</Text>
) : null}
{subtitle}
</>
);
}
function BackButton({ onBack }: { onBack?: () => void }) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
if (onBack) {
onBack();
return;
}
router.back();
}, [onBack]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("common.actions.back")}
tooltipKeys={NO_SHORTCUT}
tooltipSide="right"
testID="header-back-button"
accessibilityRole="button"
accessibilityLabel={t("common.actions.back")}
>
<ThemedArrowLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
);
}
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,
},
}));
@@ -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 { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; 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 { isWeb } from "@/constants/platform";
import { import {
iconButtonChromeFrameStyle, iconButtonChromeFrameStyle,
iconButtonChromeStyle, useIconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome"; } from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState { interface HeaderToggleButtonState {
@@ -46,17 +46,11 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>) ? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null; : null;
const combinedStyle = useMemo( const combinedStyle = useIconButtonChromeStyle({
() => size: "large",
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) => disabled: Boolean(disabled),
iconButtonChromeStyle({ style,
size: "large", });
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
return ( return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -1,16 +1,10 @@
import { useCallback, useMemo, type ReactNode } from "react"; import type { ReactNode } from "react";
import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header"; import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title"; import { ScreenTitle } from "./screen-title";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button"; import { CompactHeader } from "./compact-header";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store"; import { SidebarMenuToggle } from "./sidebar-menu-toggle";
import { useIsCompactFormFactor } from "@/constants/layout"; 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 { interface MenuHeaderProps {
title?: string; title?: string;
@@ -18,130 +12,11 @@ interface MenuHeaderProps {
borderless?: boolean; borderless?: boolean;
} }
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
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 (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
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 (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
) : (
<PanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
);
}
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 (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) { export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
}
return ( return (
<ScreenHeader <ScreenHeader
left={ left={
@@ -158,31 +33,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
left: { left: {
gap: theme.spacing[2], 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,
},
})); }));
@@ -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<ViewStyle>;
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 (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
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 (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon extraMuted={extraMutedIdleIcon} />
) : (
<ThemedPanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={extraMutedIdleIcon ? extraMutedIconColorMapping : mutedIconColorMapping}
/>
)}
</HeaderToggleButton>
);
}
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 (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
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,
},
}));
+25 -30
View File
@@ -1,5 +1,13 @@
import { router } from "expo-router"; 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 { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { import {
@@ -26,7 +34,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; 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 { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout"; import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -37,6 +45,7 @@ import {
type SidebarProjectEntry, type SidebarProjectEntry,
type SidebarWorkspaceEntry, type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list"; } from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel"; import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -280,30 +289,25 @@ function FooterIconButton({
onPress, onPress,
testID, testID,
label, label,
icon: Icon, icon,
iconSizeAdjustment = 0, iconSizeAdjustment = 0,
shortcutKeys, shortcutKeys,
theme,
}: { }: {
onPress: () => void; onPress: () => void;
testID: string; testID: string;
label: string; label: string;
icon: typeof FolderPlus; icon: LucideIcon;
/** Only for a glyph that reads larger than the others at the same size. */ /** Only for a glyph that reads larger than the others at the same size. */
iconSizeAdjustment?: number; iconSizeAdjustment?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>; shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>; buttonRef?: RefObject<View | null>;
}) { }) {
const isCompact = useIsCompactFormFactor();
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
return ( return (
<Tooltip delayDuration={300}> <Tooltip delayDuration={300}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Pressable <Pressable
ref={buttonRef} ref={buttonRef}
style={styles.footerIconButton(isCompact)} style={styles.footerIconButton}
testID={testID} testID={testID}
nativeID={testID} nativeID={testID}
collapsable={false} collapsable={false}
@@ -313,9 +317,11 @@ function FooterIconButton({
onPress={onPress} onPress={onPress}
> >
{({ hovered }) => ( {({ hovered }) => (
<Icon <DensityIcon
size={iconSize + iconSizeAdjustment} icon={icon}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted} size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
/> />
)} )}
</Pressable> </Pressable>
@@ -328,12 +334,10 @@ function FooterIconButton({
} }
function SidebarHostPicker({ function SidebarHostPicker({
theme,
label, label,
onAddHost, onAddHost,
onOpenHostSettings, onOpenHostSettings,
}: { }: {
theme: SidebarTheme;
label: string; label: string;
onAddHost: () => void; onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void; onOpenHostSettings: (serverId: string) => void;
@@ -377,7 +381,6 @@ function SidebarHostPicker({
icon={Server} icon={Server}
// Server's two boxes fill more of the square than the other glyphs. // Server's two boxes fill more of the square than the other glyphs.
iconSizeAdjustment={-1} iconSizeAdjustment={-1}
theme={theme}
/> />
</HostPicker> </HostPicker>
); );
@@ -399,7 +402,6 @@ function IconTooltipContent({
} }
function SidebarFooter({ function SidebarFooter({
theme,
handleOpenProject, handleOpenProject,
handleSettings, handleSettings,
labels, labels,
@@ -407,7 +409,6 @@ function SidebarFooter({
handleOpenHostSettings, handleOpenHostSettings,
onBeforeNavigate, onBeforeNavigate,
}: { }: {
theme: SidebarTheme;
handleOpenProject: () => void; handleOpenProject: () => void;
handleSettings: () => void; handleSettings: () => void;
labels: { labels: {
@@ -436,11 +437,9 @@ function SidebarFooter({
label={labels.addProject} label={labels.addProject}
icon={FolderPlus} icon={FolderPlus}
shortcutKeys={newAgentKeys} shortcutKeys={newAgentKeys}
theme={theme}
/> />
<SidebarUsageIcon label={labels.usage} theme={theme} /> <SidebarUsageIcon label={labels.usage} />
<SidebarHostPicker <SidebarHostPicker
theme={theme}
label={labels.hosts} label={labels.hosts}
onAddHost={handleAddHost} onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings} onOpenHostSettings={handleOpenHostSettings}
@@ -453,7 +452,6 @@ function SidebarFooter({
label={labels.settings} label={labels.settings}
icon={Settings} icon={Settings}
shortcutKeys={settingsKeys} shortcutKeys={settingsKeys}
theme={theme}
/> />
</View> </View>
</View> </View>
@@ -461,7 +459,7 @@ function SidebarFooter({
); );
} }
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) { function SidebarUsageIcon({ label }: { label: string }) {
const openUsage = useOpenSidebarUsage(); const openUsage = useOpenSidebarUsage();
return ( return (
<FooterIconButton <FooterIconButton
@@ -469,7 +467,6 @@ function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme
testID="sidebar-usage-icon" testID="sidebar-usage-icon"
label={label} label={label}
icon={CircleGauge} icon={CircleGauge}
theme={theme}
/> />
); );
} }
@@ -612,7 +609,6 @@ function MobileSidebar({
)} )}
<SidebarFooter <SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
handleSettings={handleSettings} handleSettings={handleSettings}
labels={labels} labels={labels}
@@ -789,7 +785,6 @@ function DesktopSidebar({
<SidebarCalloutSlot /> <SidebarCalloutSlot />
<SidebarFooter <SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject} handleOpenProject={handleOpenProject}
handleSettings={handleSettings} handleSettings={handleSettings}
labels={labels} labels={labels}
@@ -957,14 +952,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5], paddingVertical: theme.spacing[1.5],
gap: 2, gap: 2,
}, },
footerIconButton: (isCompact: boolean) => ({ footerIconButton: {
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, width: density.iconButton,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, height: density.iconButton,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
}), },
tooltipRow: { tooltipRow: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
@@ -126,9 +126,6 @@ export function ProjectEditSheet({
onClose={handleClose} onClose={handleClose}
footer={footer} footer={footer}
desktopMaxWidth={440} 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" testID="project-edit-sheet"
> >
<Field <Field
@@ -30,6 +30,7 @@ import {
type ActiveWorkspaceSelection, type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store"; } from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop"; import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -114,6 +115,7 @@ import {
SidebarWorkspaceTrailingContent, SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing, useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing"; } from "@/components/sidebar/workspace-trailing";
import { DensityIcon } from "@/components/ui/density-icon";
import { PressHighlight } from "@/components/ui/press-highlight"; import { PressHighlight } from "@/components/ui/press-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
@@ -822,9 +824,10 @@ function NewWorkspaceGhostRow({
{({ hovered, pressed }) => ( {({ hovered, pressed }) => (
<> <>
<View style={styles.newWorkspaceGhostIconSlot}> <View style={styles.newWorkspaceGhostIconSlot}>
<ThemedPlus <DensityIcon
size={14} icon={Plus}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping} size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
/> />
</View> </View>
<Text <Text
@@ -2519,7 +2522,7 @@ const styles = StyleSheet.create((theme) => ({
// the step in reads as belonging to that project. Padding rather than margin, so the hover and // 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. // pressed fills stay the same box as every other row in the sidebar.
newWorkspaceGhostRow: { newWorkspaceGhostRow: {
minHeight: 36, minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5], marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[4], 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 // The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it. // titles above it.
newWorkspaceGhostIconSlot: { newWorkspaceGhostIconSlot: {
width: theme.iconSize.md, width: densityIconBox("md"),
height: theme.iconSize.md, height: densityIconBox("md"),
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0, flexShrink: 0,
}, },
newWorkspaceGhostText: { newWorkspaceGhostText: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
minWidth: 0, minWidth: 0,
flexShrink: 1, flexShrink: 1,
}, },
newWorkspaceGhostTextHovered: { newWorkspaceGhostTextHovered: {
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
minWidth: 0, minWidth: 0,
flexShrink: 1, flexShrink: 1,
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
projectRow: { projectRow: {
position: "relative", position: "relative",
minHeight: 36, minHeight: density.rowHeight,
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
@@ -2600,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
projectTitle: { projectTitle: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
fontWeight: "400", fontWeight: "400",
minWidth: 0, minWidth: 0,
flexShrink: 1, flexShrink: 1,
@@ -2681,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({
right: theme.spacing[2], right: theme.spacing[2],
}, },
workspaceRow: { workspaceRow: {
minHeight: 36, minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5], marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2], paddingLeft: theme.spacing[2],
@@ -2,16 +2,11 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native"; import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "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 { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform"; import { isNative } from "@/constants/platform";
import type { Theme } from "@/styles/theme"; import { density, densityFontSize } from "@/styles/density";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function PinnedSectionHeader({ export function PinnedSectionHeader({
collapsed, collapsed,
@@ -23,7 +18,6 @@ export function PinnedSectionHeader({
const { t } = useTranslation(); const { t } = useTranslation();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]); const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
return ( return (
<Pressable <Pressable
@@ -37,7 +31,11 @@ export function PinnedSectionHeader({
<> <>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text> <Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? ( {hovered || isNative || isCompact ? (
<Chevron size={12} uniProps={foregroundMutedColorMapping} /> <DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
) : null} ) : null}
</> </>
)} )}
@@ -47,7 +45,7 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
header: { header: {
minHeight: 36, minHeight: density.rowHeight,
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
alignSelf: "flex-start", alignSelf: "flex-start",
@@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
title: { title: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm, fontSize: densityFontSize(theme, "sm"),
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
})); }));
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native"; import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view"; import { ProjectIconView } from "@/components/project-icon-view";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model"; 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 type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { import {
getProjectStatusBadgeContent, 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 filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots. // the passive status dots.
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's const DensityProjectIconView = withUnistyles(ProjectIconView);
// workspaceBranchText) so the icon centers on the title rather than floating above it. const projectIconProps = densityIconProps("md", () => ({}));
const LEADING_SLOT_HEIGHT = 20;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator); const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleAlert = withUnistyles(CircleAlert);
@@ -230,12 +230,12 @@ function ProjectIcon({
projectViewKey: string; projectViewKey: string;
}) { }) {
return ( return (
<ProjectIconView <DensityProjectIconView
iconDataUri={iconDataUri} iconDataUri={iconDataUri}
initial={placeholderInitial} initial={placeholderInitial}
projectViewKey={projectViewKey} projectViewKey={projectViewKey}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText} textStyle={styles.projectIconFallbackText}
uniProps={projectIconProps}
/> />
); );
} }
@@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => {
return { return {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the // 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 // icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab — // only as tall as the icon puts it above the title and the kebab — which is why the
// which is why the workspace status indicator is also 20 tall. Keep the two in step. // workspace status indicator shares the same `rowLineHeight`.
projectLeadingVisualSlot: { projectLeadingVisualSlot: {
width: theme.iconSize.md, width: densityIconBox("md"),
height: LEADING_SLOT_HEIGHT, height: density.rowLineHeight,
flexShrink: 0, flexShrink: 0,
alignItems: "center", alignItems: "center",
justifyContent: "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. // Anchors the corner badge to the icon rather than to the taller slot.
projectIconBox: { projectIconBox: {
position: "relative", position: "relative",
width: theme.iconSize.md, width: densityIconBox("md"),
height: theme.iconSize.md, height: densityIconBox("md"),
}, },
projectIconFallbackText: { projectIconFallbackText: {
fontSize: 9, fontSize: 9,
@@ -2,19 +2,11 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, ChevronUp } from "lucide-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 { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content"; import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import type { Theme } from "@/styles/theme"; import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronUp = withUnistyles(ChevronUp);
/** /**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so * 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 }) => ( {({ hovered, pressed }) => (
<> <>
<View style={styles.iconSlot}> <View style={styles.iconSlot}>
{expanded ? ( <DensityIcon
<ThemedChevronUp icon={expanded ? ChevronUp : ChevronDown}
size={14} size="sm"
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping} color={hovered || pressed ? "foreground" : "foregroundMuted"}
/> />
) : (
<ThemedChevronDown
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</View> </View>
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}> <Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
{label} {label}
@@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx. // Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
row: { row: {
minHeight: 36, minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5], marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingLeft: 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 // 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. // titles above it rather than two points to their left.
iconSlot: { iconSlot: {
width: theme.iconSize.md, width: densityIconBox("md"),
height: theme.iconSize.md, height: densityIconBox("md"),
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
flexShrink: 0, flexShrink: 0,
}, },
text: { text: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
minWidth: 0, minWidth: 0,
flexShrink: 1, flexShrink: 1,
}, },
textHovered: { textHovered: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
minWidth: 0, minWidth: 0,
flexShrink: 1, flexShrink: 1,
}, },
@@ -1,22 +1,12 @@
import { import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
useCallback,
useMemo,
useState,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import { Pressable, Text, View } from "react-native"; 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 { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { ICON_SIZE } from "@/styles/theme"; import { DensityIcon } from "@/components/ui/density-icon";
import type { Theme } from "@/styles/theme";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut"; 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"; type SidebarHeaderRowVariant = "header" | "compact" | "inline";
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>; export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
@@ -49,7 +39,7 @@ interface SidebarHeaderRowProps {
} }
export function SidebarHeaderRow({ export function SidebarHeaderRow({
icon: Icon, icon,
label, label,
onPress, onPress,
isActive = false, isActive = false,
@@ -64,9 +54,8 @@ export function SidebarHeaderRow({
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []); const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []); const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
const isHighlighted = isHovered || isActive; const isHighlighted = isHovered || isActive;
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm; const iconSize = variant === "header" ? "md" : "sm";
let right = trailing ?? null; let right = trailing ?? null;
if (right === null && shortcutKeys && isHovered) { if (right === null && shortcutKeys && isHovered) {
@@ -91,10 +80,11 @@ export function SidebarHeaderRow({
aria-selected={isActive} aria-selected={isActive}
style={styles.button} style={styles.button}
> >
{ThemedIcon ? ( {icon ? (
<ThemedIcon <DensityIcon
icon={icon}
size={iconSize} size={iconSize}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping} color={isHighlighted ? "foreground" : "foregroundMuted"}
/> />
) : ( ) : (
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} /> <View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
@@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center", alignItems: "center",
// Same row geometry as the settings sidebar items. Shorter than the header // Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator. // strip so the hover highlight clears the strip's bottom separator.
minHeight: 28, minHeight: density.tightRowHeight,
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}, },
rowHighlighted: { rowHighlighted: {
@@ -152,17 +142,17 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[2], gap: theme.spacing[2],
minHeight: 28, minHeight: density.tightRowHeight,
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
// Match the project rows' inner padding so the icons align on one vertical // Match the project rows' inner padding so the icons align on one vertical
// edge with the list below. // edge with the list below.
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
}, },
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md }, iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm }, iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
label: { label: {
flexShrink: 1, flexShrink: 1,
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}, },
@@ -14,14 +14,14 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { buttonControlHeight } from "@/components/ui/control-geometry"; import { DensityIcon } from "@/components/ui/density-icon";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability"; import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme"; import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection"; import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; 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 DISCORD_URL = "https://discord.gg/jz8T2uahpH";
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new"; const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const ThemedActivity = withUnistyles(Activity); const ThemedActivity = withUnistyles(Activity);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedGift = withUnistyles(Gift); const ThemedGift = withUnistyles(Gift);
const ThemedKeyboard = withUnistyles(Keyboard); const ThemedKeyboard = withUnistyles(Keyboard);
const ThemedDiscordIcon = withUnistyles(DiscordIcon); const ThemedDiscordIcon = withUnistyles(DiscordIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon); const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); });
@@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) {
} }
export function SidebarHelpMenu() { export function SidebarHelpMenu() {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation(); const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable(); const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -106,15 +103,16 @@ export function SidebarHelpMenu() {
<TooltipTrigger asChild> <TooltipTrigger asChild>
<View> <View>
<DropdownMenuTrigger <DropdownMenuTrigger
style={styles.trigger(isCompact)} style={styles.trigger}
testID="sidebar-help" testID="sidebar-help"
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")} accessibilityLabel={t("sidebar.help.trigger")}
> >
{({ hovered }) => ( {({ hovered }) => (
<ThemedCircleHelp <DensityIcon
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md} icon={CircleHelp}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping} size="md"
color={hovered ? "foreground" : "foregroundMuted"}
/> />
)} )}
</DropdownMenuTrigger> </DropdownMenuTrigger>
@@ -184,14 +182,14 @@ export function SidebarHelpMenu() {
} }
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
trigger: (isCompact: boolean) => ({ trigger: {
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, width: density.iconButton,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs, height: density.iconButton,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1], paddingHorizontal: theme.spacing[1],
}), },
tooltipText: { tooltipText: {
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground, color: theme.colors.popoverForeground,
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform"; import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles"; 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 type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles"; import { withUnistyles } from "react-native-unistyles";
import { import {
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction"; import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers // Themed icon wrappers
const foregroundMutedColorMapping = (theme: Theme) => ({ const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
}); }));
// One mapping per bucket, resolved through the status-dot producer so a group header and // 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. // 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, color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
}); }));
const failedColorMapping = (theme: Theme) => ({ const failedGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined, color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
}); }));
const attentionColorMapping = (theme: Theme) => ({ const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined, color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
}); }));
const runningColorMapping = (theme: Theme) => ({ const runningGroupIconProps = densityIconProps("sm", (theme) => ({
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined, color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
}); }));
const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({
return <StatusGroupIcon bucket={leading.bucket} />; return <StatusGroupIcon bucket={leading.bucket} />;
} }
if (collapsed) { if (collapsed) {
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />; return <ThemedChevronRight uniProps={mutedGroupIconProps} />;
} }
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />; return <ThemedChevronDown uniProps={mutedGroupIconProps} />;
} }
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) { function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
switch (bucket) { switch (bucket) {
case "needs_input": case "needs_input":
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />; return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
case "failed": case "failed":
return <ThemedCircleX size={14} uniProps={failedColorMapping} />; return <ThemedCircleX uniProps={failedGroupIconProps} />;
case "attention": case "attention":
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />; return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
case "running": case "running":
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />; return <ThemedCircleDot uniProps={runningGroupIconProps} />;
case "done": case "done":
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />; return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
} }
} }
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
statusWorkspaceListContainer: {}, statusWorkspaceListContainer: {},
statusGroupRow: { statusGroupRow: {
minHeight: 36, minHeight: density.rowHeight,
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
@@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
statusGroupLeadingVisualSlot: { statusGroupLeadingVisualSlot: {
position: "relative", position: "relative",
width: theme.iconSize.md, width: densityIconBox("md"),
height: theme.iconSize.md, height: densityIconBox("md"),
flexShrink: 0, flexShrink: 0,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
}, },
statusGroupTitle: { statusGroupTitle: {
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
fontWeight: "400", fontWeight: "400",
minWidth: 0, minWidth: 0,
flexShrink: 1, flexShrink: 1,
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative", position: "relative",
}, },
workspaceRow: { workspaceRow: {
minHeight: 36, minHeight: density.rowHeight,
marginBottom: theme.spacing[0.5], marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2], paddingLeft: theme.spacing[2],
@@ -16,6 +16,7 @@ import {
type SidebarWorkspaceTrailing, type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing"; } from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings"; import { useAppSettings } from "@/hooks/use-settings";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color"; import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -27,19 +28,16 @@ import {
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader"; import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring"; import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title"; import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
import { DensityIcon } from "@/components/ui/density-icon";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim"; import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels"; import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({ const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0, color: theme.colors.surface0,
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined, fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
}); });
const ThemedCircleAlert = withUnistyles(CircleAlert); const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
export function SidebarWorkspaceRowFrame({ export function SidebarWorkspaceRowFrame({
workspace, workspace,
@@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({
) : null; ) : null;
} }
let KindIcon: typeof ThemedMonitor; let KindIcon: typeof Monitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor; if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2; else if (workspaceKind === "worktree") KindIcon = FolderGit2;
else KindIcon = ThemedFolder; else KindIcon = Folder;
const dotColorStyle = getStatusDotColorStyle(bucket); const dotColorStyle = getStatusDotColorStyle(bucket);
return ( return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}> <View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<KindIcon size={14} uniProps={foregroundMutedColorMapping} /> <DensityIcon icon={KindIcon} size="sm" color="foregroundMuted" />
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null} {dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View> </View>
); );
@@ -482,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({
}, },
workspaceStatusDot: { workspaceStatusDot: {
position: "relative", position: "relative",
width: theme.iconSize.md, width: densityIconBox("md"),
height: 20, height: density.rowLineHeight,
borderRadius: theme.borderRadius.full, borderRadius: theme.borderRadius.full,
flexShrink: 0, flexShrink: 0,
alignItems: "center", 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. // to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: { workspaceBranchText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.base, fontSize: densityFontSize(theme, "base"),
fontWeight: "400", fontWeight: "400",
lineHeight: 20, lineHeight: density.rowLineHeight,
opacity: 0.76, opacity: 0.76,
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
@@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import type { HostBadgeModel } from "@/hosts/appearance"; import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list"; import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop"; import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types"; import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut"; import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative", position: "relative",
}, },
workspaceRow: { workspaceRow: {
minHeight: 36, minHeight: density.rowHeight,
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2], paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2], paddingLeft: theme.spacing[2],
+55 -43
View File
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input"; import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout"; 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 Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Check, File, Folder, Search } from "lucide-react-native"; 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 { isWeb } from "@/constants/platform";
import { import {
IsolatedBottomSheetModal, IsolatedBottomSheetModal,
SheetVisibleFrame,
useIsolatedBottomSheetVisibility, useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal"; } from "./isolated-bottom-sheet-modal";
import { import {
@@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }], () => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor], [props.titleColor],
); );
const { footer, safeAreaBottom } = props;
const frameStyle = useMemo( const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }], () => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
[props.safeAreaBottom], [footer, safeAreaBottom],
);
const footerStyle = useMemo(
() => [styles.footer, { paddingBottom: safeAreaBottom }],
[safeAreaBottom],
);
const footerView = useMemo(
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
[footer, footerStyle],
); );
const body = props.hasChildren ? ( const body = props.hasChildren ? (
@@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none" keyboardBlurBehavior="none"
presentation={props.presentation} presentation={props.presentation}
> >
<View style={frameStyle}> <SheetVisibleFrame footer={footerView}>
{props.header ? ( <View style={frameStyle}>
<SheetHeaderView header={props.header} onClose={props.onClose} /> {props.header ? (
) : ( <SheetHeaderView header={props.header} onClose={props.onClose} />
<> ) : (
<View style={styles.bottomSheetHeader}> <>
<Text key={props.titleColor} style={comboboxTitleStyle}> <View style={styles.bottomSheetHeader}>
{props.title} <Text key={props.titleColor} style={comboboxTitleStyle}>
</Text> {props.title}
</View> </Text>
{props.stickyHeader} </View>
{!props.hasChildren && props.searchable ? ( {props.stickyHeader}
<SearchInput {!props.hasChildren && props.searchable ? (
placeholder={props.searchPlaceholder} <SearchInput
onChangeText={props.setSearchQueryWithCallback} placeholder={props.searchPlaceholder}
onSubmitEditing={props.handleSubmitSearch} onChangeText={props.setSearchQueryWithCallback}
autoFocus={false} onSubmitEditing={props.handleSubmitSearch}
useBottomSheetInput autoFocus={false}
resetKey={props.searchResetKey} useBottomSheetInput
/> resetKey={props.searchResetKey}
) : null} />
</> ) : null}
)} </>
{props.hasChildren && !props.mobileChildrenScrollEnabled ? ( )}
body {props.hasChildren && !props.mobileChildrenScrollEnabled ? (
) : ( body
<BottomSheetScrollView ) : (
style={styles.mobileSheetBody} <BottomSheetScrollView
contentContainerStyle={[ style={styles.mobileSheetBody}
styles.comboboxScrollContent, contentContainerStyle={[
props.mobileChildrenContentContainerStyle, styles.comboboxScrollContent,
]} props.mobileChildrenContentContainerStyle,
keyboardShouldPersistTaps="handled" ]}
showsVerticalScrollIndicator={false} keyboardShouldPersistTaps="handled"
> showsVerticalScrollIndicator={false}
{body} >
</BottomSheetScrollView> {body}
)} </BottomSheetScrollView>
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null} )}
</View> </View>
</SheetVisibleFrame>
</IsolatedBottomSheetModal> </IsolatedBottomSheetModal>
); );
} }
@@ -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 <Icon size={size} color={color} strokeWidth={strokeWidth} />;
}
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 <ThemedIconGlyph icon={icon} strokeWidth={strokeWidth} uniProps={uniProps} />;
}
@@ -1,3 +1,4 @@
import { createContext, useCallback, useContext } from "react";
import type { StyleProp, ViewStyle } from "react-native"; import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry"; import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/
export type IconButtonChromeSize = "large" | "small"; export type IconButtonChromeSize = "large" | "small";
const SMALL_ICON_BUTTON_SIZE = 20; 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; const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
export interface IconButtonChromeState { export interface IconButtonChromeState {
@@ -27,20 +30,53 @@ interface IconButtonChromeOptions {
state?: IconButtonChromeState; state?: IconButtonChromeState;
compact?: boolean; compact?: boolean;
disabled?: boolean; disabled?: boolean;
/** A circular hitbox and highlight, for buttons inside a pill-shaped container. */
round?: boolean;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
} }
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<IconButtonChromeOptions, "state"> & { 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. */ /** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */
export function iconButtonChromeStyle({ export function iconButtonChromeStyle({
size, size,
state, state,
compact = false, compact = false,
disabled = false, disabled = false,
round = false,
style, style,
}: IconButtonChromeOptions): StyleProp<ViewStyle> { }: IconButtonChromeOptions): StyleProp<ViewStyle> {
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open; const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
return [ return [
resolveIconButtonFrame(size, compact), resolveIconButtonFrame(size, compact),
round ? styles.round : null,
style, style,
highlighted ? styles.highlighted : null, highlighted ? styles.highlighted : null,
disabled ? styles.disabled : null, disabled ? styles.disabled : null,
@@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
large: { large: {
width: { width: {
xs: 32, xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT, md: HEADER_CONTROL_HEIGHT,
}, },
height: { height: {
xs: 32, xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
md: HEADER_CONTROL_HEIGHT, md: HEADER_CONTROL_HEIGHT,
}, },
padding: 0, padding: 0,
@@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0, outlineWidth: 0,
outlineColor: "transparent", outlineColor: "transparent",
}, },
round: {
borderRadius: theme.borderRadius.full,
},
highlighted: { highlighted: {
backgroundColor: theme.colors.interactionHighlight, backgroundColor: theme.colors.interactionHighlight,
}, },
@@ -152,3 +152,5 @@ export function useIsolatedBottomSheetVisibility({
handleSheetDismiss, handleSheetDismiss,
}; };
} }
export { SheetVisibleFrame } from "./visible-frame";
@@ -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);
});
@@ -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),
);
}
@@ -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 (
<Animated.View style={[styles.frame, visibleStyle]}>
<View style={styles.body}>{children}</View>
{footer}
</Animated.View>
);
}
const styles = StyleSheet.create({
frame: { minHeight: 0, overflow: "hidden" },
body: { flex: 1, minHeight: 0 },
});
@@ -16,6 +16,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { import {
iconButtonChromeGlyphSize, iconButtonChromeGlyphSize,
iconButtonChromeStyle, iconButtonChromeStyle,
useIconButtonChromeStyle,
smallIconButtonChromeFrameSize, smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome"; } from "@/components/ui/icon-button-chrome";
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives"; import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
@@ -60,6 +61,8 @@ interface ToolbarButtonCommonProps extends Omit<
tooltip?: ReactNode; tooltip?: ReactNode;
selected?: boolean; selected?: boolean;
compact?: boolean; compact?: boolean;
/** A circular highlight, for buttons inside a pill-shaped container. */
round?: boolean;
shortcut?: ShortcutKey[][] | null; shortcut?: ShortcutKey[][] | null;
tooltipSide?: "left" | "right" | "top" | "bottom"; tooltipSide?: "left" | "right" | "top" | "bottom";
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
@@ -81,6 +84,7 @@ export function ToolbarButton({
tooltip: tooltipBody, tooltip: tooltipBody,
selected = false, selected = false,
compact = false, compact = false,
round = false,
shortcut, shortcut,
tooltipSide = "bottom", tooltipSide = "bottom",
style, style,
@@ -90,17 +94,14 @@ export function ToolbarButton({
testID, testID,
...props ...props
}: ToolbarButtonProps) { }: ToolbarButtonProps) {
const buttonStyle = useMemo( const buttonStyle = useIconButtonChromeStyle({
() => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) => size: "small",
iconButtonChromeStyle({ compact,
size: "small", active: selected,
compact, disabled: Boolean(disabled),
state: { ...state, active: selected }, round,
disabled: Boolean(disabled), style,
style, });
}),
[compact, disabled, selected, style],
);
const accessibilityState = useMemo( const accessibilityState = useMemo(
() => ({ disabled: Boolean(disabled), selected }), () => ({ disabled: Boolean(disabled), selected }),
[disabled, selected], [disabled, selected],
@@ -298,7 +298,6 @@ export function CompactModelSheet({
onClose={close} onClose={close}
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}
scrollable={false} scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
contentStyle={styles.sheetBody} contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet" testID="agent-controls-model-sheet"
> >
@@ -346,7 +345,6 @@ export function CompactModelSheet({
onClose={closeModelBrowser} onClose={closeModelBrowser}
snapPoints={SNAP_POINTS} snapPoints={SNAP_POINTS}
scrollable={false} scrollable={false}
sizeContentToCurrentSnapPoint
presentation="push" presentation="push"
contentStyle={styles.sheetBody} contentStyle={styles.sheetBody}
testID="agent-controls-model-browser-sheet" testID="agent-controls-model-browser-sheet"
+6 -1
View File
@@ -33,13 +33,18 @@ export {
getIsElectronMac as getIsElectronRuntimeMac, getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform"; } 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. * Reactive hook — re-renders the component when the breakpoint changes.
* Always use this instead of reading UnistylesRuntime.breakpoint directly. * Always use this instead of reading UnistylesRuntime.breakpoint directly.
*/ */
export function useIsCompactFormFactor(): boolean { export function useIsCompactFormFactor(): boolean {
const { rt } = useUnistyles(); const { rt } = useUnistyles();
return rt.breakpoint === "xs" || rt.breakpoint === "sm"; return isCompactBreakpoint(rt.breakpoint);
} }
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers. // SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
+36 -26
View File
@@ -7,9 +7,11 @@ import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge"; import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button"; import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { CompactHeader } from "@/components/headers/compact-header";
import { ScreenHeader } from "@/components/headers/screen-header"; import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title"; import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter"; import { HostFilter } from "@/components/hosts/host-filter";
import type { HostPickerHost } from "@/components/hosts/host-picker";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
@@ -87,19 +89,14 @@ function PluginHostFilter({
pluginId, pluginId,
identity, identity,
params, params,
serverIds, hosts,
}: { }: {
serverId: string; serverId: string;
pluginId: string; pluginId: string;
identity: PluginSurfaceContributionIdentity; identity: PluginSurfaceContributionIdentity;
params: PluginScreenParams; params: PluginScreenParams;
serverIds: string[]; hosts: HostPickerHost[];
}) { }) {
const allHosts = useHosts();
const hosts = useMemo(
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
[allHosts, serverIds],
);
const selectHost = useCallback( const selectHost = useCallback(
(nextServerId: string) => { (nextServerId: string) => {
// A legacy row remembers its own host; a screen's host carries to all the plugin's items. // 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], [identity, params, pluginId],
); );
const show = serverIds.length > 1 && hosts.length > 1;
if (!show) return null;
return ( return (
<HostFilter <HostFilter
hosts={hosts} hosts={hosts}
@@ -155,11 +149,12 @@ export function PluginSurfaceScreen() {
[identity, plugin], [identity, plugin],
); );
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
const contributionServerIds = useMemo( // The hosts this contribution is installed on; the filter shows when there is a choice.
() => const filterHosts = useMemo(() => {
identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [], if (!identity) return [];
[identity, installations, pluginId], const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity);
); return hosts.filter((candidate) => serverIds.includes(candidate.serverId));
}, [hosts, identity, installations, pluginId]);
const title = useMemo( const title = useMemo(
() => () =>
surface surface
@@ -187,18 +182,23 @@ export function PluginSurfaceScreen() {
), ),
[Icon, title], [Icon, title],
); );
const hostFilter = useMemo(
() =>
identity && filterHosts.length > 1 ? (
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
hosts={filterHosts}
/>
) : null,
[filterHosts, identity, params, pluginId, serverId],
);
const headerRight = useMemo( const headerRight = useMemo(
() => ( () => (
<> <>
{identity ? ( {hostFilter}
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
serverIds={contributionServerIds}
/>
) : null}
<HeaderToggleButton <HeaderToggleButton
accessibilityLabel="Close plugin" accessibilityLabel="Close plugin"
onPress={close} onPress={close}
@@ -211,12 +211,22 @@ export function PluginSurfaceScreen() {
</HeaderToggleButton> </HeaderToggleButton>
</> </>
), ),
[close, contributionServerIds, identity, params, pluginId, serverId], [close, hostFilter],
); );
return ( return (
<View style={styles.screen}> <View style={styles.screen}>
<ScreenHeader left={headerLeft} right={headerRight} /> {compact ? (
<CompactHeader
navigation="back"
onBack={close}
title={title}
titleTestID="plugin-surface-title"
actions={hostFilter}
/>
) : (
<ScreenHeader left={headerLeft} right={headerRight} />
)}
<View style={styles.body}> <View style={styles.body}>
{plugin && surface && client ? ( {plugin && surface && client ? (
<SurfaceErrorBoundary <SurfaceErrorBoundary
@@ -31,8 +31,7 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { Shortcut } from "@/components/ui/shortcut"; import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region"; import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { SidebarMenuToggle } from "@/components/headers/menu-header"; import { MenuHeader } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { useIsCompactFormFactor } from "@/constants/layout"; import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context"; import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft"; import { useAgentInputDraft } from "@/composer/draft/input-draft";
@@ -2368,7 +2367,6 @@ export function NewWorkspaceScreen({
}, },
}); });
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
const importSession = useImportSession({ serverId: selectedServerId }); const importSession = useImportSession({ serverId: selectedServerId });
const composer = isTerminalLaunch ? ( const composer = isTerminalLaunch ? (
@@ -2431,7 +2429,7 @@ export function NewWorkspaceScreen({
); );
return ( return (
<FileDropZone style={styles.container}> <FileDropZone style={styles.container}>
<ScreenHeader left={screenHeaderLeft} borderless /> <MenuHeader borderless />
<View style={styles.content}> <View style={styles.content}>
<TitlebarDragRegion /> <TitlebarDragRegion />
<NewWorkspaceLayout <NewWorkspaceLayout
@@ -0,0 +1,72 @@
import { useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CompactHeader } from "@/components/headers/compact-header";
import {
EXPLORER_TOGGLE_KEYS,
WorkspaceExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row";
export { MobileWorkspaceTabSwitcher } from "./tab-switcher";
interface CompactWorkspaceHeaderProps {
serverId: string;
isLoading: boolean;
title: string;
subtitle: string;
isSubtitleDistinct: boolean;
onToggleExplorer: () => 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(
() => (
<WorkspaceHeaderProjectRow
subtitle={subtitle}
isSubtitleDistinct={isSubtitleDistinct}
serverId={serverId}
/>
),
[subtitle, isSubtitleDistinct, serverId],
);
const actions = (
<>
{children}
<WorkspaceExplorerToggle
onPress={onToggleExplorer}
label={explorerToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
accessibilityState={explorerToggleAccessibilityState}
mobile
/>
</>
);
return (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actions}
/>
);
}
@@ -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> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | 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> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) {
const { t } = useTranslation();
const tabMenuLabels = useMemo<WorkspaceTabMenuLabels>(
() => ({
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(
() => (
<MobileTabTrailingAccessory
menuTestIDBase={menuTestIDBase}
presentationLabel={fallbackLabel}
menuEntries={menuEntries}
/>
),
[menuTestIDBase, fallbackLabel, menuEntries],
);
const renderPresentation = useCallback(
(presentation: WorkspaceTabPresentation) => (
<WorkspaceTabOptionRow
presentation={presentation}
selected={selected}
active={active}
onPress={onPress}
trailingAccessory={trailingAccessory}
/>
),
[selected, active, onPress, trailingAccessory],
);
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{renderPresentation}
</WorkspaceTabPresentationResolver>
);
}
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<View>(null);
const tabIndexByKey = useMemo(() => {
const map = new Map<string, number>();
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(
() => (
<View style={styles.tabSwitcherFooter}>
<Button
testID="workspace-tab-switcher-new-agent"
style={styles.tabSwitcherFooterButton}
variant="secondary"
leftIcon={SquarePen}
onPress={handleNewAgent}
>
{t("workspace.header.actions.newAgent")}
</Button>
<Button
testID="workspace-tab-switcher-new-terminal"
style={styles.tabSwitcherFooterButton}
variant="secondary"
leftIcon={SquareTerminal}
disabled={createTerminalDisabled}
onPress={handleNewTerminal}
>
{t("workspace.header.actions.newTerminal")}
</Button>
</View>
),
[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 <View />;
}
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
if (tabIndex < 0) {
return <View />;
}
return (
<MobileWorkspaceTabOption
tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected}
active={active}
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
onCloseOtherTabs={onCloseOtherTabs}
/>
);
},
[
tabByKey,
tabIndexByKey,
tabs.length,
normalizedServerId,
normalizedWorkspaceId,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
],
);
return (
<>
<Pressable
ref={anchorRef}
testID="workspace-tab-switcher-trigger"
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.switcher.trigger", { count: tabs.length })}
hitSlop={TAB_SWITCHER_TRIGGER_HIT_SLOP}
style={triggerStyle}
onPress={handleOpenSwitcher}
>
<View style={styles.tabCountBadge}>
<Text style={styles.tabCountText}>{tabs.length}</Text>
</View>
</Pressable>
<Combobox
options={tabSwitcherOptions}
value={activeTabKey}
onSelect={onSelectSwitcherTab}
searchable={false}
title={t("workspace.tabs.switcher.title")}
searchPlaceholder={t("workspace.tabs.switcher.searchPlaceholder")}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
renderOption={renderTabOption}
footer={footer}
/>
</>
);
});
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"],
},
}));
@@ -11,6 +11,8 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedPanelRight = withUnistyles(PanelRight); const ThemedPanelRight = withUnistyles(PanelRight);
export const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
interface WorkspaceExplorerToggleProps { interface WorkspaceExplorerToggleProps {
onPress: () => void; onPress: () => void;
label: string; label: string;
@@ -2,14 +2,7 @@ import React, { useCallback, useMemo } from "react";
import { View } from "react-native"; import { View } from "react-native";
import { useRouter, type Href } from "expo-router"; import { useRouter, type Href } from "expo-router";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { Copy, Ellipsis, Globe, Import as ImportIcon, Settings } from "lucide-react-native";
Copy,
Ellipsis,
Globe,
Import as ImportIcon,
Settings,
SquarePen,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages"; import type { TerminalProfile } from "@getpaseo/protocol/messages";
import { import {
@@ -24,6 +17,7 @@ import {
extraMutedIconColorMapping, extraMutedIconColorMapping,
iconButtonChromeGlyphSize, iconButtonChromeGlyphSize,
iconButtonChromeStyle, iconButtonChromeStyle,
useIconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome"; } from "@/components/ui/icon-button-chrome";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon"; import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { useDaemonConfig } from "@/hooks/use-daemon-config"; import { useDaemonConfig } from "@/hooks/use-daemon-config";
@@ -36,16 +30,13 @@ import type { Theme } from "@/styles/theme";
const ThemedEllipsis = withUnistyles(Ellipsis); const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedCopy = withUnistyles(Copy); const ThemedCopy = withUnistyles(Copy);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedGlobe = withUnistyles(Globe); const ThemedGlobe = withUnistyles(Globe);
const ThemedImport = withUnistyles(ImportIcon); const ThemedImport = withUnistyles(ImportIcon);
const ThemedSettings = withUnistyles(Settings); const ThemedSettings = withUnistyles(Settings);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />; const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <TerminalProfileIcon iconKey={undefined} size={16} />;
const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />; const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />; const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />; const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
@@ -203,28 +194,25 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace
normalizedServerId: string; normalizedServerId: string;
showCreateBrowserTab: boolean; showCreateBrowserTab: boolean;
createTerminalDisabled: boolean; createTerminalDisabled: boolean;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfile) => void; onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
onCreateBrowser: () => void; onCreateBrowser: () => void;
} }
/** /**
* Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace * Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer;
* actions. * the rarer launches stay here alongside the workspace actions.
*/ */
export function WorkspaceHeaderMenuMobile({ export function WorkspaceHeaderMenuMobile({
normalizedServerId, normalizedServerId,
showCreateBrowserTab, showCreateBrowserTab,
createTerminalDisabled, createTerminalDisabled,
onCreateDraftTab,
onCreateTerminal,
onCreateTerminalWithProfile, onCreateTerminalWithProfile,
onCreateBrowser, onCreateBrowser,
...workspaceActions ...workspaceActions
}: WorkspaceHeaderMenuMobileProps) { }: WorkspaceHeaderMenuMobileProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter(); const router = useRouter();
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
const { config } = useDaemonConfig(normalizedServerId); const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo( const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles), () => resolveTerminalProfiles(config?.terminalProfiles),
@@ -239,7 +227,7 @@ export function WorkspaceHeaderMenuMobile({
<DropdownMenu compactMode="sheet"> <DropdownMenu compactMode="sheet">
<DropdownMenuTrigger <DropdownMenuTrigger
testID="workspace-header-menu-trigger" testID="workspace-header-menu-trigger"
style={workspaceHeaderMenuButtonStyle} style={triggerStyle}
hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP} hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")} accessibilityLabel={t("workspace.header.actions.workspaceActions")}
@@ -252,13 +240,6 @@ export function WorkspaceHeaderMenuMobile({
testID="workspace-header-menu" testID="workspace-header-menu"
sheetTitle={t("workspace.header.actions.workspaceActions")} sheetTitle={t("workspace.header.actions.workspaceActions")}
> >
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={MENU_NEW_AGENT_ICON}
onSelect={onCreateDraftTab}
>
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
{showCreateBrowserTab ? ( {showCreateBrowserTab ? (
<DropdownMenuItem <DropdownMenuItem
testID="workspace-header-new-browser" testID="workspace-header-new-browser"
@@ -271,14 +252,6 @@ export function WorkspaceHeaderMenuMobile({
<WorkspaceHeaderWorkspaceActionItems {...workspaceActions} /> <WorkspaceHeaderWorkspaceActionItems {...workspaceActions} />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel> <DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={MENU_NEW_TERMINAL_ICON}
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{profiles.map((profile) => ( {profiles.map((profile) => (
<HeaderMenuProfileItem <HeaderMenuProfileItem
key={profile.id} key={profile.id}
@@ -0,0 +1,68 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HostBadge } from "@/hosts/host-badge";
import { useHostBadges } from "@/hosts/use-host-badges";
/**
* 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.
*/
export 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 (
<View style={styles.row}>
{showProject ? (
<Text testID="workspace-header-subtitle" style={styles.project} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{showProject && hostBadge ? <Text style={styles.separator}>·</Text> : null}
{hostBadge ? <HostBadge badge={hostBadge} /> : null}
</View>
);
}
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,
},
}));
File diff suppressed because it is too large Load Diff
+57
View File
@@ -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<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
const COMPACT_TEXT_SIZE: Record<TextSize, keyof Theme["fontSize"]> = { sm: "base", base: "lg" };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(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<P extends object>(
size: DensityIconSize,
mapping: (theme: Theme) => P,
) {
return (theme: Theme, rt: { breakpoint?: string }) => ({
...mapping(theme),
size: densityIconSize(rt, size),
});
}
+3 -3
View File
@@ -68,9 +68,9 @@ describe("Pure black theme", () => {
}); });
describe("Sidebar interaction surfaces", () => { describe("Sidebar interaction surfaces", () => {
it("keeps Light selection distinct from the sidebar surface", () => { it("keeps Light hover and selection darker than the sidebar surface", () => {
expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1); expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3);
expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3); expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4);
expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar); expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar);
}); });
+2 -2
View File
@@ -267,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
surface4: tint.surface4, surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty, surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar, surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surface1, surfaceSidebarHover: tint.surface3,
surfaceSidebarSelected: tint.surface3, surfaceSidebarSelected: tint.surface4,
surfaceWorkspace: tint.surface0, surfaceWorkspace: tint.surface0,
interactionHighlight: "rgba(0, 0, 0, 0.06)", interactionHighlight: "rgba(0, 0, 0, 0.06)",
+16 -5
View File
@@ -31,7 +31,7 @@ export function UsageControls({
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing); const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
return ( return (
<View style={styles.controls}> <View style={styles.controls}>
{hostSelection && hostSelection.hosts.length > 1 ? ( {hasHostChoice(hostSelection) ? (
<HostFilter <HostFilter
hosts={hostSelection.hosts} hosts={hostSelection.hosts}
selectedHost={hostSelection.serverId} selectedHost={hostSelection.serverId}
@@ -51,6 +51,12 @@ export function UsageControls({
); );
} }
function hasHostChoice(
hostSelection: UsageHostSelection | undefined,
): hostSelection is UsageHostSelection {
return hostSelection !== undefined && hostSelection.hosts.length > 1;
}
function usageHostOptionTestID(serverId: string): string { function usageHostOptionTestID(serverId: string): string {
return `usage-host-filter-item-${serverId}`; 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): { export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView; view: UsageView;
refresh: () => void; refresh: () => void;
controls: ReactElement; controls: ReactElement | null;
} { } {
const { view, refresh } = useHostUsage(hostSelection.serverId); const { view, refresh } = useHostUsage(hostSelection.serverId);
const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable";
const controls = useMemo( const controls = useMemo(
() => <UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />, () =>
[hostSelection, refresh, view], hasControls ? (
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
) : null,
[hasControls, hostSelection, refresh, view],
); );
return { view, refresh, controls }; return { view, refresh, controls };
} }
+4 -1
View File
@@ -9,7 +9,10 @@ import { usageCopy } from "./copy";
const ThemedRotateCw = withUnistyles(RotateCw); const ThemedRotateCw = withUnistyles(RotateCw);
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); 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 }) { export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const compact = useIsCompactFormFactor(); const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact); const iconSize = paneContentToolbarIconSize(compact);
@@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({
onClose={onClose} onClose={onClose}
header={header} header={header}
footer={footer} 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" testID="workspace-label-manager"
> >
{draft && editing ? ( {draft && editing ? (