mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +00:00
Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e172d18c4 | ||
|
|
b3693b79d1 | ||
|
|
0458988825 | ||
|
|
4bd31e1a2e | ||
|
|
1939ee79ed | ||
|
|
8d9e7d3ad3 | ||
|
|
114389383d | ||
|
|
804335bc00 | ||
|
|
e2fd5a9c57 | ||
|
|
b412374f0c | ||
|
|
a3f75aa9e4 | ||
|
|
46b1284177 | ||
|
|
a5dba3c992 | ||
|
|
dd0350bc2b | ||
|
|
cfb18f16d4 | ||
|
|
7f9b65d464 | ||
|
|
7e66657bf0 | ||
|
|
446749102e | ||
|
|
e61009d5fb | ||
|
|
ee0d901efb | ||
|
|
d5513472ed | ||
|
|
1228c01a10 | ||
|
|
563c014dc1 | ||
|
|
01f9bc8291 | ||
|
|
d0728cd1ed | ||
|
|
0ba4d2526e | ||
|
|
7868f5caf6 | ||
|
|
0147edaaac | ||
|
|
bd78e5efa4 | ||
|
|
e81d5d02b1 |
@@ -252,7 +252,6 @@
|
||||
"packages/app/src/components/download-toast.tsx",
|
||||
"packages/app/src/components/draggable-list.native.tsx",
|
||||
"packages/app/src/components/headers/back-header.tsx",
|
||||
"packages/app/src/components/headers/menu-header.tsx",
|
||||
"packages/app/src/components/headers/screen-header.tsx",
|
||||
"packages/app/src/components/host-status-dot.tsx",
|
||||
"packages/app/src/components/hosts/host-picker.tsx",
|
||||
|
||||
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
||||
await row.click();
|
||||
|
||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
|
||||
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||
await page.screenshot({
|
||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||
fullPage: true,
|
||||
|
||||
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
|
||||
|
||||
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
|
||||
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||
expect(toggleTestId).not.toBeNull();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test, type Page } from "../support/fixtures";
|
||||
import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
fillComposerDraft,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
|
||||
// The popover has no semantic role; its rows are React Native Pressables.
|
||||
function fileSuggestions(page: Page) {
|
||||
return page.getByTestId("composer-autocomplete-popover");
|
||||
}
|
||||
|
||||
async function selectReadmeMention(page: Page): Promise<void> {
|
||||
await fillComposerDraft(page, "@README");
|
||||
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
|
||||
await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, '"README.md"');
|
||||
}
|
||||
|
||||
async function sendEmailIntact(page: Page, email: string): Promise<void> {
|
||||
await fillComposerDraft(page, email);
|
||||
await expect(fileSuggestions(page)).not.toBeVisible();
|
||||
await composerLocator(page).press("Enter");
|
||||
await expectComposerDraft(page, "");
|
||||
await expect(page.getByText(email, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
for (const email of [
|
||||
"user <12345+user@noreply.example.com>",
|
||||
"alice@example.com",
|
||||
"noreply+@example.com",
|
||||
]) {
|
||||
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
|
||||
const agent = await seedMockAgentWorkspace({
|
||||
repoPrefix: "composer-email-",
|
||||
title: "Email mention regression",
|
||||
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, agent);
|
||||
await expectComposerVisible(page);
|
||||
await test.step("complete a workspace file mention with Enter", async () => {
|
||||
await selectReadmeMention(page);
|
||||
});
|
||||
await test.step("send an email without offering or inserting workspace files", async () => {
|
||||
await sendEmailIntact(page, email);
|
||||
});
|
||||
} finally {
|
||||
await agent.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -96,6 +96,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
||||
}
|
||||
|
||||
test.describe("CodeMirror workspace file editing", () => {
|
||||
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "encoded-markdown-link-",
|
||||
title: "Non-ASCII file link",
|
||||
initialPrompt: "Show the report link",
|
||||
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
|
||||
});
|
||||
try {
|
||||
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
|
||||
await openAgentRoute(page, session);
|
||||
await openAssistantMarkdownLink(page, "报告");
|
||||
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText("Report opened", { exact: true }),
|
||||
).toBeVisible();
|
||||
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const order of ["raw-first", "href-first"] as const) {
|
||||
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "percent-link-cache-",
|
||||
title: "Distinct file links",
|
||||
initialPrompt: "Show both filenames",
|
||||
featureValues: {
|
||||
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
|
||||
},
|
||||
});
|
||||
try {
|
||||
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
|
||||
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
|
||||
await openAgentRoute(page, session);
|
||||
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
|
||||
for (const link of links) {
|
||||
await returnToAssistant(page, session.agentId);
|
||||
if (link === "raw") {
|
||||
await openAssistantInlinePath(page, "literal%20.md");
|
||||
await expectFileTabOpen(page, "literal%20.md");
|
||||
await expectPreviewText(page, "Literal filename");
|
||||
} else {
|
||||
await openAssistantMarkdownLink(page, "Decoded");
|
||||
await expectFileTabOpen(page, "literal .md");
|
||||
await expectPreviewText(page, "Decoded filename");
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "relative-reference-qa-",
|
||||
@@ -757,3 +815,24 @@ test.describe("CodeMirror workspace file editing", () => {
|
||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
|
||||
await page.getByRole("link", { name, exact: true }).first().click();
|
||||
}
|
||||
|
||||
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
|
||||
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
|
||||
}
|
||||
|
||||
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
|
||||
await page.getByText(token, { exact: true }).click();
|
||||
}
|
||||
|
||||
async function expectPreviewText(page: Page, text: string): Promise<void> {
|
||||
await expect(
|
||||
page
|
||||
.getByTestId("workspace-file-pane")
|
||||
.filter({ visible: true })
|
||||
.getByText(text, { exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { Page, TestInfo } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { test, expect } from "../support/fixtures";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
||||
|
||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
||||
|
||||
test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
|
||||
|
||||
async function openTabSwitcher(page: Page) {
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
const footer = page.getByRole("button", { name: "New terminal", exact: true });
|
||||
await expect(footer).toBeVisible();
|
||||
// Visibility alone passes while the sheet is still travelling up from below the viewport.
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await footer.boundingBox();
|
||||
return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
|
||||
})
|
||||
.toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
|
||||
}
|
||||
|
||||
async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
|
||||
await page.screenshot({ path: testInfo.outputPath(name + ".png") });
|
||||
}
|
||||
|
||||
async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
|
||||
await openTabSwitcher(page);
|
||||
await page.getByRole("button", { name: kind, exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||
const result = await client.fetchAgents({ scope: "active" });
|
||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
|
||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||
page,
|
||||
}) => {
|
||||
}, testInfo) => {
|
||||
test.setTimeout(120_000);
|
||||
|
||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await captureScreen(page, testInfo, "compact-chat-header");
|
||||
|
||||
// Open the sessions dropdown (a bottom sheet on compact).
|
||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||
{
|
||||
timeout: 10_000,
|
||||
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||
await renameModalSubmit(page, modalPrefix).click();
|
||||
|
||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
||||
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
|
||||
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
|
||||
await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
|
||||
await captureScreen(page, testInfo, "renamed-tab-in-switcher");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("switcher footer creates tabs and returns to the existing chat", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "compact-header-actions-",
|
||||
title: "Existing chat",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
|
||||
|
||||
await createTabFromSwitcher(page, "New terminal");
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
|
||||
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
|
||||
await captureScreen(page, testInfo, "compact-terminal-header");
|
||||
|
||||
await createTabFromSwitcher(page, "New agent");
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
|
||||
await expect(
|
||||
page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
|
||||
).toHaveCount(1);
|
||||
await captureScreen(page, testInfo, "compact-draft-header");
|
||||
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
|
||||
await captureScreen(page, testInfo, "switcher-three-tabs");
|
||||
await page.getByText("Existing chat", { exact: true }).click();
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
|
||||
|
||||
await openTabSwitcher(page);
|
||||
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
|
||||
await page
|
||||
.getByRole("button", { name: "Bottom sheet backdrop" })
|
||||
.click({ position: { x: 10, y: 10 } });
|
||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||
await captureScreen(page, testInfo, "returned-chat-header");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
|
||||
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-surface\"" 10000
|
||||
press "id=\"terminal-surface\""
|
||||
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
|
||||
|
||||
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
|
||||
env APP_ID=sh.paseo.debug
|
||||
|
||||
open "${APP_ID}"
|
||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
||||
press "id=\"workspace-header-menu-trigger\""
|
||||
press "id=\"workspace-header-new-terminal\""
|
||||
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||
press "id=\"workspace-tab-switcher-trigger\""
|
||||
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||
press "id=\"terminal-keyboard-toggle\""
|
||||
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
|
||||
|
||||
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
|
||||
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||
run_host_scenario chat
|
||||
ad keyboard dismiss || true
|
||||
ad press 'id="workspace-header-menu-trigger"' --settle
|
||||
ad wait 'id="workspace-header-new-agent"' 10000
|
||||
ad press 'id="workspace-header-new-agent"' --settle
|
||||
ad press 'id="workspace-tab-switcher-trigger"' --settle
|
||||
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
|
||||
ad press 'id="workspace-tab-switcher-new-agent"' --settle
|
||||
ad wait 'text="New Agent"' 10000
|
||||
run_host_scenario workspace-draft
|
||||
ad keyboard dismiss || true
|
||||
|
||||
@@ -30,7 +30,6 @@ async function capture(
|
||||
name: string,
|
||||
subject: Locator = headerButtons(page),
|
||||
) {
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await expect(subject).toBeInViewport();
|
||||
await waitForSettledPosition(subject);
|
||||
const file = info.outputPath(`${name}.png`);
|
||||
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
|
||||
refreshFromHeaderAndComposer: () =>
|
||||
test.step("icon-only header action and labeled composer action update in place", async () => {
|
||||
await exampleCommand(page, "action");
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await headerButtons(page).click();
|
||||
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
|
||||
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
|
||||
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
|
||||
).toContainText("Refreshed · 2");
|
||||
await capture(page, info, "01-actions");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await capture(page, info, "02-compact-actions");
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await capture(
|
||||
page,
|
||||
info,
|
||||
"02-compact-actions",
|
||||
page.getByRole("button", { name: "Refresh context", exact: true }),
|
||||
);
|
||||
}),
|
||||
useMenusAndToggleButtons: () =>
|
||||
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
|
||||
@@ -128,7 +134,8 @@ export async function withButtonAuthorExample(
|
||||
await capture(page, info, "05-disabled");
|
||||
await exampleCommand(page, "enable");
|
||||
await page.setViewportSize(COMPACT);
|
||||
await headerButtons(page).click();
|
||||
await expect(headerButtons(page)).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||
).toBeInViewport();
|
||||
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
|
||||
inspectLiveWorkspaceDetails: () =>
|
||||
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
|
||||
await exampleCommand(page, "popover");
|
||||
await expect(headerButtons(page)).toHaveCount(1);
|
||||
await headerButtons(page).click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||
|
||||
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
|
||||
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
|
||||
}
|
||||
|
||||
async function holdButton(page: Page, button: Locator) {
|
||||
await button.hover();
|
||||
await page.mouse.down();
|
||||
}
|
||||
|
||||
async function openCompactOverflowWithHeaderChrome(page: Page) {
|
||||
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
|
||||
const action = page.getByRole("button", { name: "Deploy application", exact: true });
|
||||
const overflow = page
|
||||
.getByRole("toolbar", { name: "Workspace actions", exact: true })
|
||||
.getByRole("button", { name: "More actions", exact: true });
|
||||
await expect(action).toHaveCSS("border-top-width", "0px");
|
||||
await expect(overflow).toHaveCSS("border-top-width", "0px");
|
||||
await hamburger.hover();
|
||||
const highlight = await hamburger.evaluate(
|
||||
(element) => getComputedStyle(element).backgroundColor,
|
||||
);
|
||||
await action.hover();
|
||||
await expect(action).toHaveCSS("background-color", highlight);
|
||||
await holdButton(page, overflow);
|
||||
await expect(overflow).toHaveCSS("background-color", highlight);
|
||||
await expectSurfaceVisible(page);
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
export async function withButtonShowcase(
|
||||
page: Page,
|
||||
run: (buttons: {
|
||||
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
|
||||
);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
|
||||
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
|
||||
"Button showcase with a long workspace title",
|
||||
);
|
||||
await expectHeaderTextTruncated(page);
|
||||
await expectSurfaceVisible(page);
|
||||
await openCompactOverflowWithHeaderChrome(page);
|
||||
await expect(
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
).toBeDisabled();
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
||||
);
|
||||
await choose(page, "Header checks");
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Deployment details");
|
||||
await expectDetails(page);
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("button", { name: "Close deployment details", exact: true }),
|
||||
);
|
||||
await press(page, "Close deployment details");
|
||||
await showcase.setTitle("Compact checks");
|
||||
await press(page, "Header checks");
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectSurfaceVisible(
|
||||
page,
|
||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
||||
);
|
||||
await choose(page, "Run checks");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }),
|
||||
).toBeInViewport();
|
||||
await showcase.setTitle("Button showcase");
|
||||
await press(page, "Composer status");
|
||||
await expectDetails(page);
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
type HistoryStartPaginationInput,
|
||||
type HistoryStartPaginationTransition,
|
||||
} from "./history-start-pagination";
|
||||
import { useStreamTopOverlayInset } from "./top-overlay";
|
||||
import {
|
||||
createHistoryStartSettleScheduler,
|
||||
type HistoryStartSettleScheduler,
|
||||
@@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||
? undefined
|
||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
||||
const topOverlayInset = useStreamTopOverlayInset();
|
||||
// The list is inverted, so its content's bottom padding is the visual top.
|
||||
const listContentContainerStyle = useMemo(
|
||||
() => [
|
||||
baseListContentContainerStyle,
|
||||
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
|
||||
{
|
||||
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
|
||||
},
|
||||
],
|
||||
[
|
||||
baseListContentContainerStyle,
|
||||
streamKeyboardInset.contentContainerPaddingBottom,
|
||||
topOverlayInset,
|
||||
],
|
||||
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
|
||||
);
|
||||
const listInsetProps = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -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 { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||
import { LeftSidebar } from "@/components/left-sidebar";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
|
||||
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||
|
||||
@@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => {
|
||||
}
|
||||
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
|
||||
});
|
||||
|
||||
it("decodes percent-encoded relative markdown hrefs", () => {
|
||||
// markdown-it percent-encodes non-ASCII characters in hrefs, so a link
|
||||
// written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
|
||||
expect(
|
||||
parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
|
||||
path: "/Users/test/project/docs/reports/开户赠金.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded spaces in relative markdown hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("docs/my%20file.md#L5", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/my%20file.md#L5",
|
||||
path: "/Users/test/project/docs/my file.md",
|
||||
lineStart: 5,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded absolute hrefs with line suffixes", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: 3,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded home-relative hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "~/notes/%E5%BC%80%E6%88%B7.md",
|
||||
path: "~/notes/开户.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes percent-encoded Windows hrefs", () => {
|
||||
expect(
|
||||
parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
|
||||
workspaceRoot: "C:/repo",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "C:/repo/my%20dir/app.tsx#L8",
|
||||
path: "C:/repo/my dir/app.tsx",
|
||||
lineStart: 8,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps literal percent signs in raw paths that are not hrefs", () => {
|
||||
// Inline-code and linkified tokens are raw file paths: a real file named
|
||||
// "my%20file.md" must not be decoded into "my file.md".
|
||||
expect(
|
||||
parseAssistantFileLink("docs/my%20file.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my%20file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/my%20file.md:3", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/my%20file.md:3",
|
||||
path: "/tmp/my%20file.md",
|
||||
lineStart: 3,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-ASCII characters in raw absolute paths", () => {
|
||||
// `new URL` percent-encodes non-ASCII, so raw paths must not be routed
|
||||
// through URL parsing — `/tmp/开户.md` must round-trip unchanged.
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/开户.md", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/开户.md",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
expect(
|
||||
parseAssistantFileLink("/tmp/开户.md#L4", {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
}),
|
||||
).toEqual({
|
||||
raw: "/tmp/开户.md#L4",
|
||||
path: "/tmp/开户.md",
|
||||
lineStart: 4,
|
||||
lineEnd: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeInlinePathTarget", () => {
|
||||
@@ -469,3 +591,23 @@ describe("normalizeInlinePathTarget", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("encoded filename line markers", () => {
|
||||
it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
|
||||
"keeps encoded line syntax in the filename %s",
|
||||
(href) => {
|
||||
expect(
|
||||
parseAssistantFileLink(href, {
|
||||
workspaceRoot: "/workspace",
|
||||
decodeHref: true,
|
||||
}),
|
||||
).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
|
||||
expect(
|
||||
parseAssistantFileLink(href, {
|
||||
workspaceRoot: "/workspace",
|
||||
decodeHref: true,
|
||||
})?.lineStart,
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
|
||||
|
||||
export interface AssistantHrefParseOptions {
|
||||
workspaceRoot?: string;
|
||||
/**
|
||||
* True only when `value` is a markdown href — the renderer percent-encodes
|
||||
* non-ASCII and space characters, so the path must be decoded. Raw path
|
||||
* text (inline code, linkified tokens) is not encoded; decoding it would
|
||||
* send a real file named `my%20file.md` to `my file.md`.
|
||||
*/
|
||||
decodeHref?: boolean;
|
||||
}
|
||||
|
||||
export type AssistantFileLinkClassification =
|
||||
@@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
||||
};
|
||||
}
|
||||
|
||||
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
|
||||
function parseAssistantInlinePathLink(
|
||||
value: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): InlinePathTarget | null {
|
||||
const inlinePathTarget = parseInlinePathToken(value);
|
||||
if (!inlinePathTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(inlinePathTarget.path);
|
||||
let pathPart = inlinePathTarget.path;
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
@@ -277,7 +291,7 @@ export function classifyAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
|
||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||
return {
|
||||
kind: "ambiguousFileCandidate",
|
||||
target,
|
||||
@@ -308,32 +322,19 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
|
||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
|
||||
if (inlinePathTarget) {
|
||||
return inlinePathTarget;
|
||||
}
|
||||
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (windowsPathMatch) {
|
||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw: value,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
|
||||
if (windowsTarget) {
|
||||
return windowsTarget;
|
||||
}
|
||||
|
||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
decodeHref: options.decodeHref,
|
||||
});
|
||||
if (relativeTarget) {
|
||||
return relativeTarget;
|
||||
@@ -343,19 +344,33 @@ export function parseAssistantFileLink(
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||
} catch {
|
||||
return null;
|
||||
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
|
||||
// normalize before decode. Raw paths must not: `new URL` percent-encodes
|
||||
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
|
||||
// still-encoded name.
|
||||
let pathPart: string;
|
||||
let hashPart: string;
|
||||
if (options.decodeHref) {
|
||||
let parsedUrl: URL;
|
||||
try {
|
||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
|
||||
hashPart = parsedUrl.hash;
|
||||
} else {
|
||||
const hashIndex = trimmed.indexOf("#");
|
||||
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
|
||||
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
|
||||
}
|
||||
|
||||
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(parsedUrl.hash);
|
||||
const lines = parseLineFragment(hashPart);
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
@@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean {
|
||||
return isPlausibleAssistantLocalPath(path);
|
||||
}
|
||||
|
||||
function parseWindowsFileLink(
|
||||
trimmed: string,
|
||||
raw: string,
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): InlinePathTarget | null {
|
||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
||||
if (!windowsPathMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let pathPart = windowsPathMatch[1] ?? "";
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
const normalizedPath = normalizePathToken(pathPart);
|
||||
if (!normalizedPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
||||
if (!lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
path: normalizedPath,
|
||||
...lines,
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkspaceRelativeFileLink(
|
||||
value: string,
|
||||
options: AssistantHrefParseOptions,
|
||||
): InlinePathTarget | null {
|
||||
const parsed = parseLocalPathParts(value);
|
||||
const parsed = parseLocalPathParts(value, options);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return null;
|
||||
}
|
||||
@@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink(
|
||||
|
||||
function parseLocalPathParts(
|
||||
value: string,
|
||||
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
|
||||
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||
): {
|
||||
path: string;
|
||||
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
||||
} | null {
|
||||
const normalized = normalizePathToken(value);
|
||||
if (!normalized || normalized.includes("?")) {
|
||||
return null;
|
||||
@@ -436,14 +486,19 @@ function parseLocalPathParts(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Split literal line markers before decoding; encoded punctuation belongs
|
||||
// to the filename. Raw inline-code/path text stays untouched.
|
||||
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||
if (inlinePathTarget) {
|
||||
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
|
||||
let pathPart = inlinePathTarget.path;
|
||||
if (options.decodeHref) {
|
||||
pathPart = safeDecodeURIComponent(pathPart);
|
||||
}
|
||||
if (!isPlausibleAssistantLocalPath(pathPart)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: inlinePathTarget.path,
|
||||
path: pathPart,
|
||||
lines: {
|
||||
lineStart: inlinePathTarget.lineStart,
|
||||
lineEnd: inlinePathTarget.lineEnd,
|
||||
@@ -455,12 +510,17 @@ function parseLocalPathParts(
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isPlausibleAssistantLocalPath(beforeHash)) {
|
||||
let decodedBeforeHash = beforeHash;
|
||||
if (options.decodeHref) {
|
||||
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
||||
}
|
||||
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
||||
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
path: beforeHash,
|
||||
path: decodedBeforeHash,
|
||||
lines: fragmentLines,
|
||||
};
|
||||
}
|
||||
@@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean {
|
||||
function isAmbiguousWorkspaceCandidate(
|
||||
value: string,
|
||||
target: InlinePathTarget,
|
||||
workspaceRoot?: string,
|
||||
options: AssistantHrefParseOptions = {},
|
||||
): boolean {
|
||||
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
||||
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = parseLocalPathParts(value);
|
||||
const parsed = parseLocalPathParts(value, options);
|
||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import {
|
||||
classifyForResolution,
|
||||
fetchDaemonResolution,
|
||||
@@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = {
|
||||
workspaceRoot: "/Users/test/project",
|
||||
};
|
||||
|
||||
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
|
||||
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
|
||||
try {
|
||||
await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
|
||||
await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
|
||||
const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
|
||||
const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
|
||||
expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
|
||||
const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
|
||||
expect(resolution.kind).toBe("resolved");
|
||||
if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
|
||||
throw new Error("Expected a directly resolved file link");
|
||||
}
|
||||
expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
|
||||
} finally {
|
||||
await rm(workspaceRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function resolvedSuggestions(
|
||||
entries: DirectorySuggestionResult["entries"],
|
||||
): DirectorySuggestionResult {
|
||||
@@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
|
||||
};
|
||||
|
||||
describe("classifyForResolution", () => {
|
||||
it.each([
|
||||
["C:/repo/my%20file.md", undefined],
|
||||
["C:\\repo\\my%20file.md", undefined],
|
||||
["C:/repo/my%20file.md:12", 12],
|
||||
["C:\\repo\\my%20file.md:12", 12],
|
||||
])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
|
||||
for (const text of [href, undefined]) {
|
||||
const result = classifyForResolution(
|
||||
{ href, text, sourceType: "inline-code" },
|
||||
{ workspaceRoot: "C:/repo" },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
|
||||
});
|
||||
}
|
||||
expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
|
||||
kind: "resolved",
|
||||
value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
|
||||
});
|
||||
});
|
||||
it("returns the directFile target synchronously", () => {
|
||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||
|
||||
@@ -165,6 +209,46 @@ describe("classifyForResolution", () => {
|
||||
|
||||
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
||||
});
|
||||
|
||||
it("decodes percent-encoded markdown hrefs", () => {
|
||||
const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "resolved",
|
||||
value: {
|
||||
kind: "file",
|
||||
target: {
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps literal percent signs in inline-code paths", () => {
|
||||
const result = classifyForResolution(
|
||||
{
|
||||
href: "docs/my%20file.md",
|
||||
text: "docs/my%20file.md",
|
||||
sourceType: "inline-code",
|
||||
},
|
||||
CONTEXT,
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
kind: "needsLookup",
|
||||
ambiguousQuery: "docs/my%20file.md",
|
||||
token: "docs/my%20file.md",
|
||||
target: {
|
||||
raw: "docs/my%20file.md",
|
||||
path: "/Users/test/project/docs/my%20file.md",
|
||||
lineStart: undefined,
|
||||
lineEnd: undefined,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetchDaemonResolution", () => {
|
||||
|
||||
@@ -108,13 +108,15 @@ export function classifyForResolution(
|
||||
source: AssistantFileLinkSource,
|
||||
context: AssistantFileLinkContext,
|
||||
): AssistantFileLinkResolution {
|
||||
const token = getAssistantFileLinkToken(source).trim();
|
||||
const tokenInfo = getAssistantFileLinkTokenInfo(source);
|
||||
const token = tokenInfo.token.trim();
|
||||
if (!token) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
}
|
||||
|
||||
const classification = classifyAssistantFileLink(token, {
|
||||
workspaceRoot: context.workspaceRoot,
|
||||
decodeHref: tokenInfo.fromHref,
|
||||
});
|
||||
if (!classification) {
|
||||
return { kind: "resolved", value: { kind: "ignored" } };
|
||||
@@ -148,14 +150,26 @@ export function classifyForResolution(
|
||||
}
|
||||
|
||||
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
|
||||
return getAssistantFileLinkTokenInfo(source).token;
|
||||
}
|
||||
|
||||
// Markdown link hrefs are percent-encoded by the renderer; inline-code and
|
||||
// linkified text are raw file paths that must not be decoded.
|
||||
function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
|
||||
token: string;
|
||||
fromHref: boolean;
|
||||
} {
|
||||
if (source.sourceType === "inline-code") {
|
||||
return { token: source.text?.trim() || source.href, fromHref: false };
|
||||
}
|
||||
if (isLinkifiedSource(source)) {
|
||||
const text = source.text?.trim();
|
||||
if (text && isFileLookingAssistantToken(text)) {
|
||||
return text;
|
||||
return { token: text, fromHref: false };
|
||||
}
|
||||
}
|
||||
|
||||
return source.href;
|
||||
return { token: source.href, fromHref: true };
|
||||
}
|
||||
|
||||
export function getAmbiguousSuggestionQuery(
|
||||
|
||||
@@ -29,14 +29,21 @@ export interface AssistantFileLinkActions {
|
||||
canResolveFile(source: AssistantFileLinkSource): boolean;
|
||||
}
|
||||
|
||||
interface AssistantFileLinkLookupKey {
|
||||
query: string;
|
||||
path: string;
|
||||
lineStart: number | null;
|
||||
lineEnd: number | null;
|
||||
}
|
||||
|
||||
type AssistantFileLinkQueryKey = readonly [
|
||||
"assistantFileLink",
|
||||
string | null,
|
||||
string | null,
|
||||
string,
|
||||
AssistantFileLinkLookupKey | null,
|
||||
];
|
||||
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
|
||||
|
||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||
const { t } = useTranslation();
|
||||
@@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
||||
? assistantFileLinkQueryKey({
|
||||
serverId,
|
||||
workspaceRoot,
|
||||
token: resolution.token,
|
||||
resolution,
|
||||
})
|
||||
: DISABLED_QUERY_KEY,
|
||||
[resolution, serverId, workspaceRoot],
|
||||
@@ -179,7 +186,7 @@ function openAssistantFileLink(input: {
|
||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||
serverId: capturedConfig.serverId,
|
||||
workspaceRoot: capturedConfig.workspaceRoot,
|
||||
token: capturedResolution.token,
|
||||
resolution: capturedResolution,
|
||||
});
|
||||
|
||||
const run = async () => {
|
||||
@@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
||||
function assistantFileLinkQueryKey(input: {
|
||||
serverId?: string;
|
||||
workspaceRoot?: string;
|
||||
token: string;
|
||||
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
|
||||
}): AssistantFileLinkQueryKey {
|
||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
||||
return [
|
||||
"assistantFileLink",
|
||||
input.serverId ?? null,
|
||||
input.workspaceRoot ?? null,
|
||||
{
|
||||
query: input.resolution.ambiguousQuery,
|
||||
path: input.resolution.target.path,
|
||||
lineStart: input.resolution.target.lineStart ?? null,
|
||||
lineEnd: input.resolution.target.lineEnd ?? null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function dispatchResolvedLink(input: {
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getBottomSheetVisibleContentHeight,
|
||||
getCompactSheetSafeAreaPadding,
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
|
||||
describe("getCompactSheetSafeAreaPadding", () => {
|
||||
it("assigns safe clearance to the footer independently of decorative padding", () => {
|
||||
@@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => {
|
||||
).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBottomSheetVisibleContentHeight", () => {
|
||||
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
|
||||
const layout = {
|
||||
containerHeight: 874,
|
||||
contentPosition: 88,
|
||||
handleHeight: 24,
|
||||
keyboardHeight: 344,
|
||||
};
|
||||
|
||||
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
|
||||
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,28 +10,6 @@ export interface CompactSheetSafeAreaPadding {
|
||||
footerPaddingBottom?: number;
|
||||
}
|
||||
|
||||
interface BottomSheetVisibleContentHeightInput {
|
||||
containerHeight: number;
|
||||
contentPosition: number;
|
||||
handleHeight: number;
|
||||
keyboardHeight: number;
|
||||
isKeyboardVisible: boolean;
|
||||
}
|
||||
|
||||
export function getBottomSheetVisibleContentHeight({
|
||||
containerHeight,
|
||||
contentPosition,
|
||||
handleHeight,
|
||||
keyboardHeight,
|
||||
isKeyboardVisible,
|
||||
}: BottomSheetVisibleContentHeightInput): number {
|
||||
"worklet";
|
||||
return Math.max(
|
||||
0,
|
||||
containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
|
||||
);
|
||||
}
|
||||
|
||||
export function getCompactSheetSafeAreaPadding({
|
||||
isCompact,
|
||||
isKeyboardVisible,
|
||||
|
||||
@@ -12,22 +12,16 @@ import {
|
||||
useGlobalWebOverlayLayer,
|
||||
useWebOverlayRegistration,
|
||||
} from "../lib/overlay-root";
|
||||
import {
|
||||
KEYBOARD_STATUS,
|
||||
useBottomSheetInternal,
|
||||
type BottomSheetBackgroundProps,
|
||||
} from "@gorhom/bottom-sheet";
|
||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated from "react-native-reanimated";
|
||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
SheetVisibleFrame,
|
||||
type ContextBridge,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||
import {
|
||||
getBottomSheetVisibleContentHeight,
|
||||
getCompactSheetSafeAreaPadding,
|
||||
} from "@/components/adaptive-modal-sheet-layout";
|
||||
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||
@@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
},
|
||||
bottomSheetVisibleContent: {
|
||||
minHeight: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
bottomSheetVisibleScroll: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
@@ -263,40 +253,6 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
|
||||
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({
|
||||
header,
|
||||
onClose,
|
||||
@@ -471,8 +427,6 @@ export interface AdaptiveModalSheetProps {
|
||||
bodyStyle?: StyleProp<ViewStyle>;
|
||||
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
|
||||
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. */
|
||||
contextBridge?: ContextBridge | null;
|
||||
}
|
||||
@@ -493,7 +447,6 @@ export function AdaptiveModalSheet({
|
||||
presentation,
|
||||
contentStyle,
|
||||
bodyStyle,
|
||||
sizeContentToCurrentSnapPoint = true,
|
||||
contextBridge = null,
|
||||
}: AdaptiveModalSheetProps) {
|
||||
const { theme } = useUnistyles();
|
||||
@@ -519,11 +472,15 @@ export function AdaptiveModalSheet({
|
||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||
[compactSafeAreaPadding.footerPaddingBottom],
|
||||
);
|
||||
const footerView = footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null;
|
||||
const footerView = useMemo(
|
||||
() =>
|
||||
footer ? (
|
||||
<View style={footerClearanceStyle}>
|
||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||
</View>
|
||||
) : null,
|
||||
[footer, footerClearanceStyle, footerContainerStyle],
|
||||
);
|
||||
const handleIndicatorStyle = useMemo(
|
||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||
[theme.colors.palette.zinc],
|
||||
@@ -628,7 +585,6 @@ export function AdaptiveModalSheet({
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{footerView}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -650,11 +606,7 @@ export function AdaptiveModalSheet({
|
||||
accessible={false}
|
||||
presentation={presentation}
|
||||
>
|
||||
{sizeContentToCurrentSnapPoint ? (
|
||||
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
|
||||
) : (
|
||||
sheetContent
|
||||
)}
|
||||
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
|
||||
</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 { ScreenHeader } from "./screen-header";
|
||||
import { ScreenTitle } from "./screen-title";
|
||||
import { CompactHeader } from "./compact-header";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
|
||||
interface BackHeaderProps {
|
||||
title?: string;
|
||||
titleAccessory?: ReactNode;
|
||||
rightContent?: ReactNode;
|
||||
onBack?: () => void;
|
||||
}
|
||||
@@ -18,7 +19,15 @@ function goBack(): void {
|
||||
router.back();
|
||||
}
|
||||
|
||||
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
|
||||
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
if (isCompact) {
|
||||
return <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 { t } = useTranslation();
|
||||
const handleBack = useCallback(() => {
|
||||
@@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
|
||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||
</Pressable>
|
||||
{title && <ScreenTitle>{title}</ScreenTitle>}
|
||||
{titleAccessory}
|
||||
</>
|
||||
}
|
||||
right={rightContent}
|
||||
@@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
backButton: {
|
||||
padding: {
|
||||
xs: theme.spacing[3],
|
||||
md: theme.spacing[2],
|
||||
},
|
||||
padding: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 { StyleSheet } from "react-native-unistyles";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -7,7 +7,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
iconButtonChromeFrameStyle,
|
||||
iconButtonChromeStyle,
|
||||
useIconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
|
||||
interface HeaderToggleButtonState {
|
||||
@@ -46,17 +46,11 @@ export function HeaderToggleButton({
|
||||
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
|
||||
: null;
|
||||
|
||||
const combinedStyle = useMemo(
|
||||
() =>
|
||||
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
|
||||
iconButtonChromeStyle({
|
||||
size: "large",
|
||||
state: { hovered: Boolean(hovered), pressed },
|
||||
disabled: Boolean(disabled),
|
||||
style,
|
||||
}),
|
||||
[disabled, style],
|
||||
);
|
||||
const combinedStyle = useIconButtonChromeStyle({
|
||||
size: "large",
|
||||
disabled: Boolean(disabled),
|
||||
style,
|
||||
});
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import { useCallback, useMemo, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { View, type StyleProp, type ViewStyle } from "react-native";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { PanelLeft } from "lucide-react-native";
|
||||
import type { ReactNode } from "react";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { ScreenHeader } from "./screen-header";
|
||||
import { ScreenTitle } from "./screen-title";
|
||||
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
|
||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
||||
import { CompactHeader } from "./compact-header";
|
||||
import { SidebarMenuToggle } from "./sidebar-menu-toggle";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { getShortcutOs } from "@/utils/shortcut-platform";
|
||||
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
|
||||
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
|
||||
|
||||
interface MenuHeaderProps {
|
||||
title?: string;
|
||||
@@ -18,130 +12,11 @@ interface MenuHeaderProps {
|
||||
borderless?: boolean;
|
||||
}
|
||||
|
||||
interface SidebarMenuToggleProps {
|
||||
style?: StyleProp<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) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
if (isCompact) {
|
||||
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
|
||||
}
|
||||
return (
|
||||
<ScreenHeader
|
||||
left={
|
||||
@@ -158,31 +33,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
leadingToggle: {
|
||||
marginLeft: {
|
||||
xs: 0,
|
||||
md: -theme.spacing[2],
|
||||
},
|
||||
},
|
||||
left: {
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
mobileMenuIcon: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: 12,
|
||||
justifyContent: "space-between",
|
||||
alignItems: "flex-start",
|
||||
},
|
||||
desktopMenuIconSpace: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
},
|
||||
mobileMenuLine: {
|
||||
width: MOBILE_MENU_LINE_WIDTH,
|
||||
height: MOBILE_MENU_LINE_HEIGHT,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
mobileMenuLineShort: {
|
||||
width: MOBILE_MENU_LINE_SHORT_WIDTH,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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 { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
|
||||
import {
|
||||
CircleGauge,
|
||||
FolderPlus,
|
||||
GitBranch,
|
||||
Server,
|
||||
Settings,
|
||||
X,
|
||||
type LucideIcon,
|
||||
} from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
@@ -26,7 +34,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -37,6 +45,7 @@ import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { density } from "@/styles/density";
|
||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||
@@ -280,30 +289,25 @@ function FooterIconButton({
|
||||
onPress,
|
||||
testID,
|
||||
label,
|
||||
icon: Icon,
|
||||
icon,
|
||||
iconSizeAdjustment = 0,
|
||||
shortcutKeys,
|
||||
theme,
|
||||
}: {
|
||||
onPress: () => void;
|
||||
testID: string;
|
||||
label: string;
|
||||
icon: typeof FolderPlus;
|
||||
icon: LucideIcon;
|
||||
/** Only for a glyph that reads larger than the others at the same size. */
|
||||
iconSizeAdjustment?: number;
|
||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||
theme: SidebarTheme;
|
||||
buttonRef?: RefObject<View | null>;
|
||||
}) {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
|
||||
|
||||
return (
|
||||
<Tooltip delayDuration={300}>
|
||||
<TooltipTrigger asChild>
|
||||
<Pressable
|
||||
ref={buttonRef}
|
||||
style={styles.footerIconButton(isCompact)}
|
||||
style={styles.footerIconButton}
|
||||
testID={testID}
|
||||
nativeID={testID}
|
||||
collapsable={false}
|
||||
@@ -313,9 +317,11 @@ function FooterIconButton({
|
||||
onPress={onPress}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<Icon
|
||||
size={iconSize + iconSizeAdjustment}
|
||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
||||
<DensityIcon
|
||||
icon={icon}
|
||||
size="md"
|
||||
sizeAdjustment={iconSizeAdjustment}
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
@@ -328,12 +334,10 @@ function FooterIconButton({
|
||||
}
|
||||
|
||||
function SidebarHostPicker({
|
||||
theme,
|
||||
label,
|
||||
onAddHost,
|
||||
onOpenHostSettings,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
label: string;
|
||||
onAddHost: () => void;
|
||||
onOpenHostSettings: (serverId: string) => void;
|
||||
@@ -377,7 +381,6 @@ function SidebarHostPicker({
|
||||
icon={Server}
|
||||
// Server's two boxes fill more of the square than the other glyphs.
|
||||
iconSizeAdjustment={-1}
|
||||
theme={theme}
|
||||
/>
|
||||
</HostPicker>
|
||||
);
|
||||
@@ -399,7 +402,6 @@ function IconTooltipContent({
|
||||
}
|
||||
|
||||
function SidebarFooter({
|
||||
theme,
|
||||
handleOpenProject,
|
||||
handleSettings,
|
||||
labels,
|
||||
@@ -407,7 +409,6 @@ function SidebarFooter({
|
||||
handleOpenHostSettings,
|
||||
onBeforeNavigate,
|
||||
}: {
|
||||
theme: SidebarTheme;
|
||||
handleOpenProject: () => void;
|
||||
handleSettings: () => void;
|
||||
labels: {
|
||||
@@ -436,11 +437,9 @@ function SidebarFooter({
|
||||
label={labels.addProject}
|
||||
icon={FolderPlus}
|
||||
shortcutKeys={newAgentKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
<SidebarUsageIcon label={labels.usage} theme={theme} />
|
||||
<SidebarUsageIcon label={labels.usage} />
|
||||
<SidebarHostPicker
|
||||
theme={theme}
|
||||
label={labels.hosts}
|
||||
onAddHost={handleAddHost}
|
||||
onOpenHostSettings={handleOpenHostSettings}
|
||||
@@ -453,7 +452,6 @@ function SidebarFooter({
|
||||
label={labels.settings}
|
||||
icon={Settings}
|
||||
shortcutKeys={settingsKeys}
|
||||
theme={theme}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
@@ -461,7 +459,7 @@ function SidebarFooter({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
|
||||
function SidebarUsageIcon({ label }: { label: string }) {
|
||||
const openUsage = useOpenSidebarUsage();
|
||||
return (
|
||||
<FooterIconButton
|
||||
@@ -469,7 +467,6 @@ function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme
|
||||
testID="sidebar-usage-icon"
|
||||
label={label}
|
||||
icon={CircleGauge}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -612,7 +609,6 @@ function MobileSidebar({
|
||||
)}
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
@@ -789,7 +785,6 @@ function DesktopSidebar({
|
||||
<SidebarCalloutSlot />
|
||||
|
||||
<SidebarFooter
|
||||
theme={theme}
|
||||
handleOpenProject={handleOpenProject}
|
||||
handleSettings={handleSettings}
|
||||
labels={labels}
|
||||
@@ -957,14 +952,14 @@ const styles = StyleSheet.create((theme) => ({
|
||||
paddingVertical: theme.spacing[1.5],
|
||||
gap: 2,
|
||||
},
|
||||
footerIconButton: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
footerIconButton: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
}),
|
||||
},
|
||||
tooltipRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -391,6 +391,43 @@ describe("splitHtmlishMarkdown", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps table rows after a comparison that only looks like a tag", () => {
|
||||
const source = [
|
||||
"| ID | Note | Status |",
|
||||
"|---|---|---|",
|
||||
"| a-1 | accepts 1<size<2 only | open |",
|
||||
"| a-2 | second row | open |",
|
||||
"",
|
||||
"Later x -> y.",
|
||||
].join("\n");
|
||||
|
||||
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
|
||||
});
|
||||
|
||||
it.each(["See <https://example.com/docs> for more.", "Stop when a<b", "Keep </ b> and a <b"])(
|
||||
"keeps %j literal because it is not HTML",
|
||||
(source) => {
|
||||
expect(normalizeHtmlishMarkdown(source)).toBe(source);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"Call As<Integer>() before limits<DWORD>::max()",
|
||||
"<Kbd Data-Key='K'>Ctrl</Kbd> then <Foo/>",
|
||||
])("echoes unknown tags in %j as written", (source) => {
|
||||
expect(normalizeHtmlishMarkdown(source)).toBe(source);
|
||||
});
|
||||
|
||||
it("rebuilds an unknown tag that a code span splits", () => {
|
||||
expect(normalizeHtmlishMarkdown('<Tip title="`x`">hi</Tip>')).toBe("`x`<tip title>hi</Tip>");
|
||||
});
|
||||
|
||||
it("still reads tags and comments next to literal angle brackets", () => {
|
||||
expect(normalizeHtmlishMarkdown("1<n<2 <b>bold</b><!-- hidden --> 3<4")).toBe(
|
||||
"1<n<2 **bold** 3<4",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to inert markdown when details are unclosed", () => {
|
||||
const source = "<details><summary>Open</summary>Still open";
|
||||
|
||||
|
||||
@@ -29,6 +29,12 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
||||
const BACKTICK_RUN_RE = /`+/g;
|
||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
|
||||
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
|
||||
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
|
||||
const HTML_TAG_RE = new RegExp(
|
||||
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
|
||||
"y",
|
||||
);
|
||||
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
|
||||
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
||||
b: ["**", "**"],
|
||||
@@ -373,14 +379,16 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
|
||||
}
|
||||
|
||||
const children = tokens.slice(index + 1, closeIndex);
|
||||
output += renderHtmlTag(token, children);
|
||||
const close = tokens[closeIndex];
|
||||
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
|
||||
output += renderHtmlTag(token, children, closeRaw);
|
||||
index = closeIndex;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
|
||||
if (token.name === "a") {
|
||||
return renderLinkToken(token, children);
|
||||
}
|
||||
@@ -400,7 +408,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
||||
if (token.name === "p" || token.name === "div") {
|
||||
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
|
||||
}
|
||||
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
|
||||
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
|
||||
}
|
||||
|
||||
function renderTableTokens(tokens: HtmlToken[]): string {
|
||||
@@ -606,7 +614,15 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||
let stripNextLeadingLineBreak = false;
|
||||
let skippedLength = 0;
|
||||
let writtenLength = 0;
|
||||
let chunkStart = 0;
|
||||
let parser!: Parser;
|
||||
// Unknown tags render as text, so keep them as written: the parser lowercases names
|
||||
// and rewrites attributes. A tag split by a protected range has no single slice.
|
||||
const readRawTag = (): string | null =>
|
||||
parser.startIndex < chunkStart
|
||||
? null
|
||||
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
|
||||
parser = new Parser(
|
||||
{
|
||||
onopentag(name, attributes, isImplied) {
|
||||
@@ -620,7 +636,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
||||
closing: false,
|
||||
selfClosing,
|
||||
attributes,
|
||||
raw: renderStartTag(name, attributes),
|
||||
raw: readRawTag() ?? renderStartTag(name, attributes),
|
||||
});
|
||||
},
|
||||
onclosetag(name, isImplied) {
|
||||
@@ -633,7 +649,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
||||
closing: true,
|
||||
selfClosing: false,
|
||||
attributes: {},
|
||||
raw: `</${name}>`,
|
||||
raw: readRawTag() ?? `</${name}>`,
|
||||
});
|
||||
},
|
||||
ontext(value) {
|
||||
@@ -660,6 +676,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
||||
);
|
||||
return {
|
||||
write(chunk) {
|
||||
chunkStart = writtenLength;
|
||||
writtenLength += chunk.length;
|
||||
parser.write(chunk);
|
||||
},
|
||||
skip(length) {
|
||||
@@ -698,7 +716,58 @@ function escapeAttribute(value: string): string {
|
||||
|
||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||
const fencedRanges = getFencedCodeRanges(source);
|
||||
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
|
||||
const codeRanges = mergeProtectedRanges([
|
||||
...fencedRanges,
|
||||
...getInlineCodeRanges(source, fencedRanges),
|
||||
]);
|
||||
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
|
||||
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
|
||||
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
|
||||
*/
|
||||
function getLiteralAngleBracketRanges(
|
||||
source: string,
|
||||
codeRanges: ProtectedMarkdownRange[],
|
||||
): ProtectedMarkdownRange[] {
|
||||
const ranges: ProtectedMarkdownRange[] = [];
|
||||
let codeIndex = 0;
|
||||
// Reused across comment openers so unclosed comments stay linear.
|
||||
let commentClose = -1;
|
||||
let index = source.indexOf("<");
|
||||
|
||||
while (index !== -1) {
|
||||
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
|
||||
codeIndex += 1;
|
||||
}
|
||||
const code = codeRanges[codeIndex];
|
||||
if (code && code.start <= index) {
|
||||
index = source.indexOf("<", code.end);
|
||||
continue;
|
||||
}
|
||||
|
||||
let htmlEnd: number | null;
|
||||
if (source.startsWith("<!--", index)) {
|
||||
if (commentClose !== source.length && commentClose < index + 2) {
|
||||
const close = source.indexOf("-->", index + 2);
|
||||
commentClose = close === -1 ? source.length : close;
|
||||
}
|
||||
htmlEnd = commentClose === source.length ? null : commentClose + 3;
|
||||
} else {
|
||||
HTML_TAG_RE.lastIndex = index;
|
||||
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
|
||||
}
|
||||
|
||||
if (htmlEnd === null) {
|
||||
ranges.push({ start: index, end: index + 1 });
|
||||
htmlEnd = index + 1;
|
||||
}
|
||||
index = source.indexOf("<", htmlEnd);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||
|
||||
@@ -126,9 +126,6 @@ export function ProjectEditSheet({
|
||||
onClose={handleClose}
|
||||
footer={footer}
|
||||
desktopMaxWidth={440}
|
||||
// Bound the compact scroller to the live snap height so the footer stays
|
||||
// on screen instead of being pushed past the bottom of the sheet.
|
||||
sizeContentToCurrentSnapPoint
|
||||
testID="project-edit-sheet"
|
||||
>
|
||||
<Field
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
type ActiveWorkspaceSelection,
|
||||
} from "@/stores/navigation-active-workspace-store";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||
@@ -114,6 +115,7 @@ import {
|
||||
SidebarWorkspaceTrailingContent,
|
||||
useSidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
@@ -822,9 +824,10 @@ function NewWorkspaceGhostRow({
|
||||
{({ hovered, pressed }) => (
|
||||
<>
|
||||
<View style={styles.newWorkspaceGhostIconSlot}>
|
||||
<ThemedPlus
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
<DensityIcon
|
||||
icon={Plus}
|
||||
size="sm"
|
||||
color={hovered || pressed ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</View>
|
||||
<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
|
||||
// pressed fills stay the same box as every other row in the sidebar.
|
||||
newWorkspaceGhostRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[4],
|
||||
@@ -2539,27 +2542,27 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// The width of a workspace row's status slot, so the label lands on the same rail as the
|
||||
// titles above it.
|
||||
newWorkspaceGhostIconSlot: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
newWorkspaceGhostText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
newWorkspaceGhostTextHovered: {
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
projectRow: {
|
||||
position: "relative",
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -2600,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
@@ -2681,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
right: theme.spacing[2],
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
|
||||
@@ -2,16 +2,11 @@ import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
import { density, densityFontSize } from "@/styles/density";
|
||||
|
||||
export function PinnedSectionHeader({
|
||||
collapsed,
|
||||
@@ -23,7 +18,6 @@ export function PinnedSectionHeader({
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
@@ -37,7 +31,11 @@ export function PinnedSectionHeader({
|
||||
<>
|
||||
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
|
||||
{hovered || isNative || isCompact ? (
|
||||
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
|
||||
<DensityIcon
|
||||
icon={collapsed ? ChevronRight : ChevronDown}
|
||||
size="xs"
|
||||
color="foregroundMuted"
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
@@ -47,7 +45,7 @@ export function PinnedSectionHeader({
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
header: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
alignSelf: "flex-start",
|
||||
@@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
title: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
fontSize: densityFontSize(theme, "sm"),
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||
import { ProjectIconView } from "@/components/project-icon-view";
|
||||
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import {
|
||||
getProjectStatusBadgeContent,
|
||||
@@ -35,9 +36,8 @@ const STATUS_BADGE_OFFSET = -4;
|
||||
//
|
||||
// The filled alert occupies the full badge shell so needs-input remains more prominent than
|
||||
// the passive status dots.
|
||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
||||
const LEADING_SLOT_HEIGHT = 20;
|
||||
const DensityProjectIconView = withUnistyles(ProjectIconView);
|
||||
const projectIconProps = densityIconProps("md", () => ({}));
|
||||
|
||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
@@ -230,12 +230,12 @@ function ProjectIcon({
|
||||
projectViewKey: string;
|
||||
}) {
|
||||
return (
|
||||
<ProjectIconView
|
||||
<DensityProjectIconView
|
||||
iconDataUri={iconDataUri}
|
||||
initial={placeholderInitial}
|
||||
projectViewKey={projectViewKey}
|
||||
size={ICON_SIZE.md}
|
||||
textStyle={styles.projectIconFallbackText}
|
||||
uniProps={projectIconProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => {
|
||||
|
||||
return {
|
||||
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
|
||||
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
|
||||
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
|
||||
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
|
||||
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
|
||||
// only as tall as the icon puts it above the title and the kebab — which is why the
|
||||
// workspace status indicator shares the same `rowLineHeight`.
|
||||
projectLeadingVisualSlot: {
|
||||
width: theme.iconSize.md,
|
||||
height: LEADING_SLOT_HEIGHT,
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -284,8 +284,8 @@ const styles = StyleSheet.create((theme) => {
|
||||
// Anchors the corner badge to the icon rather than to the taller slot.
|
||||
projectIconBox: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
},
|
||||
projectIconFallbackText: {
|
||||
fontSize: 9,
|
||||
|
||||
@@ -2,19 +2,11 @@ import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
const foregroundColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foreground,
|
||||
});
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronUp = withUnistyles(ChevronUp);
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
|
||||
/**
|
||||
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
|
||||
@@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({
|
||||
{({ hovered, pressed }) => (
|
||||
<>
|
||||
<View style={styles.iconSlot}>
|
||||
{expanded ? (
|
||||
<ThemedChevronUp
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
) : (
|
||||
<ThemedChevronDown
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
<DensityIcon
|
||||
icon={expanded ? ChevronUp : ChevronDown}
|
||||
size="sm"
|
||||
color={hovered || pressed ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
</View>
|
||||
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
||||
{label}
|
||||
@@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
|
||||
row: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
@@ -106,21 +92,21 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// The width of a workspace row's status slot, so the label lands on the same rail as the
|
||||
// titles above it rather than two points to their left.
|
||||
iconSlot: {
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
flexShrink: 0,
|
||||
},
|
||||
text: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
textHovered: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
},
|
||||
|
||||
@@ -1,22 +1,12 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
type ComponentType,
|
||||
type ReactNode,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
|
||||
import { Pressable, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
|
||||
|
||||
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
|
||||
@@ -49,7 +39,7 @@ interface SidebarHeaderRowProps {
|
||||
}
|
||||
|
||||
export function SidebarHeaderRow({
|
||||
icon: Icon,
|
||||
icon,
|
||||
label,
|
||||
onPress,
|
||||
isActive = false,
|
||||
@@ -64,9 +54,8 @@ export function SidebarHeaderRow({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
|
||||
const isHighlighted = isHovered || isActive;
|
||||
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
|
||||
const iconSize = variant === "header" ? "md" : "sm";
|
||||
|
||||
let right = trailing ?? null;
|
||||
if (right === null && shortcutKeys && isHovered) {
|
||||
@@ -91,10 +80,11 @@ export function SidebarHeaderRow({
|
||||
aria-selected={isActive}
|
||||
style={styles.button}
|
||||
>
|
||||
{ThemedIcon ? (
|
||||
<ThemedIcon
|
||||
{icon ? (
|
||||
<DensityIcon
|
||||
icon={icon}
|
||||
size={iconSize}
|
||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
color={isHighlighted ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
) : (
|
||||
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
|
||||
@@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
alignItems: "center",
|
||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||
// strip so the hover highlight clears the strip's bottom separator.
|
||||
minHeight: 28,
|
||||
minHeight: density.tightRowHeight,
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
},
|
||||
rowHighlighted: {
|
||||
@@ -152,17 +142,17 @@ const styles = StyleSheet.create((theme) => ({
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
minHeight: 28,
|
||||
minHeight: density.tightRowHeight,
|
||||
paddingVertical: theme.spacing[1],
|
||||
// Match the project rows' inner padding so the icons align on one vertical
|
||||
// edge with the list below.
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
},
|
||||
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
|
||||
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
|
||||
iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
|
||||
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
|
||||
label: {
|
||||
flexShrink: 1,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: theme.fontWeight.normal,
|
||||
color: theme.colors.foregroundMuted,
|
||||
},
|
||||
|
||||
@@ -14,14 +14,14 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { density } from "@/styles/density";
|
||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||
import type { HostProfile } from "@/types/host-connection";
|
||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||
@@ -32,12 +32,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
||||
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||
const ThemedActivity = withUnistyles(Activity);
|
||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
||||
const ThemedGift = withUnistyles(Gift);
|
||||
const ThemedKeyboard = withUnistyles(Keyboard);
|
||||
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
|
||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
@@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
||||
}
|
||||
|
||||
export function SidebarHelpMenu() {
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const { t } = useTranslation();
|
||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||
@@ -106,15 +103,16 @@ export function SidebarHelpMenu() {
|
||||
<TooltipTrigger asChild>
|
||||
<View>
|
||||
<DropdownMenuTrigger
|
||||
style={styles.trigger(isCompact)}
|
||||
style={styles.trigger}
|
||||
testID="sidebar-help"
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("sidebar.help.trigger")}
|
||||
>
|
||||
{({ hovered }) => (
|
||||
<ThemedCircleHelp
|
||||
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
|
||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
<DensityIcon
|
||||
icon={CircleHelp}
|
||||
size="md"
|
||||
color={hovered ? "foreground" : "foregroundMuted"}
|
||||
/>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
@@ -184,14 +182,14 @@ export function SidebarHelpMenu() {
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
trigger: (isCompact: boolean) => ({
|
||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
||||
trigger: {
|
||||
width: density.iconButton,
|
||||
height: density.iconButton,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
}),
|
||||
},
|
||||
tooltipText: {
|
||||
fontSize: theme.fontSize.base,
|
||||
color: theme.colors.popoverForeground,
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import { withUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
|
||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||
|
||||
// Themed icon wrappers
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
});
|
||||
}));
|
||||
// One mapping per bucket, resolved through the status-dot producer so a group header and
|
||||
// the rows under it cannot disagree about what "failed" looks like.
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
const failedColorMapping = (theme: Theme) => ({
|
||||
}));
|
||||
const failedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||
});
|
||||
const attentionColorMapping = (theme: Theme) => ({
|
||||
}));
|
||||
const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||
});
|
||||
const runningColorMapping = (theme: Theme) => ({
|
||||
}));
|
||||
const runningGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||
});
|
||||
}));
|
||||
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
@@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({
|
||||
return <StatusGroupIcon bucket={leading.bucket} />;
|
||||
}
|
||||
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 }) {
|
||||
switch (bucket) {
|
||||
case "needs_input":
|
||||
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
|
||||
return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
|
||||
case "failed":
|
||||
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
|
||||
return <ThemedCircleX uniProps={failedGroupIconProps} />;
|
||||
case "attention":
|
||||
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
|
||||
return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
|
||||
case "running":
|
||||
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
|
||||
return <ThemedCircleDot uniProps={runningGroupIconProps} />;
|
||||
case "done":
|
||||
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
||||
return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusWorkspaceListContainer: {},
|
||||
statusGroupRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
@@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusGroupLeadingVisualSlot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: theme.iconSize.md,
|
||||
width: densityIconBox("md"),
|
||||
height: densityIconBox("md"),
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
statusGroupTitle: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: "400",
|
||||
minWidth: 0,
|
||||
flexShrink: 1,
|
||||
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[0.5],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type SidebarWorkspaceTrailing,
|
||||
} from "@/components/sidebar/workspace-trailing";
|
||||
import { useAppSettings } from "@/hooks/use-settings";
|
||||
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||
@@ -27,19 +28,16 @@ import {
|
||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||
import { StatusRing } from "@/components/status-ring";
|
||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||
import { DensityIcon } from "@/components/ui/density-icon";
|
||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
|
||||
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const needsInputColorMapping = (theme: Theme) => ({
|
||||
color: theme.colors.surface0,
|
||||
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||
});
|
||||
|
||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||
const ThemedMonitor = withUnistyles(Monitor);
|
||||
const ThemedFolder = withUnistyles(Folder);
|
||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
||||
|
||||
export function SidebarWorkspaceRowFrame({
|
||||
workspace,
|
||||
@@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({
|
||||
) : null;
|
||||
}
|
||||
|
||||
let KindIcon: typeof ThemedMonitor;
|
||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
||||
else KindIcon = ThemedFolder;
|
||||
let KindIcon: typeof Monitor;
|
||||
if (workspaceKind === "local_checkout") KindIcon = Monitor;
|
||||
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
|
||||
else KindIcon = Folder;
|
||||
|
||||
const dotColorStyle = getStatusDotColorStyle(bucket);
|
||||
return (
|
||||
<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}
|
||||
</View>
|
||||
);
|
||||
@@ -482,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
position: "relative",
|
||||
width: theme.iconSize.md,
|
||||
height: 20,
|
||||
width: densityIconBox("md"),
|
||||
height: density.rowLineHeight,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
alignItems: "center",
|
||||
@@ -515,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
// to the meta row, so it takes the full width the trailing slot leaves behind.
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.base,
|
||||
fontSize: densityFontSize(theme, "base"),
|
||||
fontWeight: "400",
|
||||
lineHeight: 20,
|
||||
lineHeight: density.rowLineHeight,
|
||||
opacity: 0.76,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native";
|
||||
import { StyleSheet } from "react-native-unistyles";
|
||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||
import { density } from "@/styles/density";
|
||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
@@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({
|
||||
position: "relative",
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 36,
|
||||
minHeight: density.rowHeight,
|
||||
marginBottom: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingLeft: theme.spacing[2],
|
||||
|
||||
@@ -160,3 +160,16 @@ describe("combobox above-search ordering", () => {
|
||||
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("search-only option text", () => {
|
||||
it("finds hidden search text while keeping title matches first", () => {
|
||||
const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" };
|
||||
const titleMatch = { id: "pr:8", label: "2024 release" };
|
||||
expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([
|
||||
titleMatch,
|
||||
bodyMatch,
|
||||
]);
|
||||
expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]);
|
||||
expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,18 +10,22 @@ export interface ComboboxOptionModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Additional text to match without displaying it in the option row. */
|
||||
searchText?: string;
|
||||
kind?: ComboboxOptionKind;
|
||||
}
|
||||
|
||||
const DESCRIPTION_FALLBACK_TIER = 99;
|
||||
const SECONDARY_TEXT_FALLBACK_TIER = 99;
|
||||
|
||||
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
||||
const best = scoreTextFields(search, [opt.label, opt.id]);
|
||||
if (best) return best;
|
||||
if (!opt.description) return null;
|
||||
const descriptionScore = scoreTextFields(search, [opt.description]);
|
||||
if (!descriptionScore) return null;
|
||||
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
|
||||
const secondaryFields = opt.searchText
|
||||
? [opt.label, opt.id, opt.description ?? "", opt.searchText]
|
||||
: [opt.description ?? ""];
|
||||
const secondaryScore = scoreTextFields(search, secondaryFields);
|
||||
if (!secondaryScore) return null;
|
||||
return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER };
|
||||
}
|
||||
|
||||
export interface BuildVisibleComboboxOptionsInput {
|
||||
|
||||
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { Check, File, Folder, Search } from "lucide-react-native";
|
||||
@@ -50,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import {
|
||||
IsolatedBottomSheetModal,
|
||||
SheetVisibleFrame,
|
||||
useIsolatedBottomSheetVisibility,
|
||||
} from "./isolated-bottom-sheet-modal";
|
||||
import {
|
||||
@@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||
[props.titleColor],
|
||||
);
|
||||
const { footer, safeAreaBottom } = props;
|
||||
const frameStyle = useMemo(
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
|
||||
[props.safeAreaBottom],
|
||||
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
||||
[footer, safeAreaBottom],
|
||||
);
|
||||
const footerStyle = useMemo(
|
||||
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
||||
[safeAreaBottom],
|
||||
);
|
||||
|
||||
const footerView = useMemo(
|
||||
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
||||
[footer, footerStyle],
|
||||
);
|
||||
|
||||
const body = props.hasChildren ? (
|
||||
@@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
||||
keyboardBlurBehavior="none"
|
||||
presentation={props.presentation}
|
||||
>
|
||||
<View style={frameStyle}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
||||
</View>
|
||||
<SheetVisibleFrame footer={footerView}>
|
||||
<View style={frameStyle}>
|
||||
{props.header ? (
|
||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||
) : (
|
||||
<>
|
||||
<View style={styles.bottomSheetHeader}>
|
||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||
{props.title}
|
||||
</Text>
|
||||
</View>
|
||||
{props.stickyHeader}
|
||||
{!props.hasChildren && props.searchable ? (
|
||||
<SearchInput
|
||||
placeholder={props.searchPlaceholder}
|
||||
onChangeText={props.setSearchQueryWithCallback}
|
||||
onSubmitEditing={props.handleSubmitSearch}
|
||||
autoFocus={false}
|
||||
useBottomSheetInput
|
||||
resetKey={props.searchResetKey}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||
body
|
||||
) : (
|
||||
<BottomSheetScrollView
|
||||
style={styles.mobileSheetBody}
|
||||
contentContainerStyle={[
|
||||
styles.comboboxScrollContent,
|
||||
props.mobileChildrenContentContainerStyle,
|
||||
]}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{body}
|
||||
</BottomSheetScrollView>
|
||||
)}
|
||||
</View>
|
||||
</SheetVisibleFrame>
|
||||
</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 { StyleSheet } from "react-native-unistyles";
|
||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||
@@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/
|
||||
export type IconButtonChromeSize = "large" | "small";
|
||||
|
||||
const SMALL_ICON_BUTTON_SIZE = 20;
|
||||
/** Width and height of a large icon button on compact layouts. */
|
||||
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
|
||||
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
|
||||
|
||||
export interface IconButtonChromeState {
|
||||
@@ -27,20 +30,53 @@ interface IconButtonChromeOptions {
|
||||
state?: IconButtonChromeState;
|
||||
compact?: boolean;
|
||||
disabled?: boolean;
|
||||
/** A circular hitbox and highlight, for buttons inside a pill-shaped container. */
|
||||
round?: boolean;
|
||||
style?: StyleProp<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. */
|
||||
export function iconButtonChromeStyle({
|
||||
size,
|
||||
state,
|
||||
compact = false,
|
||||
disabled = false,
|
||||
round = false,
|
||||
style,
|
||||
}: IconButtonChromeOptions): StyleProp<ViewStyle> {
|
||||
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
|
||||
return [
|
||||
resolveIconButtonFrame(size, compact),
|
||||
round ? styles.round : null,
|
||||
style,
|
||||
highlighted ? styles.highlighted : null,
|
||||
disabled ? styles.disabled : null,
|
||||
@@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
large: {
|
||||
width: {
|
||||
xs: 32,
|
||||
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
height: {
|
||||
xs: 32,
|
||||
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
|
||||
md: HEADER_CONTROL_HEIGHT,
|
||||
},
|
||||
padding: 0,
|
||||
@@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({
|
||||
outlineWidth: 0,
|
||||
outlineColor: "transparent",
|
||||
},
|
||||
round: {
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
highlighted: {
|
||||
backgroundColor: theme.colors.interactionHighlight,
|
||||
},
|
||||
|
||||
@@ -152,3 +152,5 @@ export function useIsolatedBottomSheetVisibility({
|
||||
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 {
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
useIconButtonChromeStyle,
|
||||
smallIconButtonChromeFrameSize,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
|
||||
@@ -60,6 +61,8 @@ interface ToolbarButtonCommonProps extends Omit<
|
||||
tooltip?: ReactNode;
|
||||
selected?: boolean;
|
||||
compact?: boolean;
|
||||
/** A circular highlight, for buttons inside a pill-shaped container. */
|
||||
round?: boolean;
|
||||
shortcut?: ShortcutKey[][] | null;
|
||||
tooltipSide?: "left" | "right" | "top" | "bottom";
|
||||
style?: StyleProp<ViewStyle>;
|
||||
@@ -81,6 +84,7 @@ export function ToolbarButton({
|
||||
tooltip: tooltipBody,
|
||||
selected = false,
|
||||
compact = false,
|
||||
round = false,
|
||||
shortcut,
|
||||
tooltipSide = "bottom",
|
||||
style,
|
||||
@@ -90,17 +94,14 @@ export function ToolbarButton({
|
||||
testID,
|
||||
...props
|
||||
}: ToolbarButtonProps) {
|
||||
const buttonStyle = useMemo(
|
||||
() => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) =>
|
||||
iconButtonChromeStyle({
|
||||
size: "small",
|
||||
compact,
|
||||
state: { ...state, active: selected },
|
||||
disabled: Boolean(disabled),
|
||||
style,
|
||||
}),
|
||||
[compact, disabled, selected, style],
|
||||
);
|
||||
const buttonStyle = useIconButtonChromeStyle({
|
||||
size: "small",
|
||||
compact,
|
||||
active: selected,
|
||||
disabled: Boolean(disabled),
|
||||
round,
|
||||
style,
|
||||
});
|
||||
const accessibilityState = useMemo(
|
||||
() => ({ disabled: Boolean(disabled), selected }),
|
||||
[disabled, selected],
|
||||
|
||||
@@ -298,7 +298,6 @@ export function CompactModelSheet({
|
||||
onClose={close}
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
sizeContentToCurrentSnapPoint={usesBottomSheet}
|
||||
contentStyle={styles.sheetBody}
|
||||
testID="agent-controls-model-sheet"
|
||||
>
|
||||
@@ -346,7 +345,6 @@ export function CompactModelSheet({
|
||||
onClose={closeModelBrowser}
|
||||
snapPoints={SNAP_POINTS}
|
||||
scrollable={false}
|
||||
sizeContentToCurrentSnapPoint
|
||||
presentation="push"
|
||||
contentStyle={styles.sheetBody}
|
||||
testID="agent-controls-model-browser-sheet"
|
||||
|
||||
@@ -33,13 +33,18 @@ export {
|
||||
getIsElectronMac as getIsElectronRuntimeMac,
|
||||
} from "./platform";
|
||||
|
||||
/** Compact layouts are the breakpoints below `md`. */
|
||||
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
|
||||
return breakpoint === "xs" || breakpoint === "sm";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive hook — re-renders the component when the breakpoint changes.
|
||||
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
|
||||
*/
|
||||
export function useIsCompactFormFactor(): boolean {
|
||||
const { rt } = useUnistyles();
|
||||
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
|
||||
return isCompactBreakpoint(rt.breakpoint);
|
||||
}
|
||||
|
||||
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
|
||||
|
||||
@@ -7,9 +7,11 @@ import { Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
|
||||
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
|
||||
import { CompactHeader } from "@/components/headers/compact-header";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { ScreenTitle } from "@/components/headers/screen-title";
|
||||
import { HostFilter } from "@/components/hosts/host-filter";
|
||||
import type { HostPickerHost } from "@/components/hosts/host-picker";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -87,19 +89,14 @@ function PluginHostFilter({
|
||||
pluginId,
|
||||
identity,
|
||||
params,
|
||||
serverIds,
|
||||
hosts,
|
||||
}: {
|
||||
serverId: string;
|
||||
pluginId: string;
|
||||
identity: PluginSurfaceContributionIdentity;
|
||||
params: PluginScreenParams;
|
||||
serverIds: string[];
|
||||
hosts: HostPickerHost[];
|
||||
}) {
|
||||
const allHosts = useHosts();
|
||||
const hosts = useMemo(
|
||||
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
|
||||
[allHosts, serverIds],
|
||||
);
|
||||
const selectHost = useCallback(
|
||||
(nextServerId: string) => {
|
||||
// A legacy row remembers its own host; a screen's host carries to all the plugin's items.
|
||||
@@ -113,9 +110,6 @@ function PluginHostFilter({
|
||||
},
|
||||
[identity, params, pluginId],
|
||||
);
|
||||
const show = serverIds.length > 1 && hosts.length > 1;
|
||||
if (!show) return null;
|
||||
|
||||
return (
|
||||
<HostFilter
|
||||
hosts={hosts}
|
||||
@@ -155,11 +149,12 @@ export function PluginSurfaceScreen() {
|
||||
[identity, plugin],
|
||||
);
|
||||
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
|
||||
const contributionServerIds = useMemo(
|
||||
() =>
|
||||
identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [],
|
||||
[identity, installations, pluginId],
|
||||
);
|
||||
// The hosts this contribution is installed on; the filter shows when there is a choice.
|
||||
const filterHosts = useMemo(() => {
|
||||
if (!identity) return [];
|
||||
const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity);
|
||||
return hosts.filter((candidate) => serverIds.includes(candidate.serverId));
|
||||
}, [hosts, identity, installations, pluginId]);
|
||||
const title = useMemo(
|
||||
() =>
|
||||
surface
|
||||
@@ -187,18 +182,23 @@ export function PluginSurfaceScreen() {
|
||||
),
|
||||
[Icon, title],
|
||||
);
|
||||
const hostFilter = useMemo(
|
||||
() =>
|
||||
identity && filterHosts.length > 1 ? (
|
||||
<PluginHostFilter
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
identity={identity}
|
||||
params={params}
|
||||
hosts={filterHosts}
|
||||
/>
|
||||
) : null,
|
||||
[filterHosts, identity, params, pluginId, serverId],
|
||||
);
|
||||
const headerRight = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{identity ? (
|
||||
<PluginHostFilter
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
identity={identity}
|
||||
params={params}
|
||||
serverIds={contributionServerIds}
|
||||
/>
|
||||
) : null}
|
||||
{hostFilter}
|
||||
<HeaderToggleButton
|
||||
accessibilityLabel="Close plugin"
|
||||
onPress={close}
|
||||
@@ -211,12 +211,22 @@ export function PluginSurfaceScreen() {
|
||||
</HeaderToggleButton>
|
||||
</>
|
||||
),
|
||||
[close, contributionServerIds, identity, params, pluginId, serverId],
|
||||
[close, hostFilter],
|
||||
);
|
||||
|
||||
return (
|
||||
<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}>
|
||||
{plugin && surface && client ? (
|
||||
<SurfaceErrorBoundary
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildVisibleComboboxOptions } from "@/components/ui/combobox-options";
|
||||
import type { ForgeSearchItem } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
type BranchPickerDetail,
|
||||
@@ -340,3 +341,48 @@ describe("defaultBasePickerItem", () => {
|
||||
expect(defaultBasePickerItem({ currentBranch: null })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("starting-ref search", () => {
|
||||
it.each(["gitlab", "github"] as const)(
|
||||
"keeps a %s change request returned for a body-only match",
|
||||
(forge) => {
|
||||
const item: ForgeSearchItem = {
|
||||
...prItem,
|
||||
forge,
|
||||
number: 3896,
|
||||
title: "fix(config): send environment credentials only to trusted hosts",
|
||||
body: "This mirrors the fix shipped for CVE-2024-53858.",
|
||||
headRefName: "8545-duo-test",
|
||||
};
|
||||
const { options } = buildPickerOptionData({
|
||||
branchDetails: [],
|
||||
prItems: [item],
|
||||
baseItem: null,
|
||||
});
|
||||
const visible = buildVisibleComboboxOptions({
|
||||
options,
|
||||
searchQuery: "2024",
|
||||
searchable: true,
|
||||
allowCustomValue: false,
|
||||
customValuePrefix: "Use",
|
||||
});
|
||||
expect(visible.map((option) => option.label)).toEqual([
|
||||
`${forge === "gitlab" ? "!" : "#"}3896 fix(config): send environment credentials only to trusted hosts`,
|
||||
]);
|
||||
const selected = buildPickerOptionData({
|
||||
branchDetails: [],
|
||||
prItems: [],
|
||||
baseItem: { kind: "github-pr", item },
|
||||
});
|
||||
expect(
|
||||
buildVisibleComboboxOptions({
|
||||
options: selected.options,
|
||||
searchQuery: "2024",
|
||||
searchable: true,
|
||||
allowCustomValue: false,
|
||||
customValuePrefix: "Use",
|
||||
}),
|
||||
).toEqual(visible);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -264,7 +264,10 @@ export function buildPickerOptionData(input: {
|
||||
itemById.set(id, { kind: "github-pr", item: pr });
|
||||
const updatedAtMs = pr.updatedAt ? Date.parse(pr.updatedAt) : 0;
|
||||
const timestamp = Number.isNaN(updatedAtMs) ? 0 : Math.floor(updatedAtMs / 1000);
|
||||
timedOptions.push({ option: { id, label: formatPrLabel(pr) }, timestamp });
|
||||
timedOptions.push({
|
||||
option: { id, label: formatPrLabel(pr), searchText: pr.body ?? undefined },
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
const baseItem = input.baseItem;
|
||||
@@ -279,7 +282,13 @@ export function buildPickerOptionData(input: {
|
||||
const labeledItem = disambiguate(baseItem, timedOptions);
|
||||
itemById.set(selectedOptionId, labeledItem);
|
||||
timedOptions.push({
|
||||
option: { id: selectedOptionId, label: pickerItemLabel(labeledItem) },
|
||||
option: {
|
||||
id: selectedOptionId,
|
||||
label: pickerItemLabel(labeledItem),
|
||||
...(labeledItem.kind === "github-pr"
|
||||
? { searchText: labeledItem.item.body ?? undefined }
|
||||
: {}),
|
||||
},
|
||||
// The base sorts first: it is what a workspace is created from unless you pick something.
|
||||
timestamp: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
|
||||
@@ -31,8 +31,7 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
|
||||
import { SidebarMenuToggle } from "@/components/headers/menu-header";
|
||||
import { ScreenHeader } from "@/components/headers/screen-header";
|
||||
import { MenuHeader } from "@/components/headers/menu-header";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useAgentInputDraft } from "@/composer/draft/input-draft";
|
||||
@@ -2368,7 +2367,6 @@ export function NewWorkspaceScreen({
|
||||
},
|
||||
});
|
||||
|
||||
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
|
||||
const importSession = useImportSession({ serverId: selectedServerId });
|
||||
|
||||
const composer = isTerminalLaunch ? (
|
||||
@@ -2431,7 +2429,7 @@ export function NewWorkspaceScreen({
|
||||
);
|
||||
return (
|
||||
<FileDropZone style={styles.container}>
|
||||
<ScreenHeader left={screenHeaderLeft} borderless />
|
||||
<MenuHeader borderless />
|
||||
<View style={styles.content}>
|
||||
<TitlebarDragRegion />
|
||||
<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);
|
||||
|
||||
export const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
|
||||
|
||||
interface WorkspaceExplorerToggleProps {
|
||||
onPress: () => void;
|
||||
label: string;
|
||||
|
||||
@@ -2,14 +2,7 @@ import React, { useCallback, useMemo } from "react";
|
||||
import { View } from "react-native";
|
||||
import { useRouter, type Href } from "expo-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Copy,
|
||||
Ellipsis,
|
||||
Globe,
|
||||
Import as ImportIcon,
|
||||
Settings,
|
||||
SquarePen,
|
||||
} from "lucide-react-native";
|
||||
import { Copy, Ellipsis, Globe, Import as ImportIcon, Settings } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import type { TerminalProfile } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
@@ -24,6 +17,7 @@ import {
|
||||
extraMutedIconColorMapping,
|
||||
iconButtonChromeGlyphSize,
|
||||
iconButtonChromeStyle,
|
||||
useIconButtonChromeStyle,
|
||||
} from "@/components/ui/icon-button-chrome";
|
||||
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
|
||||
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
||||
@@ -36,16 +30,13 @@ import type { Theme } from "@/styles/theme";
|
||||
|
||||
const ThemedEllipsis = withUnistyles(Ellipsis);
|
||||
const ThemedCopy = withUnistyles(Copy);
|
||||
const ThemedSquarePen = withUnistyles(SquarePen);
|
||||
const ThemedGlobe = withUnistyles(Globe);
|
||||
const ThemedImport = withUnistyles(ImportIcon);
|
||||
const ThemedSettings = withUnistyles(Settings);
|
||||
|
||||
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
|
||||
const MENU_NEW_AGENT_ICON = <ThemedSquarePen 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_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
|
||||
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
|
||||
@@ -203,28 +194,25 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace
|
||||
normalizedServerId: string;
|
||||
showCreateBrowserTab: boolean;
|
||||
createTerminalDisabled: boolean;
|
||||
onCreateDraftTab: () => void;
|
||||
onCreateTerminal: () => void;
|
||||
onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
|
||||
onCreateBrowser: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace
|
||||
* actions.
|
||||
* Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer;
|
||||
* the rarer launches stay here alongside the workspace actions.
|
||||
*/
|
||||
export function WorkspaceHeaderMenuMobile({
|
||||
normalizedServerId,
|
||||
showCreateBrowserTab,
|
||||
createTerminalDisabled,
|
||||
onCreateDraftTab,
|
||||
onCreateTerminal,
|
||||
onCreateTerminalWithProfile,
|
||||
onCreateBrowser,
|
||||
...workspaceActions
|
||||
}: WorkspaceHeaderMenuMobileProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
|
||||
const { config } = useDaemonConfig(normalizedServerId);
|
||||
const profiles = useMemo(
|
||||
() => resolveTerminalProfiles(config?.terminalProfiles),
|
||||
@@ -239,7 +227,7 @@ export function WorkspaceHeaderMenuMobile({
|
||||
<DropdownMenu compactMode="sheet">
|
||||
<DropdownMenuTrigger
|
||||
testID="workspace-header-menu-trigger"
|
||||
style={workspaceHeaderMenuButtonStyle}
|
||||
style={triggerStyle}
|
||||
hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
|
||||
@@ -252,13 +240,6 @@ export function WorkspaceHeaderMenuMobile({
|
||||
testID="workspace-header-menu"
|
||||
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 ? (
|
||||
<DropdownMenuItem
|
||||
testID="workspace-header-new-browser"
|
||||
@@ -271,14 +252,6 @@ export function WorkspaceHeaderMenuMobile({
|
||||
<WorkspaceHeaderWorkspaceActionItems {...workspaceActions} />
|
||||
<DropdownMenuSeparator />
|
||||
<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) => (
|
||||
<HeaderMenuProfileItem
|
||||
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", () => {
|
||||
it("keeps Light selection distinct from the sidebar surface", () => {
|
||||
expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface1);
|
||||
expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface3);
|
||||
it("keeps Light hover and selection darker than the sidebar surface", () => {
|
||||
expect(lightTheme.colors.surfaceSidebarHover).toBe(lightTheme.colors.surface3);
|
||||
expect(lightTheme.colors.surfaceSidebarSelected).toBe(lightTheme.colors.surface4);
|
||||
expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar);
|
||||
});
|
||||
|
||||
|
||||
@@ -267,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
|
||||
surface4: tint.surface4,
|
||||
surfaceDiffEmpty: tint.surfaceDiffEmpty,
|
||||
surfaceSidebar: tint.surfaceSidebar,
|
||||
surfaceSidebarHover: tint.surface1,
|
||||
surfaceSidebarSelected: tint.surface3,
|
||||
surfaceSidebarHover: tint.surface3,
|
||||
surfaceSidebarSelected: tint.surface4,
|
||||
surfaceWorkspace: tint.surface0,
|
||||
interactionHighlight: "rgba(0, 0, 0, 0.06)",
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ export function UsageControls({
|
||||
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
|
||||
return (
|
||||
<View style={styles.controls}>
|
||||
{hostSelection && hostSelection.hosts.length > 1 ? (
|
||||
{hasHostChoice(hostSelection) ? (
|
||||
<HostFilter
|
||||
hosts={hostSelection.hosts}
|
||||
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 {
|
||||
return `usage-host-filter-item-${serverId}`;
|
||||
}
|
||||
@@ -65,17 +71,22 @@ const styles = StyleSheet.create((theme) => ({
|
||||
}));
|
||||
|
||||
/**
|
||||
* One host's usage and the title-row controls that go with it, for the Usage modal.
|
||||
* One host’s usage and title-row controls for the Usage modal.
|
||||
* Returns null when there is nothing to control, so headers leave no empty island.
|
||||
*/
|
||||
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
|
||||
view: UsageView;
|
||||
refresh: () => void;
|
||||
controls: ReactElement;
|
||||
controls: ReactElement | null;
|
||||
} {
|
||||
const { view, refresh } = useHostUsage(hostSelection.serverId);
|
||||
const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable";
|
||||
const controls = useMemo(
|
||||
() => <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 };
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import { usageCopy } from "./copy";
|
||||
const ThemedRotateCw = withUnistyles(RotateCw);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
|
||||
/** Refreshes every report on the host; spins while a refresh is in flight. */
|
||||
/**
|
||||
* Refreshes every report on the host; spins while a refresh is in flight. On compact it sits in the
|
||||
* header's island, so its highlight is round.
|
||||
*/
|
||||
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
|
||||
const compact = useIsCompactFormFactor();
|
||||
const iconSize = paneContentToolbarIconSize(compact);
|
||||
|
||||
@@ -48,6 +48,74 @@ describe("findActiveFileMention", () => {
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for the issue #1364 angle-bracket email example", () => {
|
||||
const text = "user <12345+user@noreply.example.com>";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for email addresses (word char before @)", () => {
|
||||
const text = "alice@example.com";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for email addresses (+ immediately before @)", () => {
|
||||
// Local part ends with + so the character at index-1 is '+', not a word char.
|
||||
const text = "noreply+@example.com";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for plus-tagged email local parts", () => {
|
||||
const text = "noreply+tag@example.com";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when query contains angle brackets", () => {
|
||||
const text = "check @foo<bar> end";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.indexOf(" end"),
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when query ends with a closing angle bracket", () => {
|
||||
const text = "check @foo>";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
});
|
||||
expect(mention).toBeNull();
|
||||
});
|
||||
|
||||
it("still detects a file mention after an email in the same input", () => {
|
||||
const text = "user <12345+user@noreply.example.com> and also @file";
|
||||
const mention = findActiveFileMention({
|
||||
text,
|
||||
cursorIndex: text.length,
|
||||
});
|
||||
expect(mention).toEqual({
|
||||
start: text.lastIndexOf("@"),
|
||||
end: text.length,
|
||||
query: "file",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatQuotedFileMentionPath", () => {
|
||||
|
||||
@@ -15,7 +15,7 @@ interface ApplyFileMentionReplacementInput {
|
||||
relativePath: string;
|
||||
}
|
||||
|
||||
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"']/;
|
||||
const INVALID_MENTION_QUERY_CHARS = /[\s\n\r\t"'<>]/;
|
||||
|
||||
export function findActiveFileMention(input: FindActiveFileMentionInput): FileMentionRange | null {
|
||||
const clampedCursor = Math.max(0, Math.min(input.cursorIndex, input.text.length));
|
||||
@@ -26,6 +26,11 @@ export function findActiveFileMention(input: FindActiveFileMentionInput): FileMe
|
||||
atIndex >= 0;
|
||||
atIndex = atIndex === 0 ? -1 : beforeCursor.lastIndexOf("@", atIndex - 1)
|
||||
) {
|
||||
// Skip @ that looks like part of an email address (local-part char immediately before @).
|
||||
if (atIndex > 0 && /[\w+]/.test(beforeCursor[atIndex - 1]!)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const query = beforeCursor.slice(atIndex + 1);
|
||||
if (INVALID_MENTION_QUERY_CHARS.test(query)) {
|
||||
continue;
|
||||
|
||||
@@ -174,9 +174,6 @@ export function WorkspaceLabelManagerModal({
|
||||
onClose={onClose}
|
||||
header={header}
|
||||
footer={footer}
|
||||
// Bound the compact scroller to the live snap height, so the edit view's footer stays on
|
||||
// screen instead of being pushed past the bottom of the sheet.
|
||||
sizeContentToCurrentSnapPoint
|
||||
testID="workspace-label-manager"
|
||||
>
|
||||
{draft && editing ? (
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
const appDir = fileURLToPath(new URL(".", import.meta.url));
|
||||
const rootDir = path.resolve(appDir, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const vitestEntry = path.join(path.dirname(require.resolve("vitest/package.json")), "vitest.mjs");
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "root",
|
||||
cwd: rootDir,
|
||||
args: ["packages/app/src/screens/workspace/use-mounted-tab-set.test.ts"],
|
||||
},
|
||||
{
|
||||
name: "app",
|
||||
cwd: appDir,
|
||||
args: ["--project", "unit", "src/screens/workspace/use-mounted-tab-set.test.ts"],
|
||||
},
|
||||
])(
|
||||
"$name tests can render React when the shell exports NODE_ENV=production",
|
||||
({ cwd, args }) => {
|
||||
const result = spawnSync(process.execPath, [vitestEntry, "run", ...args], {
|
||||
cwd,
|
||||
env: { ...process.env, NODE_ENV: "production" },
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.status, result.stdout + result.stderr).toBe(0);
|
||||
},
|
||||
40_000,
|
||||
);
|
||||
@@ -3,6 +3,9 @@ import { playwright } from "@vitest/browser-playwright";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
|
||||
// Set this before Vite resolves dependencies and Vitest starts its workers.
|
||||
process.env.NODE_ENV = "test";
|
||||
|
||||
const appNodeModules = path.resolve(__dirname, "node_modules");
|
||||
const rootNodeModules = path.resolve(__dirname, "../../node_modules");
|
||||
const resolvePackageEntry = (packageName: string) => {
|
||||
@@ -26,6 +29,7 @@ export default defineConfig({
|
||||
"src/**/*.{test,spec}.{ts,tsx}",
|
||||
"native-release-version.test.ts",
|
||||
"app.config.test.ts",
|
||||
"vitest-environment.test.ts",
|
||||
],
|
||||
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
|
||||
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import { createFollowTranscriptWriter, formatAgentActivityTranscript } from "./logs.js";
|
||||
|
||||
function collect(items: readonly AgentTimelineItem[], turnId?: string): string {
|
||||
let output = "";
|
||||
const writer = createFollowTranscriptWriter((chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
for (const item of items) {
|
||||
writer.push(item, turnId);
|
||||
}
|
||||
writer.end();
|
||||
return output;
|
||||
}
|
||||
|
||||
describe("createFollowTranscriptWriter", () => {
|
||||
it("matches one-shot logs when reasoning and assistant text alternate", () => {
|
||||
const items: AgentTimelineItem[] = [
|
||||
{ type: "reasoning", text: "Check " },
|
||||
{ type: "reasoning", text: "the commands." },
|
||||
{ type: "assistant_message", text: "I will list the commands.", messageId: "msg_1" },
|
||||
{ type: "reasoning", text: "Then summarize." },
|
||||
{ type: "assistant_message", text: "Done.", messageId: "msg_2" },
|
||||
];
|
||||
|
||||
expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items));
|
||||
});
|
||||
|
||||
it("keeps a word that arrives split across fragments intact", () => {
|
||||
const output = collect([
|
||||
{ type: "assistant_message", text: "the paseo-pr", messageId: "msg_1" },
|
||||
{ type: "assistant_message", text: "-cycle label\n", messageId: "msg_1" },
|
||||
]);
|
||||
|
||||
expect(output).toBe("the paseo-pr-cycle label\n");
|
||||
});
|
||||
|
||||
it("streams text as it arrives without inventing a line break", () => {
|
||||
const chunks: string[] = [];
|
||||
const writer = createFollowTranscriptWriter((chunk) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
writer.push({
|
||||
type: "assistant_message",
|
||||
text: "first line",
|
||||
messageId: "msg_1",
|
||||
});
|
||||
expect(chunks.join("")).toBe("first line");
|
||||
|
||||
writer.push({
|
||||
type: "assistant_message",
|
||||
text: "\nsecond",
|
||||
messageId: "msg_1",
|
||||
});
|
||||
expect(chunks.join("")).toBe("first line\nsecond");
|
||||
|
||||
writer.push({
|
||||
type: "assistant_message",
|
||||
text: " line\n",
|
||||
messageId: "msg_1",
|
||||
});
|
||||
expect(chunks.join("")).toBe("first line\nsecond line");
|
||||
|
||||
writer.end();
|
||||
expect(chunks.join("")).toBe("first line\nsecond line\n");
|
||||
});
|
||||
|
||||
it("emits the same transcript the one-shot command prints for the same items", () => {
|
||||
const items: AgentTimelineItem[] = [
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "Plan for paseo-pr",
|
||||
messageId: "msg_1",
|
||||
},
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: "-cycle:\n- build\n- test\n",
|
||||
messageId: "msg_1",
|
||||
},
|
||||
{ type: "error", message: "boom" },
|
||||
{ type: "assistant_message", text: "done", messageId: "msg_2" },
|
||||
];
|
||||
|
||||
expect(collect(items).trimEnd()).toBe(formatAgentActivityTranscript(items));
|
||||
});
|
||||
|
||||
it("starts a new message when the messageId changes", () => {
|
||||
const output = collect([
|
||||
{ type: "assistant_message", text: "first", messageId: "msg_1" },
|
||||
{ type: "assistant_message", text: "second", messageId: "msg_2" },
|
||||
]);
|
||||
|
||||
expect(output).toBe("first\nsecond\n");
|
||||
});
|
||||
|
||||
it("does not merge fragments across turns", () => {
|
||||
let output = "";
|
||||
const writer = createFollowTranscriptWriter((chunk) => {
|
||||
output += chunk;
|
||||
});
|
||||
|
||||
writer.push({ type: "assistant_message", text: "first" }, "turn_1");
|
||||
writer.push({ type: "assistant_message", text: "second" }, "turn_2");
|
||||
writer.end();
|
||||
|
||||
expect(output).toBe("first\nsecond\n");
|
||||
});
|
||||
|
||||
it("prints the thought prefix once for a merged reasoning stream", () => {
|
||||
const output = collect([
|
||||
{ type: "reasoning", text: "checking the" },
|
||||
{ type: "reasoning", text: " lockfile\nthen the build\n" },
|
||||
]);
|
||||
|
||||
expect(output).toBe("[Thought] checking the lockfile\nthen the build\n");
|
||||
});
|
||||
|
||||
it("keeps reasoning blocks separate across hidden timeline items", () => {
|
||||
let output = "";
|
||||
const writer = createFollowTranscriptWriter((chunk) => {
|
||||
output += chunk;
|
||||
}, "reasoning");
|
||||
writer.push({ type: "reasoning", text: "checking files" });
|
||||
writer.push({
|
||||
type: "tool_call",
|
||||
callId: "tool_1",
|
||||
name: "Bash",
|
||||
status: "completed",
|
||||
error: null,
|
||||
detail: { type: "unknown", input: "ls", output: "files" },
|
||||
});
|
||||
writer.push({ type: "reasoning", text: "running tests" });
|
||||
writer.end();
|
||||
|
||||
expect(output).toBe("[Thought] checking files\n[Thought] running tests\n");
|
||||
});
|
||||
|
||||
it("keeps interior blank lines and drops trailing whitespace", () => {
|
||||
const output = collect([
|
||||
{
|
||||
type: "assistant_message",
|
||||
text: " # Title\n\nBody\n\n ",
|
||||
messageId: "msg_1",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(output).toBe("# Title\n\nBody\n");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Command } from "commander";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { waitForStop } from "../../utils/wait-for-stop.js";
|
||||
import type { CommandOptions } from "../../output/index.js";
|
||||
import type { CommandError, CommandOptions } from "../../output/index.js";
|
||||
import {
|
||||
fetchProjectedTimelineItems,
|
||||
LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
@@ -32,12 +32,29 @@ export type AgentLogsResult = void;
|
||||
|
||||
export const NO_ACTIVITY_MESSAGE = "No activity to display.";
|
||||
|
||||
interface FetchAgentTimelineItemsOptions {
|
||||
timeoutMs?: number;
|
||||
sinceTimestampMs?: number;
|
||||
}
|
||||
|
||||
interface FollowModeInput {
|
||||
client: DaemonClient;
|
||||
agentId: string;
|
||||
options: AgentLogsOptions;
|
||||
sinceTimestampMs: number | undefined;
|
||||
}
|
||||
|
||||
export async function fetchAgentTimelineItems(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
options?: { timeoutMs?: number },
|
||||
options?: FetchAgentTimelineItemsOptions,
|
||||
): Promise<AgentTimelineItem[]> {
|
||||
return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
|
||||
return fetchProjectedTimelineItems({
|
||||
client,
|
||||
agentId,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
sinceTimestampMs: options?.sinceTimestampMs,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatAgentActivityTranscript(
|
||||
@@ -53,6 +70,134 @@ export function formatAgentActivityTranscript(
|
||||
);
|
||||
}
|
||||
|
||||
type StreamingTextType = "assistant_message" | "reasoning";
|
||||
|
||||
const THOUGHT_PREFIX = "[Thought] ";
|
||||
|
||||
interface PendingStreamingText {
|
||||
type: StreamingTextType;
|
||||
turnId: string | undefined;
|
||||
messageId: string | undefined;
|
||||
buffer: string;
|
||||
emitted: boolean;
|
||||
}
|
||||
|
||||
export interface FollowTranscriptWriter {
|
||||
push(item: AgentTimelineItem, turnId?: string): void;
|
||||
end(): void;
|
||||
}
|
||||
|
||||
function lastNonWhitespaceIndex(text: string): number {
|
||||
for (let index = text.length - 1; index >= 0; index -= 1) {
|
||||
if (!/\s/.test(text[index])) {
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow mode receives one streaming fragment per event, while the one-shot
|
||||
* transcript sees the whole timeline and merges consecutive assistant and
|
||||
* reasoning fragments with exact concatenation (`projectTimelineRows`). Printing
|
||||
* each fragment on its own line splits words at fragment boundaries and
|
||||
* reprints the reasoning prefix, so a chain of fragments is written as one
|
||||
* stream instead: text goes out as it arrives, and only the trailing whitespace
|
||||
* is held back so the message ends trimmed the way the transcript does.
|
||||
*/
|
||||
export function createFollowTranscriptWriter(
|
||||
write: (chunk: string) => void = (chunk) => void process.stdout.write(chunk),
|
||||
filter?: string,
|
||||
): FollowTranscriptWriter {
|
||||
let pending: PendingStreamingText | null = null;
|
||||
|
||||
const emit = (chain: PendingStreamingText, text: string): void => {
|
||||
if (!chain.emitted) {
|
||||
chain.emitted = true;
|
||||
if (chain.type === "reasoning") {
|
||||
write(THOUGHT_PREFIX);
|
||||
}
|
||||
}
|
||||
write(text);
|
||||
};
|
||||
|
||||
const flushContent = (chain: PendingStreamingText): void => {
|
||||
if (!chain.emitted) {
|
||||
chain.buffer = chain.buffer.replace(/^\s+/, "");
|
||||
}
|
||||
const lastContent = lastNonWhitespaceIndex(chain.buffer);
|
||||
if (lastContent < 0) {
|
||||
return;
|
||||
}
|
||||
emit(chain, chain.buffer.slice(0, lastContent + 1));
|
||||
chain.buffer = chain.buffer.slice(lastContent + 1);
|
||||
};
|
||||
|
||||
const closePending = (): void => {
|
||||
const chain = pending;
|
||||
pending = null;
|
||||
if (!chain) {
|
||||
return;
|
||||
}
|
||||
const tail = chain.emitted ? chain.buffer.trimEnd() : chain.buffer.trim();
|
||||
if (tail) {
|
||||
emit(chain, tail);
|
||||
}
|
||||
if (chain.emitted) {
|
||||
write("\n");
|
||||
}
|
||||
};
|
||||
|
||||
const continuesChain = (item: AgentTimelineItem, turnId: string | undefined): boolean => {
|
||||
if (!pending || pending.type !== item.type || pending.turnId !== turnId) {
|
||||
return false;
|
||||
}
|
||||
if (item.type !== "assistant_message") {
|
||||
return true;
|
||||
}
|
||||
return item.messageId === undefined || item.messageId === pending.messageId;
|
||||
};
|
||||
|
||||
const startChain = (
|
||||
item: Extract<AgentTimelineItem, { type: StreamingTextType }>,
|
||||
turnId: string | undefined,
|
||||
): PendingStreamingText => {
|
||||
closePending();
|
||||
const chain: PendingStreamingText = {
|
||||
type: item.type,
|
||||
turnId,
|
||||
messageId: item.type === "assistant_message" ? item.messageId : undefined,
|
||||
buffer: "",
|
||||
emitted: false,
|
||||
};
|
||||
pending = chain;
|
||||
return chain;
|
||||
};
|
||||
|
||||
return {
|
||||
push(item, turnId) {
|
||||
if (!matchesFilter(item, filter)) {
|
||||
closePending();
|
||||
return;
|
||||
}
|
||||
if (item.type === "assistant_message" || item.type === "reasoning") {
|
||||
const chain = continuesChain(item, turnId) && pending ? pending : startChain(item, turnId);
|
||||
chain.buffer += item.text;
|
||||
flushContent(chain);
|
||||
return;
|
||||
}
|
||||
closePending();
|
||||
const transcript = formatAgentActivityTranscript([item]);
|
||||
if (transcript && transcript !== NO_ACTIVITY_MESSAGE) {
|
||||
write(`${transcript}\n`);
|
||||
}
|
||||
},
|
||||
end() {
|
||||
closePending();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function parseTailCount(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
@@ -62,6 +207,19 @@ function parseTailCount(raw: string | undefined): number | undefined {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseSinceTimestamp(raw: string | undefined): number | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
const timestampMs = Date.parse(raw);
|
||||
if (Number.isNaN(timestampMs)) {
|
||||
throw {
|
||||
code: "INVALID_TIMESTAMP",
|
||||
message: `Invalid --since value: ${raw}`,
|
||||
details: "Use a timestamp such as 2026-01-01T00:00:00Z.",
|
||||
} satisfies CommandError;
|
||||
}
|
||||
return timestampMs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a timeline item matches the filter type
|
||||
*/
|
||||
@@ -98,6 +256,7 @@ export async function runLogsCommand(
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sinceTimestampMs = parseSinceTimestamp(options.since);
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
@@ -118,12 +277,19 @@ export async function runLogsCommand(
|
||||
await client.close().catch(() => {});
|
||||
process.exit(1);
|
||||
}
|
||||
await runFollowMode(client, resolvedId, options);
|
||||
await runFollowMode({
|
||||
client,
|
||||
agentId: resolvedId,
|
||||
options,
|
||||
sinceTimestampMs,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch timeline directly via cursor RPC.
|
||||
let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
|
||||
let timelineItems = await fetchAgentTimelineItems(client, resolvedId, {
|
||||
sinceTimestampMs,
|
||||
});
|
||||
|
||||
// Apply filter
|
||||
if (options.filter) {
|
||||
@@ -159,11 +325,12 @@ export async function runLogsCommand(
|
||||
/**
|
||||
* Follow mode: stream logs in real-time until interrupted
|
||||
*/
|
||||
async function runFollowMode(
|
||||
client: DaemonClient,
|
||||
agentId: string,
|
||||
options: AgentLogsOptions,
|
||||
): Promise<void> {
|
||||
async function runFollowMode({
|
||||
client,
|
||||
agentId,
|
||||
options,
|
||||
sinceTimestampMs,
|
||||
}: FollowModeInput): Promise<void> {
|
||||
const DEFAULT_FOLLOW_TAIL = 10;
|
||||
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
|
||||
|
||||
@@ -172,6 +339,7 @@ async function runFollowMode(
|
||||
try {
|
||||
existingItems = await fetchAgentTimelineItems(client, agentId, {
|
||||
timeoutMs: LIVE_HISTORY_FETCH_TIMEOUT_MS,
|
||||
sinceTimestampMs,
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Warning: failed to fetch existing timeline", error);
|
||||
@@ -194,31 +362,39 @@ async function runFollowMode(
|
||||
const tailLabel =
|
||||
tailCount === 0 ? "no history" : `last ${tailCount} entr${tailCount === 1 ? "y" : "ies"}`;
|
||||
|
||||
const writer = createFollowTranscriptWriter(undefined, options.filter);
|
||||
|
||||
const unsubscribe = client.subscribeAgentTimeline(agentId, (message) => {
|
||||
if (message.type === "agent.timeline.replacement") {
|
||||
writer.end();
|
||||
console.log("\n[Timeline replaced; earlier output is no longer current]");
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === "agent.timeline.error") {
|
||||
writer.end();
|
||||
console.error(`Timeline observation stopped: ${message.payload.error}`);
|
||||
return;
|
||||
}
|
||||
if (message.type === "agent.timeline.subscription_restored") {
|
||||
writer.end();
|
||||
console.log("\n[Reconnected; live output resumed. Events may have been missed.]");
|
||||
return;
|
||||
}
|
||||
if (message.payload.event.type === "timeline") {
|
||||
const item = message.payload.event.item;
|
||||
// Apply filter
|
||||
if (options.filter && !matchesFilter(item, options.filter)) {
|
||||
const matchesSince =
|
||||
sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
|
||||
if (!matchesSince) {
|
||||
return;
|
||||
}
|
||||
// Print each timeline item as it arrives using the curator format
|
||||
const transcript = formatAgentActivityTranscript([item]);
|
||||
if (transcript !== NO_ACTIVITY_MESSAGE) {
|
||||
console.log(transcript);
|
||||
}
|
||||
const item = message.payload.event.item;
|
||||
writer.push(item, message.payload.event.turnId);
|
||||
} else if (
|
||||
message.payload.event.type === "turn_completed" ||
|
||||
message.payload.event.type === "turn_failed" ||
|
||||
message.payload.event.type === "turn_canceled"
|
||||
) {
|
||||
writer.end();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -226,6 +402,7 @@ async function runFollowMode(
|
||||
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
|
||||
|
||||
await waitForStop();
|
||||
writer.end();
|
||||
unsubscribe();
|
||||
await client.close();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { Command } from "commander";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
@@ -55,106 +58,136 @@ export interface PermitAllowOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runAllowCommand(
|
||||
agentIdOrPrefix: string,
|
||||
reqId: string | undefined,
|
||||
options: PermitAllowOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitAllowResult> {
|
||||
// No validation needed - if no reqId provided, allow all by default
|
||||
export interface PermitClient {
|
||||
fetchAgent(options: { agentId: string }): Promise<{
|
||||
agent: { id: string; pendingPermissions?: AgentPermissionRequest[] };
|
||||
} | null>;
|
||||
respondToPermissionAndWait(
|
||||
agentId: string,
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<unknown>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
// Parse input JSON if provided
|
||||
let updatedInput: Record<string, unknown> | undefined;
|
||||
if (options.input) {
|
||||
try {
|
||||
updatedInput = JSON.parse(options.input);
|
||||
} catch (err) {
|
||||
const error: CommandError = {
|
||||
code: "INVALID_JSON",
|
||||
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
|
||||
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export type PermitClientConnector = (
|
||||
options: Parameters<typeof connectToDaemon>[0],
|
||||
) => Promise<PermitClient>;
|
||||
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
export function createAllowCommand(connect: PermitClientConnector = connectToDaemon) {
|
||||
return async function runAllowCommand(
|
||||
agentIdOrPrefix: string,
|
||||
reqId: string | undefined,
|
||||
options: PermitAllowOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitAllowResult> {
|
||||
// No validation needed - if no reqId provided, allow all by default
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
if (!fetchResult) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agent = fetchResult.agent;
|
||||
const resolvedAgentId = agent.id;
|
||||
|
||||
// Get pending permissions for this agent
|
||||
const pendingPermissions = agent.pendingPermissions || [];
|
||||
if (pendingPermissions.length === 0) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "NO_PENDING_PERMISSIONS",
|
||||
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Determine which permissions to allow
|
||||
let permissionsToAllow: AgentPermissionRequest[];
|
||||
if (!reqId || options.all) {
|
||||
// Default: allow all pending permissions if no req_id specified
|
||||
// --all flag is kept as an explicit alias for clarity
|
||||
permissionsToAllow = pendingPermissions;
|
||||
} else {
|
||||
// Find permission by ID prefix
|
||||
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
|
||||
if (!permission) {
|
||||
await client.close();
|
||||
// Parse input JSON if provided
|
||||
let updatedInput: Record<string, unknown> | undefined;
|
||||
if (options.input) {
|
||||
try {
|
||||
updatedInput = JSON.parse(options.input);
|
||||
} catch (err) {
|
||||
const error: CommandError = {
|
||||
code: "PERMISSION_NOT_FOUND",
|
||||
message: `Permission request not found: ${reqId}`,
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
|
||||
code: "INVALID_JSON",
|
||||
message: `Invalid JSON for --input: ${err instanceof Error ? err.message : String(err)}`,
|
||||
details: 'Provide valid JSON, e.g., --input \'{"key": "value"}\'',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
permissionsToAllow = [permission];
|
||||
}
|
||||
|
||||
// Allow permissions
|
||||
const results: PermissionResponseItem[] = await Promise.all(
|
||||
permissionsToAllow.map(async (permission) => {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
|
||||
}),
|
||||
);
|
||||
const client = await connect({ target: options.daemonTarget });
|
||||
|
||||
await client.close();
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
if (!fetchResult) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agent = fetchResult.agent;
|
||||
const resolvedAgentId = agent.id;
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: results,
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
// Re-throw CommandErrors
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
// Get pending permissions for this agent
|
||||
const pendingPermissions = agent.pendingPermissions || [];
|
||||
if (pendingPermissions.length === 0) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "NO_PENDING_PERMISSIONS",
|
||||
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Determine which permissions to allow
|
||||
let permissionsToAllow: AgentPermissionRequest[];
|
||||
if (!reqId || options.all) {
|
||||
// Default: allow all pending permissions if no req_id specified
|
||||
// --all flag is kept as an explicit alias for clarity
|
||||
permissionsToAllow = pendingPermissions;
|
||||
} else {
|
||||
// Find permission by ID prefix
|
||||
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId));
|
||||
if (!permission) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "PERMISSION_NOT_FOUND",
|
||||
message: `Permission request not found: ${reqId}`,
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
permissionsToAllow = [permission];
|
||||
}
|
||||
|
||||
// Allow permissions
|
||||
const results: PermissionResponseItem[] = await Promise.all(
|
||||
permissionsToAllow.map(async (permission) => {
|
||||
try {
|
||||
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
|
||||
behavior: "allow",
|
||||
...(updatedInput ? { updatedInput } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "PERMISSION_RESPONSE_UNCONFIRMED",
|
||||
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
|
||||
details: 'Use "paseo permit ls" to check whether the request is still pending',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "allowed");
|
||||
}),
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: results,
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
// Re-throw CommandErrors
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "ALLOW_PERMISSION_FAILED",
|
||||
message: `Failed to allow permission: ${message}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "ALLOW_PERMISSION_FAILED",
|
||||
message: `Failed to allow permission: ${message}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const runAllowCommand = createAllowCommand();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
permitResponseSchema,
|
||||
toPermissionResponseItem,
|
||||
type PermissionResponseItem,
|
||||
type PermitClientConnector,
|
||||
} from "./allow.js";
|
||||
|
||||
export type PermitDenyResult = ListResult<PermissionResponseItem>;
|
||||
@@ -17,98 +18,114 @@ export interface PermitDenyOptions extends CommandOptions {
|
||||
host?: string;
|
||||
}
|
||||
|
||||
export async function runDenyCommand(
|
||||
agentIdOrPrefix: string,
|
||||
reqId: string | undefined,
|
||||
options: PermitDenyOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitDenyResult> {
|
||||
// Validate arguments
|
||||
if (!options.all && !reqId) {
|
||||
const error: CommandError = {
|
||||
code: "MISSING_ARGUMENT",
|
||||
message: "Request ID is required unless --all is specified",
|
||||
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
if (!fetchResult) {
|
||||
await client.close();
|
||||
export function createDenyCommand(connect: PermitClientConnector = connectToDaemon) {
|
||||
return async function runDenyCommand(
|
||||
agentIdOrPrefix: string,
|
||||
reqId: string | undefined,
|
||||
options: PermitDenyOptions,
|
||||
_command: Command,
|
||||
): Promise<PermitDenyResult> {
|
||||
// Validate arguments
|
||||
if (!options.all && !reqId) {
|
||||
const error: CommandError = {
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
const agent = fetchResult.agent;
|
||||
const resolvedAgentId = agent.id;
|
||||
|
||||
// Get pending permissions for this agent
|
||||
const pendingPermissions = agent.pendingPermissions || [];
|
||||
if (pendingPermissions.length === 0) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "NO_PENDING_PERMISSIONS",
|
||||
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||
code: "MISSING_ARGUMENT",
|
||||
message: "Request ID is required unless --all is specified",
|
||||
details: "Usage: paseo permit deny <agent> <req_id> or paseo permit deny <agent> --all",
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Determine which permissions to deny
|
||||
let permissionsToDeny: AgentPermissionRequest[];
|
||||
if (options.all) {
|
||||
permissionsToDeny = pendingPermissions;
|
||||
} else {
|
||||
// Find permission by ID prefix
|
||||
const permission = pendingPermissions.find((p) => p.id === reqId || p.id.startsWith(reqId!));
|
||||
if (!permission) {
|
||||
const client = await connect({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
const fetchResult = await client.fetchAgent({ agentId: agentIdOrPrefix });
|
||||
if (!fetchResult) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "PERMISSION_NOT_FOUND",
|
||||
message: `Permission request not found: ${reqId}`,
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
|
||||
code: "AGENT_NOT_FOUND",
|
||||
message: `Agent not found: ${agentIdOrPrefix}`,
|
||||
details: 'Use "paseo ls" to list available agents',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
permissionsToDeny = [permission];
|
||||
const agent = fetchResult.agent;
|
||||
const resolvedAgentId = agent.id;
|
||||
|
||||
// Get pending permissions for this agent
|
||||
const pendingPermissions = agent.pendingPermissions || [];
|
||||
if (pendingPermissions.length === 0) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "NO_PENDING_PERMISSIONS",
|
||||
message: `No pending permissions for agent ${agent.id.slice(0, 7)}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Determine which permissions to deny
|
||||
let permissionsToDeny: AgentPermissionRequest[];
|
||||
if (options.all) {
|
||||
permissionsToDeny = pendingPermissions;
|
||||
} else {
|
||||
// Find permission by ID prefix
|
||||
const permission = pendingPermissions.find(
|
||||
(p) => p.id === reqId || p.id.startsWith(reqId!),
|
||||
);
|
||||
if (!permission) {
|
||||
await client.close();
|
||||
const error: CommandError = {
|
||||
code: "PERMISSION_NOT_FOUND",
|
||||
message: `Permission request not found: ${reqId}`,
|
||||
details: `Available requests: ${pendingPermissions.map((p) => p.id.slice(0, 8)).join(", ")}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
permissionsToDeny = [permission];
|
||||
}
|
||||
|
||||
// Deny permissions
|
||||
const results: PermissionResponseItem[] = await Promise.all(
|
||||
permissionsToDeny.map(async (permission) => {
|
||||
try {
|
||||
await client.respondToPermissionAndWait(resolvedAgentId, permission.id, {
|
||||
behavior: "deny",
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "PERMISSION_RESPONSE_UNCONFIRMED",
|
||||
message: `Permission response unconfirmed for request ${permission.id}: ${message}`,
|
||||
details: 'Use "paseo permit ls" to check whether the request is still pending',
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
|
||||
}),
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: results,
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
// Re-throw CommandErrors
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DENY_PERMISSION_FAILED",
|
||||
message: `Failed to deny permission: ${message}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Deny permissions
|
||||
const results: PermissionResponseItem[] = await Promise.all(
|
||||
permissionsToDeny.map(async (permission) => {
|
||||
await client.respondToPermission(resolvedAgentId, permission.id, {
|
||||
behavior: "deny",
|
||||
...(options.message ? { message: options.message } : {}),
|
||||
...(options.interrupt ? { interrupt: true } : {}),
|
||||
});
|
||||
return toPermissionResponseItem(resolvedAgentId, permission, "denied");
|
||||
}),
|
||||
);
|
||||
|
||||
await client.close();
|
||||
|
||||
return {
|
||||
type: "list",
|
||||
data: results,
|
||||
schema: permitResponseSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
// Re-throw CommandErrors
|
||||
if (err && typeof err === "object" && "code" in err) {
|
||||
throw err;
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const error: CommandError = {
|
||||
code: "DENY_PERMISSION_FAILED",
|
||||
message: `Failed to deny permission: ${message}`,
|
||||
};
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const runDenyCommand = createDenyCommand();
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
import { setImmediate as waitForCommandContinuations } from "node:timers/promises";
|
||||
import { Command } from "commander";
|
||||
import type {
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAllowCommand, type PermitClient } from "./allow.js";
|
||||
import { createDenyCommand } from "./deny.js";
|
||||
|
||||
const agentId = "2d85f428-fdaf-4d98-906f-c583de2b805f";
|
||||
const requests: AgentPermissionRequest[] = [
|
||||
{
|
||||
id: "permission-write",
|
||||
provider: "codex",
|
||||
name: "CodexBash",
|
||||
kind: "tool",
|
||||
},
|
||||
{ id: "permission-edit", provider: "codex", name: "CodexBash", kind: "tool" },
|
||||
];
|
||||
|
||||
class PermissionClient implements PermitClient {
|
||||
readonly pending = [...requests];
|
||||
readonly responses = new Map<string, AgentPermissionResponse>();
|
||||
closed = false;
|
||||
private notifyReceived = () => {};
|
||||
private readonly confirmations = new Map<
|
||||
string,
|
||||
{
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
}
|
||||
>();
|
||||
|
||||
async fetchAgent() {
|
||||
return { agent: { id: agentId, pendingPermissions: this.pending } };
|
||||
}
|
||||
|
||||
async respondToPermission(
|
||||
_agentId: string,
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
) {
|
||||
this.responses.set(requestId, response);
|
||||
this.notifyReceived();
|
||||
}
|
||||
|
||||
async respondToPermissionAndWait(
|
||||
responseAgentId: string,
|
||||
requestId: string,
|
||||
response: AgentPermissionResponse,
|
||||
): Promise<void> {
|
||||
const confirmation = new Promise<void>((resolve, reject) => {
|
||||
this.confirmations.set(requestId, { resolve, reject });
|
||||
});
|
||||
await this.respondToPermission(responseAgentId, requestId, response);
|
||||
await confirmation;
|
||||
}
|
||||
|
||||
async close() {
|
||||
this.closed = true;
|
||||
}
|
||||
|
||||
async waitForResponses(count: number) {
|
||||
while (this.responses.size < count) {
|
||||
await new Promise<void>((resolve) => {
|
||||
this.notifyReceived = resolve;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
confirm(requestId: string) {
|
||||
const confirmation = this.confirmations.get(requestId);
|
||||
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
|
||||
this.pending.splice(
|
||||
this.pending.findIndex((request) => request.id === requestId),
|
||||
1,
|
||||
);
|
||||
confirmation.resolve();
|
||||
}
|
||||
|
||||
fail(requestId: string, error: Error) {
|
||||
const confirmation = this.confirmations.get(requestId);
|
||||
if (!confirmation) throw new Error(`No confirmation waiter: ${requestId}`);
|
||||
confirmation.reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
daemonTarget: { kind: "endpoint" as const, host: "test.invalid" },
|
||||
};
|
||||
const actions = [
|
||||
{ name: "allow", create: createAllowCommand, result: "allowed" },
|
||||
{ name: "deny", create: createDenyCommand, result: "denied" },
|
||||
];
|
||||
for (const action of actions) {
|
||||
describe(`permit ${action.name}`, () => {
|
||||
it("returns success and closes only after confirmation", async () => {
|
||||
const client = new PermissionClient();
|
||||
let completed = false;
|
||||
const command = action.create(async () => client)(
|
||||
agentId,
|
||||
requests[0].id,
|
||||
options,
|
||||
new Command(),
|
||||
);
|
||||
void command.then(
|
||||
() => (completed = true),
|
||||
() => false,
|
||||
);
|
||||
await client.waitForResponses(1);
|
||||
await waitForCommandContinuations();
|
||||
expect(completed).toBe(false);
|
||||
expect(client.closed).toBe(false);
|
||||
expect(client.pending).toEqual(requests);
|
||||
client.confirm(requests[0].id);
|
||||
const result = await command;
|
||||
expect(result.data).toEqual([
|
||||
{
|
||||
requestId: requests[0].id,
|
||||
agentId,
|
||||
agentShortId: agentId.slice(0, 7),
|
||||
name: "CodexBash",
|
||||
result: action.result,
|
||||
},
|
||||
]);
|
||||
expect(client.closed).toBe(true);
|
||||
expect(client.pending).toEqual([requests[1]]);
|
||||
});
|
||||
|
||||
it("waits for every confirmation with --all", async () => {
|
||||
const client = new PermissionClient();
|
||||
let completed = false;
|
||||
const command = action.create(async () => client)(
|
||||
agentId,
|
||||
undefined,
|
||||
{ ...options, all: true },
|
||||
new Command(),
|
||||
);
|
||||
void command.then(
|
||||
() => (completed = true),
|
||||
() => false,
|
||||
);
|
||||
await client.waitForResponses(2);
|
||||
client.confirm(requests[0].id);
|
||||
// Wait beyond the first reply's promise continuations while withholding the second.
|
||||
await waitForCommandContinuations();
|
||||
expect(completed).toBe(false);
|
||||
expect(client.closed).toBe(false);
|
||||
client.confirm(requests[1].id);
|
||||
const result = await command;
|
||||
expect(result.data.map((item) => item.result)).toEqual([action.result, action.result]);
|
||||
expect(client.closed).toBe(true);
|
||||
expect(client.pending).toEqual([]);
|
||||
});
|
||||
|
||||
it("rejects an unconfirmed response without returning success", async () => {
|
||||
const client = new PermissionClient();
|
||||
const command = action.create(async () => client)(
|
||||
agentId,
|
||||
requests[0].id,
|
||||
options,
|
||||
new Command(),
|
||||
);
|
||||
const failure = expect(command).rejects.toMatchObject({
|
||||
code: "PERMISSION_RESPONSE_UNCONFIRMED",
|
||||
message: expect.stringContaining(requests[0].id),
|
||||
details: 'Use "paseo permit ls" to check whether the request is still pending',
|
||||
});
|
||||
await client.waitForResponses(1);
|
||||
client.fail(requests[0].id, new Error("Timeout waiting for message (15000ms)"));
|
||||
await failure;
|
||||
expect(client.closed).toBe(true);
|
||||
expect(client.pending).toEqual(requests);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import path from "path";
|
||||
import type { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
|
||||
import { resolveRepositoryDirectory } from "./repository-directory.js";
|
||||
import type {
|
||||
CommandOptions,
|
||||
SingleResult,
|
||||
@@ -31,6 +32,7 @@ export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
|
||||
|
||||
export interface WorktreeArchiveOptions extends CommandOptions {
|
||||
host?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>;
|
||||
@@ -49,6 +51,7 @@ export async function runArchiveCommandWithDeps(
|
||||
deps: { connectToDaemon: typeof connectToDaemon },
|
||||
): Promise<WorktreeArchiveCommandResult> {
|
||||
const host = getDaemonHost({ target: options.daemonTarget });
|
||||
const cwd = resolveRepositoryDirectory(options);
|
||||
|
||||
// Validate arguments
|
||||
if (!nameArg || nameArg.trim().length === 0) {
|
||||
@@ -76,7 +79,7 @@ export async function runArchiveCommandWithDeps(
|
||||
|
||||
try {
|
||||
// Get the list of worktrees first to resolve the name
|
||||
const listResponse = await client.getPaseoWorktreeList({});
|
||||
const listResponse = await client.getPaseoWorktreeList({ cwd });
|
||||
|
||||
if (listResponse.error) {
|
||||
const error: CommandError = {
|
||||
|
||||
@@ -9,7 +9,10 @@ export function createWorktreeCommand(): Command {
|
||||
const worktree = new Command("worktree").description("Manage Paseo-managed git worktrees");
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
worktree.command("ls").description("List Paseo-managed git worktrees"),
|
||||
worktree
|
||||
.command("ls")
|
||||
.description("List Paseo-managed git worktrees")
|
||||
.option("--cwd <path>", "Repository directory (default: current)"),
|
||||
).action(withOutput(runLsCommand));
|
||||
|
||||
addJsonAndDaemonHostOptions(
|
||||
@@ -31,7 +34,8 @@ export function createWorktreeCommand(): Command {
|
||||
worktree
|
||||
.command("archive")
|
||||
.description("Archive a worktree (removes worktree and associated branch)")
|
||||
.argument("<name>", "Worktree name or branch name"),
|
||||
.argument("<name>", "Worktree name or branch name")
|
||||
.option("--cwd <path>", "Repository directory (default: current)"),
|
||||
).action(withOutput(runArchiveCommand));
|
||||
|
||||
return worktree;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Command } from "commander";
|
||||
import { basename } from "node:path";
|
||||
import { connectToDaemon } from "../../utils/client.js";
|
||||
import { resolveRepositoryDirectory } from "./repository-directory.js";
|
||||
import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js";
|
||||
|
||||
/** Worktree list item for display */
|
||||
@@ -40,12 +41,14 @@ export type WorktreeLsResult = ListResult<WorktreeListItem>;
|
||||
|
||||
export interface WorktreeLsOptions extends CommandOptions {
|
||||
host?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
export async function runLsCommand(
|
||||
options: WorktreeLsOptions,
|
||||
_command: Command,
|
||||
): Promise<WorktreeLsResult> {
|
||||
const cwd = resolveRepositoryDirectory(options);
|
||||
const client = await connectToDaemon({ target: options.daemonTarget });
|
||||
|
||||
try {
|
||||
@@ -53,7 +56,7 @@ export async function runLsCommand(
|
||||
const agents = agentsPayload.entries.map((entry) => entry.agent);
|
||||
|
||||
// Get worktree list from daemon
|
||||
const response = await client.getPaseoWorktreeList({});
|
||||
const response = await client.getPaseoWorktreeList({ cwd });
|
||||
|
||||
await client.close();
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { resolve } from "node:path";
|
||||
import type { DaemonTarget } from "../../utils/daemon-target.js";
|
||||
|
||||
export function resolveRepositoryDirectory(options: {
|
||||
cwd?: string;
|
||||
daemonTarget: DaemonTarget;
|
||||
}): string {
|
||||
const cwd = options.cwd ?? process.cwd();
|
||||
if (options.daemonTarget.kind === "instance") {
|
||||
return resolve(cwd);
|
||||
}
|
||||
return cwd;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ interface FetchProjectedTimelineItemsInput {
|
||||
client: DaemonClient;
|
||||
agentId: string;
|
||||
timeoutMs?: number;
|
||||
sinceTimestampMs?: number;
|
||||
}
|
||||
|
||||
export async function fetchProjectedTimelineItems(
|
||||
@@ -18,5 +19,10 @@ export async function fetchProjectedTimelineItems(
|
||||
projection: "projected",
|
||||
timeout: input.timeoutMs,
|
||||
});
|
||||
return timeline.entries.map((entry) => entry.item);
|
||||
let entries = timeline.entries;
|
||||
const sinceTimestampMs = input.sinceTimestampMs;
|
||||
if (sinceTimestampMs !== undefined) {
|
||||
entries = entries.filter((entry) => Date.parse(entry.timestamp) >= sinceTimestampMs);
|
||||
}
|
||||
return entries.map((entry) => entry.item);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@
|
||||
import assert from "node:assert";
|
||||
import { getAvailablePort } from "./helpers/network.ts";
|
||||
import { $ } from "zx";
|
||||
import { mkdtemp, rm } from "fs/promises";
|
||||
import { mkdtemp, rm, mkdir } from "fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { runPaseoCli, startTestDaemon } from "./helpers/test-daemon.ts";
|
||||
import { tmpdir } from "os";
|
||||
import { join } from "path";
|
||||
|
||||
@@ -50,6 +53,7 @@ try {
|
||||
const result = await $`npx paseo worktree ls --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "worktree ls --help should exit 0");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
|
||||
console.log("✓ worktree ls --help shows options\n");
|
||||
}
|
||||
|
||||
@@ -86,6 +90,7 @@ try {
|
||||
const result = await $`npx paseo worktree archive --help`.nothrow();
|
||||
assert.strictEqual(result.exitCode, 0, "worktree archive --help should exit 0");
|
||||
assert(result.stdout.includes("--host"), "help should mention --host option");
|
||||
assert(result.stdout.includes("--cwd"), "help should mention --cwd option");
|
||||
assert(result.stdout.includes("<name>"), "help should mention required name argument");
|
||||
console.log("✓ worktree archive --help shows options\n");
|
||||
}
|
||||
@@ -167,4 +172,66 @@ try {
|
||||
await rm(paseoHome, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
// Exercise repository selection through the real CLI and daemon. An empty
|
||||
// request used to fail before Git could inspect even a valid repository.
|
||||
const daemon = await startTestDaemon();
|
||||
const git = promisify(execFile);
|
||||
try {
|
||||
const repo = join(daemon.workDir, "repository");
|
||||
await mkdir(repo);
|
||||
await git("git", ["init", repo]);
|
||||
await git("git", [
|
||||
"-C",
|
||||
repo,
|
||||
"-c",
|
||||
"user.name=Test",
|
||||
"-c",
|
||||
"user.email=test@example.com",
|
||||
"commit",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"Initial commit",
|
||||
]);
|
||||
|
||||
for (const args of [
|
||||
["worktree", "ls", "--json"],
|
||||
["worktree", "ls", "--cwd", repo, "--json"],
|
||||
["worktree", "ls", "--cwd", "./repository", "--json"],
|
||||
["worktree", "ls", "--host", `127.0.0.1:${daemon.port}`, "--cwd", ".", "--json"],
|
||||
]) {
|
||||
const explicit = args.includes("--cwd");
|
||||
const result = await runPaseoCli(daemon, args, {
|
||||
cwd: explicit ? daemon.workDir : repo,
|
||||
env: { FORCE_COLOR: undefined },
|
||||
});
|
||||
assert.strictEqual(result.exitCode, 0, result.stderr);
|
||||
assert.deepStrictEqual(JSON.parse(result.stdout), []);
|
||||
}
|
||||
|
||||
for (const args of [
|
||||
["worktree", "archive", "missing", "--json"],
|
||||
["worktree", "archive", "missing", "--cwd", repo, "--json"],
|
||||
["worktree", "archive", "missing", "--cwd", "./repository", "--json"],
|
||||
[
|
||||
"worktree",
|
||||
"archive",
|
||||
"missing",
|
||||
"--host",
|
||||
`127.0.0.1:${daemon.port}`,
|
||||
"--cwd",
|
||||
".",
|
||||
"--json",
|
||||
],
|
||||
]) {
|
||||
const result = await runPaseoCli(daemon, args, {
|
||||
cwd: args.includes("--cwd") ? daemon.workDir : repo,
|
||||
env: { FORCE_COLOR: undefined },
|
||||
});
|
||||
assert.notStrictEqual(result.exitCode, 0);
|
||||
assert.strictEqual(JSON.parse(result.stderr).error.code, "WORKTREE_NOT_FOUND", result.stderr);
|
||||
}
|
||||
} finally {
|
||||
await daemon.stop();
|
||||
}
|
||||
|
||||
console.log("=== All worktree tests passed ===");
|
||||
|
||||
@@ -364,10 +364,6 @@ try {
|
||||
ids.every((id) => id.startsWith("gpt-")),
|
||||
"all codex model IDs should be from the gpt family",
|
||||
);
|
||||
assert(
|
||||
ids.some((id) => id.includes("codex")),
|
||||
"codex model list should include at least one codex-optimized model",
|
||||
);
|
||||
assert(
|
||||
data.every((m) => m.model && m.id && m.description),
|
||||
"every codex model should have model, id, and description fields",
|
||||
|
||||
@@ -47,3 +47,33 @@ describe("renderTerminalSnapshotToAnsi", () => {
|
||||
expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderTerminalSnapshotToAnsi wide chars", () => {
|
||||
it("skips wide-char continuation cells so the glyph keeps its two columns", () => {
|
||||
// The daemon captures "통" as the glyph followed by an empty continuation
|
||||
// cell. Emitting anything for that cell pushes the rest of the row right.
|
||||
const state: TerminalState = {
|
||||
rows: 1,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]],
|
||||
cursor: { row: 0, col: 5 },
|
||||
};
|
||||
|
||||
expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!");
|
||||
});
|
||||
|
||||
it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => {
|
||||
// Old daemons already collapsed the continuation to " ". The serializer
|
||||
// cannot tell that apart from a real space, so it must not guess.
|
||||
const state: TerminalState = {
|
||||
rows: 1,
|
||||
cols: 10,
|
||||
scrollback: [],
|
||||
grid: [cells("통 합 !")],
|
||||
cursor: { row: 0, col: 6 },
|
||||
};
|
||||
|
||||
expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +87,12 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const cell = row[index] ?? { char: " " };
|
||||
// An empty char marks a wide-char continuation cell. The glyph in the
|
||||
// previous cell already advances the cursor two columns, so emitting
|
||||
// anything here would shift the rest of the row one column right.
|
||||
if (cell.char === "") {
|
||||
continue;
|
||||
}
|
||||
const nextStyle = getTerminalStyle(cell);
|
||||
if (!terminalStylesEqual(previousStyle, nextStyle)) {
|
||||
output.push(styleToAnsi(nextStyle));
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -39,6 +39,45 @@ function row(seq: number, item: AgentTimelineItem): AgentTimelineRow {
|
||||
}
|
||||
|
||||
describe("curateAgentActivity", () => {
|
||||
it("keeps reasoning before the assistant text that followed it", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "reasoning", text: "Check the commands first." },
|
||||
{ type: "assistant_message", text: "I will list the commands." },
|
||||
toolCallItem({
|
||||
callId: "shell-order",
|
||||
name: "Bash",
|
||||
detail: {
|
||||
type: "shell",
|
||||
command: "ls packages/cli/src/commands/agent/",
|
||||
output: "",
|
||||
exitCode: 0,
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
expect(curateAgentActivity(timeline)).toBe(
|
||||
"[Thought] Check the commands first.\nI will list the commands.\n[Shell] ls packages/cli/src/commands/agent/",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves alternating text kinds, labels, and fragment concatenation", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "assistant_message", text: "First message." },
|
||||
{ type: "reasoning", text: "First " },
|
||||
{ type: "reasoning", text: "thought." },
|
||||
{ type: "assistant_message", text: "Second " },
|
||||
{ type: "assistant_message", text: "message." },
|
||||
{ type: "reasoning", text: "Second thought." },
|
||||
];
|
||||
|
||||
expect(curateAgentActivity(timeline, { labelAssistantMessages: true })).toBe(
|
||||
"[Assistant] First message.\n[Thought] First thought.\n[Assistant] Second message.\n[Thought] Second thought.",
|
||||
);
|
||||
expect(curateAgentActivity(timeline, { maxItems: 2 })).toBe(
|
||||
"Second message.\n[Thought] Second thought.",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders user/assistant/reasoning entries", () => {
|
||||
const timeline: AgentTimelineItem[] = [
|
||||
{ type: "user_message", text: "Hello" },
|
||||
|
||||
@@ -25,36 +25,20 @@ interface ActivityEntry {
|
||||
|
||||
type TextAgentAttachment = Extract<AgentAttachment, { type: "text" }>;
|
||||
|
||||
function appendText(buffer: string, text: string): string {
|
||||
const normalized = text.trim();
|
||||
if (!normalized) {
|
||||
return buffer;
|
||||
}
|
||||
if (!buffer) {
|
||||
return normalized;
|
||||
}
|
||||
return `${buffer}\n${normalized}`;
|
||||
}
|
||||
|
||||
function activityEntry(text: string): ActivityEntry {
|
||||
return { text };
|
||||
}
|
||||
|
||||
function flushBuffers(
|
||||
function appendTextEntry(
|
||||
entries: ActivityEntry[],
|
||||
buffers: { message: string; thought: string },
|
||||
item: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
|
||||
options?: ActivityCuratorOptions,
|
||||
) {
|
||||
if (buffers.message.trim()) {
|
||||
const text = buffers.message.trim();
|
||||
entries.push(activityEntry(options?.labelAssistantMessages ? `[Assistant] ${text}` : text));
|
||||
}
|
||||
if (buffers.thought.trim()) {
|
||||
const text = buffers.thought.trim();
|
||||
entries.push(activityEntry(`[Thought] ${text}`));
|
||||
}
|
||||
buffers.message = "";
|
||||
buffers.thought = "";
|
||||
const text = item.text.trim();
|
||||
if (!text) return;
|
||||
let prefix = options?.labelAssistantMessages ? "[Assistant] " : "";
|
||||
if (item.type === "reasoning") prefix = "[Thought] ";
|
||||
entries.push(activityEntry(`${prefix}${text}`));
|
||||
}
|
||||
|
||||
function formatToolInputJson(input: unknown): string | null {
|
||||
@@ -147,7 +131,6 @@ function curateProjectedActivityEntries(
|
||||
const recentItems = maxItems > 0 && items.length > maxItems ? items.slice(-maxItems) : items;
|
||||
|
||||
const entries: ActivityEntry[] = [];
|
||||
const buffers = { message: "", thought: "" };
|
||||
|
||||
for (const item of recentItems) {
|
||||
if (!shouldIncludeItem(item, options)) {
|
||||
@@ -156,17 +139,13 @@ function curateProjectedActivityEntries(
|
||||
|
||||
switch (item.type) {
|
||||
case "user_message":
|
||||
flushBuffers(entries, buffers, options);
|
||||
entries.push(activityEntry(`[User] ${item.text.trim()}`));
|
||||
break;
|
||||
case "assistant_message":
|
||||
buffers.message = appendText(buffers.message, item.text);
|
||||
break;
|
||||
case "reasoning":
|
||||
buffers.thought = appendText(buffers.thought, item.text);
|
||||
appendTextEntry(entries, item, options);
|
||||
break;
|
||||
case "tool_call": {
|
||||
flushBuffers(entries, buffers, options);
|
||||
entries.push(formatToolCallEntry(item, options));
|
||||
if (item.detail.type === "sub_agent" && item.detail.log.trim()) {
|
||||
entries.push(activityEntry(item.detail.log.trim()));
|
||||
@@ -174,7 +153,6 @@ function curateProjectedActivityEntries(
|
||||
break;
|
||||
}
|
||||
case "todo":
|
||||
flushBuffers(entries, buffers, options);
|
||||
entries.push(activityEntry("[Tasks]"));
|
||||
for (const entry of item.items) {
|
||||
const checkbox = entry.completed ? "[x]" : "[ ]";
|
||||
@@ -183,18 +161,14 @@ function curateProjectedActivityEntries(
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
flushBuffers(entries, buffers, options);
|
||||
entries.push(activityEntry(`[Error] ${item.message}`));
|
||||
break;
|
||||
case "compaction":
|
||||
flushBuffers(entries, buffers, options);
|
||||
entries.push(activityEntry("[Compacted]"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
flushBuffers(entries, buffers, options);
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
|
||||
@@ -2486,7 +2486,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
|
||||
version: 1,
|
||||
baseRefName: "existing-feature",
|
||||
baseRefName: "main",
|
||||
});
|
||||
expect(generateCalls).toBe(1);
|
||||
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { mkdtemp, mkdir, rm } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { ProviderOverridesSchema } from "@getpaseo/protocol/provider-config";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
import type { AgentSession } from "./agent-sdk-types.js";
|
||||
|
||||
// Retained reproduction for #3178, awaiting the built-in ID configuration decision.
|
||||
// Requires OMP on PATH (or OMP_COMMAND). Uses no model calls or existing provider state.
|
||||
const runReproduction = process.env.OMP_PROVIDER_COLLISION_REPRO === "1";
|
||||
|
||||
for (const providerId of ["omp-acp", "omp"]) {
|
||||
test.skipIf(!runReproduction)(
|
||||
`explicit ACP configuration creates an ACP session under '${providerId}'`,
|
||||
async () => {
|
||||
const cwd = await mkdtemp(path.join(process.cwd(), ".provider-collision-"));
|
||||
const agentDir = path.join(cwd, "agent");
|
||||
await mkdir(agentDir);
|
||||
const providerOverrides = ProviderOverridesSchema.parse({
|
||||
[providerId]: {
|
||||
extends: "acp",
|
||||
label: "Oh My Pi ACP",
|
||||
command: [process.env.OMP_COMMAND ?? "omp", "acp"],
|
||||
enabled: true,
|
||||
env: {
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
OMP_PROFILE: "default",
|
||||
PI_PROFILE: "default",
|
||||
XDG_CONFIG_HOME: path.join(cwd, "config"),
|
||||
XDG_DATA_HOME: path.join(cwd, "data"),
|
||||
XDG_CACHE_HOME: path.join(cwd, "cache"),
|
||||
XDG_STATE_HOME: path.join(cwd, "state"),
|
||||
},
|
||||
},
|
||||
});
|
||||
const logger = createTestLogger();
|
||||
const registry = buildProviderRegistry(logger, { providerOverrides });
|
||||
const client = registry[providerId]!.createClient(logger);
|
||||
let session: AgentSession | undefined;
|
||||
try {
|
||||
session = await client.createSession({ provider: providerId, cwd });
|
||||
expect(session.provider).toBe(providerId);
|
||||
} finally {
|
||||
await session?.close();
|
||||
await client.shutdown?.();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,10 @@ import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
||||
import type { PaseoToolCatalog } from "../../tools/types.js";
|
||||
import { createPaseoToolCatalog } from "../../tools/paseo-tools.js";
|
||||
import { AgentManager } from "../../agent-manager.js";
|
||||
import { AgentStorage } from "../../agent-storage.js";
|
||||
import { ProviderSnapshotManager } from "../../provider-snapshot-manager.js";
|
||||
import {
|
||||
OpenCodeBridge,
|
||||
loadOpenCodeBridgePluginArtifact,
|
||||
@@ -291,7 +295,10 @@ describe("OpenCodeBridge", () => {
|
||||
).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] });
|
||||
await expect(
|
||||
tools.get("paseo_echo_context")!.execute({ value: "blocked" }, { sessionID: "disabled" }),
|
||||
).rejects.toThrow("HTTP 403");
|
||||
).rejects.toMatchObject({
|
||||
message: "Paseo tools are disabled for this session",
|
||||
status: 403,
|
||||
});
|
||||
await dispose();
|
||||
} finally {
|
||||
release();
|
||||
@@ -300,6 +307,72 @@ describe("OpenCodeBridge", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("v2 plugin preserves create_agent validation errors from the daemon", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-errors-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
const logger = createTestLogger();
|
||||
const providerSnapshotManager = new ProviderSnapshotManager({ logger });
|
||||
const catalog = createPaseoToolCatalog({
|
||||
agentManager: new AgentManager({ logger }),
|
||||
agentStorage: new AgentStorage(paseoHome, logger),
|
||||
providerSnapshotManager,
|
||||
logger,
|
||||
});
|
||||
const bridge = new OpenCodeBridge({ paseoHome, logger });
|
||||
bridge.setManifestCatalog(catalog);
|
||||
await bridge.start();
|
||||
const release = bridge.bindSession({ sessionId: "caller", env: {}, tools: catalog });
|
||||
try {
|
||||
const env = await bridge.decorateV2ServerEnv({});
|
||||
const { plugins } = JSON.parse(env.OPENCODE_CONFIG_CONTENT);
|
||||
const plugin = plugins[0];
|
||||
const module = await import(
|
||||
pathToFileURL(path.join(fileURLToPath(plugin.package), "server.js")).href
|
||||
);
|
||||
const tools = new Map<string, V2TestTool>();
|
||||
const dispose = await module.default.setup({
|
||||
options: plugin.options,
|
||||
tool: {
|
||||
transform: async (transform: (editor: { add(tool: V2TestTool): void }) => void) => {
|
||||
transform({ add: (tool) => tools.set(tool.name, tool) });
|
||||
return { dispose: async () => undefined };
|
||||
},
|
||||
},
|
||||
session: {
|
||||
context: async () => [],
|
||||
get: async () => ({ parentID: "" }),
|
||||
hook: async () => ({ dispose: async () => undefined }),
|
||||
},
|
||||
} satisfies V2TestPluginContext);
|
||||
try {
|
||||
const input = { provider: "codex", title: "Invalid provider", initialPrompt: "Hello" };
|
||||
const response = await fetch(
|
||||
`${plugin.options.baseUrl}/_internal/opencode/sessions/caller/tools/create_agent`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${plugin.options.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(500);
|
||||
const payload = await response.json();
|
||||
expect(payload.error).toContain("provider must be provider/model");
|
||||
await expect(
|
||||
tools.get("paseo_create_agent")!.execute(input, { sessionID: "caller" }),
|
||||
).rejects.toMatchObject({ message: payload.error, status: 500 });
|
||||
} finally {
|
||||
await dispose();
|
||||
}
|
||||
} finally {
|
||||
release();
|
||||
await bridge.close();
|
||||
await providerSnapshotManager.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("v2 plugin returns a tool image as OpenCode file content", async () => {
|
||||
const paseoHome = await mkdtemp(path.join(tmpdir(), "paseo-opencode-v2-image-"));
|
||||
temporaryDirectories.push(paseoHome);
|
||||
|
||||
@@ -13,7 +13,16 @@ export default {
|
||||
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||
});
|
||||
if (allowMissing && response.status === 404) return null;
|
||||
if (!response.ok) throw new Error(`Paseo tool bridge returned HTTP ${response.status}`);
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
const message =
|
||||
typeof payload?.error === "string" && payload.error
|
||||
? payload.error
|
||||
: `Paseo tool bridge returned HTTP ${response.status}`;
|
||||
const error = new Error(message);
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
async function scope(sessionID) {
|
||||
|
||||
@@ -134,6 +134,44 @@ describe("daemon web UI bootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("spells out the scheme default port when the Host header omits it", async () => {
|
||||
const distDir = await createWebUiDist();
|
||||
|
||||
daemonHandle = await createTestPaseoDaemon({
|
||||
mcpEnabled: false,
|
||||
webUi: {
|
||||
enabled: true,
|
||||
distDir,
|
||||
},
|
||||
});
|
||||
|
||||
// A browser reaching a proxy that terminates TLS on 443 sends a Host header
|
||||
// with no port at all. The app rejects a portless `listen`, so the daemon
|
||||
// has to supply the default itself.
|
||||
const httpsHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({
|
||||
port: daemonHandle.port,
|
||||
headers: { host: "paseo.example.test", "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
const httpHint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({
|
||||
port: daemonHandle.port,
|
||||
headers: { host: "paseo.example.test" },
|
||||
}),
|
||||
);
|
||||
const ipv6Hint = readInjectedConnectionHint(
|
||||
await fetchDaemonWebUi({
|
||||
port: daemonHandle.port,
|
||||
headers: { host: "[::1]", "x-forwarded-proto": "https" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(httpsHint.listen).toBe("paseo.example.test:443");
|
||||
expect(httpHint.listen).toBe("paseo.example.test:80");
|
||||
expect(ipv6Hint.listen).toBe("[::1]:443");
|
||||
});
|
||||
|
||||
test("ignores forwarded HTTPS when proxy trust is disabled", async () => {
|
||||
const distDir = await createWebUiDist();
|
||||
|
||||
|
||||
@@ -832,6 +832,9 @@ export async function createPaseoDaemon(
|
||||
try {
|
||||
fileHandle = await open(entry.absolutePath, DOWNLOAD_OPEN_FLAGS);
|
||||
const fileStats = await fileHandle.stat();
|
||||
if (res.destroyed) {
|
||||
return;
|
||||
}
|
||||
if (!fileStats.isFile()) {
|
||||
res.status(404).json({ error: "File not found" });
|
||||
return;
|
||||
@@ -843,6 +846,7 @@ export async function createPaseoDaemon(
|
||||
|
||||
const stream = fileHandle.createReadStream();
|
||||
fileHandle = null;
|
||||
res.once("close", () => stream.destroy());
|
||||
stream.on("error", (err) => {
|
||||
logger.error({ err }, "Failed to stream download");
|
||||
if (!res.headersSent) {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { test } from "vitest";
|
||||
import assert from "node:assert";
|
||||
import { spawn } from "node:child_process";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Readable } from "node:stream";
|
||||
import { join } from "node:path";
|
||||
import { createDaemonTestContext } from "./test-utils/daemon-test-context.js";
|
||||
import { createTestAgentClients } from "./test-utils/fake-agent-client.js";
|
||||
import type { AgentStreamEvent } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
interface CliResult {
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
type CliProcess = Promise<CliResult> & { stdout: Readable; kill: (signal: NodeJS.Signals) => void };
|
||||
|
||||
function waitForCliOutput(child: CliProcess, text: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let output = "";
|
||||
const onData = (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
if (output.includes(text)) {
|
||||
cleanup();
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
clearTimeout(timer);
|
||||
child.stdout.off("data", onData);
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`CLI did not print ${text}:\n${output}`));
|
||||
}, 20_000);
|
||||
child.stdout.on("data", onData);
|
||||
void child.then(
|
||||
(result) => {
|
||||
cleanup();
|
||||
return reject(new Error(`CLI exited before ${text}:\n${result.stderr}`));
|
||||
},
|
||||
(error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("logs --since filters history and live output through the CLI and daemon", async () => {
|
||||
const cliHome = await mkdtemp(join(tmpdir(), "paseo-logs-cli-home-"));
|
||||
const port = 1; // Invalid dates must fail before trying this unavailable endpoint.
|
||||
const runLocalPaseo = (
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
cwd = cliHome,
|
||||
): CliProcess => {
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--no-warnings",
|
||||
"--conditions=source",
|
||||
"--import",
|
||||
import.meta.resolve("tsx"),
|
||||
fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)),
|
||||
...args,
|
||||
],
|
||||
{
|
||||
cwd,
|
||||
env: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
|
||||
),
|
||||
...env,
|
||||
HOME: cliHome,
|
||||
USERPROFILE: cliHome,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: 25_000,
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
const done = new Promise<CliResult>((resolve, reject) => {
|
||||
child.on("error", reject);
|
||||
child.on("close", (exitCode) => resolve({ exitCode, stdout, stderr }));
|
||||
});
|
||||
return Object.assign(done, {
|
||||
stdout: child.stdout,
|
||||
kill: (signal: NodeJS.Signals) => {
|
||||
child.kill(signal);
|
||||
},
|
||||
});
|
||||
};
|
||||
// Follow assertions check output; Windows forcibly terminates Node on SIGINT.
|
||||
try {
|
||||
console.log("--since filters logs through the real CLI and daemon");
|
||||
const historyEvents: AgentStreamEvent[] = [];
|
||||
const ctx = await createDaemonTestContext({
|
||||
agentClients: createTestAgentClients({ historyEvents }),
|
||||
});
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: ctx.daemon.paseoHome,
|
||||
title: "logs since",
|
||||
});
|
||||
const manager = ctx.daemon.daemon.agentManager;
|
||||
await manager.appendTimelineItem(agent.id, { type: "user_message", text: "OLD_USER" });
|
||||
await manager.appendTimelineItem(agent.id, { type: "assistant_message", text: "OLD_REPLY" });
|
||||
const host = `127.0.0.1:${ctx.daemon.port}`;
|
||||
const logs = (args: string[]) =>
|
||||
runLocalPaseo(["--host", host, ...args], {}, ctx.daemon.paseoHome);
|
||||
|
||||
const baseline = await logs(["logs", agent.id]);
|
||||
assert.strictEqual(baseline.exitCode, 0, baseline.stderr);
|
||||
assert.strictEqual(baseline.stdout, "[User] OLD_USER\nOLD_REPLY\n");
|
||||
|
||||
for (const command of [["logs"], ["agent", "logs"]]) {
|
||||
const result = await logs([...command, agent.id, "--since", "2100-01-01T00:00:00Z"]);
|
||||
console.log(`paseo ${command.join(" ")} <id> --since 2100-01-01T00:00:00Z`);
|
||||
console.log(`exit=${result.exitCode}\nstdout:\n${result.stdout}stderr:\n${result.stderr}`);
|
||||
assert.strictEqual(result.exitCode, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, "No activity to display.\n");
|
||||
}
|
||||
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id, {
|
||||
direction: "tail",
|
||||
limit: 0,
|
||||
projection: "projected",
|
||||
});
|
||||
const firstTimestamp = timeline.entries[0]?.timestamp;
|
||||
assert(firstTimestamp !== undefined, "the fixture should have timestamped activity");
|
||||
for (const since of ["2000-01-01T00:00:00Z", firstTimestamp]) {
|
||||
const result = await logs(["logs", agent.id, "--since", since]);
|
||||
assert.strictEqual(result.exitCode, 0, result.stderr);
|
||||
assert.strictEqual(result.stdout, baseline.stdout);
|
||||
}
|
||||
|
||||
const offset = await logs(["logs", agent.id, "--since", "2100-01-01T08:00:00+08:00"]);
|
||||
assert.strictEqual(offset.exitCode, 0, offset.stderr);
|
||||
assert.strictEqual(offset.stdout, "No activity to display.\n");
|
||||
|
||||
const tail = await logs([
|
||||
"logs",
|
||||
agent.id,
|
||||
"--since",
|
||||
"2000-01-01T00:00:00Z",
|
||||
"--filter",
|
||||
"text",
|
||||
"--tail",
|
||||
"1",
|
||||
]);
|
||||
assert.strictEqual(tail.exitCode, 0, tail.stderr);
|
||||
assert.strictEqual(tail.stdout, "OLD_REPLY\n");
|
||||
|
||||
const empty = await logs(["logs", agent.id, "--since", firstTimestamp, "--tail", "0"]);
|
||||
assert.strictEqual(empty.exitCode, 0, empty.stderr);
|
||||
assert.strictEqual(empty.stdout, "");
|
||||
|
||||
for (const followArgs of [[], ["--follow"]]) {
|
||||
const invalid = await runLocalPaseo([
|
||||
"--host",
|
||||
`127.0.0.1:${port}`,
|
||||
"logs",
|
||||
"missing-agent",
|
||||
"--since",
|
||||
"not-a-date",
|
||||
"--json",
|
||||
...followArgs,
|
||||
]);
|
||||
assert.strictEqual(invalid.exitCode, 1, invalid.stderr);
|
||||
assert.strictEqual(invalid.stdout, "");
|
||||
assert.deepStrictEqual(JSON.parse(invalid.stderr), {
|
||||
error: {
|
||||
code: "INVALID_TIMESTAMP",
|
||||
message: "Invalid --since value: not-a-date",
|
||||
details: "Use a timestamp such as 2026-01-01T00:00:00Z.",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log("--since filters initial history in follow mode");
|
||||
const follow = logs(["logs", agent.id, "--follow", "--since", "2100-01-01T00:00:00Z"]);
|
||||
try {
|
||||
await waitForCliOutput(follow, "--- Following logs");
|
||||
} finally {
|
||||
await follow.kill("SIGINT");
|
||||
}
|
||||
const followed = await follow;
|
||||
console.log(`follow exit=${followed.exitCode}\nstdout:\n${followed.stdout}`);
|
||||
assert.strictEqual(followed.stdout.includes("OLD_USER"), false);
|
||||
assert.strictEqual(followed.stdout.includes("OLD_REPLY"), false);
|
||||
|
||||
console.log("--since filters live events in follow mode");
|
||||
const futureFollow = logs([
|
||||
"agent",
|
||||
"logs",
|
||||
agent.id,
|
||||
"-f",
|
||||
"--tail",
|
||||
"0",
|
||||
"--since",
|
||||
"2100-01-01T00:00:00Z",
|
||||
]);
|
||||
try {
|
||||
await waitForCliOutput(futureFollow, "--- Following logs");
|
||||
// A timestamped history replay follows the live item on the same socket.
|
||||
// Its visible output proves the CLI consumed the earlier item too.
|
||||
await Promise.all([
|
||||
waitForCliOutput(futureFollow, "AFTER_CUTOFF"),
|
||||
(async () => {
|
||||
await manager.appendTimelineItem(agent.id, {
|
||||
type: "user_message",
|
||||
text: "LIVE_BEFORE_CUTOFF",
|
||||
});
|
||||
historyEvents.push({
|
||||
type: "timeline",
|
||||
provider: "codex",
|
||||
timestamp: "2110-01-01T00:00:00Z",
|
||||
item: { type: "user_message", text: "AFTER_CUTOFF" },
|
||||
});
|
||||
await ctx.client.refreshAgent(agent.id);
|
||||
})(),
|
||||
]);
|
||||
} finally {
|
||||
await futureFollow.kill("SIGINT");
|
||||
}
|
||||
const futureOutput = await futureFollow;
|
||||
console.log(`future follow exit=${futureOutput.exitCode}\nstdout:\n${futureOutput.stdout}`);
|
||||
assert.strictEqual(futureOutput.stdout.includes("LIVE_BEFORE_CUTOFF"), false);
|
||||
|
||||
console.log("follow still prints live events after --since");
|
||||
const pastFollow = logs([
|
||||
"logs",
|
||||
agent.id,
|
||||
"--follow",
|
||||
"--tail",
|
||||
"0",
|
||||
"--filter",
|
||||
"text",
|
||||
"--since",
|
||||
"2000-01-01T00:00:00Z",
|
||||
]);
|
||||
try {
|
||||
await waitForCliOutput(pastFollow, "--- Following logs");
|
||||
await Promise.all([
|
||||
// Wait for a later visible item to close the streamed reply.
|
||||
// Windows SIGINT forcibly exits without running shutdown handlers.
|
||||
waitForCliOutput(pastFollow, "[User] LIVE_AFTER_REPLY\n"),
|
||||
(async () => {
|
||||
await manager.appendTimelineItem(agent.id, { type: "user_message", text: "LIVE_USER" });
|
||||
await manager.appendTimelineItem(agent.id, {
|
||||
type: "assistant_message",
|
||||
text: "LIVE_REPLY",
|
||||
});
|
||||
await manager.appendTimelineItem(agent.id, {
|
||||
type: "user_message",
|
||||
text: "LIVE_AFTER_REPLY",
|
||||
});
|
||||
})(),
|
||||
]);
|
||||
} finally {
|
||||
await pastFollow.kill("SIGINT");
|
||||
}
|
||||
const pastOutput = await pastFollow;
|
||||
console.log(`past follow exit=${pastOutput.exitCode}\nstdout:\n${pastOutput.stdout}`);
|
||||
assert(pastOutput.stdout.includes("[User] LIVE_USER\nLIVE_REPLY\n"), pastOutput.stdout);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
}
|
||||
} finally {
|
||||
await rm(cliHome, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
@@ -0,0 +1,87 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
import { createDaemonTestContext } from "../test-utils/index.js";
|
||||
|
||||
function git(cwd: string, ...args: string[]): string {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8" });
|
||||
}
|
||||
|
||||
test.each(["uncommitted", "base", "commit"] as const)(
|
||||
"%s diff preserves removed SQL comments and their deletion counts",
|
||||
async (mode) => {
|
||||
const ctx = await createDaemonTestContext();
|
||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-content-"));
|
||||
|
||||
try {
|
||||
git(cwd, "init", "-b", "main");
|
||||
git(cwd, "config", "user.name", "Test");
|
||||
git(cwd, "config", "user.email", "test@example.com");
|
||||
writeFileSync(join(cwd, "query.sql"), "-- old comment\nSELECT 1;\n");
|
||||
writeFileSync(join(cwd, "control.sql"), "SELECT 1;\n");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||
writeFileSync(join(cwd, "query.sql"), "-- new comment\nSELECT 1;\n");
|
||||
writeFileSync(join(cwd, "control.sql"), "SELECT 2;\n");
|
||||
|
||||
expect(git(cwd, "diff", "--numstat", "HEAD", "--", "query.sql").trim()).toBe(
|
||||
"1\t1\tquery.sql",
|
||||
);
|
||||
|
||||
if (mode !== "uncommitted") {
|
||||
git(cwd, "checkout", "-b", "feature");
|
||||
git(cwd, "add", ".");
|
||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||
}
|
||||
|
||||
const files =
|
||||
mode === "commit"
|
||||
? [
|
||||
(
|
||||
await ctx.client.getCommitFileDiff(
|
||||
cwd,
|
||||
git(cwd, "rev-parse", "HEAD").trim(),
|
||||
"query.sql",
|
||||
)
|
||||
).file,
|
||||
(
|
||||
await ctx.client.getCommitFileDiff(
|
||||
cwd,
|
||||
git(cwd, "rev-parse", "HEAD").trim(),
|
||||
"control.sql",
|
||||
)
|
||||
).file,
|
||||
]
|
||||
: (await ctx.client.getCheckoutDiff(cwd, { mode, baseRef: "main" })).files;
|
||||
|
||||
const control = files.find((file) => file?.path === "control.sql");
|
||||
expect(control).toMatchObject({ additions: 1, deletions: 1 });
|
||||
expect(control?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "remove", content: "SELECT 1;" }),
|
||||
expect.objectContaining({ type: "add", content: "SELECT 2;" }),
|
||||
]),
|
||||
);
|
||||
|
||||
const query = files.find((file) => file?.path === "query.sql");
|
||||
expect
|
||||
.soft(query)
|
||||
.toMatchObject({ additions: 1, deletions: 1, isNew: false, isDeleted: false });
|
||||
expect
|
||||
.soft(query?.hunks.flatMap((hunk) => hunk.lines))
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "remove", content: "-- old comment" }),
|
||||
expect.objectContaining({ type: "add", content: "-- new comment" }),
|
||||
expect.objectContaining({ type: "context", content: "SELECT 1;" }),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
60_000,
|
||||
);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user