mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
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:
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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],
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
+58
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user