Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 9f9982b488 fix(gitlab): resolve merge requests with legacy null references
Successful legacy MR responses were rejected before branch status could be resolved. Accept null at the existing response boundary, as with absent reference metadata, while keeping current response validation.
2026-10-10 13:03:59 +02:00
97 changed files with 1577 additions and 4478 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
@@ -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 ===");
@@ -47,33 +47,3 @@ describe("renderTerminalSnapshotToAnsi", () => {
expect(ansi).toContain("ABCDEFGHIJ\r\nKLMNOP");
});
});
describe("renderTerminalSnapshotToAnsi wide chars", () => {
it("skips wide-char continuation cells so the glyph keeps its two columns", () => {
// The daemon captures "통" as the glyph followed by an empty continuation
// cell. Emitting anything for that cell pushes the rest of the row right.
const state: TerminalState = {
rows: 1,
cols: 10,
scrollback: [],
grid: [[{ char: "통" }, { char: "" }, { char: "합" }, { char: "" }, { char: "!" }]],
cursor: { row: 0, col: 5 },
};
expect(renderTerminalSnapshotToAnsi(state)).toContain("통합!");
});
it("replays snapshots from an old daemon verbatim, including its continuation spaces", () => {
// Old daemons already collapsed the continuation to " ". The serializer
// cannot tell that apart from a real space, so it must not guess.
const state: TerminalState = {
rows: 1,
cols: 10,
scrollback: [],
grid: [cells("통 합 !")],
cursor: { row: 0, col: 6 },
};
expect(renderTerminalSnapshotToAnsi(state)).toContain("통 합 !");
});
});
@@ -87,12 +87,6 @@ function renderTerminalRow(row: TerminalCell[], padToCols?: number): string {
for (let index = 0; index < length; index += 1) {
const cell = row[index] ?? { char: " " };
// An empty char marks a wide-char continuation cell. The glyph in the
// previous cell already advances the cursor two columns, so emitting
// anything here would shift the rest of the row one column right.
if (cell.char === "") {
continue;
}
const nextStyle = getTerminalStyle(cell);
if (!terminalStylesEqual(previousStyle, nextStyle)) {
output.push(styleToAnsi(nextStyle));
+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();
@@ -298,6 +298,35 @@ describe("createGitLabService", () => {
expect(calls[1]).toEqual(["mr", "view", "14", "-F", "json"]);
});
it.each(["list", "view"])(
"resolves the current merge request when %s returns legacy null references",
async (legacyCommand) => {
const legacyMr = {
...OPEN_MR,
reference: "!14",
references: null,
web_url: "https://gitlab.example.com/example-group/example-project/merge_requests/14",
};
const { service } = makeService((args) => {
const mr = args[1] === legacyCommand ? legacyMr : OPEN_MR;
if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([mr]));
if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(mr));
if (args[0] === "api") return ok("{}");
throw new Error(`unexpected call: ${args.join(" ")}`);
});
await expect(
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0" }),
).resolves.toMatchObject({
number: 14,
title: OPEN_MR.title,
state: "open",
headRefName: "release/v0.4.0",
url: legacyCommand === "view" ? legacyMr.web_url : OPEN_MR.web_url,
});
},
);
it("reports a conflicting merge request as CONFLICTING", async () => {
const conflicting = {
...OPEN_MR,
@@ -1328,132 +1357,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]));
+33 -74
View File
@@ -168,7 +168,7 @@ const GitLabMergeRequestSchema = z
description: z.string().nullable().optional(),
labels: z.array(z.string()).optional(),
updated_at: z.string().optional(),
references: z.object({ full: z.string().optional() }).passthrough().optional(),
references: z.object({ full: z.string().optional() }).passthrough().nullable().optional(),
head_pipeline: GitLabPipelineSchema.nullable().optional(),
})
.passthrough();
@@ -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,
@@ -24,8 +24,6 @@ import {
} from "node:fs";
import { spawnSync } from "node:child_process";
import * as pty from "node-pty";
import xterm from "@xterm/headless";
import { renderTerminalSnapshotToAnsi } from "@getpaseo/protocol/terminal-snapshot";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { setImmediate as waitForImmediate, setTimeout as delay } from "node:timers/promises";
@@ -52,10 +50,6 @@ function getLines(state: ReturnType<TerminalSession["getState"]>): string[] {
return state.grid.map(rowToText);
}
function rowHasChar(row: TerminalRow, char: string): boolean {
return row.some((cell) => cell.char === char);
}
// Wait for terminal state to match expected lines
async function waitForLines(
session: TerminalSession,
@@ -529,44 +523,6 @@ describe("createTerminal", () => {
expect(withoutFlags.scrollbackWrapped).toBeUndefined();
});
it("restores CJK text without injected spaces when a snapshot round-trips through the client", async () => {
// Each Hangul syllable is a wide char: xterm stores it as a 2-cell pair
// whose second cell has no chars. Capturing that continuation as " " made
// every restored syllable three columns wide (paseo#3434).
const text = "통합 구현은 전체 테스트를 통과했습니다";
const session = trackSession(
await createTerminal({
workspaceId: "ws-test",
cwd: realpathSync(tmpdir()),
cols: 80,
rows: 10,
command: process.execPath,
args: [
"-e",
`process.stdout.write(${JSON.stringify(text)}); setInterval(() => {}, 100000);`,
],
}),
);
const state = await waitForState(session, (current) => rowHasChar(current.grid[0], "다"));
// Replay the snapshot the way the client does on tab switch and read the
// row back the way a selection copy would.
const restored = new xterm.Terminal({ cols: 80, rows: 10, allowProposedApi: true });
try {
await new Promise<void>((resolve) =>
restored.write(renderTerminalSnapshotToAnsi(state), resolve),
);
expect(restored.buffer.active.getLine(0)?.translateToString(true)).toBe(text);
} finally {
restored.dispose();
}
// The continuation cell must stay empty so the restore path can skip it.
expect(state.grid[0][0].char).toBe("통");
expect(state.grid[0][1].char).toBe("");
});
it("captures exit diagnostics from the terminal buffer", async () => {
const session = trackSession(
await createTerminal({
+2 -6
View File
@@ -580,9 +580,7 @@ function extractCell(terminal: TerminalType, row: number, col: number): Terminal
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
return {
// Wide chars (CJK) span two cells; xterm returns "" for the zero-width
// continuation. Collapsing it to " " injects a real space on restore.
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
char: cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,
@@ -641,9 +639,7 @@ function extractScrollback(
const fg = fgMode !== 0 ? cell.getFgColor() : undefined;
const bg = bgMode !== 0 ? cell.getBgColor() : undefined;
rowCells.push({
// Wide chars (CJK) span two cells; xterm returns "" for the zero-width
// continuation. Collapsing it to " " injects a real space on restore.
char: cell.getWidth() === 0 ? "" : cell.getChars() || " ",
char: cell.getChars() || " ",
fg,
bg,
fgMode: fgMode !== 0 ? fgMode : undefined,
@@ -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,