Compare commits

..
94 changed files with 1556 additions and 4391 deletions
+1
View File
@@ -252,6 +252,7 @@
"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(212, 212, 216)");
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -1,96 +0,0 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
const labels = {
long: {
title: "workspace-with-a-very-long-release-and-investigation-name",
project: "project-with-a-very-long-customer-integration-name",
},
short: { title: "main", project: "Paseo" },
};
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
try {
await workspace.client.renameProject(workspace.projectId, names.project);
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Header layout check",
modeId: "load-test",
model: "e2e-fast-stream",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
await expect(
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
).toHaveCount(1);
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
const title = page.getByTestId("workspace-header-title");
const project = page.getByTestId("workspace-header-subtitle");
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
const trigger = page.getByRole("button", { name: /Switch tabs/ });
const measure = async () => ({
title: await title.boundingBox(),
project: await project.boundingBox(),
badge: await badge.boundingBox(),
icon: await badge.locator("svg").boundingBox(),
trigger: await trigger.boundingBox(),
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
});
await page.screenshot({ path: info.outputPath("header.png") });
await expect(async () => {
const bounds = await measure();
expect(bounds.title).not.toBeNull();
expect(bounds.project).not.toBeNull();
expect(bounds.badge).not.toBeNull();
expect(bounds.icon).not.toBeNull();
expect(bounds.trigger).not.toBeNull();
// Approved geometry: 12px outside margin, 46px leading island, 8px title gap.
expect(bounds.title!.x).toBe(66);
expect(bounds.project!.x).toBe(bounds.title!.x);
expect(bounds.trigger!.width).toBe(32);
expect(bounds.trigger!.height).toBe(32);
// The action frame starts 7px inside its island; preserve the 8px gap before it.
const contentRight = bounds.trigger!.x - 15;
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1);
}
expect(bounds.project!.width).toBeGreaterThan(20);
expect(bounds.icon!.width).toBe(12);
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
expect(bounds.titleTruncated).toBe(long);
expect(bounds.projectTruncated).toBe(long);
}).toPass({ timeout: 5_000 });
await info.attach("header-bounds", {
body: JSON.stringify(await measure(), null, 2),
contentType: "application/json",
});
}
for (const width of [320, 390]) {
for (const kind of ["long", "short"] as const) {
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 844 });
const workspace = await openNamedWorkspace(page, labels[kind]);
try {
await expectHeaderContentsFit(page, info, kind === "long");
} finally {
await workspace.cleanup();
}
});
}
}
@@ -1,55 +0,0 @@
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,64 +96,6 @@ 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-",
@@ -815,24 +757,3 @@ 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,4 +1,3 @@
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";
@@ -7,31 +6,6 @@ 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;
@@ -46,7 +20,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);
@@ -62,10 +36,8 @@ 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 openTabSwitcher(page);
await page.getByRole("button", { name: /Switch tabs/ }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{
timeout: 10_000,
@@ -93,58 +65,12 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
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-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-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-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-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-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-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-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
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 wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
@@ -30,6 +30,7 @@ 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`);
@@ -79,7 +80,6 @@ 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,13 +88,7 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
await expect(headerButtons(page)).toHaveCount(0);
await capture(
page,
info,
"02-compact-actions",
page.getByRole("button", { name: "Refresh context", exact: true }),
);
await capture(page, info, "02-compact-actions");
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
@@ -134,8 +128,7 @@ export async function withButtonAuthorExample(
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
await expect(headerButtons(page)).toHaveCount(0);
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await headerButtons(page).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
@@ -150,7 +143,6 @@ 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,6 +202,31 @@ 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: {
@@ -383,7 +408,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -401,12 +426,39 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await expectSurfaceVisible(page);
await openCompactOverflowWithHeaderChrome(page);
await expect(
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
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"),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Open menu", exact: true }),
).toBeInViewport();
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
@@ -42,7 +42,6 @@ import {
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -275,20 +274,12 @@ 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 + topOverlayInset,
},
],
[
baseListContentContainerStyle,
streamKeyboardInset.contentContainerPaddingBottom,
topOverlayInset,
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
],
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
);
const listInsetProps = useMemo(
() =>
@@ -1,14 +0,0 @@
import { createContext, useContext } from "react";
import { isNative } from "@/constants/platform";
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
export const supportsStreamTopOverlay = isNative;
const StreamTopOverlayContext = createContext(0);
/** Visual-top clearance supplied by the containing layout, in points. */
export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
export function useStreamTopOverlayInset(): number {
return useContext(StreamTopOverlayContext);
}
+1 -1
View File
@@ -35,7 +35,7 @@ import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
@@ -417,128 +417,6 @@ 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", () => {
@@ -591,23 +469,3 @@ 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();
},
);
});
+38 -98
View File
@@ -80,13 +80,6 @@ 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 =
@@ -241,20 +234,13 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
};
}
function parseAssistantInlinePathLink(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
const inlinePathTarget = parseInlinePathToken(value);
if (!inlinePathTarget) {
return null;
}
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
const normalizedPath = normalizePathToken(inlinePathTarget.path);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -291,7 +277,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -322,19 +308,32 @@ export function parseAssistantFileLink(
return null;
}
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
if (inlinePathTarget) {
return inlinePathTarget;
}
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
if (windowsTarget) {
return windowsTarget;
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 relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
});
if (relativeTarget) {
return relativeTarget;
@@ -344,33 +343,19 @@ export function parseAssistantFileLink(
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) : "";
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
const normalizedPath = normalizePathToken(pathPart);
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
const lines = parseLineFragment(hashPart);
const lines = parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
}
@@ -401,42 +386,11 @@ 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, options);
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
@@ -468,11 +422,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
} | null {
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?")) {
return null;
@@ -486,19 +436,14 @@ 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) {
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
return null;
}
return {
path: pathPart,
path: inlinePathTarget.path,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
@@ -510,17 +455,12 @@ function parseLocalPathParts(
return null;
}
let decodedBeforeHash = beforeHash;
if (options.decodeHref) {
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
if (!isPlausibleAssistantLocalPath(beforeHash)) {
return null;
}
return {
path: decodedBeforeHash,
path: beforeHash,
lines: fragmentLines,
};
}
@@ -605,14 +545,14 @@ function isExternalHref(value: string): boolean {
function isAmbiguousWorkspaceCandidate(
value: string,
target: InlinePathTarget,
options: AssistantHrefParseOptions = {},
workspaceRoot?: string,
): boolean {
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
const parsed = parseLocalPathParts(value, options);
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return false;
}
@@ -1,8 +1,4 @@
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,
@@ -18,25 +14,6 @@ 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 {
@@ -75,27 +52,6 @@ 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);
@@ -209,46 +165,6 @@ 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,15 +108,13 @@ export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const tokenInfo = getAssistantFileLinkTokenInfo(source);
const token = tokenInfo.token.trim();
const token = getAssistantFileLinkToken(source).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" } };
@@ -150,26 +148,14 @@ export function classifyForResolution(
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
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)) {
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return { token: text, fromHref: false };
return text;
}
}
return { token: source.href, fromHref: true };
return source.href;
}
export function getAmbiguousSuggestionQuery(
@@ -29,21 +29,14 @@ 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,
AssistantFileLinkLookupKey | null,
string,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
@@ -66,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
resolution,
token: resolution.token,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -186,7 +179,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
resolution: capturedResolution,
token: capturedResolution.token,
});
const run = async () => {
@@ -252,19 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
token: string;
}): AssistantFileLinkQueryKey {
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,
},
];
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
}
async function dispatchResolvedLink(input: {
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => {
it("assigns safe clearance to the footer independently of decorative padding", () => {
@@ -46,3 +49,17 @@ 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,6 +10,28 @@ 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,16 +12,22 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import {
KEYBOARD_STATUS,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } 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 { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import {
getBottomSheetVisibleContentHeight,
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";
@@ -201,6 +207,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
@@ -253,6 +263,40 @@ 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,
@@ -427,6 +471,8 @@ 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;
}
@@ -447,6 +493,7 @@ export function AdaptiveModalSheet({
presentation,
contentStyle,
bodyStyle,
sizeContentToCurrentSnapPoint = true,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
@@ -472,15 +519,11 @@ export function AdaptiveModalSheet({
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom],
);
const footerView = useMemo(
() =>
footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const footerView = footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null;
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
@@ -585,6 +628,7 @@ export function AdaptiveModalSheet({
</View>
)}
</View>
{footerView}
</>
);
@@ -606,7 +650,11 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
) : (
sheetContent
)}
</IsolatedBottomSheetModal>
);
}
@@ -1,99 +0,0 @@
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,11 +6,10 @@ 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;
}
@@ -19,15 +18,7 @@ function goBack(): void {
router.back();
}
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) {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => {
@@ -51,6 +42,7 @@ function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && <ScreenTitle>{title}</ScreenTitle>}
{titleAccessory}
</>
}
right={rightContent}
@@ -64,7 +56,10 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
backButton: {
padding: theme.spacing[2],
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
@@ -1,168 +0,0 @@
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 { type ReactElement, type ReactNode } from "react";
import { useMemo, 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,
useIconButtonChromeStyle,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
@@ -46,11 +46,17 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null;
const combinedStyle = useIconButtonChromeStyle({
size: "large",
disabled: Boolean(disabled),
style,
});
const combinedStyle = useMemo(
() =>
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
iconButtonChromeStyle({
size: "large",
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -1,10 +1,16 @@
import type { ReactNode } from "react";
import { StyleSheet } from "react-native-unistyles";
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 { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { CompactHeader } from "./compact-header";
import { SidebarMenuToggle } from "./sidebar-menu-toggle";
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 { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -12,11 +18,130 @@ interface MenuHeaderProps {
borderless?: boolean;
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
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) {
return (
<ScreenHeader
left={
@@ -33,7 +158,31 @@ 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,
},
}));
@@ -1,167 +0,0 @@
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,
},
}));
+30 -25
View File
@@ -1,13 +1,5 @@
import { router } from "expo-router";
import {
CircleGauge,
FolderPlus,
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -34,7 +26,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 { DensityIcon } from "@/components/ui/density-icon";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -45,7 +37,6 @@ 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";
@@ -289,25 +280,30 @@ function FooterIconButton({
onPress,
testID,
label,
icon,
icon: Icon,
iconSizeAdjustment = 0,
shortcutKeys,
theme,
}: {
onPress: () => void;
testID: string;
label: string;
icon: LucideIcon;
icon: typeof FolderPlus;
/** 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}
style={styles.footerIconButton(isCompact)}
testID={testID}
nativeID={testID}
collapsable={false}
@@ -317,11 +313,9 @@ function FooterIconButton({
onPress={onPress}
>
{({ hovered }) => (
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
<Icon
size={iconSize + iconSizeAdjustment}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
@@ -334,10 +328,12 @@ function FooterIconButton({
}
function SidebarHostPicker({
theme,
label,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
label: string;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
@@ -381,6 +377,7 @@ function SidebarHostPicker({
icon={Server}
// Server's two boxes fill more of the square than the other glyphs.
iconSizeAdjustment={-1}
theme={theme}
/>
</HostPicker>
);
@@ -402,6 +399,7 @@ function IconTooltipContent({
}
function SidebarFooter({
theme,
handleOpenProject,
handleSettings,
labels,
@@ -409,6 +407,7 @@ function SidebarFooter({
handleOpenHostSettings,
onBeforeNavigate,
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleSettings: () => void;
labels: {
@@ -437,9 +436,11 @@ function SidebarFooter({
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<SidebarUsageIcon label={labels.usage} />
<SidebarUsageIcon label={labels.usage} theme={theme} />
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
@@ -452,6 +453,7 @@ function SidebarFooter({
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
@@ -459,7 +461,7 @@ function SidebarFooter({
);
}
function SidebarUsageIcon({ label }: { label: string }) {
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
const openUsage = useOpenSidebarUsage();
return (
<FooterIconButton
@@ -467,6 +469,7 @@ function SidebarUsageIcon({ label }: { label: string }) {
testID="sidebar-usage-icon"
label={label}
icon={CircleGauge}
theme={theme}
/>
);
}
@@ -609,6 +612,7 @@ function MobileSidebar({
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -785,6 +789,7 @@ function DesktopSidebar({
<SidebarCalloutSlot />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -952,14 +957,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5],
gap: 2,
},
footerIconButton: {
width: density.iconButton,
height: density.iconButton,
footerIconButton: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
}),
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -126,6 +126,9 @@ 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,7 +30,6 @@ 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";
@@ -115,7 +114,6 @@ 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";
@@ -824,10 +822,9 @@ function NewWorkspaceGhostRow({
{({ hovered, pressed }) => (
<>
<View style={styles.newWorkspaceGhostIconSlot}>
<DensityIcon
icon={Plus}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
<ThemedPlus
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</View>
<Text
@@ -2522,7 +2519,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: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[4],
@@ -2542,27 +2539,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: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
},
projectRow: {
position: "relative",
minHeight: density.rowHeight,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -2603,7 +2600,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -2684,7 +2681,7 @@ const styles = StyleSheet.create((theme) => ({
right: theme.spacing[2],
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -2,11 +2,16 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density, densityFontSize } from "@/styles/density";
import type { Theme } from "@/styles/theme";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function PinnedSectionHeader({
collapsed,
@@ -18,6 +23,7 @@ export function PinnedSectionHeader({
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
return (
<Pressable
@@ -31,11 +37,7 @@ export function PinnedSectionHeader({
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
) : null}
</>
)}
@@ -45,7 +47,7 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
minHeight: 36,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
@@ -56,7 +58,7 @@ const styles = StyleSheet.create((theme) => ({
},
title: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "sm"),
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -4,8 +4,7 @@ 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 { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
getProjectStatusBadgeContent,
@@ -36,8 +35,9 @@ const STATUS_BADGE_OFFSET = -4;
//
// The filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots.
const DensityProjectIconView = withUnistyles(ProjectIconView);
const projectIconProps = densityIconProps("md", () => ({}));
// 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 ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
@@ -230,12 +230,12 @@ function ProjectIcon({
projectViewKey: string;
}) {
return (
<DensityProjectIconView
<ProjectIconView
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 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`.
// 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.
projectLeadingVisualSlot: {
width: densityIconBox("md"),
height: density.rowLineHeight,
width: theme.iconSize.md,
height: LEADING_SLOT_HEIGHT,
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: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIconFallbackText: {
fontSize: 9,
@@ -2,11 +2,19 @@ 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 } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
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);
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -54,11 +62,17 @@ export function SidebarGroupToggleRow({
{({ hovered, pressed }) => (
<>
<View style={styles.iconSlot}>
<DensityIcon
icon={expanded ? ChevronUp : ChevronDown}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
/>
{expanded ? (
<ThemedChevronUp
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<ThemedChevronDown
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</View>
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
{label}
@@ -72,7 +86,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: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -92,21 +106,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: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
@@ -1,12 +1,22 @@
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
import {
useCallback,
useMemo,
useState,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
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 }>;
@@ -39,7 +49,7 @@ interface SidebarHeaderRowProps {
}
export function SidebarHeaderRow({
icon,
icon: Icon,
label,
onPress,
isActive = false,
@@ -54,8 +64,9 @@ 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" ? "md" : "sm";
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
let right = trailing ?? null;
if (right === null && shortcutKeys && isHovered) {
@@ -80,11 +91,10 @@ export function SidebarHeaderRow({
aria-selected={isActive}
style={styles.button}
>
{icon ? (
<DensityIcon
icon={icon}
{ThemedIcon ? (
<ThemedIcon
size={iconSize}
color={isHighlighted ? "foreground" : "foregroundMuted"}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
@@ -130,7 +140,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: density.tightRowHeight,
minHeight: 28,
borderRadius: theme.borderRadius.lg,
},
rowHighlighted: {
@@ -142,17 +152,17 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: density.tightRowHeight,
minHeight: 28,
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: densityIconBox("md"), height: densityIconBox("md") },
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
label: {
flexShrink: 1,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -14,14 +14,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
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,10 +32,12 @@ 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,
});
@@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
}
export function SidebarHelpMenu() {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -103,16 +106,15 @@ export function SidebarHelpMenu() {
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
style={styles.trigger(isCompact)}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered }) => (
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
<ThemedCircleHelp
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
@@ -182,14 +184,14 @@ export function SidebarHelpMenu() {
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
trigger: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
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 { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
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 mutedGroupIconProps = densityIconProps("sm", (theme) => ({
const foregroundMutedColorMapping = (theme: 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 needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
const needsInputColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
}));
const failedGroupIconProps = densityIconProps("sm", (theme) => ({
});
const failedColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
}));
const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
});
const attentionColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
}));
const runningGroupIconProps = densityIconProps("sm", (theme) => ({
});
const runningColorMapping = (theme: 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 uniProps={mutedGroupIconProps} />;
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
}
return <ThemedChevronDown uniProps={mutedGroupIconProps} />;
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
}
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
switch (bucket) {
case "needs_input":
return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
case "failed":
return <ThemedCircleX uniProps={failedGroupIconProps} />;
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
case "attention":
return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
case "running":
return <ThemedCircleDot uniProps={runningGroupIconProps} />;
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
case "done":
return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
}
}
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusWorkspaceListContainer: {},
statusGroupRow: {
minHeight: density.rowHeight,
minHeight: 36,
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: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -16,7 +16,6 @@ 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";
@@ -28,16 +27,19 @@ 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,
@@ -239,15 +241,15 @@ function WorkspaceStatusIndicator({
) : null;
}
let KindIcon: typeof Monitor;
if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
else KindIcon = Folder;
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<DensityIcon icon={KindIcon} size="sm" color="foregroundMuted" />
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View>
);
@@ -480,8 +482,8 @@ const styles = StyleSheet.create((theme) => ({
},
workspaceStatusDot: {
position: "relative",
width: densityIconBox("md"),
height: density.rowLineHeight,
width: theme.iconSize.md,
height: 20,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -513,9 +515,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: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: density.rowLineHeight,
lineHeight: 20,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -4,7 +4,6 @@ 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";
@@ -486,7 +485,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
+43 -55
View File
@@ -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, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { BottomSheetScrollView, 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,7 +50,6 @@ import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
import {
@@ -949,19 +948,9 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const { footer, safeAreaBottom } = props;
const frameStyle = useMemo(
() => [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],
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
[props.safeAreaBottom],
);
const body = props.hasChildren ? (
@@ -994,47 +983,46 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<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>
<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>
</IsolatedBottomSheetModal>
);
}
@@ -1,51 +0,0 @@
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,4 +1,3 @@
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";
@@ -9,8 +8,6 @@ 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 {
@@ -30,53 +27,20 @@ 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,
@@ -103,11 +67,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
const styles = StyleSheet.create((theme) => ({
large: {
width: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
padding: 0,
@@ -140,9 +104,6 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
round: {
borderRadius: theme.borderRadius.full,
},
highlighted: {
backgroundColor: theme.colors.interactionHighlight,
},
@@ -152,5 +152,3 @@ export function useIsolatedBottomSheetVisibility({
handleSheetDismiss,
};
}
export { SheetVisibleFrame } from "./visible-frame";
@@ -1,58 +0,0 @@
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);
});
@@ -1,25 +0,0 @@
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),
);
}
@@ -1,42 +0,0 @@
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,7 +16,6 @@ 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";
@@ -61,8 +60,6 @@ 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>;
@@ -84,7 +81,6 @@ export function ToolbarButton({
tooltip: tooltipBody,
selected = false,
compact = false,
round = false,
shortcut,
tooltipSide = "bottom",
style,
@@ -94,14 +90,17 @@ export function ToolbarButton({
testID,
...props
}: ToolbarButtonProps) {
const buttonStyle = useIconButtonChromeStyle({
size: "small",
compact,
active: selected,
disabled: Boolean(disabled),
round,
style,
});
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 accessibilityState = useMemo(
() => ({ disabled: Boolean(disabled), selected }),
[disabled, selected],
@@ -298,6 +298,7 @@ export function CompactModelSheet({
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
@@ -345,6 +346,7 @@ export function CompactModelSheet({
onClose={closeModelBrowser}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint
presentation="push"
contentStyle={styles.sheetBody}
testID="agent-controls-model-browser-sheet"
+1 -6
View File
@@ -33,18 +33,13 @@ 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 isCompactBreakpoint(rt.breakpoint);
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
+26 -36
View File
@@ -7,11 +7,9 @@ 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";
@@ -89,14 +87,19 @@ function PluginHostFilter({
pluginId,
identity,
params,
hosts,
serverIds,
}: {
serverId: string;
pluginId: string;
identity: PluginSurfaceContributionIdentity;
params: PluginScreenParams;
hosts: HostPickerHost[];
serverIds: string[];
}) {
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.
@@ -110,6 +113,9 @@ function PluginHostFilter({
},
[identity, params, pluginId],
);
const show = serverIds.length > 1 && hosts.length > 1;
if (!show) return null;
return (
<HostFilter
hosts={hosts}
@@ -149,12 +155,11 @@ export function PluginSurfaceScreen() {
[identity, plugin],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
// 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 contributionServerIds = useMemo(
() =>
identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [],
[identity, installations, pluginId],
);
const title = useMemo(
() =>
surface
@@ -182,23 +187,18 @@ 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(
() => (
<>
{hostFilter}
{identity ? (
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
serverIds={contributionServerIds}
/>
) : null}
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
@@ -211,22 +211,12 @@ export function PluginSurfaceScreen() {
</HeaderToggleButton>
</>
),
[close, hostFilter],
[close, contributionServerIds, identity, params, pluginId, serverId],
);
return (
<View style={styles.screen}>
{compact ? (
<CompactHeader
navigation="back"
onBack={close}
title={title}
titleTestID="plugin-surface-title"
actions={hostFilter}
/>
) : (
<ScreenHeader left={headerLeft} right={headerRight} />
)}
<ScreenHeader left={headerLeft} right={headerRight} />
<View style={styles.body}>
{plugin && surface && client ? (
<SurfaceErrorBoundary
@@ -0,0 +1,11 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
import { runInNewContext } from "node:vm";
import { expect, it } from "vitest";
it("loads the native terminal dependency with the initial React Native navigator", () => {
const require = createRequire(import.meta.url);
const source = readFileSync(require.resolve("@xterm/headless"), "utf8");
// React Native exposes navigator before the app's layout initializes its polyfill.
expect(() => runInNewContext(source, { navigator: {}, module: { exports: {} } })).not.toThrow();
});
@@ -31,7 +31,8 @@ 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 { MenuHeader } from "@/components/headers/menu-header";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
@@ -2367,6 +2368,7 @@ export function NewWorkspaceScreen({
},
});
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
const importSession = useImportSession({ serverId: selectedServerId });
const composer = isTerminalLaunch ? (
@@ -2429,7 +2431,7 @@ export function NewWorkspaceScreen({
);
return (
<FileDropZone style={styles.container}>
<MenuHeader borderless />
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<NewWorkspaceLayout
@@ -1,72 +0,0 @@
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}
/>
);
}
@@ -1,501 +0,0 @@
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,8 +11,6 @@ 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,7 +2,14 @@ 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 } from "lucide-react-native";
import {
Copy,
Ellipsis,
Globe,
Import as ImportIcon,
Settings,
SquarePen,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
@@ -17,7 +24,6 @@ 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";
@@ -30,13 +36,16 @@ 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} />;
@@ -194,25 +203,28 @@ 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. New agent and New terminal sit in the tab switcher's footer;
* the rarer launches stay here alongside the workspace actions.
* Compact layouts have no tab strip to launch from, so new tabs live 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),
@@ -227,7 +239,7 @@ export function WorkspaceHeaderMenuMobile({
<DropdownMenu compactMode="sheet">
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={triggerStyle}
style={workspaceHeaderMenuButtonStyle}
hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
@@ -240,6 +252,13 @@ 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"
@@ -252,6 +271,14 @@ 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}
@@ -1,68 +0,0 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HostBadge } from "@/hosts/host-badge";
import { useHostBadges } from "@/hosts/use-host-badges";
/**
* Which project the workspace belongs to, and which machine it runs on.
*
* Compact gets both, on their own line under the workspace name: this header is the only thing on
* screen that says where the workspace lives, because the sidebar that normally carries the host
* badge is closed. It still follows the host's own badge setting, so a purely local setup stays
* quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit
* side by side, and kept on compact, where the line exists for the host anyway.
*/
export function WorkspaceHeaderProjectRow({
subtitle,
isSubtitleDistinct,
serverId,
}: {
subtitle: string;
isSubtitleDistinct: boolean;
serverId: string;
}) {
const isCompact = useIsCompactFormFactor();
const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null;
const showProject = isSubtitleDistinct || isCompact;
if (!showProject && !hostBadge) {
return null;
}
return (
<View style={styles.row}>
{showProject ? (
<Text testID="workspace-header-subtitle" style={styles.project} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{showProject && hostBadge ? <Text style={styles.separator}>·</Text> : null}
{hostBadge ? <HostBadge badge={hostBadge} /> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// The compact header stretches this row to its available title width. Within that width,
// the project truncates and HostBadge yields its label while retaining its fixed icon.
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
flexShrink: 1,
},
project: {
color: theme.colors.foregroundMuted,
fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
flexShrink: 1,
minWidth: 0,
},
separator: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
}));
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
import { isCompactBreakpoint } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact
* layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay
* desktop-sized everywhere, so a surface migrates by swapping its sizes for these.
*
* Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes
* are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping.
*/
export type DensityIconSize = keyof typeof ICON_SIZE;
type TextSize = "sm" | "base";
const COMPACT_ICON_SIZE: Record<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
const COMPACT_TEXT_SIZE: Record<TextSize, keyof Theme["fontSize"]> = { sm: "base", base: "lg" };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(compact: T, desktop: T) {
return { xs: compact, md: desktop };
}
export const density = {
/** A list row such as a sidebar entry. */
rowHeight: byLayout(44, 36),
/** A row in a tight group, such as the sidebar's navigation rows. */
tightRowHeight: byLayout(40, 28),
/** The line height of a row's `base` text; slots that align to its first line share it. */
rowLineHeight: byLayout(22, 20),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
};
/** The width or height of a slot that holds an icon of `size`. */
export function densityIconBox(size: DensityIconSize) {
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
}
export function densityFontSize(theme: Theme, size: TextSize) {
return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
}
/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
export function densityIconProps<P extends object>(
size: DensityIconSize,
mapping: (theme: Theme) => P,
) {
return (theme: Theme, rt: { breakpoint?: string }) => ({
...mapping(theme),
size: densityIconSize(rt, size),
});
}
+3 -3
View File
@@ -68,9 +68,9 @@ describe("Pure black theme", () => {
});
describe("Sidebar interaction surfaces", () => {
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);
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);
expect(lightTheme.colors.surfaceSidebarSelected).not.toBe(lightTheme.colors.surfaceSidebar);
});
+2 -2
View File
@@ -267,8 +267,8 @@ export function buildLightSemanticColors(tint: LightThemeConfig) {
surface4: tint.surface4,
surfaceDiffEmpty: tint.surfaceDiffEmpty,
surfaceSidebar: tint.surfaceSidebar,
surfaceSidebarHover: tint.surface3,
surfaceSidebarSelected: tint.surface4,
surfaceSidebarHover: tint.surface1,
surfaceSidebarSelected: tint.surface3,
surfaceWorkspace: tint.surface0,
interactionHighlight: "rgba(0, 0, 0, 0.06)",
+5 -16
View File
@@ -31,7 +31,7 @@ export function UsageControls({
const busy = view.kind === "loading" || (view.kind === "ready" && view.isRefreshing);
return (
<View style={styles.controls}>
{hasHostChoice(hostSelection) ? (
{hostSelection && hostSelection.hosts.length > 1 ? (
<HostFilter
hosts={hostSelection.hosts}
selectedHost={hostSelection.serverId}
@@ -51,12 +51,6 @@ 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}`;
}
@@ -71,22 +65,17 @@ const styles = StyleSheet.create((theme) => ({
}));
/**
* 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.
* One host's usage and the title-row controls that go with it, for the Usage modal.
*/
export function useHostUsageWithControls(hostSelection: UsageHostSelection): {
view: UsageView;
refresh: () => void;
controls: ReactElement | null;
controls: ReactElement;
} {
const { view, refresh } = useHostUsage(hostSelection.serverId);
const hasControls = hasHostChoice(hostSelection) || view.kind !== "unavailable";
const controls = useMemo(
() =>
hasControls ? (
<UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />
) : null,
[hasControls, hostSelection, refresh, view],
() => <UsageControls view={view} onRefresh={refresh} hostSelection={hostSelection} />,
[hostSelection, refresh, view],
);
return { view, refresh, controls };
}
+1 -4
View File
@@ -9,10 +9,7 @@ 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. On compact it sits in the
* header's island, so its highlight is round.
*/
/** Refreshes every report on the host; spins while a refresh is in flight. */
export function UsageRefreshButton({ busy, onRefresh }: { busy: boolean; onRefresh: () => void }) {
const compact = useIsCompactFormFactor();
const iconSize = paneContentToolbarIconSize(compact);
@@ -48,74 +48,6 @@ 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,11 +26,6 @@ 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,6 +174,9 @@ 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 ? (
@@ -1,139 +0,0 @@
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("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");
});
});
+18 -195
View File
@@ -1,7 +1,7 @@
import { Command } from "commander";
import { connectToDaemon } from "../../utils/client.js";
import { waitForStop } from "../../utils/wait-for-stop.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
import type { CommandOptions } from "../../output/index.js";
import {
fetchProjectedTimelineItems,
LIVE_HISTORY_FETCH_TIMEOUT_MS,
@@ -32,29 +32,12 @@ 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?: FetchAgentTimelineItemsOptions,
options?: { timeoutMs?: number },
): Promise<AgentTimelineItem[]> {
return fetchProjectedTimelineItems({
client,
agentId,
timeoutMs: options?.timeoutMs,
sinceTimestampMs: options?.sinceTimestampMs,
});
return fetchProjectedTimelineItems({ client, agentId, timeoutMs: options?.timeoutMs });
}
export function formatAgentActivityTranscript(
@@ -70,134 +53,6 @@ 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);
@@ -207,19 +62,6 @@ 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
*/
@@ -256,7 +98,6 @@ export async function runLogsCommand(
process.exit(1);
}
const sinceTimestampMs = parseSinceTimestamp(options.since);
const client = await connectToDaemon({ target: options.daemonTarget });
try {
@@ -277,19 +118,12 @@ export async function runLogsCommand(
await client.close().catch(() => {});
process.exit(1);
}
await runFollowMode({
client,
agentId: resolvedId,
options,
sinceTimestampMs,
});
await runFollowMode(client, resolvedId, options);
return;
}
// Fetch timeline directly via cursor RPC.
let timelineItems = await fetchAgentTimelineItems(client, resolvedId, {
sinceTimestampMs,
});
let timelineItems = await fetchAgentTimelineItems(client, resolvedId);
// Apply filter
if (options.filter) {
@@ -325,12 +159,11 @@ export async function runLogsCommand(
/**
* Follow mode: stream logs in real-time until interrupted
*/
async function runFollowMode({
client,
agentId,
options,
sinceTimestampMs,
}: FollowModeInput): Promise<void> {
async function runFollowMode(
client: DaemonClient,
agentId: string,
options: AgentLogsOptions,
): Promise<void> {
const DEFAULT_FOLLOW_TAIL = 10;
const tailCount = parseTailCount(options.tail) ?? DEFAULT_FOLLOW_TAIL;
@@ -339,7 +172,6 @@ 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);
@@ -362,39 +194,31 @@ 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 matchesSince =
sinceTimestampMs === undefined || Date.parse(message.payload.timestamp) >= sinceTimestampMs;
if (!matchesSince) {
const item = message.payload.event.item;
// Apply filter
if (options.filter && !matchesFilter(item, options.filter)) {
return;
}
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();
// Print each timeline item as it arrives using the curator format
const transcript = formatAgentActivityTranscript([item]);
if (transcript !== NO_ACTIVITY_MESSAGE) {
console.log(transcript);
}
}
});
@@ -402,7 +226,6 @@ async function runFollowMode({
console.log(`\n--- Following logs (${tailLabel}; Ctrl+C to stop) ---\n`);
await waitForStop();
writer.end();
unsubscribe();
await client.close();
}
@@ -2,7 +2,6 @@ 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,
@@ -32,7 +31,6 @@ export const archiveSchema: OutputSchema<WorktreeArchiveResult> = {
export interface WorktreeArchiveOptions extends CommandOptions {
host?: string;
cwd?: string;
}
export type WorktreeArchiveCommandResult = SingleResult<WorktreeArchiveResult>;
@@ -51,7 +49,6 @@ 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) {
@@ -79,7 +76,7 @@ export async function runArchiveCommandWithDeps(
try {
// Get the list of worktrees first to resolve the name
const listResponse = await client.getPaseoWorktreeList({ cwd });
const listResponse = await client.getPaseoWorktreeList({});
if (listResponse.error) {
const error: CommandError = {
+2 -6
View File
@@ -9,10 +9,7 @@ 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")
.option("--cwd <path>", "Repository directory (default: current)"),
worktree.command("ls").description("List Paseo-managed git worktrees"),
).action(withOutput(runLsCommand));
addJsonAndDaemonHostOptions(
@@ -34,8 +31,7 @@ export function createWorktreeCommand(): Command {
worktree
.command("archive")
.description("Archive a worktree (removes worktree and associated branch)")
.argument("<name>", "Worktree name or branch name")
.option("--cwd <path>", "Repository directory (default: current)"),
.argument("<name>", "Worktree name or branch name"),
).action(withOutput(runArchiveCommand));
return worktree;
+1 -4
View File
@@ -1,7 +1,6 @@
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 */
@@ -41,14 +40,12 @@ 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 {
@@ -56,7 +53,7 @@ export async function runLsCommand(
const agents = agentsPayload.entries.map((entry) => entry.agent);
// Get worktree list from daemon
const response = await client.getPaseoWorktreeList({ cwd });
const response = await client.getPaseoWorktreeList({});
await client.close();
@@ -1,13 +0,0 @@
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;
}
+1 -7
View File
@@ -7,7 +7,6 @@ interface FetchProjectedTimelineItemsInput {
client: DaemonClient;
agentId: string;
timeoutMs?: number;
sinceTimestampMs?: number;
}
export async function fetchProjectedTimelineItems(
@@ -19,10 +18,5 @@ export async function fetchProjectedTimelineItems(
projection: "projected",
timeout: input.timeoutMs,
});
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);
return timeline.entries.map((entry) => entry.item);
}
+1 -68
View File
@@ -21,10 +21,7 @@
import assert from "node:assert";
import { getAvailablePort } from "./helpers/network.ts";
import { $ } from "zx";
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 { mkdtemp, rm } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
@@ -53,7 +50,6 @@ 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");
}
@@ -90,7 +86,6 @@ 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");
}
@@ -172,66 +167,4 @@ 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 ===");
+1 -1
View File
@@ -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/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: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: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",
@@ -2486,7 +2486,7 @@ describe("create_agent MCP tool", () => {
});
expect(readPaseoWorktreeMetadata(agentCwd)).toMatchObject({
version: 1,
baseRefName: "main",
baseRefName: "existing-feature",
});
expect(generateCalls).toBe(1);
expect(workspaceGitService.getSnapshot).not.toHaveBeenCalled();
@@ -9,10 +9,6 @@ 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,
@@ -295,10 +291,7 @@ describe("OpenCodeBridge", () => {
).resolves.toMatchObject({ content: [{ type: "text", text: "child result" }] });
await expect(
tools.get("paseo_echo_context")!.execute({ value: "blocked" }, { sessionID: "disabled" }),
).rejects.toMatchObject({
message: "Paseo tools are disabled for this session",
status: 403,
});
).rejects.toThrow("HTTP 403");
await dispose();
} finally {
release();
@@ -307,72 +300,6 @@ 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,16 +13,7 @@ export default {
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
if (allowMissing && response.status === 404) return null;
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;
}
if (!response.ok) throw new Error(`Paseo tool bridge returned HTTP ${response.status}`);
return response.json();
}
async function scope(sessionID) {
@@ -1,283 +0,0 @@
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);
@@ -1,89 +0,0 @@
import { spawn } from "node:child_process";
import { once } from "node:events";
import { fileURLToPath } from "node:url";
import { expect, test } from "vitest";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { DaemonClient } from "../test-utils/daemon-client.js";
// A real daemon and CLI with a deterministic provider emitting two text chunks.
// The live Claude reproduction is recorded in the PR; this locks its CLI behavior.
test("logs --follow preserves the same assistant text as one-shot logs", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
let follow: ReturnType<typeof spawn> | undefined;
let stdout = "";
let stderr = "";
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "claude",
cwd: daemon.paseoHome,
title: "Follow transcript regression",
modeId: "bypassPermissions",
});
follow = spawn(
process.execPath,
[
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
"logs",
"--follow",
agent.id,
"--tail",
"0",
"--filter",
"assistant_message",
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
},
);
const exited = once(follow, "exit");
follow.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
follow.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
await expect
.poll(
() => {
if (follow?.exitCode !== null) {
throw new Error(`CLI exited before following logs: ${stderr}`);
}
return stdout;
},
{ timeout: 20_000 },
)
.toContain("Following logs");
await client.sendMessage(agent.id, "respond with exactly: paseo-pr-cycle 한국어 보고서");
await client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "idle", 20_000);
const transcript = await client.fetchAgentTimeline(agent.id, {
direction: "tail",
limit: 100,
projection: "projected",
});
const assistantText = transcript.entries
.filter((row) => row.item.type === "assistant_message")
.map((row) => (row.item.type === "assistant_message" ? row.item.text : ""))
.join("");
expect(assistantText).not.toBe("");
// Completion closes the message while follow mode is still running.
// Then stop through the CLI's normal signal path.
await expect.poll(() => stdout, { timeout: 10_000 }).toContain(`${assistantText.trim()}\n`);
follow.kill("SIGINT");
await exited;
const output = stdout.slice(stdout.indexOf("---\n") + 4).trim();
expect(output, stderr).toBe(assistantText.trim());
} finally {
follow?.kill("SIGTERM");
await client.close();
await daemon.close();
}
}, 60_000);
@@ -12,32 +12,6 @@ async function createTempDir(prefix: string): Promise<string> {
}
describe.skipIf(isPlatform("win32"))("service POSIX-only", () => {
it("lists and expands the reported relative directory symlink", async () => {
const root = await createTempDir("paseo-file-explorer-");
try {
await mkdir(path.join(root, "test"));
await writeFile(path.join(root, "test", "x.txt"), "inside\n", "utf-8");
await symlink("./test", path.join(root, "test2"));
const listing = await listDirectoryEntries({ root });
expect(listing.entries.find((entry) => entry.name === "test2")).toMatchObject({
path: "test2",
kind: "directory",
});
const expanded = await listDirectoryEntries({ root, relativePath: "test2" });
expect(expanded.path).toBe("test2");
expect(expanded.entries.map((entry) => entry.name)).toEqual(["x.txt"]);
expect(await readExplorerFile({ root, relativePath: "test2/x.txt" })).toMatchObject({
path: "test2/x.txt",
content: "inside\n",
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("lists directory entries even when a dangling symlink exists", async () => {
const root = await createTempDir("paseo-file-explorer-");
@@ -791,14 +791,13 @@ test("does not mark checkout branch worktrees as eligible for first-agent rename
createDeps(),
);
// baseRefName must be the repo's default branch, not the checked-out branch
// itself — otherwise the diff panel compares the branch against itself and
// always shows no changes.
expect(readPaseoWorktreeMetadata(created.worktree.worktreePath)).toMatchObject({
version: 1,
baseRefName: "main",
baseRefName: "dev",
});
expect(created.workspace.baseBranch).toBe("main");
// A checkout-branch worktree has no distinct base, so the workspace records a
// null baseBranch even though worktree.json's baseRefName is the branch itself.
expect(created.workspace.baseBranch).toBe(null);
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
@@ -44,37 +44,6 @@ interface ScheduleServiceInternals {
executeSchedule(schedule: StoredSchedule, runId: string): Promise<ScheduleExecutionResult>;
}
class OverlappingTickRunner {
private resolveFirstRunStarted: () => void = () => {};
private resolveFirstRunBlocked: () => void = () => {};
private laterScheduleRunCount = 0;
readonly firstRunStarted = new Promise<void>((resolve) => {
this.resolveFirstRunStarted = resolve;
});
private readonly firstRunBlocked = new Promise<void>((resolve) => {
this.resolveFirstRunBlocked = resolve;
});
async run(schedule: StoredSchedule): Promise<ScheduleExecutionResult> {
if (schedule.prompt === "block the first tick") {
this.resolveFirstRunStarted();
await this.firstRunBlocked;
} else {
this.laterScheduleRunCount += 1;
}
return { agentId: null, output: "ok" };
}
unblockFirstRun(): void {
this.resolveFirstRunBlocked();
}
get laterScheduleRuns(): number {
return this.laterScheduleRunCount;
}
}
const SCHEDULE_TEST_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true,
supportsSessionPersistence: true,
@@ -376,241 +345,6 @@ describe("ScheduleService", () => {
expect(inspected.nextRunAt).toBe("2026-01-01T00:02:00.000Z");
});
test.each(["new-agent", "agent", "maxRuns: 1"] as const)(
"dispatches a shared cron slot only once for %s after an earlier tick resumes",
async (targetKind) => {
const runner = new OverlappingTickRunner();
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: (schedule) => runner.run(schedule),
});
const cadence = { type: "cron" as const, expression: "0 6 * * 1", timezone: "Europe/Prague" };
await service.create({
prompt: "block the first tick",
cadence,
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
// Give the later schedule a distinct creation time so file ordering cannot affect the race.
now = new Date("2026-01-01T00:00:01.000Z");
const agentId = "00000000-0000-0000-0000-000000000001";
await agentStorage.upsert(
buildAgentRecord({ id: agentId, cwd: tempDir, iso: now.toISOString() }),
);
const later = await service.create({
prompt: "later due schedule",
cadence,
target:
targetKind === "agent"
? { type: "agent", agentId }
: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
maxRuns: targetKind === "maxRuns: 1" ? 1 : undefined,
});
now = new Date("2026-01-05T05:00:00.000Z");
const delayedTick = service.tick();
await runner.firstRunStarted;
try {
now = new Date("2026-01-05T05:00:01.000Z");
await service.tick();
} finally {
runner.unblockFirstRun();
await delayedTick;
}
const result = await service.inspect(later.id);
expect.soft(runner.laterScheduleRuns).toBe(1);
expect.soft(result.runs).toHaveLength(1);
expect.soft(result.runs.map((run) => run.scheduledFor)).toEqual(["2026-01-05T05:00:00.000Z"]);
expect(result.nextRunAt).toBe(
targetKind === "maxRuns: 1" ? null : "2026-01-12T05:00:00.000Z",
);
expect(result.status).toBe(targetKind === "maxRuns: 1" ? "completed" : "active");
},
);
test("advances the next scheduled slot past a run that crosses a cadence boundary", async () => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => {
now = new Date("2026-01-01T00:02:00.000Z");
return { agentId: null, output: "ok" };
},
});
const created = await service.create({
prompt: "long-running task",
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
expect((await service.inspect(created.id)).nextRunAt).toBe("2026-01-01T00:03:00.000Z");
});
test("runs the final allowed cron slot without requiring another occurrence", async () => {
let dispatches = 0;
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => {
dispatches += 1;
return { agentId: null, output: "done" };
},
});
const created = await service.create({
prompt: "Run once on January 5 when it is Monday",
cadence: { type: "cron", expression: "0 6 5 1 1" },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
maxRuns: 1,
});
now = new Date("2026-01-05T06:00:00.000Z");
await service.tick();
const result = await service.inspect(created.id);
expect(dispatches).toBe(1);
expect(result.status).toBe("completed");
expect(result.nextRunAt).toBeNull();
expect(result.runs).toHaveLength(1);
expect(result.runs[0].status).toBe("succeeded");
});
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"keeps firing future slots after an outcome could not be saved",
async () => {
const schedulesDir = join(tempDir, "schedules");
let dispatches = 0;
const failOutcomeWrite = async (): Promise<ScheduleExecutionResult> => {
dispatches += 1;
if (dispatches === 1) {
await chmod(schedulesDir, 0o555);
}
return { agentId: null, output: "done" };
};
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: failOutcomeWrite,
});
const created = await service.create({
prompt: "Check status each minute",
runOnCreate: false,
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
});
now = new Date("2026-01-01T00:01:00.000Z");
try {
await expect(service.tick()).rejects.toThrow();
} finally {
await chmod(schedulesDir, 0o755);
}
expect((await service.inspect(created.id)).runs[0].status).toBe("running");
now = new Date("2026-01-01T00:02:00.000Z");
await service.tick();
const result = await service.inspect(created.id);
expect(dispatches).toBe(2);
expect(result.runs.map((run) => run.scheduledFor)).toEqual([
"2026-01-01T00:01:00.000Z",
"2026-01-01T00:02:00.000Z",
]);
expect(result.nextRunAt).toBe("2026-01-01T00:03:00.000Z");
},
);
test("completes an interrupted final slot during restart recovery", async () => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async () => ({ agentId: null, output: "done" }),
});
const created = await service.create({
prompt: "Run once",
cadence: { type: "cron", expression: "0 6 5 1 1" },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
maxRuns: 1,
});
now = new Date("2026-01-05T06:00:00.000Z");
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
await store.update(created.id, (schedule) => ({
...schedule,
nextRunAt: null,
runs: [
{
id: "interrupted-final-slot",
scheduledFor: now.toISOString(),
startedAt: now.toISOString(),
endedAt: null,
status: "running",
agentId: null,
output: null,
error: null,
},
],
}));
now = new Date("2026-01-05T06:01:00.000Z");
await service.start();
try {
const result = await service.inspect(created.id);
expect(result.status).toBe("completed");
expect(result.nextRunAt).toBeNull();
expect(result.runs).toHaveLength(1);
expect(result.runs[0].status).toBe("failed");
} finally {
await service.stop();
}
});
test.each([2, null])(
"keeps a next slot when the final run's limit changes to %s",
async (maxRuns) => {
const service = createScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: new AgentManager({ logger: createTestLogger() }),
agentStorage,
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
now: () => now,
runner: async (schedule) => {
await service.update({ id: schedule.id, maxRuns });
return { agentId: null, output: "done" };
},
});
const created = await service.create({
prompt: "Allow more runs during execution",
runOnCreate: false,
cadence: { type: "every", everyMs: 60_000 },
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:30.000Z");
await service.tick();
const result = await service.inspect(created.id);
expect(result.status).toBe("active");
expect(result.nextRunAt).toBe("2026-01-01T00:02:00.000Z");
expect(result.runs).toHaveLength(1);
},
);
test("pause and resume update persisted schedule state", async () => {
const service = createScheduleService({
paseoHome: tempDir,
+18 -98
View File
@@ -38,18 +38,6 @@ export class ScheduleTargetGoneError extends Error {
}
}
function advanceNextRunAtPast(
cadence: StoredSchedule["cadence"],
nextRunAt: Date,
now: Date,
): Date {
let next = nextRunAt;
while (next.getTime() <= now.getTime()) {
next = computeNextRunAt(cadence, next);
}
return next;
}
function trimOptionalName(value: string | null | undefined): string | null {
if (typeof value !== "string") {
return null;
@@ -633,9 +621,6 @@ export class ScheduleService {
error: "Daemon restarted before the scheduled run completed",
};
updated = { ...updated, runs };
if (shouldCompleteSchedule(updated, now)) {
updated = completeSchedule(updated, now);
}
dirty = true;
}
@@ -705,31 +690,20 @@ export class ScheduleService {
options?: { manual?: boolean },
): Promise<void> {
const manual = options?.manual === true;
// Reserve before store I/O; persisted history cannot prove a run is still alive.
this.runningScheduleIds.add(schedule.id);
try {
let runId: string;
let scheduleWithRun: StoredSchedule;
if (manual) {
runId = randomUUID();
const runningRun: ScheduleRun = {
id: runId,
scheduledFor: now.toISOString(),
startedAt: now.toISOString(),
endedAt: null,
status: "running",
agentId: null,
output: null,
error: null,
};
scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun);
} else {
const claimed = await this.claimDueRun(schedule.id, now);
if (!claimed) {
return;
}
({ schedule: scheduleWithRun, runId } = claimed);
}
const runId = randomUUID();
const runningRun: ScheduleRun = {
id: runId,
scheduledFor: manual ? now.toISOString() : (schedule.nextRunAt ?? now.toISOString()),
startedAt: now.toISOString(),
endedAt: null,
status: "running",
agentId: null,
output: null,
error: null,
};
const scheduleWithRun = await this.appendRunningRun(schedule.id, runningRun);
try {
const result = await this.runner(scheduleWithRun, runId);
@@ -760,58 +734,6 @@ export class ScheduleService {
}
}
private async claimDueRun(
scheduleId: string,
now: Date,
): Promise<{ schedule: StoredSchedule; runId: string } | null> {
let claimed: { schedule: StoredSchedule; runId: string } | null = null;
const updatedSchedule = await this.store.update(scheduleId, (schedule) => {
if (
schedule.status !== "active" ||
!schedule.nextRunAt ||
shouldCompleteSchedule(schedule, now) ||
new Date(schedule.nextRunAt).getTime() > now.getTime()
) {
return schedule;
}
const scheduledFor = schedule.nextRunAt;
const isFinalRun =
schedule.maxRuns !== null && countCompletedRuns(schedule) + 1 >= schedule.maxRuns;
const nextRunAt = isFinalRun
? null
: advanceNextRunAtPast(
schedule.cadence,
computeNextRunAt(schedule.cadence, new Date(scheduledFor)),
now,
).toISOString();
// Claim the due slot and advance its cursor in the same serialized update.
// A tick can hold a stale list while another tick finishes this schedule.
const runId = randomUUID();
const runningRun: ScheduleRun = {
id: runId,
scheduledFor,
startedAt: now.toISOString(),
endedAt: null,
status: "running",
agentId: null,
output: null,
error: null,
};
const updated: StoredSchedule = {
...schedule,
nextRunAt,
updatedAt: now.toISOString(),
runs: [...schedule.runs, runningRun],
};
claimed = { schedule: updated, runId };
return updated;
});
requireSchedule(updatedSchedule, scheduleId);
return claimed;
}
private async appendRunningRun(
scheduleId: string,
runningRun: ScheduleRun,
@@ -872,16 +794,14 @@ export class ScheduleService {
nextRunAt: null,
};
} else {
// A final slot has no successor until its run limit is raised or cleared.
// Every runner invocation has a recorded run with this id.
const finishedRun = completedRuns.find((run) => run.id === params.runId)!;
const nextRunAt = updated.nextRunAt
? new Date(updated.nextRunAt)
: computeNextRunAt(updated.cadence, new Date(finishedRun.scheduledFor));
// Move an overdue cursor past a long run without advancing a future one twice.
const after = new Date(schedule.nextRunAt ?? now.toISOString());
let nextRunAt = computeNextRunAt(updated.cadence, after);
while (nextRunAt.getTime() <= now.getTime()) {
nextRunAt = computeNextRunAt(updated.cadence, nextRunAt);
}
updated = {
...updated,
nextRunAt: advanceNextRunAtPast(updated.cadence, nextRunAt, now).toISOString(),
nextRunAt: nextRunAt.toISOString(),
};
}
@@ -60,7 +60,6 @@ interface FakeAgentSessionOptions {
closeSession?: () => Promise<void>;
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
historyEvents?: readonly AgentStreamEvent[];
}
export interface TestAgentClientOptions {
@@ -69,7 +68,6 @@ export interface TestAgentClientOptions {
onStartTurn?: (prompt: AgentPromptInput, config: AgentSessionConfig) => void;
beforeTurnComplete?: (prompt: AgentPromptInput) => Promise<void>;
supportsMcpServers?: boolean;
historyEvents?: readonly AgentStreamEvent[];
}
function createDeferred<T>(): Deferred<T> {
@@ -343,7 +341,6 @@ class FakeAgentSession implements AgentSession {
private readonly closeSession: (() => Promise<void>) | undefined;
private readonly onStartTurn: TestAgentClientOptions["onStartTurn"];
private readonly beforeTurnComplete: ((prompt: AgentPromptInput) => Promise<void>) | undefined;
private readonly historyEvents: readonly AgentStreamEvent[] | undefined;
constructor(options: FakeAgentSessionOptions) {
this.capabilities = {
@@ -357,7 +354,6 @@ class FakeAgentSession implements AgentSession {
this.closeSession = options.closeSession;
this.onStartTurn = options.onStartTurn;
this.beforeTurnComplete = options.beforeTurnComplete;
this.historyEvents = options.historyEvents;
this.historyPath = path.join(
tmpdir(),
"paseo-fake-provider-history",
@@ -821,10 +817,6 @@ class FakeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (this.historyEvents) {
yield* this.historyEvents;
return;
}
let contents: string;
try {
contents = await readFile(this.historyPath, "utf8");
@@ -1234,7 +1226,6 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents,
});
}
@@ -1261,7 +1252,6 @@ class FakeAgentClient implements AgentClient {
closeSession: this.options.closeSession,
onStartTurn: this.options.onStartTurn,
beforeTurnComplete: this.options.beforeTurnComplete,
historyEvents: this.options.historyEvents,
});
}
@@ -64,48 +64,6 @@ test("workspace.create worktree source forwards action=checkout + refName into t
}
}, 180000);
test("existing branch changes remain visible after archive and restore", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.11.2",
});
onTestFinished(async () => {
await client.close().catch(() => undefined);
await daemon.close();
rmSync(tempRoot, { recursive: true, force: true });
});
runGit(repoDir, "switch", "feature/existing-branch");
commitFile(repoDir, "feature.txt", "feature change\n");
runGit(repoDir, "switch", "main");
await client.connect();
const created = await client.createWorkspace({
source: {
kind: "worktree",
cwd: repoDir,
action: "checkout",
refName: "feature/existing-branch",
},
});
expect(created.error).toBeNull();
const workspace = created.workspace!;
const cwd = workspace.workspaceDirectory;
const head = runGit(cwd, "rev-parse", "HEAD");
const before = await client.getCheckoutDiff(cwd, { mode: "base" });
expect(before.error).toBeNull();
expect(before.files.map((file) => file.path)).toEqual(["feature.txt"]);
expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main");
expect((await client.archiveWorkspace(workspace.id)).error).toBeNull();
expect(existsSync(cwd)).toBe(false);
await client.restoreWorkspace(workspace.id);
expect(runGit(cwd, "rev-parse", "HEAD")).toBe(head);
expect((await client.getCheckoutStatus(cwd)).baseRef).toBe("main");
const after = await client.getCheckoutDiff(cwd, { mode: "base" });
expect(after.error).toBeNull();
expect(after.files.map((file) => file.path)).toEqual(["feature.txt"]);
}, 180000);
test("workspace.create keeps a branch-off name separate from its worktree slug", async () => {
const daemon = await createTestPaseoDaemon();
const { repoDir, tempRoot } = createGitRepoWithBranch();
@@ -1328,132 +1328,6 @@ describe("createGitLabService", () => {
);
});
it("finds a merge request by number even when its title and description omit it", async () => {
const { service } = makeService((args) => {
// GitLab text search does not search the IID. Model both API behaviors.
if (args.includes("--search")) return ok("[]");
if (args[0] === "api" && args[1].includes("iids[]=14")) {
return ok(JSON.stringify([OPEN_MR]));
}
throw new Error(`unexpected glab args: ${args.join(" ")}`);
});
const result = await service.searchIssuesAndPrs({
cwd: "/repo",
query: " 14 ",
kinds: ["change_request"],
});
expect(result.items).toEqual([
expect.objectContaining({
kind: "change_request",
number: 14,
title: OPEN_MR.title,
headRefName: OPEN_MR.source_branch,
baseRefName: OPEN_MR.target_branch,
}),
]);
});
it("finds issues by number through the same search interface", async () => {
const { service } = makeService((args) => {
if (args.includes("--search")) return ok("[]");
if (args[0] === "api" && args[1].includes("iids[]=7")) {
return ok(JSON.stringify([OPEN_ISSUE]));
}
throw new Error(`unexpected glab args: ${args.join(" ")}`);
});
const result = await service.searchIssuesAndPrs({
cwd: "/repo",
query: "7",
kinds: ["issue"],
});
expect(result.items).toEqual([
expect.objectContaining({ kind: "issue", number: 7, title: OPEN_ISSUE.title }),
]);
});
it.each([
{ kind: "change_request" as const, item: OPEN_MR },
{ kind: "issue" as const, item: OPEN_ISSUE },
])(
"preserves numeric text matches after the exact $kind number, without duplicates",
async ({ kind, item }) => {
const textMatches = [
{
...item,
iid: 90,
title: `References ${item.iid}`,
updated_at: "2026-07-01T00:00:00.000Z",
},
item,
{
...item,
iid: 91,
description: `References ${item.iid}`,
updated_at: "2026-06-30T00:00:00.000Z",
},
];
const { service } = makeService((args) => {
if (args.includes("--search")) return ok(JSON.stringify(textMatches));
if (args[0] === "api") return ok(JSON.stringify([item]));
throw new Error(`unexpected glab args: ${args.join(" ")}`);
});
const result = await service.searchIssuesAndPrs({
cwd: "/repo",
query: String(item.iid),
limit: 2,
kinds: [kind],
});
expect(
result.items.map((resultItem) => ({ kind: resultItem.kind, number: resultItem.number })),
).toEqual([
{ kind, number: item.iid },
{ kind, number: 90 },
]);
},
);
it.each([
{ kind: "change_request" as const, item: OPEN_MR },
{ kind: "issue" as const, item: OPEN_ISSUE },
])("keeps $kind text matches when the queried number is not open", async ({ kind, item }) => {
const { service } = makeService((args) => {
if (args.includes("--search")) {
return ok(JSON.stringify([{ ...item, description: "References 2024" }]));
}
if (args[0] === "api") return ok("[]");
throw new Error(`unexpected glab args: ${args.join(" ")}`);
});
const result = await service.searchIssuesAndPrs({
cwd: "/repo",
query: "2024",
kinds: [kind],
});
expect(result.items.map(({ number }) => number)).toEqual([item.iid]);
});
it("keeps closed and missing merge requests out of number search results", async () => {
const closedMr = { ...OPEN_MR, state: "closed" };
const { service } = makeService((args) => {
if (args.includes("--search")) return ok("[]");
if (args[0] !== "api") throw new Error(`unexpected glab args: ${args.join(" ")}`);
const endpoint = new URL(args[1], "https://gitlab.example.com/api/v4/");
const matches =
endpoint.searchParams.get("iids[]") === String(closedMr.iid) &&
endpoint.searchParams.get("state") !== "opened";
return ok(JSON.stringify(matches ? [closedMr] : []));
});
for (const query of ["14", "999999"]) {
const result = await service.searchIssuesAndPrs({
cwd: "/repo",
query,
limit: 10,
kinds: ["change_request"],
});
expect(result.authState).toBe("authenticated");
expect(result.items).toEqual([]);
}
});
it("searches issues and merge requests and maps them to neutral results", async () => {
const { service, calls } = makeService((args) => {
if (args[0] === "issue") return ok(JSON.stringify([OPEN_ISSUE]));
+32 -73
View File
@@ -882,31 +882,6 @@ function throwFirstNonGlabAuthSearchRejection(results: PromiseSettledResult<unkn
}
}
function buildGitLabListArgs({
kind,
input,
}: {
kind: "mr" | "issue";
input: ListPullRequestsOptions | ListIssuesOptions;
}): string[] {
const query = input.query?.trim();
// issue list uses -O for JSON; its -F flag controls details/ids/urls.
// mr list uses -F for JSON instead.
const args = [kind, "list", kind === "mr" ? "-F" : "-O", "json"];
if (query) {
args.push("--search", query);
}
if (typeof input.limit === "number") {
args.push("-P", String(input.limit));
}
return args;
}
function gitLabNumberQuery(query: string | undefined): string | null {
const trimmed = query?.trim();
return trimmed && /^\d+$/.test(trimmed) ? trimmed : null;
}
export function createGitLabService(options: CreateGitLabServiceOptions = {}): ForgeService {
const runner = options.runner ?? runGlabCommand;
const resolveGlab = createCachedCliPathResolver(options.resolveGlabPath ?? resolveGlabPath);
@@ -1082,51 +1057,42 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
}
}
async function runList<T extends { iid: number }>({
kind,
input,
schema,
}: {
kind: "mr" | "issue";
input: ListPullRequestsOptions | ListIssuesOptions;
schema: z.ZodType<T[]>;
}): Promise<T[]> {
const textSearch = runJson(buildGitLabListArgs({ kind, input }), { cwd: input.cwd }, schema);
const numberQuery = gitLabNumberQuery(input.query);
if (!numberQuery) return textSearch;
// GitLab text search omits IIDs, so supplement it with an open-item lookup.
const resource = kind === "mr" ? "merge_requests" : "issues";
const [numberMatches, textMatches] = await Promise.all([
runJson(
["api", `projects/:id/${resource}?state=opened&iids[]=${numberQuery}`],
{ cwd: input.cwd },
schema,
),
textSearch,
]);
const seen = new Set<number>();
const items = [...numberMatches, ...textMatches].filter((item) => {
if (seen.has(item.iid)) return false;
seen.add(item.iid);
return true;
});
return typeof input.limit === "number" ? items.slice(0, input.limit) : items;
}
async function runMergeRequestList(
input: ListPullRequestsOptions,
): Promise<PullRequestSummary[]> {
const mergeRequests = await runList({
kind: "mr",
input,
schema: z.array(GitLabMergeRequestSchema),
});
const args = ["mr", "list", "-F", "json"];
const query = input.query?.trim();
if (query) {
args.push("--search", query);
}
if (typeof input.limit === "number") {
args.push("-P", String(input.limit));
}
const mergeRequests = await runJson(
args,
{ cwd: input.cwd },
z.array(GitLabMergeRequestSchema),
);
return mergeRequests.map(toPullRequestSummary);
}
/**
* `glab issue list` toggles JSON output with `-O/--output` (text|json); its
* `-F/--output-format` flag means something else (details|ids|urls) and
* silently falls back to the human-readable table for an unknown value. This
* differs from `glab mr list`, where `-F/--output` is the JSON toggle. Using
* the wrong flag here would emit a text table that fails JSON parsing.
*/
async function runIssueList(input: ListIssuesOptions): Promise<IssueSummary[]> {
const issues = await runList({ kind: "issue", input, schema: z.array(GitLabIssueSchema) });
const args = ["issue", "list", "-O", "json"];
const query = input.query?.trim();
if (query) {
args.push("--search", query);
}
if (typeof input.limit === "number") {
args.push("-P", String(input.limit));
}
const issues = await runJson(args, { cwd: input.cwd }, z.array(GitLabIssueSchema));
return issues.map(toIssueSummary);
}
@@ -1393,16 +1359,9 @@ export function createGitLabService(options: CreateGitLabServiceOptions = {}): F
});
}
}
const numberQuery = gitLabNumberQuery(input.query);
items.sort((left, right) => {
if (numberQuery) {
const numberMatchOrder =
Number(right.number === Number(numberQuery)) -
Number(left.number === Number(numberQuery));
if (numberMatchOrder !== 0) return numberMatchOrder;
}
return parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt);
});
items.sort(
(left, right) => parseOptionalTime(right.updatedAt) - parseOptionalTime(left.updatedAt),
);
return {
items,
@@ -1,123 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { getCheckoutDiff, getCommitFileDiff } from "./checkout-git.js";
const repos: string[] = [];
afterEach(() => {
for (const repo of repos.splice(0)) rmSync(repo, { recursive: true, force: true });
});
it.each(["diff.mnemonicPrefix=true", "color.diff=always", "diff.noprefix=true"])(
"preserves batched paths, spaces and literal prefix directories with %s",
async (config) => {
const cwd = createChangedRepo(config);
mkdirSync(join(cwd, "a"));
for (const name of ["a/literal.txt", "with spaces.txt"]) {
writeFileSync(join(cwd, name), "before\n");
}
git(cwd, "add", "a/literal.txt", "with spaces.txt");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "add files");
for (const name of ["a/literal.txt", "with spaces.txt"]) {
writeFileSync(join(cwd, name), "after\n");
}
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured?.map((file) => file.path)).toEqual([
"a/literal.txt",
"file.txt",
"with spaces.txt",
]);
for (const file of result.structured ?? []) {
expect(file.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
}
expect(git(cwd, "config", "--get", config.split("=")[0]).trim()).toBe(config.split("=")[1]);
},
);
function git(cwd: string, ...args: string[]): string {
return execFileSync("git", args, { cwd, encoding: "utf8" });
}
function createChangedRepo(config: string): string {
const cwd = realpathSync.native(mkdtempSync(join(tmpdir(), "checkout-patch-format-")));
repos.push(cwd);
git(cwd, "init", "-b", "main");
git(cwd, "config", "user.name", "Test");
git(cwd, "config", "user.email", "test@example.com");
writeFileSync(join(cwd, "file.txt"), "before\n");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
git(cwd, "config", ...config.split("="));
writeFileSync(join(cwd, "file.txt"), "after\n");
return cwd;
}
describe.each(["diff.mnemonicPrefix=true", "color.diff=always"])(
"patch format with %s",
(config) => {
it("returns displayable uncommitted diff lines with their file counts", async () => {
const cwd = createChangedRepo(config);
const result = await getCheckoutDiff(cwd, { mode: "uncommitted", includeStructured: true });
expect(result.structured).toHaveLength(1);
expect(result.structured?.[0]).toMatchObject({
path: "file.txt",
additions: 1,
deletions: 1,
});
expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
});
it("returns displayable committed changes against the base branch", async () => {
const cwd = createChangedRepo(config);
git(cwd, "checkout", "-b", "feature");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
const result = await getCheckoutDiff(cwd, {
mode: "base",
baseRef: "main",
includeStructured: true,
});
expect(result.structured?.[0]).toMatchObject({
path: "file.txt",
additions: 1,
deletions: 1,
});
expect(result.structured?.[0].hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]),
);
});
it("returns displayable lines when opening an individual commit file", async () => {
const cwd = createChangedRepo(config);
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
const file = await getCommitFileDiff({
cwd,
sha: git(cwd, "rev-parse", "HEAD").trim(),
path: "file.txt",
});
expect(file).toMatchObject({ path: "file.txt", additions: 1, deletions: 1 });
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([expect.objectContaining({ type: "add", content: "after" })]),
);
});
},
);
@@ -523,7 +523,6 @@ describe("checkout git utilities", () => {
it("honors commit signing configuration", async () => {
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "signed\n");
@@ -1508,10 +1507,8 @@ const x = 1;
expect(diff.diff).toContain("# generated.js: diff too large omitted");
expect(diff.diff).toContain(`-export const value = "old";`);
expect(diff.diff).toContain(`+export const value = "new";`);
expect(commands).toContain("diff --no-color --src-prefix=a/ --dst-prefix=b/ --numstat HEAD");
expect(commands).toContain(
"diff --no-color --src-prefix=a/ --dst-prefix=b/ HEAD -- :(literal)generated.js :(literal)small.ts",
);
expect(commands).toContain("diff --numstat HEAD");
expect(commands).toContain("diff HEAD -- :(literal)generated.js :(literal)small.ts");
expect(metrics.maxConcurrent).toBeLessThanOrEqual(8);
});
@@ -3579,7 +3576,6 @@ const x = 1;
cwd: repoDir,
});
execFileSync("git", ["config", "commit.gpgsign", "true"], { cwd: repoDir });
execFileSync("git", ["config", "gpg.format", "openpgp"], { cwd: repoDir });
execFileSync("git", ["config", "gpg.program", process.execPath], { cwd: repoDir });
await expect(mergeToBase(repoDir, { baseRef: "main", mode: "squash" })).rejects.toThrow(
+3 -15
View File
@@ -636,16 +636,8 @@ function normalizeNumstatPath(pathField: string): string {
return pathField;
}
// Parsed patches need a stable wire format regardless of terminal presentation settings.
const GIT_PATCH_FORMAT_ARGS = ["--no-color", "--src-prefix=a/", "--dst-prefix=b/"];
function buildGitDiffArgs(args: { ignoreWhitespace?: boolean; extra: string[] }): string[] {
return [
"diff",
...GIT_PATCH_FORMAT_ARGS,
...(args.ignoreWhitespace ? ["-w"] : []),
...args.extra,
];
return ["diff", ...(args.ignoreWhitespace ? ["-w"] : []), ...args.extra];
}
const TRACKED_DIFF_NUMSTAT_MAX_BYTES = 2 * 1024 * 1024; // 2MB
@@ -1568,15 +1560,11 @@ export async function resolveRepositoryDefaultBranch(
);
return localName;
} catch {
await getRunGitCommand(context)(["show-ref", "--verify", "--quiet", ref], {
cwd: repoRoot,
envOverlay: READ_ONLY_GIT_ENV,
});
return remoteShort;
}
}
} catch {
// A stale origin/HEAD must fall through to the available local branches.
// ignore
}
const { stdout } = await getRunGitCommand(context)(["branch", "--format=%(refname:short)"], {
@@ -2643,7 +2631,7 @@ export async function getCommitFileDiff({
path: string;
}): Promise<ParsedDiffFile | null> {
const { stdout } = await runGitCommand(
["show", sha, "--format=", "--diff-merges=first-parent", ...GIT_PATCH_FORMAT_ARGS, "--", path],
["show", sha, "--format=", "--diff-merges=first-parent", "--", path],
{
cwd,
envOverlay: READ_ONLY_GIT_ENV,
@@ -265,38 +265,6 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
});
});
it.each([false, true])(
"shows committed changes for an existing branch (already checked out: %s)",
async (alreadyCheckedOut) => {
execFileSync("git", ["checkout", "-b", "feature/existing"], { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "feature change\n");
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], {
cwd: repoDir,
});
if (!alreadyCheckedOut) {
execFileSync("git", ["checkout", "main"], { cwd: repoDir });
}
const result = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "existing-feature",
source: { kind: "checkout-branch", branchName: "feature/existing" },
runSetup: false,
paseoHome,
});
const status = await getCheckoutStatus(result.worktreePath, { paseoHome });
const diff = await getCheckoutDiff(result.worktreePath, { mode: "base" }, { paseoHome });
expect(diff.diff).toContain("+feature change");
expect(status).toMatchObject({
isGit: true,
baseRef: "main",
aheadBehind: { ahead: 1, behind: 0 },
});
expect(result.comparisonBaseRef).toBe("main");
},
);
it("checks out an existing local branch that is not checked out elsewhere", async () => {
execFileSync("git", ["branch", "dev"], { cwd: repoDir });
@@ -316,65 +284,15 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
.trim();
expect(currentBranch).toBe("dev");
// baseRefName must be the repo's default branch, not the checked-out branch
// itself — otherwise the diff panel compares the branch against itself and
// always shows no changes.
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({
version: 1,
baseRefName: "main",
baseRefName: "dev",
changeRequestLookupTarget: { headRef: "dev", localBranchName: "dev" },
});
});
it("shows committed changes when origin HEAD points to a deleted default branch", async () => {
execFileSync("git", ["branch", "-m", "main", "master"], { cwd: repoDir });
execFileSync(
"git",
["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"],
{ cwd: repoDir },
);
execFileSync("git", ["checkout", "-b", "feature/stale-default"], { cwd: repoDir });
writeFileSync(join(repoDir, "file.txt"), "feature change\n");
execFileSync("git", ["add", "file.txt"], { cwd: repoDir });
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "feature change"], {
cwd: repoDir,
});
execFileSync("git", ["checkout", "master"], { cwd: repoDir });
const created = await createWorktreePrimitive({
cwd: repoDir,
worktreeSlug: "stale-default",
source: { kind: "checkout-branch", branchName: "feature/stale-default" },
runSetup: false,
paseoHome,
});
const diff = await getCheckoutDiff(created.worktreePath, { mode: "base" }, { paseoHome });
expect(diff.diff).toContain("+feature change");
expect(created.comparisonBaseRef).toBe("master");
});
it("falls back to the currently checked out branch when no default branch can be resolved", async () => {
// No origin remote and no local main/master — resolveRepositoryDefaultBranch
// can't find a default branch, so the fix must fall back to whatever is
// currently checked out in the main repo (here: "develop").
execFileSync("git", ["branch", "-m", "main", "develop"], { cwd: repoDir });
execFileSync("git", ["branch", "feature-x"], { cwd: repoDir });
const result = await createLegacyWorktreeForTest({
cwd: repoDir,
worktreeSlug: "feature-x-worktree",
source: { kind: "checkout-branch", branchName: "feature-x" },
runSetup: true,
paseoHome,
});
expect(existsSync(result.worktreePath)).toBe(true);
const metadataPath = getPaseoWorktreeMetadataPath(result.worktreePath);
const metadata = JSON.parse(readFileSync(metadataPath, "utf8"));
expect(metadata).toMatchObject({ version: 1, baseRefName: "develop" });
});
it("checks out an existing local branch whose name contains uppercase letters and dots", async () => {
execFileSync("git", ["branch", "release/1.1.15"], { cwd: repoDir });
+6 -20
View File
@@ -39,7 +39,6 @@ import {
writePaseoWorktreeRuntimeMetadata,
} from "./worktree-metadata.js";
import { runGitCommand } from "./run-git-command.js";
import { getCurrentBranch, resolveRepositoryDefaultBranch } from "./checkout-git.js";
import { spawnProcess } from "./spawn.js";
import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
@@ -1280,7 +1279,10 @@ export const createWorktree = async ({
return {
branchName: sourcePlan.branchName,
worktreePath,
comparisonBaseRef: sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName,
comparisonBaseRef:
source.kind === "checkout-branch"
? null
: (sourcePlan.metadataBaseRef ?? sourcePlan.metadataBaseRefName),
};
};
@@ -1397,12 +1399,11 @@ async function resolveWorktreeSourcePlan({
case "checkout-branch": {
await validateGitBranchName(cwd, source.branchName);
await ensureLocalBranch(cwd, source.branchName);
const baseRef = await resolveCheckoutBranchBaseRef(cwd, source.branchName);
if (await isBranchCheckedOut(cwd, source.branchName)) {
const branchName = await resolveUniqueLocalBranchName(cwd, source.branchName);
return {
branchName,
metadataBaseRefName: baseRef,
metadataBaseRefName: source.branchName,
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
headRef: branchName,
localBranchName: branchName,
@@ -1413,7 +1414,7 @@ async function resolveWorktreeSourcePlan({
return {
branchName: source.branchName,
metadataBaseRefName: baseRef,
metadataBaseRefName: source.branchName,
changeRequestLookupTarget: createPaseoWorktreeChangeRequestHint({
headRef: source.branchName,
localBranchName: source.branchName,
@@ -1668,21 +1669,6 @@ function normalizeRequiredBaseBranch(baseBranch: string): string {
return normalizedBaseBranch;
}
// The checked-out branch itself isn't a base to diff against — resolve the
// repository's default branch so the diff panel has a real comparison target.
// If the default branch can't be determined (no origin/HEAD, no local
// main/master — e.g. a local-only repo based on "develop"), fall back to
// whichever branch is currently checked out in the main repo, since that's
// almost always the branch this one was intended to be compared against.
async function resolveCheckoutBranchBaseRef(cwd: string, branchName: string): Promise<string> {
const defaultBranch = await resolveRepositoryDefaultBranch(cwd);
if (defaultBranch) {
return defaultBranch;
}
const currentBranch = await getCurrentBranch(cwd);
return currentBranch && currentBranch !== branchName ? currentBranch : branchName;
}
async function resolveBaseBranchForWorktree(
cwd: string,
requestedBaseBranch: string,