Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 4c6f285930 test(worktrees): await repository cleanup after remote timeouts 2026-10-12 06:20:51 +02:00
paseo-bot[bot] 23fb1cc85a test(schedules): use the canonical Git root for worktree fixtures 2026-10-12 06:20:51 +02:00
paseo-bot[bot]and见安 fd1551d46e fix(cli): reject fractional integer options before rounding
Validate decimal integrality before Number conversion can hide a fraction. Keep exact integer scientific notation, signed capture indices, and schedule limit updates working.

Co-authored-by: 见安 <73627805+Mar-garet@users.noreply.github.com>
2026-10-12 06:20:51 +02:00
见安 4d67f08922 test(cli): verify integer options against a real daemon 2026-10-12 06:20:51 +02:00
见安 35edd688b5 fix(terminal): validate complete capture line numbers 2026-10-12 06:20:22 +02:00
见安 eed6478a7d fix(schedule): validate complete max-runs values 2026-10-12 06:20:22 +02:00
101 changed files with 797 additions and 3325 deletions
@@ -25,12 +25,6 @@ import {
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
attachPromptTimelineEvidence,
expectPromptsDrawnByPlugin,
expectVirtualizedPluginPromptVisible,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -111,60 +105,6 @@ test.describe("desktop chat outline", () => {
}
});
test("jumps to prompts a timeline plugin draws", async ({ page }) => {
test.setTimeout(180_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await withPromptTimelinePlugin(async () => {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await expectPromptsDrawnByPlugin(page);
await expectTimelinePromptNotMounted(page, agent.prompts[39]);
// Unloaded, then loaded, then near the newest edge.
await clickChatOutlineRowEdge(page, 40);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
await attachPromptTimelineEvidence(page, test.info());
await clickChatOutlineRowEdge(page, 45);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
await clickChatOutlineRowEdge(page, 78);
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
await expectPromptsDrawnByPlugin(page);
});
} finally {
await agent.cleanup();
}
});
test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
page,
}) => {
test.setTimeout(180_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
const prompt = agent.prompts[29]!;
try {
await withPromptTimelinePlugin(async () => {
const history = await holdOlderHistoryPages(page, agent);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptNotMounted(page, prompt);
const requestsBeforeJump = history.requestCount();
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, prompt);
await expectVirtualizedPluginPromptVisible(page, prompt);
await attachPromptTimelineEvidence(page, test.info());
expect(history.requestCount()).toBe(requestsBeforeJump);
}, [prompt]);
} finally {
await agent.cleanup();
}
});
test.describe("with the whole conversation loaded", () => {
let agent: LongTimelineAgent;
@@ -4,10 +4,6 @@ import {
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
selectComposerDraftRange,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -57,48 +53,3 @@ for (const email of [
}
});
}
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-mention-",
title: "Fullscreen mention keys",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const draft = "First line\nSecond line\n@README";
await fillComposerDraft(page, draft);
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
await enterComposerFullscreen(page);
await expect(fileSuggestions(page)).not.toBeVisible();
await selectComposerDraftRange(page, draft.length, draft.length);
await composerLocator(page).press("ArrowUp");
await expect
.poll(() =>
composerLocator(page).evaluate((element) => {
const input = element as HTMLTextAreaElement;
return input.selectionStart === input.selectionEnd && input.selectionStart < 23;
}),
)
.toBe(true);
await selectComposerDraftRange(page, draft.length, draft.length);
await composerLocator(page).press("Enter");
await expectComposerDraft(page, `${draft}\n`);
await exitComposerFullscreen(page);
await selectReadmeMention(page);
const commandDraft = "/cl\nSecond line\nThird line";
await fillComposerDraft(page, commandDraft);
await selectComposerDraftRange(page, 2, 2);
await composerLocator(page).press("ArrowRight");
await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible();
await enterComposerFullscreen(page);
await expect(fileSuggestions(page)).not.toBeVisible();
await composerLocator(page).press("Enter");
await expectComposerDraft(page, "/cl\n\nSecond line\nThird line");
} finally {
await agent.cleanup();
}
});
@@ -1,5 +1,3 @@
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expect, test, type Page } from "../support/fixtures";
import {
expectNearBottom,
@@ -7,27 +5,7 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import {
attachComposerTestImage,
pasteComposerTestImage,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
selectComposerDraftRange,
expectComposerDraftSelection,
expectComposerFullscreenControls,
sendComposerWithButton,
queueComposerWithButton,
configureComposerQueueSend,
activateRetainedComposerTab,
recordVisibleComposerGeometry,
expectNoCollapsedVisibleComposer,
expectAttachmentPill,
expectQueuedMessageButton,
startRunningMockAgent,
composerLocator,
expectComposerVisible,
} from "../support/helpers/composer";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function composerHeight(page: Page): Promise<number> {
@@ -155,300 +133,3 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
await agent.cleanup();
}
});
async function writeCompactDraft(page: Page, text: string): Promise<void> {
await composerLocator(page).fill(text);
await expect(composerLocator(page)).toHaveValue(text);
}
test("compact long drafts stop growing before the header", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-cap-",
title: "Composer cap",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "A long draft line\n".repeat(40));
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
} finally {
await agent.cleanup();
}
});
test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-",
title: "Fullscreen composer",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await attachComposerTestImage(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await writeCompactDraft(page, "First line\nSecond line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await writeCompactDraft(
page,
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
4,
),
);
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await selectComposerDraftRange(page, 4, 17);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
await selectComposerDraftRange(page, 3, 12);
await exitComposerFullscreen(page);
await expect(composerLocator(page)).toHaveValue(
"Edited fullscreen draft\nSecond line\nThird line",
);
await expectComposerDraftSelection(page, 3, 12);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(composerLocator(page)).toBeFocused();
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
await expectComposerFullscreenControls(page);
await selectComposerDraftRange(page, 0, 5);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 0, 5);
await expect(composerLocator(page)).toHaveValue("Short draft");
await expect(composerLocator(page)).toBeFocused();
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
} finally {
await agent.cleanup();
}
});
test("fullscreen submit rejection retains the editable draft and supports retry", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-retry-",
title: "Fullscreen retry",
featureValues: { mockPromptRejections: 1 },
});
const draft = "First line\nSecond line\nThird line";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await sendComposerWithButton(page);
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
"Requested mock prompt rejection",
);
await expect(composerLocator(page)).toHaveValue(draft);
await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
} finally {
await agent.cleanup();
}
});
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
page,
}) => {
await configureComposerQueueSend(page);
await resizeComposerViewport(page, "portrait");
const agent = await startRunningMockAgent(page, {
prefix: "composer-fullscreen-queue-",
model: "one-minute-stream",
prompt: "Stay running for fullscreen queue test.",
});
try {
await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await queueComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expectQueuedMessageButton(page);
} finally {
await agent.cleanup();
}
});
test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-paste-remount-",
title: "Composer paste",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
expect(await pasteComposerTestImage(page)).toBe(true);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
await enterComposerFullscreen(page);
expect(await pasteComposerTestImage(page)).toBe(true);
await exitComposerFullscreen(page);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
expect(await pasteComposerTestImage(page)).toBe(true);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
} finally {
await agent.cleanup();
}
});
test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "First retained chat",
});
const second = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Second retained chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await resizeComposerViewport(page, "portrait");
const draft = "Live first line\nLive second line\nLive third line";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await selectComposerDraftRange(page, 4, 17);
await activateRetainedComposerTab(page, first.title);
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "Other tab draft");
await shrinkVisibleViewport(page);
await activateRetainedComposerTab(page, second.title);
await expectComposerFullscreenControls(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(composerLocator(page)).toBeFocused();
expect(await pasteComposerTestImage(page)).toBe(true);
await exitComposerFullscreen(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
} finally {
await workspace.cleanup();
}
});
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "First capacity chat",
});
const second = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Second capacity chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await resizeComposerViewport(page, "portrait");
const draft = "Retained long line\n".repeat(40);
await writeCompactDraft(page, draft);
await recordVisibleComposerGeometry(page);
await activateRetainedComposerTab(page, first.title);
await writeCompactDraft(page, "Other visible draft");
await activateRetainedComposerTab(page, second.title);
await expect(composerLocator(page)).toHaveValue(draft);
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
await expectNoCollapsedVisibleComposer(page);
} finally {
await workspace.cleanup();
}
});
async function shrinkVisibleViewport(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
await cdp.detach();
}
async function panVisibleViewport(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: 4, y: 300 }],
});
for (let y = 290; y >= 100; y -= 10) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: 4, y }],
});
}
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
await cdp.detach();
}
async function expectFullscreenInsideVisibleViewport(page: Page): Promise<void> {
await expect
.poll(() =>
page.getByTestId("composer-fullscreen").evaluate((element) => {
const viewport = window.visualViewport!;
const frame = element.getBoundingClientRect();
const send = element.querySelector('[aria-label="Send message"]')!.getBoundingClientRect();
return {
frameFits:
Math.abs(frame.top - viewport.offsetTop) < 1 &&
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
Math.abs(frame.height - viewport.height) < 1 &&
Math.abs(frame.width - viewport.width) < 1,
sendFits:
send.top >= viewport.offsetTop &&
send.bottom <= viewport.offsetTop + viewport.height &&
send.left >= viewport.offsetLeft &&
send.right <= viewport.offsetLeft + viewport.width,
};
}),
)
.toEqual({ frameFits: true, sendFits: true });
}
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-visible-viewport-",
title: "Visible viewport composer",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectFullscreenInsideVisibleViewport(page);
await shrinkVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await panVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
} finally {
await agent.cleanup();
}
});
@@ -6,15 +6,6 @@ import {
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
composerLocator,
expectComposerVisible,
fillComposerDraft,
enterComposerFullscreen,
resizeComposerViewport,
expectComposerFullscreenControls,
} from "../support/helpers/composer";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
@@ -205,52 +196,3 @@ test.describe("New Workspace dictation submit", () => {
}
});
});
async function startDictationWithShortcut(page: Page): Promise<void> {
await composerLocator(page).press("Control+d");
}
test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "fullscreen-dictation-",
title: "Fullscreen dictation shortcut",
});
await installSyntheticMicrophone(page);
const harness = await installDictationFailureHarness(page);
const draft = "First line\nSecond line\nThird line";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await fillComposerDraft(page, draft);
await enterComposerFullscreen(page);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await startDictationWithShortcut(page);
await harness.waitForAudio();
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Insert transcription", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Insert transcription and send", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue(draft);
await resizeComposerViewport(page, "portrait");
await enterComposerFullscreen(page);
await resizeComposerViewport(page, "landscape");
await composerLocator(page).press("Control+Shift+d");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await expect(
page.getByText("Realtime voice is disabled in this test.", { exact: true }),
).toBeVisible();
await expect(composerLocator(page)).toHaveValue(draft);
} finally {
await agent.cleanup();
}
});
@@ -9,12 +9,13 @@ import {
expectTerminalPreviewCommand,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
launchTrigger,
seedTerminalProfiles,
selectLaunchOption,
submitTerminalLaunch,
terminalLaunchSubmit,
terminalPromptInput,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// Someone who wants a terminal agent instead of a chat: they pick a profile
@@ -41,18 +42,17 @@ const BARE_PROFILE: TerminalProfile = {
args: ["-c", "echo bare-launch-static-line; exec cat"],
};
test.use({
e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } },
});
test.describe("New workspace: launching a terminal", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
@@ -97,51 +97,4 @@ test.describe("New workspace: launching a terminal", () => {
await expectTerminalOutputContains(page, "bare-launch-static-line");
});
});
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
page,
}) => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await page.setViewportSize({ width: 390, height: 844 });
const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
const selectProfile = async (profile: TerminalProfile) => {
await launchTrigger(page).click();
const option = page.getByRole("menuitem").filter({ hasText: profile.name });
await option.click();
await expect(option).not.toBeVisible();
};
const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
await test.step("restore a multiline prompt after a read-only profile", async () => {
await selectProfile(PROMPT_PROFILE);
await fillTerminalPrompt(page, prompt);
await expect(fullscreen).toBeVisible();
await selectProfile(BARE_PROFILE);
await expect(terminalPromptInput(page)).toHaveCount(0);
await selectProfile(PROMPT_PROFILE);
await expect(terminalPromptInput(page)).toHaveValue(prompt);
await expect(fullscreen).toBeVisible();
});
await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
await fillTerminalPrompt(page, "Short draft");
await expect(fullscreen).toHaveCount(0);
await selectProfile(BARE_PROFILE);
await selectProfile(PROMPT_PROFILE);
await expect(terminalPromptInput(page)).toHaveValue("Short draft");
await expect(fullscreen).toHaveCount(0);
await terminalPromptInput(page).press("End");
await terminalPromptInput(page).press("Shift+Enter");
await terminalPromptInput(page).pressSequentially("Second");
await terminalPromptInput(page).press("Shift+Enter");
await terminalPromptInput(page).pressSequentially("Third");
await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
await expect(fullscreen).toBeVisible();
});
});
});
@@ -1,64 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test as base } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const test = base.extend<{ projectWithTrailingSpace: string }>({
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
try {
const repoPath = path.join(root, "repo ");
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.local",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repoPath, stdio: "pipe" },
);
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
const added = await client.addProject(repoPath);
expect(added.error).toBeNull();
const projectId = added.project!.projectId;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repoPath, projectId },
});
expect(created.error).toBeNull();
await provide(created.workspace!.id);
} finally {
await client.removeProject(projectId);
}
} finally {
await rm(root, { recursive: true, force: true });
}
},
});
test.skip(
process.platform === "win32",
"Windows does not preserve trailing spaces in folder names",
);
test("Changes shows files in a project whose folder ends in a space", async ({
page,
projectWithTrailingSpace,
}, testInfo) => {
await gotoWorkspace(page, projectWithTrailingSpace);
await openChangesTreePanel(page);
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("changes.png") });
});
@@ -1,62 +0,0 @@
// Open Settings > Appearance in a native Android app loaded from this checkout.
// AGENT_DEVICE_STATE_DIR=<state-dir> node packages/app/e2e/mobile/system-bars/android.mjs <session> <adb-serial> <app-id>
// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, serial, appId] = process.argv;
assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
function windowState() {
const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
assert(regions, "Android must report the status-bar appearance region");
return regions.includes("LIGHT_STATUS_BARS");
}
async function expectDarkIcons(expected, context) {
const deadline = Date.now() + 5_000;
do {
if (windowState() === expected) {
console.log(`PASS: ${context}`);
return;
}
await delay(100);
} while (Date.now() < deadline);
assert.equal(windowState(), expected, context);
}
function selectTheme(theme) {
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
assert(current, "Start on Settings > Appearance");
ad("press", `label="Theme: ${current}"`, "--settle");
ad("press", `label="${theme}"`, "--settle");
ad("wait", "text", `Theme: ${theme}`, "5000");
}
ad("settings", "appearance", "light");
selectTheme("Light");
await expectDarkIcons(true, "Light app theme uses dark status icons");
selectTheme("Dark");
await expectDarkIcons(false, "Dark app theme uses light status icons");
selectTheme("System");
await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
ad("settings", "appearance", "dark");
await expectDarkIcons(
false,
"System theme follows an OS change to dark without reopening settings",
);
ad("settings", "appearance", "light");
await expectDarkIcons(true, "System theme follows an OS change back to light");
@@ -316,133 +316,3 @@ export async function openGithubWorkspace(
},
};
}
const COMPOSER_TEST_IMAGE_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
export async function attachComposerTestImage(page: Page): Promise<void> {
await attachImageFromMenu(page, {
name: "fullscreen.png",
mimeType: "image/png",
buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"),
});
}
export async function pasteComposerTestImage(page: Page): Promise<boolean> {
return composerInput(page).evaluate((input, base64) => {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
input.dispatchEvent(event);
return event.defaultPrevented;
}, COMPOSER_TEST_IMAGE_BASE64);
}
export async function enterComposerFullscreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Fullscreen", exact: true }).click();
}
export async function exitComposerFullscreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click();
}
export async function resizeComposerViewport(
page: Page,
orientation: "portrait" | "landscape" | "short",
): Promise<void> {
const sizes = {
portrait: { width: 390, height: 844 },
landscape: { width: 844, height: 390 },
short: { width: 390, height: 390 },
};
await page.setViewportSize(sizes[orientation]);
}
export async function selectComposerDraftRange(
page: Page,
start: number,
end: number,
): Promise<void> {
await composerInput(page).focus();
await composerInput(page).evaluate(
(element, selection) => {
const input = element as HTMLTextAreaElement;
input.setSelectionRange(selection.start, selection.end);
input.dispatchEvent(new Event("select", { bubbles: true }));
},
{ start, end },
);
}
export async function expectComposerDraftSelection(
page: Page,
start: number,
end: number,
): Promise<void> {
await expect
.poll(() =>
composerInput(page).evaluate((element) => {
const input = element as HTMLTextAreaElement;
return [input.selectionStart, input.selectionEnd];
}),
)
.toEqual([start, end]);
}
export async function expectComposerFullscreenControls(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
await expect(composerInput(page)).toHaveCount(1);
}
export async function sendComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Send message", exact: true }).click();
}
export async function queueComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Queue message", exact: true }).click();
}
export async function configureComposerQueueSend(page: Page): Promise<void> {
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })),
);
}
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
* This exercises retained activity changes, not a claim that covered chrome is clickable.
*/
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
await page
.getByTestId("workspace-tab-switcher-trigger")
.evaluate((element) => (element as HTMLElement).click());
const option = page.getByText(title, { exact: true }).filter({ visible: true }).last();
await expect(option).toBeVisible();
await option.evaluate((element) => (element as HTMLElement).click());
}
export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
await page.evaluate(() => {
const frames: { input: number; root: number }[] = [];
Reflect.set(window, "__composerGeometryFrames", frames);
let running = true;
const tick = () => {
if (!running) return;
for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) {
if (root.getClientRects().length === 0) continue;
const input = root.querySelector("textarea");
if (input)
frames.push({
input: input.getBoundingClientRect().height,
root: root.getBoundingClientRect().height,
});
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
Reflect.set(window, "__stopComposerGeometry", () => {
running = false;
});
});
}
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
const frames = await page.evaluate(() => {
Reflect.get(window, "__stopComposerGeometry")?.();
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
});
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
}
@@ -47,65 +47,6 @@ export default function contribute(client) {
return () => {};
}`;
const PROMPT_PLUGIN_ID = "timeline-prompt-e2e";
function promptClientSource(prompts?: readonly string[]): string {
return `import React from "react";
import { Text, View } from "react-native";
import { z } from "zod";
const selectedPrompts = ${JSON.stringify(prompts ?? null)};
function PromptCard({ item }) {
return <View testID="plugin-prompt"><Text>{item.data.text}</Text></View>;
}
export default function contribute(client) {
client.addTimelineTransformer({
id: "prompt",
query: { itemType: "user_message" },
transform({ item }) {
if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined;
return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] };
},
});
client.addTimelineRenderer({
kind: "prompt", version: 1,
schema: z.object({ text: z.string() }),
Component: PromptCard,
});
return () => {};
}`;
}
async function withDirectoryPlugin(options: {
pluginId: string;
source: string;
run: () => Promise<void>;
}): Promise<void> {
const { pluginId, source, run } = options;
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: pluginId, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), source);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await run();
} finally {
await pluginClient.removePlugin(pluginId);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await rm(directory, { recursive: true, force: true });
}
}
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
@@ -119,6 +60,7 @@ export async function withTimelinePlugin(
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
@@ -131,53 +73,40 @@ export async function withTimelinePlugin(
}
: {},
});
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await withDirectoryPlugin({
pluginId: PLUGIN_ID,
source: CLIENT_SOURCE,
run: async () => {
try {
await openAgentRoute(page, agent);
await expect(
page.getByRole("textbox", { name: "Message agent..." }).first(),
).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
}
},
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
} finally {
await pluginClient.removePlugin(PLUGIN_ID);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await agent.cleanup();
await rm(directory, { recursive: true, force: true });
}
}
/** Draws all prompts, or only the selected prompts, as plugin cards. */
export async function withPromptTimelinePlugin(
run: () => Promise<void>,
prompts?: readonly string[],
): Promise<void> {
await withDirectoryPlugin({
pluginId: PROMPT_PLUGIN_ID,
source: promptClientSource(prompts),
run,
});
}
export async function expectPromptsDrawnByPlugin(page: Page): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 });
await expect(timeline.getByTestId("user-message")).toHaveCount(0);
}
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
@@ -213,21 +142,3 @@ export async function expectBothConsecutiveTools(page: Page): Promise<void> {
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
/** The row belongs to the virtualized history, not the always-mounted recent window. */
export async function expectVirtualizedPluginPromptVisible(
page: Page,
prompt: string,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(
timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
).toBeVisible();
}
export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise<void> {
await info.attach("plugin-outline-jump", {
body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
contentType: "image/png",
});
}
@@ -11,7 +11,7 @@ import {
type UserMessageItem,
} from "@/types/stream";
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { createStreamPresentation } from "./presentation";
import { buildAgentStreamRenderModel } from "./model";
const runtime = {
@@ -29,7 +29,7 @@ const runtime = {
} as unknown as PluginClientRuntime;
function installProbe(
itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
itemType: "assistant_message" | "tool_call" | "reasoning",
condition = "true",
): InstalledPlugin {
const clientBundle = `(function() {
@@ -160,23 +160,6 @@ describe("stream presentation through installed plugins", () => {
},
);
it("addresses a prompt a plugin replaced by the prompt's own id", () => {
const harness = streamHarness(installedTransform(installProbe("user_message")));
const rendered = rows(
harness.send({
type: "timeline",
provider: "claude",
item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" },
}),
);
const source = rows(harness.source());
expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
// The chat outline jumps to the source id; the row it lands on is the plugin's.
expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
});
it("finishes one message before streaming a different message ID", () => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
harness.send(assistant("First\n\nBody", "first"));
@@ -26,14 +26,10 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
/**
* The source message a display row belongs to. Every assistant message is split into
* Markdown blocks, so an assistant row id never equals its message id; anything that
* addresses a message — find, scroll-to-message, history reveal — asks for this. A
* plugin row a timeline transformer projected answers for the item it replaced, whose
* id is what the chat outline and find hold.
* addresses a message — find, scroll-to-message, history reveal — asks for this.
*/
export function getStreamItemMessageId(item: StreamItem): string {
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
if (item.kind === "plugin") return item.sourceId ?? item.id;
return item.id;
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
}
/**
+1 -6
View File
@@ -1,4 +1,3 @@
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
@@ -626,11 +625,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
return (
<ComposerEditingSessionsProvider>
<CommandCenterProvider>{content}</CommandCenterProvider>
</ComposerEditingSessionsProvider>
);
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
function SidebarChrome({
+1 -5
View File
@@ -21,7 +21,6 @@ import {
} from "@/plugins/themes";
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
import { applyAppearance } from "./apply";
import { AppearanceSystemBars } from "./system-bars";
interface ContributedThemes {
options: PluginThemeOption[];
@@ -108,10 +107,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
if (!hasAppliedAppearance) return null;
return (
<ContributedThemesContext.Provider value={value}>
<AppearanceSystemBars />
{children}
</ContributedThemesContext.Provider>
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
);
}
@@ -1,11 +0,0 @@
import { SystemBars } from "react-native-edge-to-edge";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
function ThemeSystemBars({ colorScheme }: Pick<Theme, "colorScheme">) {
return <SystemBars style={colorScheme === "light" ? "dark" : "light"} />;
}
export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({
colorScheme: theme.colorScheme,
}));
@@ -1,3 +0,0 @@
export function AppearanceSystemBars() {
return null;
}
@@ -56,16 +56,6 @@ afterEach(() => {
function noop() {}
function createHandleRecorder() {
const handleRef = createRef<EditingTextInputHandle>();
const received: (EditingTextInputHandle | null)[] = [];
function assignHandle(handle: EditingTextInputHandle | null) {
received.push(handle);
handleRef.current = handle;
}
return { handleRef, received, assignHandle };
}
describe("EditingTextInputNative", () => {
it("uses the bottom-sheet input only inside a bottom sheet", () => {
act(() => {
@@ -86,32 +76,6 @@ describe("EditingTextInputNative", () => {
expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" });
});
it("keeps its editing handle and selection when native editing rerenders the leaf", () => {
const { handleRef, received, assignHandle } = createHandleRecorder();
act(() =>
root?.render(<EditingTextInput ref={assignHandle} initialValue="initial" multiline />),
);
const input = container?.querySelector("textarea");
if (!input) throw new Error("Expected native editing surface");
const handle = handleRef.current;
act(() => {
handle?.focus();
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
if (!setValue) throw new Error("Expected input value setter");
setValue.call(input, "native edit");
input.setSelectionRange(2, 5);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "native edit" }));
});
expect(handleRef.current).toBe(handle);
expect(received).toEqual([handle]);
expect(handle?.getText()).toBe("native edit");
expect([input.selectionStart, input.selectionEnd]).toEqual([2, 5]);
expect(document.activeElement).toBe(input);
act(() => root?.unmount());
root = null;
expect(received).toEqual([handle, null]);
});
it("clears text via clear() when replaceText receives an empty string", () => {
const handleRef = createRef<EditingTextInputHandle>();
@@ -141,14 +105,12 @@ describe("EditingTextInputNative", () => {
);
});
const grownInput = container?.querySelector("input");
const handle = handleRef.current;
act(() => {
handleRef.current?.reset();
});
expect(container?.querySelector("input")).not.toBe(grownInput);
expect(handleRef.current).toBe(handle);
});
it("restores focus after replacing a reset native input", () => {
@@ -69,56 +69,52 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
setReplacement((current) => ({ ...current, autoFocus }));
}, []);
useImperativeHandle(
ref,
() => ({
focus: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(true);
return;
}
inputRef.current?.focus();
},
blur: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(false);
return;
}
inputRef.current?.blur();
},
isFocused: () => inputRef.current?.isFocused?.() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
return;
}
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
reset: () => {
textRef.current = "";
const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText("");
} else {
inputRef.current?.clear?.();
}
isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}),
[setReplacementFocus],
);
useImperativeHandle(ref, () => ({
focus: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(true);
return;
}
inputRef.current?.focus();
},
blur: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(false);
return;
}
inputRef.current?.blur();
},
isFocused: () => inputRef.current?.isFocused?.() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
return;
}
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
reset: () => {
textRef.current = "";
const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText("");
} else {
inputRef.current?.clear?.();
}
isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
const handleChangeText = useCallback(
(nextText: string) => {
@@ -57,35 +57,31 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
onChangeTextRef.current?.(nextText);
}, []);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => document.activeElement === inputRef.current,
getText: () => {
const input = inputRef.current as WebTextInputElement | null;
const nextText = input?.value ?? textRef.current;
textRef.current = nextText;
return nextText;
},
replaceText: (nextText, selection) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = nextText;
if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end);
}
},
reset: () => {
textRef.current = "";
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = "";
},
getNativeRef: () => inputRef.current,
}),
[],
);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => document.activeElement === inputRef.current,
getText: () => {
const input = inputRef.current as WebTextInputElement | null;
const nextText = input?.value ?? textRef.current;
textRef.current = nextText;
return nextText;
},
replaceText: (nextText, selection) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = nextText;
if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end);
}
},
reset: () => {
textRef.current = "";
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = "";
},
getNativeRef: () => inputRef.current,
}));
return (
<TextInput
@@ -1,7 +1,6 @@
import type { TextInputProps } from "react-native";
import type { NativePastedFile } from "@/composer/native-pasted-image";
/** Stable for the mounted editor lifetime; reset() owns any inner input replacement. */
export interface EditingTextInputHandle {
focus(): void;
blur(): void;
@@ -2,7 +2,6 @@ import { ComposerDockBackground } from "./internal/background";
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
@@ -18,7 +17,6 @@ import Animated, {
import { useKeyboardShift } from "@/keyboard/shift";
import {
resolveCenteredClearance,
resolveInlineComposerCapacity,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
@@ -73,13 +71,12 @@ function ComposerViewport({
/** Bound the complete composer (including its controls), not just the text input. */
function ComposerViewportContent({ style, ...props }: ViewProps) {
const compact = useIsCompactFormFactor();
const capacity = useContext(ViewportCapacity);
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
const constraint = useAnimatedStyle(() => ({
// Only viewport changes and keyboard start/end events trigger layout. Motion
// stays in KeyboardTranslateView and never changes this constraint per frame.
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
maxHeight: capacity.value,
}));
return (
+5 -17
View File
@@ -1,10 +1,8 @@
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import { useState, useCallback, useMemo, type ReactNode } from "react";
import type { ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { resolveInlineComposerCapacity } from "./internal/capacity";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
interface ComposerDockProps {
@@ -16,33 +14,23 @@ export function ComposerDock({
children: [content, composer, overlay],
centered = false,
}: ComposerDockProps) {
const compact = useIsCompactFormFactor();
const [availableHeight, setAvailableHeight] = useState<number>();
const constraint = useMemo(
() => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }),
[availableHeight, compact],
);
const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
if (height > 0) setAvailableHeight(height);
}, []);
if (centered) {
return (
<View style={styles.centered} onLayout={measureViewport}>
<View style={styles.centered}>
<View style={styles.form}>
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
<View style={styles.centeredComposer}>{composer}</View>
{overlay}
</View>
</View>
);
}
return (
<View style={styles.surface} onLayout={measureViewport}>
<View style={styles.surface}>
<View style={styles.content}>{content}</View>
<View style={[styles.composer, constraint]}>{composer}</View>
<View style={styles.composer}>{composer}</View>
{overlay}
</View>
);
@@ -1,24 +1,11 @@
import { describe, expect, it } from "vitest";
import {
resolveCenteredClearance,
resolveInlineComposerCapacity,
resolveComposerCapacity,
updateComposerCapacity,
} from "./capacity";
describe("composer viewport", () => {
it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 308,
});
expect(resolveInlineComposerCapacity(open.capacity, true)).toBe(147);
expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245);
expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined();
});
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
@@ -50,13 +50,3 @@ export function updateComposerCapacity(
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
};
}
/** Compact editing leaves conversation space above the complete composer. */
export function resolveInlineComposerCapacity(
capacity: number | undefined,
compact: boolean,
): number | undefined {
"worklet";
if (capacity === undefined) return undefined;
return compact ? capacity * 0.6 : capacity;
}
@@ -115,7 +115,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
const isHydrated = hydratedDraftKey === draftKey;
const textReplacementRevisionRef = useRef(0);
const [textReplacement, setTextReplacement] = useState<TextReplacement>(() => ({
kind: "initial",
key: `${draftKey}:0`,
text: textSource.getSnapshot(),
}));
@@ -124,7 +123,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(nextText: string) => {
textReplacementRevisionRef.current += 1;
setTextReplacement({
kind: "replace",
key: `${draftKey}:${textReplacementRevisionRef.current}`,
text: nextText,
});
@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import { ComposerEditingSession } from "./index";
function leaveLiveDraft(session: ComposerEditingSession) {
session.captureForRemount({
snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } },
focused: true,
replacementKey: "draft:applied",
});
}
describe("composer editing remount handoff", () => {
it("keeps live text and selection when remounting from an initial draft publication", () => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" });
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
expect(handoff?.focused).toBe(true);
});
it("keeps local edits when the last applied replacement is still current", () => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({
kind: "replace",
key: "draft:applied",
text: "old replacement",
});
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
});
it.each(["New explicit draft", ""])(
"honors an explicit replacement made while the editor is absent: %j",
(text) => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text });
expect(handoff?.snapshot).toEqual({
text,
selection: { start: text.length, end: text.length },
});
expect(handoff?.focused).toBe(true);
expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull();
},
);
});
@@ -1,142 +0,0 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import type { TextReplacement } from "@/composer/types";
import type { ComposerInputMode } from "@/composer/input-mode";
export interface ComposerEditingHandoff {
snapshot: { text: string; selection: { start: number; end: number } };
focused: boolean;
replacementKey: string;
}
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
export class ComposerEditingSession {
private fullscreen = false;
private handoff: ComposerEditingHandoff | null = null;
private listeners = new Set<() => void>();
getFullscreen = () => this.fullscreen;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
this.captureForRemount(handoff);
if (fullscreen === this.fullscreen) return;
this.fullscreen = fullscreen;
for (const listener of this.listeners) listener();
}
captureForRemount(handoff: ComposerEditingHandoff) {
this.handoff = handoff;
}
peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null {
const handoff = this.handoff;
if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey)
return handoff;
// A replacement command issued since capture supersedes the old editor's live buffer.
return {
snapshot: {
text: replacement.text,
selection: { start: replacement.text.length, end: replacement.text.length },
},
focused: handoff.focused,
replacementKey: replacement.key,
};
}
takeHandoff(replacement: TextReplacement) {
const handoff = this.peekHandoff(replacement);
this.handoff = null;
return handoff;
}
}
class ComposerEditingSessions {
private workspaces = new Map<
string,
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
>();
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
let workspace = this.workspaces.get(workspaceKey);
if (!workspace) {
workspace = new Map();
this.workspaces.set(workspaceKey, workspace);
}
let tab = workspace.get(tabId);
if (!tab || tab.targetKey !== targetKey) {
tab = { targetKey, sessions: new Map() };
workspace.set(tabId, tab);
}
let session = tab.sessions.get(purpose);
if (!session) {
session = new ComposerEditingSession();
tab.sessions.set(purpose, session);
}
return session;
}
retain(workspaceKey: string, tabIds: readonly string[]) {
const workspace = this.workspaces.get(workspaceKey);
if (!workspace) return;
const retained = new Set(tabIds);
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
}
}
const SessionsContext = createContext<ComposerEditingSessions | null>(null);
const WorkspaceContext = createContext<string | null>(null);
const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null);
/** App-container ownership survives replacement of both the gesture shell and tab layout. */
export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) {
const [sessions] = useState(() => new ComposerEditingSessions());
return <SessionsContext.Provider value={sessions}>{children}</SessionsContext.Provider>;
}
export function WorkspaceComposerEditingSessionsProvider({
workspaceKey,
tabIds,
children,
}: {
workspaceKey: string;
tabIds: readonly string[];
children: ReactNode;
}) {
const sessions = useContext(SessionsContext);
useEffect(() => sessions?.retain(workspaceKey, tabIds), [sessions, workspaceKey, tabIds]);
return <WorkspaceContext.Provider value={workspaceKey}>{children}</WorkspaceContext.Provider>;
}
export function ComposerEditingScope({
tabId,
targetKey,
children,
}: {
tabId: string;
targetKey: string;
children: ReactNode;
}) {
const scope = useMemo(() => ({ tabId, targetKey }), [tabId, targetKey]);
return <ScopeContext.Provider value={scope}>{children}</ScopeContext.Provider>;
}
export function useComposerEditingSession(
purpose: ComposerInputMode,
provided?: ComposerEditingSession,
): ComposerEditingSession {
const sessions = useContext(SessionsContext);
const scope = useContext(ScopeContext);
const workspaceKey = useContext(WorkspaceContext);
const [local] = useState(() => provided ?? new ComposerEditingSession());
if (provided) return provided;
return sessions && scope && workspaceKey
? sessions.get(workspaceKey, scope.tabId, scope.targetKey, purpose)
: local;
}
export function useComposerFullscreen(session: ComposerEditingSession): boolean {
return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen);
}
+4 -11
View File
@@ -1,4 +1,3 @@
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
@@ -1004,10 +1003,8 @@ function ComposerAutocompleteBinding({
cursorIndex: Math.min(cursorIndex, userInput.length),
onAutocompleteApplied: () => inputRef.current?.focus(),
});
const handleKeyPress = autocomplete.onKeyPress;
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
show,
handleKeyPress,
useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [
autocomplete.onKeyPress,
]);
const selectOption = autocomplete.onSelectOption;
const onSelect = useCallback(
@@ -1339,8 +1336,6 @@ function ComposerContentImpl({
const nextPendingFileId = useRef(0);
const isUploadingFile = pendingFiles.length > 0;
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const editingSession = useComposerEditingSession(inputMode);
const isFullscreen = useComposerFullscreen(editingSession);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
@@ -2369,7 +2364,7 @@ function ComposerContentImpl({
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{!isFullscreen && sendErrorNode}
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<ComposerAutocompleteBinding
@@ -2377,7 +2372,7 @@ function ComposerContentImpl({
cursor={cursor}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
show={mode.showAutocomplete && !isFullscreen}
show={mode.showAutocomplete}
ref={autocompleteRef}
configuration={autocompleteConfiguration}
/>
@@ -2392,8 +2387,6 @@ function ComposerContentImpl({
<RenderProfile id="MessageInput">
<StableMessageInput
ref={messageInputRef}
editingSession={editingSession}
submissionError={sendErrorNode}
value={textSource.getSnapshot()}
onChangeText={setUserInput}
onSubmit={handleSubmit}
@@ -1,57 +0,0 @@
import { useEffect, type ReactNode } from "react";
import { Portal } from "@gorhom/portal";
import { BackHandler, View, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/keyboard/shift";
/** Same-window host keeps the IME attached while escaping the dock's clipping. */
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
useEffect(() => {
if (!active) return;
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
onExit();
return true;
});
return () => handler.remove();
}, [active, onExit]);
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
const { layoutShift } = useKeyboardShift();
const frame = useAnimatedStyle(() => ({
height: Math.max(0, height - layoutShift.value),
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}));
return (
<Portal hostName={DEFAULT_FLOATING_PANEL_PORTAL_HOST}>
<View style={[styles.background, !active && styles.hidden]}>
<Animated.View style={[{ width: "100%" }, frame]}>{children}</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create((theme) => ({
hidden: { display: "none" },
background: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: theme.colors.surface1,
},
}));
@@ -1,77 +0,0 @@
import { useMemo, useLayoutEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { View } from "react-native";
import {
getOverlayRoot,
useOverlayLayer,
OverlayLayerProvider,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
const layer = useOverlayLayer("modal");
const registration = useWebOverlayRegistration({
active,
layer,
onKeyDown: (event) => {
if (event.key !== "Escape") return false;
event.preventDefault();
onExit();
return true;
},
});
const [viewport, setViewport] = useState(readVisibleViewport);
useLayoutEffect(() => {
if (!active) return;
const update = () => setViewport(readVisibleViewport());
const visualViewport = window.visualViewport;
update();
visualViewport?.addEventListener("resize", update);
visualViewport?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visualViewport?.removeEventListener("resize", update);
visualViewport?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [active]);
const frameStyle = useMemo(
() => ({
display: active ? ("flex" as const) : ("none" as const),
position: "absolute" as const,
top: viewport.top,
left: viewport.left,
width: viewport.width,
zIndex: layer,
pointerEvents: "auto" as const,
height: viewport.height,
}),
[active, layer, viewport],
);
return createPortal(
<OverlayLayerProvider layer={layer}>
<View ref={registration} style={frameStyle}>
{children}
</View>
</OverlayLayerProvider>,
getOverlayRoot(),
);
}
function readVisibleViewport() {
const viewport = window.visualViewport;
return {
top: viewport?.offsetTop ?? 0,
left: viewport?.offsetLeft ?? 0,
width: viewport?.width ?? window.innerWidth,
height: viewport?.height ?? window.innerHeight,
};
}
@@ -7,8 +7,6 @@ interface ComposerHeightArgs {
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
onRenderedLinesChange?: (lines: number) => void;
presentationKey?: boolean;
}
export function useComposerHeight({
+4 -15
View File
@@ -7,8 +7,6 @@ interface ComposerHeightArgs {
textareaRef: RefObject<HTMLElement | null>;
minHeight: number;
maxHeight: number;
onRenderedLinesChange?: (lines: number) => void;
presentationKey?: boolean;
}
const COPIED_STYLES = [
@@ -38,13 +36,11 @@ export function useComposerHeight({
textareaRef,
minHeight,
maxHeight,
onRenderedLinesChange,
presentationKey,
}: ComposerHeightArgs): ComposerHeightResult {
const [height, setHeight] = useState(minHeight);
const heightRef = useRef(minHeight);
const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange });
paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange };
const paramsRef = useRef({ getText, minHeight, maxHeight });
paramsRef.current = { getText, minHeight, maxHeight };
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
const setBoundedHeight = useCallback((nextHeight: number) => {
@@ -69,13 +65,6 @@ export function useComposerHeight({
}
mirror.style.width = `${sourceWidth}px`;
mirror.value = text.endsWith("\n") ? `${text} ` : text;
const padding =
parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom);
const lineHeight = parseFloat(computedStyle.lineHeight);
if (lineHeight > 0)
paramsRef.current.onRenderedLinesChange?.(
Math.round((mirror.scrollHeight - padding) / lineHeight),
);
setBoundedHeight(mirror.scrollHeight);
},
[setBoundedHeight, textareaRef],
@@ -112,7 +101,7 @@ export function useComposerHeight({
useLayoutEffect(() => {
measure(getText());
}, [maxHeight, minHeight, getText, measure, presentationKey]);
}, [maxHeight, minHeight, getText, measure]);
useEffect(() => {
const source = textareaRef.current;
@@ -126,7 +115,7 @@ export function useComposerHeight({
});
observer.observe(source);
return () => observer.disconnect();
}, [measure, textareaRef, presentationKey]);
}, [measure, textareaRef]);
const onTextChange = useCallback(
(_previousText: string, nextText: string) => measure(nextText),
+152 -459
View File
@@ -1,8 +1,3 @@
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
@@ -61,7 +56,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
@@ -83,7 +77,6 @@ import {
applyDictationTranscript,
computeCanStartDictation,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -113,8 +106,6 @@ export interface ComposerKeyPressEvent {
}
export interface MessageInputProps {
editingSession?: ComposerEditingSession;
submissionError?: React.ReactNode;
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
@@ -215,7 +206,6 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
>;
interface TextAreaHandle {
value?: string;
scrollHeight?: number;
clientHeight?: number;
offsetHeight?: number;
@@ -225,7 +215,7 @@ interface TextAreaHandle {
style?: {
height?: string;
overflowY?: string;
};
} & Record<string, unknown>;
}
function AttachButtonIcon({
@@ -443,7 +433,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
}
interface PasteImagesEffectArgs {
textarea: HTMLElement | null;
getWebTextArea: () => TextAreaHandle | null;
isConnected: boolean;
disabled: boolean;
isDictating: boolean;
@@ -453,7 +443,7 @@ interface PasteImagesEffectArgs {
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const {
textarea,
getWebTextArea,
isConnected,
disabled,
isDictating,
@@ -464,7 +454,19 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
useEffect(() => {
if (!isWeb || !onAddImages) return;
if (!textarea) return;
const textarea = getWebTextArea() as
| (TextAreaHandle & {
addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void;
removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void;
})
| null;
if (
!textarea ||
typeof textarea.addEventListener !== "function" ||
typeof textarea.removeEventListener !== "function"
) {
return;
}
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
@@ -491,7 +493,14 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
disposed = true;
textarea.removeEventListener?.("paste", handlePaste);
};
}, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
}, [
disabled,
getWebTextArea,
isConnected,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
]);
}
function useAutoFocusOnWebEffect(
@@ -619,7 +628,6 @@ function FocusHint({
interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textReplacementKey: string;
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: EditingTextInputProps["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
@@ -635,9 +643,6 @@ interface ComposerTextSurfaceProps {
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
onPasteError: (message: string) => void;
fullscreen: boolean;
compact: boolean;
onRenderedLinesChange: (lines: number) => void;
focusHintVisible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
focusHintLabel: string;
@@ -649,23 +654,6 @@ interface ComposerTextSurfaceProps {
* state of this composer rather than a second one.
*/
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
const { onChangeText, onRenderedLinesChange } = props;
const [measurementText, setMeasurementText] = useState(props.value);
useEffect(() => {
if (!isWeb) setMeasurementText(props.value);
}, [props.value, props.textReplacementKey]);
const handleTextChange = useCallback(
(text: string) => {
if (!isWeb) setMeasurementText(text);
onChangeText(text);
},
[onChangeText, setMeasurementText],
);
const measureLines = useCallback(
(event: import("react-native").TextLayoutEvent) =>
onRenderedLinesChange(event.nativeEvent.lines.length),
[onRenderedLinesChange],
);
if (props.readOnly) {
return (
<View style={styles.textInputScrollWrapper}>
@@ -676,28 +664,12 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
);
}
return (
<View
style={[
styles.textInputScrollWrapper,
props.fullscreen && styles.fullscreenTextSurface,
props.compact && !props.fullscreen && styles.compactTextSurface,
]}
>
{!isWeb && props.compact && !props.fullscreen ? (
<Text
accessible={false}
pointerEvents="none"
style={[props.textInputStyle, styles.lineMeasurement]}
onTextLayout={measureLines}
>
{`${measurementText}\u200b`}
</Text>
) : null}
<View style={styles.textInputScrollWrapper}>
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
initialValue={props.value}
onChangeText={handleTextChange}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
@@ -871,7 +843,6 @@ interface ToggleRealtimeVoiceContext {
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
exitFullscreen: () => void;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
@@ -887,7 +858,6 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
return;
}
ctx.exitFullscreen();
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = extractErrorMessage(error);
@@ -902,7 +872,6 @@ interface StartDictationContext {
canStartDictation: () => boolean;
toast: { error: (msg: string) => void };
startDictation: () => Promise<void>;
exitFullscreen: () => void;
}
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
@@ -913,7 +882,6 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis
if (!ctx.canStartDictation()) {
return;
}
ctx.exitFullscreen();
await ctx.startDictation();
}
@@ -1015,8 +983,7 @@ function computeIsDictationStartEnabled(
return (isReadyForDictation ?? isConnected) && !disabled;
}
function resolveMaxInputHeight(windowHeight: number, compact: boolean): number {
if (compact) return DEFAULT_MAX_INPUT_HEIGHT;
function resolveMaxInputHeight(windowHeight: number): number {
if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT;
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
}
@@ -1040,12 +1007,9 @@ function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
mountedTextArea?: TextAreaHandle | null,
): ComposerInputSnapshot {
// The inner DOM ref can detach before the outer editing handle. Keep the mounted
// element available until this owner's ref callback captures the remount handoff.
const textArea = mountedTextArea ?? getWebTextAreaImpl(current);
const text = textArea?.value ?? current?.getText() ?? fallbackText;
const text = current?.getText() ?? fallbackText;
const textArea = getWebTextAreaImpl(current);
const start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
return { text, selection: { start, end } };
@@ -1175,28 +1139,6 @@ function extractErrorMessage(error: unknown): string | null {
return null;
}
function resolvePresentationSendDisabled(input: {
fullscreen: boolean;
disabled: boolean;
hasText: boolean;
attachments: ComposerAttachment[];
hasExternalContent: boolean;
allowEmpty: boolean;
}): boolean {
return (
input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input))
);
}
function shouldShowFullscreenControl(
compact: boolean,
readOnly: boolean,
overlayVisible: boolean,
renderedLines: number,
) {
return compact && !readOnly && !overlayVisible && renderedLines > 2;
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput(props, ref) {
const {
@@ -1244,15 +1186,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textReplacement,
submitLabel,
} = resolveMessageInputProps(props);
const { isActiveComposer } = useComposerKeyboardScope();
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions();
const editingSession = useComposerEditingSession(inputMode, props.editingSession);
const isFullscreen = useComposerFullscreen(editingSession);
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const maxInputHeight = resolveMaxInputHeight(windowHeight);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const toast = useToast();
const voice = useVoiceOptional();
@@ -1269,102 +1207,34 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const handoff = editingSession.peekHandoff(textReplacement);
const focusIntentRef = useRef(Boolean(handoff?.focused));
const valueRef = useRef(handoff?.snapshot.text ?? value);
const selectionRef = useRef(
handoff?.snapshot.selection ?? { start: value.length, end: value.length },
);
const activeComposerRef = useRef(isActiveComposer);
activeComposerRef.current = isActiveComposer;
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const textReplacementRef = useRef(textReplacement);
textReplacementRef.current = textReplacement;
const webTextareaRef = useRef<HTMLElement | null>(null);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
if (hasLiveTextRef.current === nextHasLiveText) return;
hasLiveTextRef.current = nextHasLiveText;
setHasLiveText(nextHasLiveText);
}, []);
const assignTextInput = useCallback(
(input: ComposerTextInputHandle | null) => {
const previous = textInputRef.current;
if (!input && previous) {
editingSession.captureForRemount({
snapshot: getComposerInputSnapshot(
previous,
valueRef.current,
selectionRef.current,
webTextareaRef.current,
),
focused: previous.isFocused() || isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
}
textInputRef.current = input;
if (isWeb) {
const element = getWebTextAreaImpl(input) as HTMLElement | null;
webTextareaRef.current = element;
setWebTextArea(element);
}
if (!input) return;
const transfer = editingSession.takeHandoff(textReplacementRef.current);
if (transfer) {
appliedTextReplacementKeyRef.current = textReplacementRef.current.key;
valueRef.current = transfer.snapshot.text;
updateLiveTextPresence(transfer.snapshot.text);
selectionRef.current = transfer.snapshot.selection;
input.replaceText(transfer.snapshot.text, transfer.snapshot.selection);
if (transfer.focused && activeComposerRef.current) input.focus();
}
},
[editingSession, updateLiveTextPresence],
);
useLayoutEffect(() => {
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
}, [isActiveComposer]);
const getLiveText = useCallback(() => valueRef.current, []);
const composerHeight = useComposerHeight({
getText: getLiveText,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
onRenderedLinesChange: setRenderedLines,
presentationKey: isFullscreen,
});
const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight;
const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined;
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
const changePresentation = useCallback(
(fullscreen: boolean) => {
if (editingSession.getFullscreen() === fullscreen) return;
const snapshot = getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
);
editingSession.changePresentation(fullscreen, {
snapshot,
focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
valueRef.current = snapshot.text;
selectionRef.current = snapshot.selection;
},
[editingSession],
);
const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]);
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
if (hasLiveTextRef.current === nextHasLiveText) return;
hasLiveTextRef.current = nextHasLiveText;
setHasLiveText(nextHasLiveText);
}, []);
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
updateComposerHeightForText?.(valueRef.current, nextText);
@@ -1542,9 +1412,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canStartDictation,
toast,
startDictation,
exitFullscreen,
}),
[canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen],
[canStartDictation, dictationUnavailableMessage, startDictation, toast],
);
const handleVoicePress = useCallback(
@@ -1616,12 +1485,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleStopRealtimeVoice,
toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
exitFullscreen,
});
}, [
disabled,
handleStopRealtimeVoice,
exitFullscreen,
isAgentRunning,
isConnected,
t,
@@ -1705,10 +1572,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea, isFullscreen]);
}, [getWebTextArea]);
usePasteImagesEffect({
textarea: webTextArea,
getWebTextArea,
isConnected,
disabled,
isDictating,
@@ -1729,30 +1596,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const shouldHandleWebKeyPress = isWeb;
const shouldSubmitOnEnter = isWeb && !isCompact;
const handleDesktopKeyPress = useCallback(
(event: WebTextInputKeyPressEvent) => {
if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, {
onKeyPressCallback,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
handleAlternateSendAction,
handleDefaultSendAction,
});
},
[
shouldHandleWebKeyPress,
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, {
onKeyPressCallback,
shouldSubmitOnEnter,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
@@ -1760,8 +1613,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
disabled,
handleAlternateSendAction,
handleDefaultSendAction,
],
);
});
}
const primaryActionKind = resolvePrimaryActionKind({
hasSendableContent: hasSendableComposerContent({
@@ -1825,18 +1678,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
const handleInputFocus = useCallback(() => {
focusIntentRef.current = true;
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}, [isFullscreen, onFocusChange]);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
@@ -1884,12 +1735,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// `fontFamily` here is silently dropped while every other property lands.
// An inline style outranks both classes. See docs/unistyles.md.
const textInputStyle = useMemo(
() => [
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
],
[composerHeightStyle, mode.isMonospace, isFullscreen],
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle],
[composerHeightStyle, mode.isMonospace],
);
// Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height.
@@ -1933,192 +1780,107 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
);
const sendButton = useMemo(
() => (
<PrimaryAction
kind={isFullscreen ? "send" : primaryActionKind}
activeActionContent={activeActionContent}
shouldShow
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
isSendButtonDisabled={resolvePresentationSendDisabled({
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
})}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}
return (
<View
ref={rootRef}
style={styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
/>
),
[
isFullscreen,
primaryActionKind,
activeActionContent,
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
isSendButtonDisabled,
hasLiveText,
attachments,
hasExternalContent,
allowEmptySubmit,
submitAccessibilityLabel,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
submitLabel,
submitButtonTestID,
buttonIconSize,
sendTooltipLabel,
],
);
const textSurface = useMemo(
() => (
<RenderProfile id="ComposerTextSurface">
<ComposerTextSurface
readOnly={readOnly}
value={
readOnly
? value
: resolveComposerSurfaceText(
valueRef.current,
textReplacement,
appliedTextReplacementKeyRef.current,
)
}
textReplacementKey={textReplacement.key}
textInputRef={assignTextInput}
textInputStyle={textInputStyle}
readOnlyTextStyle={readOnlyTextStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
accessibilityLabel={t(mode.accessibilityLabelKey)}
onChangeText={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
fullscreen={isFullscreen}
compact={isCompact}
onRenderedLinesChange={setRenderedLines}
focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
</RenderProfile>
),
[
readOnly,
textReplacement,
editingSession,
isActiveComposer,
assignTextInput,
textInputStyle,
readOnlyTextStyle,
placeholder,
t,
mode.accessibilityLabelKey,
handleInputChange,
handleInputFocus,
handleInputBlur,
isDictating,
isRealtimeVoiceForCurrentAgent,
disabled,
isComposerScrollEnabled,
shouldHandleWebKeyPress,
handleDesktopKeyPress,
handleSelectionChange,
onPasteImages,
handlePasteError,
isFullscreen,
isCompact,
isInputFocused,
value,
focusInputKeys,
],
);
const toolbar = useMemo(
() => (
<View style={styles.buttonRow}>
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
visible={mode.showAttachments}
isConnected={isConnected}
disabled={disabled}
attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
{/* Regular input */}
<View
ref={inputWrapperRef}
style={inputWrapperCombinedStyle}
pointerEvents={surfacePresentation.input.pointerEvents}
>
{attachmentSlot}
{/* Text input */}
<RenderProfile id="ComposerTextSurface">
<ComposerTextSurface
readOnly={readOnly}
value={value}
textInputRef={textInputRef}
textInputStyle={textInputStyle}
readOnlyTextStyle={readOnlyTextStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
accessibilityLabel={t(mode.accessibilityLabelKey)}
onChangeText={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={false}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
{leftContent}
</View>
</RenderProfile>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<VoiceButtonTooltip
visible={mode.showVoice}
onVoicePress={handleVoicePress}
isDictationStartEnabled={isDictationStartEnabled}
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
voiceButtonStyle={voiceButtonStyle}
renderVoiceButtonIcon={renderVoiceButtonIcon}
voiceTooltipText={voiceTooltipText}
isRealtimeVoiceForCurrentAgent={isRealtimeVoiceForCurrentAgent}
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{rightContent}
{sendButton}
{/* Button row */}
<View style={styles.buttonRow}>
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
visible={mode.showAttachments}
isConnected={isConnected}
disabled={disabled}
attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
/>
{leftContent}
</View>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<VoiceButtonTooltip
visible={mode.showVoice}
onVoicePress={handleVoicePress}
isDictationStartEnabled={isDictationStartEnabled}
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
voiceButtonStyle={voiceButtonStyle}
renderVoiceButtonIcon={renderVoiceButtonIcon}
voiceTooltipText={voiceTooltipText}
isRealtimeVoiceForCurrentAgent={isRealtimeVoiceForCurrentAgent}
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{rightContent}
<PrimaryAction
kind={primaryActionKind}
activeActionContent={activeActionContent}
shouldShow
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
isSendButtonDisabled={isSendButtonDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}
/>
</View>
</View>
</View>
),
[
mode.showAttachments,
isConnected,
disabled,
attachButtonStyle,
renderAttachButtonIcon,
attachmentMenuItems,
t,
leftContent,
beforeVoiceContent,
mode.showVoice,
handleVoicePress,
isDictationStartEnabled,
voiceButtonAccessibilityLabel,
voiceButtonStyle,
renderVoiceButtonIcon,
voiceTooltipText,
isRealtimeVoiceForCurrentAgent,
voiceMuteToggleKeys,
dictationToggleKeys,
rightContent,
sendButton,
],
);
const voiceOverlay = useMemo(
() => (
<View
style={overlayContainerStyle}
pointerEvents={surfacePresentation.overlay.pointerEvents}
@@ -2141,62 +1903,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onRealtimeVoiceStop={handleRealtimeVoiceStop}
/>
</View>
),
[
overlayContainerStyle,
surfacePresentation.overlay.pointerEvents,
showDictationOverlay,
showRealtimeOverlay,
voice,
dictationVolume,
dictationDuration,
isDictating,
isDictationProcessing,
dictationStatus,
dictationError,
handleCancelRecording,
handleAcceptRecording,
handleAcceptAndSendRecording,
handleRetryFailedRecording,
handleDiscardFailedRecording,
handleRealtimeVoiceStop,
],
);
const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]);
return (
<View
ref={rootRef}
style={styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
iconSize={buttonIconSize}
active={isActiveComposer}
surface={textSurface}
toolbar={toolbar}
send={sendButton}
error={props.submissionError}
onExit={exitFullscreen}
inputWrapperRef={inputWrapperRef}
inputWrapperStyle={inputWrapperCombinedStyle}
inputPointerEvents={surfacePresentation.input.pointerEvents}
attachmentSlot={attachmentSlot}
showFullscreenButton={shouldShowFullscreenControl(
isCompact,
readOnly,
showOverlay,
renderedLines,
)}
onEnter={enterFullscreen}
overlay={voiceOverlay}
/>
</View>
);
},
@@ -2241,19 +1947,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputWrapperReadOnly: {
borderStyle: "dotted",
},
compactTextSurface: { paddingRight: 36 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
lineMeasurement: {
position: "absolute",
top: 0,
left: 0,
right: 36,
opacity: 0,
maxHeight: undefined,
minHeight: undefined,
height: undefined,
},
textInputScrollWrapper: {
flexShrink: 1,
position: "relative",
@@ -1,146 +0,0 @@
import { useCallback, useMemo, type ReactNode, type Ref } from "react";
import { View, type ViewProps } from "react-native";
import { Maximize2, Minimize2 } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import { ComposerFullscreenHost } from "./fullscreen-host";
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
/** Both presentations receive the same editing surface and send capability. */
export function ComposerInputPresentation({
fullscreen,
iconSize,
active,
surface,
toolbar,
send,
error,
onExit,
inputWrapperRef,
inputWrapperStyle,
inputPointerEvents,
attachmentSlot,
showFullscreenButton,
onEnter,
overlay,
}: {
fullscreen: boolean;
iconSize: number;
active: boolean;
surface: ReactNode;
toolbar: ReactNode;
send: ReactNode;
error: ReactNode;
onExit: () => void;
inputWrapperRef: Ref<View>;
inputWrapperStyle: ViewProps["style"];
inputPointerEvents: ViewProps["pointerEvents"];
attachmentSlot: ReactNode;
showFullscreenButton: boolean;
onEnter: () => void;
overlay: ReactNode;
}) {
const { t } = useTranslation();
// ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox.
// Compensate for that difference to align visible ink with Send, not just boxes.
const railAdjustment = iconSize / 12;
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={iconSize} />,
[iconSize],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={iconSize} />,
[iconSize],
);
const enterStyle = useMemo(
() => [styles.fullscreenButton, { right: -6 + railAdjustment }],
[railAdjustment],
);
const exitStyle = useMemo(
() => [styles.iconButton, { marginRight: railAdjustment }],
[railAdjustment],
);
if (!fullscreen)
return (
<>
<View ref={inputWrapperRef} style={inputWrapperStyle} pointerEvents={inputPointerEvents}>
{attachmentSlot}
<View style={styles.inlineTextSurface}>
{surface}
{showFullscreenButton ? (
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
onPress={onEnter}
style={enterStyle}
hitSlop={8}
>
{null}
</Button>
) : null}
</View>
{toolbar}
</View>
{overlay}
</>
);
return (
<ComposerFullscreenHost onExit={onExit} active={active}>
<View style={styles.fullscreen} testID="composer-fullscreen">
<View style={styles.exitRow}>
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.exitFullscreen")}
onPointerDown={keepEditorFocused}
onPress={onExit}
style={exitStyle}
leftIcon={minimizeIcon}
hitSlop={8}
>
{null}
</Button>
</View>
{surface}
{error}
<View style={styles.sendRow}>{send}</View>
</View>
</ComposerFullscreenHost>
);
}
const styles = StyleSheet.create((theme) => ({
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: 28,
height: 28,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
},
fullscreenButton: {
position: "absolute",
top: 0,
width: 28,
height: 28,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
},
fullscreen: {
flex: 1,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[4],
gap: theme.spacing[3],
},
exitRow: { alignItems: "flex-end", flexShrink: 0 },
sendRow: { flexShrink: 0, alignItems: "flex-end" },
}));
@@ -4,7 +4,6 @@ import {
computeCanStartDictation,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -317,26 +316,3 @@ describe("stopRealtimeVoice", () => {
expect(calls).toEqual(["cancel agent", "stop voice"]);
});
});
describe("composer surface text", () => {
it("measures a new nonempty replacement before it reaches the editing handle", () => {
const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" };
expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe(
replacement.text,
);
});
it("keeps live typing once the replacement is applied", () => {
const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" };
expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe(
"Edited live draft",
);
});
it("uses the live handoff when its replacement has already been applied", () => {
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
"Live remount handoff",
);
});
});
+1 -9
View File
@@ -1,6 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload, TextReplacement } from "@/composer/types";
import type { MessagePayload } from "@/composer/types";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type SendBehavior = ActiveTurnBehavior | "queue";
@@ -192,11 +192,3 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<
await ctx.voice.stopVoice();
}
export function resolveComposerSurfaceText(
liveText: string,
replacement: TextReplacement,
appliedReplacementKey: string,
): string {
return replacement.key === appliedReplacementKey ? liveText : replacement.text;
}
@@ -96,35 +96,6 @@ describe("ComposerTextInput web IME composition", () => {
expect(document.activeElement).toBe(mounted.textarea);
});
it("keeps its editing handle and selection across a rerender during composition", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
const handle = mounted.inputRef.current;
act(() => {
handle?.focus();
dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "你好");
mounted.textarea.setSelectionRange(1, 2);
mounted.root.render(
<ComposerTextInput
ref={mounted.inputRef}
initialValue="stale publication"
multiline
onChangeText={recorder.onChangeText}
placeholder="updated presentation"
/>,
);
});
expect(mounted.inputRef.current).toBe(handle);
expect(handle?.getNativeRef()).toBe(mounted.textarea);
expect([mounted.textarea.selectionStart, mounted.textarea.selectionEnd]).toEqual([1, 2]);
expect(document.activeElement).toBe(mounted.textarea);
expect(recorder.changes).toEqual([]);
act(() => dispatchComposition(mounted.textarea, "compositionend"));
expect(recorder.changes).toEqual(["你好"]);
expect(handle?.getText()).toBe("你好");
});
it("keeps locally typed text when its parent rerenders with a stale value", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
-1
View File
@@ -10,7 +10,6 @@ export interface MessagePayload {
}
export interface TextReplacement {
kind: "initial" | "replace";
key: string;
text: string;
}
-2
View File
@@ -124,8 +124,6 @@ export const ar: TranslationResources = {
input: {
accessibilityLabel: "وكيل الرسائل...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "ملء الشاشة",
exitFullscreen: "الخروج من ملء الشاشة",
focusHint: "{{shortcut}}للتركيز",
addAttachment: "إضافة مرفق",
interruptAgent: "عامل المقاطعة",
-2
View File
@@ -120,8 +120,6 @@ export const en = {
input: {
accessibilityLabel: "Message agent...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "Fullscreen",
exitFullscreen: "Exit fullscreen",
focusHint: "{{shortcut}} to focus",
addAttachment: "Add attachment",
interruptAgent: "Interrupt agent",
-2
View File
@@ -125,8 +125,6 @@ export const es: TranslationResources = {
input: {
accessibilityLabel: "Agente de mensajes...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "Pantalla completa",
exitFullscreen: "Salir de pantalla completa",
focusHint: "{{shortcut}}para enfocar",
addAttachment: "Agregar archivo adjunto",
interruptAgent: "agente de interrupción",
-2
View File
@@ -126,8 +126,6 @@ export const fr: TranslationResources = {
input: {
accessibilityLabel: "Écrire à l’agent…",
terminalAccessibilityLabel: "Prompt du terminal",
fullscreen: "Plein écran",
exitFullscreen: "Quitter le plein écran",
focusHint: "{{shortcut}} pour saisir",
addAttachment: "Ajouter une pièce jointe",
interruptAgent: "Interrompre l’agent",
-2
View File
@@ -125,8 +125,6 @@ export const ja: TranslationResources = {
input: {
accessibilityLabel: "エージェントにメッセージ...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "全画面表示",
exitFullscreen: "全画面表示を終了",
focusHint: "{{shortcut}}でフォーカス",
addAttachment: "添付ファイルを追加",
interruptAgent: "エージェントを中断",
-2
View File
@@ -124,8 +124,6 @@ export const ko: TranslationResources = {
input: {
accessibilityLabel: "에이전트에게 메시지...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "전체 화면",
exitFullscreen: "전체 화면 종료",
focusHint: "{{shortcut}}로 포커스",
addAttachment: "첨부 추가",
interruptAgent: "에이전트 중단",
-2
View File
@@ -125,8 +125,6 @@ export const ptBR: TranslationResources = {
input: {
accessibilityLabel: "Enviar mensagem ao agente...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "Tela cheia",
exitFullscreen: "Sair da tela cheia",
focusHint: "{{shortcut}} para focar",
addAttachment: "Adicionar anexo",
interruptAgent: "Interromper agente",
-2
View File
@@ -125,8 +125,6 @@ export const ru: TranslationResources = {
input: {
accessibilityLabel: "Написать агенту...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "Полный экран",
exitFullscreen: "Выйти из полноэкранного режима",
focusHint: "{{shortcut}}, для фокуса",
addAttachment: "Добавить вложение",
interruptAgent: "Прервать агента",
-2
View File
@@ -124,8 +124,6 @@ export const zhCN: TranslationResources = {
input: {
accessibilityLabel: "给 Agent 发消息...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
focusHint: "{{shortcut}} 聚焦",
addAttachment: "添加附件",
interruptAgent: "中断 Agent",
@@ -104,7 +104,6 @@ function transformSourceItem(
const projected: PluginTimelineStreamItem = {
kind: "plugin",
id: `${pluginItem.pluginId}/${pluginItem.id}`,
sourceId: item.id,
timestamp: item.timestamp,
pluginId: pluginItem.pluginId,
pluginItemId: pluginItem.id,
@@ -1723,7 +1723,7 @@ export function NewWorkspaceScreen({
[terminalComposerValue],
);
const terminalTextReplacement = useMemo(
() => ({ kind: "replace" as const, key: launchFocusKey, text: terminalComposerValue }),
() => ({ key: launchFocusKey, text: terminalComposerValue }),
[launchFocusKey, terminalComposerValue],
);
@@ -1,4 +1,3 @@
import { ComposerEditingScope } from "@/composer/editing-session";
import React, { useMemo, type ComponentType } from "react";
import invariant from "tiny-invariant";
import {
@@ -157,12 +156,7 @@ export function WorkspacePaneContent({
>
<PaneProvider value={stablePaneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<ComposerEditingScope
tabId={paneContextValue.tabId}
targetKey={JSON.stringify(paneContextValue.target)}
>
<Component key={key} />
</ComposerEditingScope>
<Component key={key} />
</PaneFocusProvider>
</PaneProvider>
</RenderProfile>
@@ -1,4 +1,3 @@
import { WorkspaceComposerEditingSessionsProvider } from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
@@ -754,20 +753,14 @@ function WorkspaceContentProviders({
function WorkspacePanelContent({
launcher,
content,
tabIds,
workspaceKey,
}: {
workspaceKey: string;
launcher: NewTabLauncher;
content: ReactNode;
tabIds: readonly string[];
}) {
return (
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
<NewTabLauncherProvider value={launcher}>
<View style={styles.content}>{content}</View>
</NewTabLauncherProvider>
</WorkspaceComposerEditingSessionsProvider>
<NewTabLauncherProvider value={launcher}>
<View style={styles.content}>{content}</View>
</NewTabLauncherProvider>
);
}
@@ -3498,8 +3491,6 @@ function WorkspaceScreenContent({
const workspacePanelContent = (
<WorkspacePanelContent
launcher={newTabLauncher}
workspaceKey={`${normalizedServerId}:${normalizedWorkspaceId}`}
tabIds={uiTabs.map((tab) => tab.tabId)}
content={isMobile ? content : desktopContent}
/>
);
-2
View File
@@ -804,8 +804,6 @@ export interface CompactionItem {
export interface PluginTimelineStreamItem {
kind: "plugin";
id: string;
/** The id of the stream item a timeline transformer replaced; set only by projection. */
sourceId?: string;
timelineCursor?: TimelinePosition;
turnId?: string;
timestamp: Date;
@@ -2,10 +2,6 @@ import { describe, expect, it } from "vitest";
import { requireWorkspaceDirectory, resolveWorkspaceDirectory } from "./workspace-directory";
describe("resolveWorkspaceDirectory", () => {
it("preserves significant whitespace in a workspace folder name", () => {
expect(resolveWorkspaceDirectory({ workspaceDirectory: "/repo " })).toBe("/repo ");
expect(requireWorkspaceDirectory({ workspaceDirectory: "/repo /" })).toBe("/repo ");
});
it("canonicalizes a workspace directory and returns null when blank", () => {
expect(resolveWorkspaceDirectory({ workspaceDirectory: "C:\\repo\\app\\" })).toBe(
"C:/repo/app",
+3 -2
View File
@@ -13,10 +13,11 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st
}
export function normalizeWorkspacePath(value: string | null | undefined): string | null {
if (typeof value !== "string" || value.trim().length === 0) {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
return null;
}
const withUnixSeparators = value.replace(/\\/g, "/");
const withUnixSeparators = trimmed.replace(/\\/g, "/");
if (withUnixSeparators === "/") {
return withUnixSeparators;
}
+3 -2
View File
@@ -1,3 +1,4 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { DaemonTarget } from "../../utils/daemon-target.js";
import { connectToDaemon, getDaemonHost } from "../../utils/client.js";
import type { CommandError, CommandOptions } from "../../output/index.js";
@@ -441,8 +442,8 @@ function buildNewAgentConfigPatch(
}
function parsePositiveInt(value: string, flag: string): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed) || parsed <= 0) {
const parsed = parseSafeInteger(value);
if (parsed === undefined || parsed <= 0) {
throw {
code: "INVALID_INTEGER",
message: `${flag} must be a positive integer`,
@@ -1,3 +1,4 @@
import { parseSafeInteger } from "../../utils/integer.js";
import type { Command } from "commander";
import { renderError, toCommandError } from "../../output/render.js";
import {
@@ -85,8 +86,8 @@ function parseLineNumber(flag: string, value?: string): number | undefined {
return undefined;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isInteger(parsed)) {
const parsed = parseSafeInteger(value);
if (parsed === undefined) {
throw {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
+20
View File
@@ -0,0 +1,20 @@
export function parseSafeInteger(value: string): number | undefined {
const input = value.trim();
const match = /^[+-]?(\d*)(?:\.(\d*))?(?:e([+-]?\d+))?$/i.exec(input);
if (!match) {
return undefined;
}
const digits = match[1] + (match[2] ?? "");
const parsed = Number(input);
if (digits.length === 0 || !Number.isSafeInteger(parsed)) {
return undefined;
}
// Check the decimal value before floating-point rounding can hide a fraction.
const decimalPlaces = (match[2]?.length ?? 0) - Number(match[3] ?? "0");
if (decimalPlaces > 0 && /[1-9]/.test(digits.slice(-decimalPlaces))) {
return undefined;
}
return parsed;
}
+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/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"",
"test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/schedule-run-lifecycle.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/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && 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",
@@ -1,133 +0,0 @@
import { createServer } from "node:http";
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
// Exercise the real Claude Code process against a local Messages API fixture.
// The input counter's location is the only difference between the two backends.
test.each([
{ placement: "start", cacheTokens: 0 },
{ placement: "delta", cacheTokens: 0 },
{ placement: "start", cacheTokens: 1_000 },
{ placement: "delta", cacheTokens: 1_000 },
])(
"counts $placement input and $cacheTokens cached tokens through Claude Code",
async ({ placement, cacheTokens }) => {
const cwd = await mkdtemp(path.resolve(".claude-context-usage-"));
const configDir = path.join(cwd, "config");
await mkdir(configDir);
let requestCount = 0;
const server = createServer(async (request, response) => {
for await (const chunk of request) {
void chunk;
}
if (!request.url?.startsWith("/v1/messages")) {
response.writeHead(200, { "content-type": "application/json" });
response.end("{}");
return;
}
if (request.url.includes("count_tokens")) {
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ input_tokens: 20_236 }));
return;
}
const inputTokens = 20_236 + requestCount++;
response.writeHead(200, { "content-type": "text/event-stream" });
const emit = (data: { type: string; [key: string]: unknown }) => {
response.write(`event: ${data.type}\ndata: ${JSON.stringify(data)}\n\n`);
};
emit({
type: "message_start",
message: {
id: `msg_context_usage_${requestCount}`,
type: "message",
role: "assistant",
model: "claude-sonnet-4-6",
content: [],
stop_reason: null,
stop_sequence: null,
usage: {
input_tokens: placement === "start" ? inputTokens : 0,
output_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: cacheTokens,
},
},
});
emit({ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } });
emit({ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hello" } });
emit({ type: "content_block_stop", index: 0 });
emit({
type: "message_delta",
delta: { stop_reason: "end_turn", stop_sequence: null },
usage: {
output_tokens: 64,
...(placement === "delta" ? { input_tokens: inputTokens } : {}),
},
});
emit({ type: "message_stop" });
response.end();
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("Fixture has no TCP address");
const client = new ClaudeAgentClient({
logger: pino({ level: "silent" }),
runtimeSettings: {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${address.port}`,
ANTHROPIC_AUTH_TOKEN: "fixture-token",
ANTHROPIC_API_KEY: "",
CLAUDE_CONFIG_DIR: configDir,
CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
},
},
});
const session = await client.createSession(
{ provider: "claude", cwd, model: "claude-sonnet-4-6" },
undefined,
{ persistSession: false },
);
try {
for (let turn = 0; turn < 2; turn++) {
const events = await collectSessionTurnEvents(session, "say hello", {
signal: AbortSignal.timeout(20_000),
});
const expectedTokens = 20_236 + turn + cacheTokens + 64;
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({
inputTokens: 20_236 + turn,
outputTokens: 64,
cachedInputTokens: cacheTokens,
contextWindowUsedTokens: expectedTokens,
}),
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
usage: expect.objectContaining({ contextWindowUsedTokens: expectedTokens }),
}),
);
}
expect(requestCount).toBe(2);
} finally {
try {
await session.close();
} finally {
try {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
} finally {
await rm(cwd, { recursive: true, force: true });
}
}
}
},
60_000,
);
@@ -2135,15 +2135,13 @@ describe("ClaudeAgentSession context window usage", () => {
}
function createMessageDeltaEvent(outputTokens: number): Record<string, unknown> {
return createMessageDeltaUsageEvent({ output_tokens: outputTokens });
}
function createMessageDeltaUsageEvent(usage: Record<string, unknown>): Record<string, unknown> {
return {
type: "stream_event",
event: {
type: "message_delta",
usage,
usage: {
output_tokens: outputTokens,
},
},
session_id: "session-1",
};
@@ -2889,119 +2887,6 @@ describe("ClaudeAgentSession context window usage", () => {
}
});
test("message_delta input usage replaces an empty message_start count", async () => {
// Translating gateways (LiteLLM in front of an OpenAI-compatible backend) do not know the
// prompt size when message_start is emitted, so they report input_tokens: 0 there and send
// the cumulative counts on message_delta, as the Anthropic Messages API allows.
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageStartEvent({
input_tokens: 0,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}),
createMessageDeltaUsageEvent({ input_tokens: 20_236, output_tokens: 64 }),
createSuccessResult({
usage: {
input_tokens: 20_236,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
output_tokens: 64,
iterations: [],
},
}),
],
]);
try {
const events = await collectStreamEvents(session);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 20_300,
},
}),
);
expect(events).toContainEqual(
expect.objectContaining({
type: "turn_completed",
usage: expect.objectContaining({
inputTokens: 20_236,
outputTokens: 64,
contextWindowUsedTokens: 20_300,
}),
}),
);
} finally {
await session.close();
}
});
test("message_delta without cache counters keeps the message_start cache usage", async () => {
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageStartEvent({
input_tokens: 5,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 1_000,
}),
createMessageDeltaUsageEvent({ input_tokens: 5, output_tokens: 64 }),
createSuccessResult(),
],
]);
try {
const events = await collectStreamEvents(session);
expect(events).toContainEqual(
expect.objectContaining({
type: "usage_updated",
provider: "claude",
usage: {
contextWindowUsedTokens: 1_069,
},
}),
);
} finally {
await session.close();
}
});
test("message_delta before this request's message_start does not seed usage", async () => {
// A canceled request can leave its terminal message_delta queued behind the next turn.
// Nothing is known about the new request until its message_start, so that straggler
// must not produce a usage_updated event or leak into the next request's counters.
const session = await createSessionForTurns([
[
createInitMessage(),
createMessageDeltaUsageEvent({ input_tokens: 20_236, output_tokens: 64 }),
createMessageStartEvent({
input_tokens: 40,
cache_creation_input_tokens: 5,
cache_read_input_tokens: 10,
}),
createMessageDeltaEvent(7),
createSuccessResult(),
],
]);
try {
const events = await collectStreamEvents(session);
const usedTokens = events
.filter((event) => event.type === "usage_updated")
.map((event) => event.usage.contextWindowUsedTokens);
expect(usedTokens).toEqual([55, 62]);
} finally {
await session.close();
}
});
test("per-request stream usage is not cumulative across API calls in a turn", async () => {
const session = await createSessionForTurns([
[
@@ -1855,59 +1855,30 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
interface StreamRequestInputUsage {
inputTokens: number;
cacheCreationInputTokens: number;
cacheReadInputTokens: number;
}
function readUsageCount(usage: Record<string, unknown>, key: string): number | undefined {
const value = usage[key];
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage);
if (!messageUsage) {
return undefined;
}
return value;
}
function readStreamRequestInputUsage(
event: Record<string, unknown>,
): StreamRequestInputUsage | undefined {
const usage = toObjectRecord(toObjectRecord(event.message)?.usage);
if (!usage) {
const usage = messageUsage;
const inputTokens =
typeof usage.input_tokens === "number" && Number.isFinite(usage.input_tokens)
? usage.input_tokens
: undefined;
const cacheCreationInputTokens =
typeof usage.cache_creation_input_tokens === "number" &&
Number.isFinite(usage.cache_creation_input_tokens)
? usage.cache_creation_input_tokens
: 0;
const cacheReadInputTokens =
typeof usage.cache_read_input_tokens === "number" &&
Number.isFinite(usage.cache_read_input_tokens)
? usage.cache_read_input_tokens
: 0;
if (typeof inputTokens !== "number" || inputTokens < 0) {
return undefined;
}
const inputTokens = readUsageCount(usage, "input_tokens");
if (inputTokens === undefined) {
return undefined;
}
return {
inputTokens,
cacheCreationInputTokens: readUsageCount(usage, "cache_creation_input_tokens") ?? 0,
cacheReadInputTokens: readUsageCount(usage, "cache_read_input_tokens") ?? 0,
};
}
/**
* The input counters on `message_delta.usage` are cumulative whole-request totals and are
* optional. Mirror the Anthropic SDK's MessageStream: a counter present on the delta replaces
* the `message_start` value, a missing counter keeps it. Translating gateways report
* `input_tokens: 0` on `message_start` and only know the real count by `message_delta`.
*/
function mergeStreamDeltaInputUsage(
current: StreamRequestInputUsage,
event: Record<string, unknown>,
): StreamRequestInputUsage {
const usage = toObjectRecord(event.usage);
if (!usage) {
return current;
}
return {
inputTokens: readUsageCount(usage, "input_tokens") ?? current.inputTokens,
cacheCreationInputTokens:
readUsageCount(usage, "cache_creation_input_tokens") ?? current.cacheCreationInputTokens,
cacheReadInputTokens:
readUsageCount(usage, "cache_read_input_tokens") ?? current.cacheReadInputTokens,
};
return inputTokens + cacheCreationInputTokens + cacheReadInputTokens;
}
function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined {
@@ -1987,7 +1958,7 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null {
class ClaudeContextUsageState {
private contextWindowMaxTokens: number | undefined;
private streamRequestInputUsage: StreamRequestInputUsage | undefined;
private streamRequestInputTokens: number | undefined;
private streamRequestOutputTokens: number | undefined;
private compactedContextWindowUsedTokens: number | undefined;
private completedResultTurns = 0;
@@ -1997,7 +1968,7 @@ class ClaudeContextUsageState {
}
beginTurn(): void {
this.streamRequestInputUsage = undefined;
this.streamRequestInputTokens = undefined;
this.streamRequestOutputTokens = undefined;
this.compactedContextWindowUsedTokens = undefined;
}
@@ -2021,11 +1992,11 @@ class ClaudeContextUsageState {
}
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputUsage = readStreamRequestInputUsage(streamEvent);
if (!inputUsage) {
const inputTokens = readStreamRequestInputTokens(streamEvent);
if (typeof inputTokens !== "number") {
return null;
}
this.streamRequestInputUsage = inputUsage;
this.streamRequestInputTokens = inputTokens;
this.streamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
@@ -2033,12 +2004,6 @@ class ClaudeContextUsageState {
return null;
}
this.streamRequestOutputTokens = outputTokens;
if (this.streamRequestInputUsage) {
this.streamRequestInputUsage = mergeStreamDeltaInputUsage(
this.streamRequestInputUsage,
streamEvent,
);
}
} else {
return null;
}
@@ -2085,15 +2050,13 @@ class ClaudeContextUsageState {
}
private streamUsedTokens(): number | undefined {
const inputUsage = this.streamRequestInputUsage;
if (!inputUsage || typeof this.streamRequestOutputTokens !== "number") {
if (
typeof this.streamRequestInputTokens !== "number" ||
typeof this.streamRequestOutputTokens !== "number"
) {
return undefined;
}
const usedTokens =
inputUsage.inputTokens +
inputUsage.cacheCreationInputTokens +
inputUsage.cacheReadInputTokens +
this.streamRequestOutputTokens;
const usedTokens = this.streamRequestInputTokens + this.streamRequestOutputTokens;
return usedTokens > 0 ? usedTokens : undefined;
}
@@ -2112,7 +2075,7 @@ class ClaudeContextUsageState {
}
buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent {
this.streamRequestInputUsage = undefined;
this.streamRequestInputTokens = undefined;
this.streamRequestOutputTokens = undefined;
this.compactedContextWindowUsedTokens = postTokens;
const usage: AgentUsage = {};
@@ -112,7 +112,7 @@ export interface PaseoToolHostDependencies {
github?: ForgeService;
workspaceGitService?: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees"
"getSnapshot" | "listWorktrees" | "resolveRepoRoot"
>;
findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"];
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
@@ -1,73 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { beforeEach, expect, onTestFinished, test } from "vitest";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
// Windows does not preserve trailing spaces in folder names.
const testOnPosix = test.skipIf(process.platform === "win32");
let client: DaemonClient;
let repoDir: string;
let projectId: string;
beforeEach(async () => {
const root = realpathSync(mkdtempSync(join(tmpdir(), "paseo-project-path-spaces-")));
onTestFinished(() => rmSync(root, { recursive: true, force: true }));
repoDir = join(root, "repo ");
execFileSync("git", ["init", "-b", "main", repoDir], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.local",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repoDir, stdio: "pipe" },
);
const daemon = await createTestPaseoDaemon();
onTestFinished(() => daemon.close());
client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
const testClient = client;
onTestFinished(() => testClient.close());
await client.connect();
const added = await client.addProject(repoDir);
expect(added.error).toBeNull();
expect(added.project?.projectRootPath).toBe(repoDir);
projectId = added.project!.projectId;
});
testOnPosix("a project folder ending in a space keeps its Git status", async () => {
const opened = await client.createWorkspace({
source: { kind: "directory", path: repoDir, projectId },
});
expect(opened.error).toBeNull();
expect(opened.workspace?.workspaceDirectory).toBe(repoDir);
const status = await client.getCheckoutStatus(repoDir);
expect(status.isGit).toBe(true);
expect(status.repoRoot).toBe(repoDir);
expect(status.currentBranch).toBe("main");
});
testOnPosix("a project folder ending in a space can create a worktree", async () => {
const created = await client.createWorkspace({
source: {
kind: "worktree",
projectId,
action: "branch-off",
branchName: "feature/spaces",
baseBranch: "main",
},
});
expect(created.error).toBeNull();
expect(created.workspace?.gitRuntime?.currentBranch).toBe("feature/spaces");
});
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, expect, test } from "vitest";
import { createPaseoClient, type PaseoClient } from "@getpaseo/client";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { runPaseoCli } from "../test-utils/cli.js";
let daemon: TestPaseoDaemon;
let client: DaemonClient;
@@ -28,6 +29,75 @@ afterEach(async () => {
await rm(cwd, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
});
async function createQuietTerminal() {
const workspaceId = await createWorkspace("CLI capture");
return sdk.terminals.create({
workspaceId,
command: process.execPath,
args: ["-e", "process.stdin.resume()"],
size: { rows: 10, cols: 40 },
});
}
test.each([
["--end", "4503599627370496.5"],
["--start", "1.0000000000000001"],
["--end", "4.5035996273704965e15"],
["--end", "1e-324"],
["--start", "1.5"],
["--end", "1.5"],
["--start", "1abc"],
["--end", "1abc"],
["--start", ""],
["--end", ""],
["--start", "9007199254740993"],
["--end", "9007199254740993"],
])("CLI terminal capture rejects %s %j", async (flag, value) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, flag, value, "--json"],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: {
code: "INVALID_LINE_NUMBER",
message: `Invalid ${flag} value: ${value}`,
details: "Use an integer line number.",
},
});
} finally {
await terminal.kill();
}
});
test.each([
{ args: [], lineCount: 10 },
{ args: ["--start", "0", "--end", "1"], lineCount: 2 },
{ args: ["--end", "1.0"], lineCount: 2 },
{ args: ["--end", "10e-1"], lineCount: 2 },
{ args: ["--end", ".1e1"], lineCount: 2 },
{ args: ["--start", "-2", "--end", "-1"], lineCount: 2 },
{ args: ["--scrollback", "--start", "7", "--end", "1"], lineCount: 2 },
])("CLI terminal capture preserves valid ranges $args", async ({ args, lineCount }) => {
const terminal = await createQuietTerminal();
try {
const result = await runPaseoCli({
daemon,
args: ["terminal", "capture", terminal.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
terminalId: terminal.id,
lines: Array.from({ length: lineCount }, () => ""),
totalLines: 10,
});
} finally {
await terminal.kill();
}
});
test("SDK and workspace handles preserve ownership and actual process directories", async () => {
const first = await createWorkspace("main");
const second = await createWorkspace("feature-work");
@@ -13,6 +13,7 @@ import {
} from "./test-utils/index.js";
import type { SessionOutboundMessage } from "./messages.js";
import { getPaseoWorktreesRoot } from "../utils/worktree.js";
import { runPaseoCli } from "./test-utils/cli.js";
type AgentUpdateMessage = Extract<SessionOutboundMessage, { type: "agent_update" }>;
type WorkspaceUpdateMessage = Extract<SessionOutboundMessage, { type: "workspace_update" }>;
@@ -42,6 +43,137 @@ function makeTempDir(prefix: string): string {
return dir;
}
test.each([
"4503599627370496.5",
"1.0000000000000001",
"2.5",
"2runs",
"9007199254740993",
"0",
"-1",
"",
])("CLI schedule create rejects max-runs %j without saving a schedule", async (maxRuns) => {
const cwd = makeTempDir("schedule-cli-integer-");
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"create",
"Integer limit",
"--cron",
"0 0 1 1 *",
"--provider",
"claude",
"--cwd",
cwd,
"--max-runs",
maxRuns,
"--json",
],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
});
expect((await ctx.client.scheduleList()).schedules).toEqual([]);
});
test.each(["2.5", "2runs", "4503599627370496.5"])(
"CLI schedule update rejects max-runs %j without changing the schedule",
async (maxRuns) => {
const cwd = makeTempDir("schedule-cli-update-integer-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
const before = await ctx.client.scheduleInspect({ id: schedule.id });
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"update",
schedule.id,
"--prompt",
"Should not apply",
"--max-runs",
maxRuns,
"--json",
],
});
expect(result.exitCode, result.stdout + result.stderr).toBe(1);
expect(JSON.parse(result.stderr)).toEqual({
error: { code: "INVALID_INTEGER", message: "--max-runs must be a positive integer" },
});
const after = await ctx.client.scheduleInspect({ id: schedule.id });
expect(after.error).toBeNull();
expect(after.schedule).toEqual(before.schedule);
},
);
test.each([
{ limitArgs: [], maxRuns: null },
{ limitArgs: ["--max-runs", "3"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "3.0"], maxRuns: 3 },
{ limitArgs: ["--max-runs", "1e2"], maxRuns: 100 },
])(
"CLI schedule create stores max-runs $maxRuns from $limitArgs",
async ({ limitArgs, maxRuns }) => {
const cwd = makeTempDir("schedule-cli-valid-integer-");
const result = await runPaseoCli({
daemon: ctx.daemon,
args: [
"schedule",
"create",
"Integer limit",
"--cron",
"0 0 1 1 *",
"--provider",
"claude",
"--cwd",
cwd,
...limitArgs,
"--json",
],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleList()).schedules).toEqual([
expect.objectContaining({ maxRuns, prompt: "Integer limit" }),
]);
},
);
test("CLI schedule update preserves, changes, and clears valid run limits", async () => {
const cwd = makeTempDir("schedule-cli-valid-update-");
const schedule = await createNewAgentSchedule({
prompt: "Original prompt",
cadence: { type: "cron", expression: "0 0 1 1 *", timezone: "UTC" },
target: { type: "new-agent", config: { provider: "claude", cwd } },
maxRuns: 3,
runOnCreate: false,
});
for (const { args, maxRuns } of [
{ args: ["--name", "Renamed"], maxRuns: 3 },
{ args: ["--max-runs", "5"], maxRuns: 5 },
{ args: ["--no-max-runs"], maxRuns: null },
]) {
const result = await runPaseoCli({
daemon: ctx.daemon,
args: ["schedule", "update", schedule.id, ...args, "--json"],
});
expect(result.exitCode, result.stderr).toBe(0);
expect((await ctx.client.scheduleInspect({ id: schedule.id })).schedule).toMatchObject({
maxRuns,
name: "Renamed",
prompt: schedule.prompt,
target: schedule.target,
cadence: schedule.cadence,
});
}
}, 60_000);
function createGitRepo(): string {
const tempRoot = makeTempDir("schedule-run-worktree-");
const repoDir = path.join(tempRoot, "repo");
@@ -60,7 +192,13 @@ function createGitRepo(): string {
cwd: repoDir,
stdio: "pipe",
});
return repoDir;
// Use Git's canonical root, as worktree creation does, including Windows path aliases.
return realpathSync(
execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: repoDir,
encoding: "utf8",
}).trim(),
);
}
async function createNewAgentSchedule(options: ScheduleCreateOptions): Promise<ScheduleSummary> {
@@ -0,0 +1,54 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import type { TestPaseoDaemon } from "./paseo-daemon.js";
interface CliOptions {
daemon: Pick<TestPaseoDaemon, "port" | "paseoHome">;
args: string[];
}
interface CliResult {
exitCode: number | null;
stdout: string;
stderr: string;
}
export function runPaseoCli({ daemon, args }: CliOptions): Promise<CliResult> {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[
"--no-warnings",
"--conditions=source",
"--import",
"tsx",
fileURLToPath(new URL("../../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${daemon.port}`,
...args,
],
{
env: { ...process.env, PASEO_HOME: daemon.paseoHome },
stdio: ["ignore", "pipe", "pipe"],
timeout: 20_000,
killSignal: "SIGKILL",
},
);
let stdout = "";
let stderr = "";
child.stdout.on("data", (chunk: Buffer) => {
stdout += chunk.toString();
});
child.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString();
});
child.once("error", reject);
child.once("close", (exitCode, signal) => {
if (signal) {
reject(new Error(`CLI exited on ${signal}:\n${stdout}\n${stderr}`));
} else {
resolve({ exitCode, stdout, stderr });
}
});
});
}
@@ -55,7 +55,6 @@ export function createNoopWorkspaceGitService(
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
invalidateWorktrees: () => {},
getProjectSlug: async (cwd: string) => {
const snapshot = createNoGitWorkspaceRuntimeSnapshot(cwd);
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
@@ -246,7 +246,6 @@ function createFallbackWorkspaceGitService(): WorkspaceGitService {
suggestBranchesForCwd: async () => [],
listStashes: async () => [],
listWorktrees: async () => [],
invalidateWorktrees: () => {},
getProjectSlug: async (cwd: string) => {
const snapshot = createFallbackWorkspaceGitSnapshot(cwd);
return deriveProjectSlug(cwd, snapshot.git.isGit ? snapshot.git.remoteUrl : null);
@@ -1,13 +1,10 @@
import { execFile, execFileSync, spawn } from "node:child_process";
import { execFileSync, spawn } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { afterEach, beforeEach, expect, test } from "vitest";
import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js";
import { createWorktree } from "../utils/worktree.js";
import {
createDaemonTestContext,
DaemonClient,
@@ -57,32 +54,6 @@ function createGitRepo(): string {
return repoDir;
}
async function runCli(args: string[], cwd: string): Promise<string> {
const result = await promisify(execFile)(
process.execPath,
[
"--conditions=source",
"--import",
import.meta.resolve("tsx"),
fileURLToPath(new URL("../../../cli/src/index.ts", import.meta.url)),
"--host",
`127.0.0.1:${ctx.daemon.port}`,
...args,
],
{
cwd,
env: {
...Object.fromEntries(
Object.entries(process.env).filter(([key]) => !key.startsWith("PASEO_")),
),
PASEO_HOME: ctx.daemon.paseoHome,
},
timeout: 20000,
},
);
return result.stdout;
}
async function createLocalWorkspace(cwd: string, title: string): Promise<string> {
const result = await ctx.client.createWorkspace({
source: { kind: "directory", path: cwd },
@@ -277,78 +248,6 @@ test("renaming a workspace updates every subscribed client", async () => {
}
});
test.each(["worktree", "workspace"] as const)(
"%s archive immediately removes the deleted worktree from a warm CLI listing",
async (command) => {
const repoDir = createGitRepo();
const created = await ctx.client.createWorkspace({
source: {
kind: "worktree",
cwd: repoDir,
worktreeSlug: "cached-worktree",
baseBranch: "main",
},
});
const workspace = created.workspace;
if (!workspace?.workspaceDirectory) {
throw new Error(created.error ?? "Failed to create worktree workspace");
}
const worktreePath = workspace.workspaceDirectory;
const name = path.basename(worktreePath);
const before = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir));
expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]);
await runCli(
[command, "archive", command === "worktree" ? name : workspace.id, "--json"],
repoDir,
);
expect(existsSync(worktreePath)).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir }).toString(),
).not.toContain(worktreePath);
const after = JSON.parse(await runCli(["worktree", "ls", "--json"], repoDir));
console.log(
`${command} archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`,
);
expect(after).toEqual([]);
},
60000,
);
test("archiving an unregistered worktree in a bare repo clears the main checkout listing", async () => {
const sourceRepo = createGitRepo();
const repoRoot = makeTempDir("workspace-archive-bare-");
const bareRepo = path.join(repoRoot, "repo.git");
const mainCheckout = path.join(repoRoot, "main");
execFileSync("git", ["clone", "--bare", sourceRepo, bareRepo], { stdio: "pipe" });
execFileSync("git", ["worktree", "add", mainCheckout, "main"], {
cwd: bareRepo,
stdio: "pipe",
});
const { worktreePath } = await createWorktree({
cwd: mainCheckout,
source: { kind: "branch-off", baseBranch: "main", branchName: "cached-bare-worktree" },
worktreeSlug: "cached-bare-worktree",
runSetup: false,
paseoHome: ctx.daemon.paseoHome,
});
expect(await activeWorkspaceIds()).toEqual(new Set());
const name = path.basename(worktreePath);
const before = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout));
expect(before).toEqual([expect.objectContaining({ name, cwd: worktreePath })]);
await runCli(["worktree", "archive", name, "--json"], mainCheckout);
expect(existsSync(worktreePath)).toBe(false);
expect(
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: mainCheckout }).toString(),
).not.toContain(worktreePath);
const after = JSON.parse(await runCli(["worktree", "ls", "--json"], mainCheckout));
console.log(
`bare repo archive: warm listing=${JSON.stringify(before)}\nimmediate listing=${JSON.stringify(after)}`,
);
expect(after).toEqual([]);
}, 60000);
test("archiving the last reference to a worktree removes it from disk regardless of the disk flag", async () => {
const repoDir = createGitRepo();
@@ -140,8 +140,7 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
github: createGitHubServiceStub(),
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
invalidateWorktrees: () => {},
} as unknown as Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">,
} as unknown as Pick<WorkspaceGitService, "getSnapshot">,
agentManager: {
listAgents: () => [],
getAgent: () => null,
@@ -618,37 +617,28 @@ describe("archiveByScope", () => {
expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled();
});
test.each(["intact", "missing"] as const)(
"worktree scope removes an owned directory with zero matching records and %s Git administration",
async (gitAdministration) => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records");
if (gitAdministration === "missing") {
const gitDir = execFileSync("git", ["rev-parse", "--absolute-git-dir"], {
cwd: worktree.worktreePath,
})
.toString()
.trim();
rmSync(gitDir, { recursive: true, force: true });
}
const dependencies = createArchiveDeps({ paseoHome, activeWorkspaces: [] });
const invalidateWorktrees = vi.fn();
dependencies.workspaceGitService.invalidateWorktrees = invalidateWorktrees;
test("worktree scope removes an owned directory with zero matching records", async () => {
const { tempDir, repoDir } = createGitRepo();
const paseoHome = path.join(tempDir, ".paseo");
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "zero-records");
const result = await archiveByScope(dependencies, {
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [],
}),
{
scope: { kind: "worktree", targetPath: worktree.worktreePath },
requestId: "req-zero-records",
});
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
expect(invalidateWorktrees).toHaveBeenCalledTimes(1);
},
);
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
});
test("marks archiving, emits an upsert carrying the archiving state, then clears it and emits a remove", async () => {
const { tempDir, repoDir } = createGitRepo();
@@ -32,7 +32,7 @@ export interface ArchiveDependencies {
// Base directory that may hold worktrees across repositories.
paseoWorktreesBaseRoot?: string;
github: ForgeService;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot">;
agentManager: Pick<AgentManager, "listAgents" | "getAgent" | "archiveAgent" | "archiveSnapshot">;
agentStorage: Pick<AgentStorage, "listByWorkspace">;
// Resolves the worktree at a path to its workspaceId for archive-by-path. The
@@ -408,7 +408,6 @@ async function maybeRemoveDirectory(
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
});
dependencies.workspaceGitService.invalidateWorktrees();
dependencies.github.invalidate({ cwd: backing.path });
return true;
} catch (error) {
@@ -1819,46 +1819,6 @@ describe("WorkspaceGitServiceImpl D2 read methods", () => {
service.dispose();
});
test("invalidating worktrees discards warm listings across checkout roots and in-flight reads", async () => {
const worktrees = [
{
path: "/tmp/paseo-home/worktrees/repo/feature",
createdAt: "2026-04-12T00:00:00.000Z",
branchName: "feature",
},
];
const readStarted = createDeferred<void>();
const oldRead = createDeferred<typeof worktrees>();
const alternateCheckout = resolvePath("/tmp/alternate-checkout");
let reads = 0;
const service = createService({
listPaseoWorktrees: async () => {
reads += 1;
if (reads <= 2) return worktrees;
if (reads === 3) {
readStarted.resolve();
return oldRead.promise;
}
return [];
},
});
try {
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual(worktrees);
await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual(worktrees);
const pending = service.listWorktrees(REPO_CWD, { force: true, reason: "test" });
await readStarted.promise;
service.invalidateWorktrees();
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]);
await expect(service.listWorktrees(alternateCheckout)).resolves.toEqual([]);
oldRead.resolve(worktrees);
await expect(pending).resolves.toEqual(worktrees);
await expect(service.listWorktrees(REPO_CWD)).resolves.toEqual([]);
} finally {
oldRead.resolve(worktrees);
await service.dispose();
}
});
test("listWorktrees shares one repo-root scoped read across sibling workspace cwds", async () => {
const tempDir = realpathSync(mkdtempSync(join(tmpdir(), "workspace-git-service-")));
const repoDir = join(tempDir, "repo");
@@ -241,7 +241,6 @@ export interface WorkspaceGitService {
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
): Promise<WorkspaceGitWorktreeInfo[]>;
invalidateWorktrees(): void;
getProjectSlug(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
resolveRepoRoot(cwd: string, options?: WorkspaceGitReadOptions): Promise<string>;
resolveDefaultBranch(cwdOrRepoRoot: string, options?: WorkspaceGitReadOptions): Promise<string>;
@@ -919,13 +918,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
: (snapshot.git.repoRoot ?? resolve(cwd));
}
invalidateWorktrees(): void {
this.assertNotDisposed();
// Checkout paths can cache the same list under different roots, including bare repos.
// Detach in-flight reads too, so a pre-mutation result cannot refill the cache.
this.worktreeListCache.clear();
}
async resolveDefaultBranch(
cwdOrRepoRoot: string,
options?: WorkspaceGitReadOptions,
@@ -2017,7 +2017,6 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2089,7 +2088,6 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2166,7 +2164,6 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2243,7 +2240,6 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -501,10 +501,7 @@ export async function handlePaseoWorktreeArchiveRequest(
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
> & {
emit: EmitSessionMessage;
workspaceGitService: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
>;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
@@ -93,10 +93,7 @@ export interface ArchiveCommandDependencies extends Omit<
ArchiveDependencies,
"workspaceGitService"
> {
workspaceGitService: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
>;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
}
export interface ArchiveCommandInput {
@@ -1,26 +0,0 @@
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
describe("Git path output", () => {
it.each(["\n", "\r\n", ""])("preserves path whitespace with delimiter %j", (delimiter) => {
expect(parseGitRevParsePath(`/repo ${delimiter}`)).toBe("/repo ");
expect(parseGitRevParsePath(` relative path ${delimiter}`)).toBe(" relative path ");
});
it.each([
"",
"\n",
"\r\n",
"--path-format=absolute\n",
"--path-format=absolute\n/repo\n",
"/repo\n/another\n",
"/repo\n\n",
])("rejects invalid output %j", (output) => {
expect(parseGitRevParsePath(output)).toBeNull();
});
it("resolves a relative path without stripping its trailing space", () => {
expect(resolveGitRevParsePath("/base", "repo \n")).toBe(resolve("/base", "repo "));
});
});
@@ -1,9 +1,18 @@
import { resolve } from "node:path";
export function parseGitRevParsePath(stdout: string): string | null {
// Remove Git's record delimiter, not whitespace belonging to the path.
const path = stdout.replace(/\r?\n$/, "");
if (!path || /[\r\n]/.test(path) || path.startsWith("--")) {
const trimmed = stdout.trim();
if (!trimmed) {
return null;
}
const lines = trimmed.split(/\r?\n/);
if (lines.length !== 1) {
return null;
}
const path = lines[0]?.trim() ?? "";
if (!path || path.startsWith("--")) {
return null;
}
+3 -2
View File
@@ -10,6 +10,7 @@ import {
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { rm } from "node:fs/promises";
import {
mkdtempSync,
mkdirSync,
@@ -89,8 +90,8 @@ describe("paseo worktree manager", () => {
});
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
});
it("treats a worktree as paseo-owned even when its .git admin is missing", async () => {
-69
View File
@@ -59,72 +59,3 @@ test("desktop mockup corners shrink in proportion on a phone", async ({ page })
await resizeToPhone(page);
await expect.poll(() => cornerProportion(mockup)).toBeCloseTo(desktopCorner, 3);
});
async function openAgentPage(page: Page, slug: string, width: number) {
await page.setViewportSize({ width, height: 900 });
await page.goto(`/${slug}`);
await expect(page.getByRole("button", { name: "Build", exact: true })).toBeVisible();
}
async function expectComposerModel(page: Page, label: string) {
const desktop = page.getByRole("img", { name: desktopName, exact: true });
await expect(desktop.getByText(label, { exact: true })).toBeVisible();
await page.getByRole("button", { name: "Extend", exact: true }).click();
await expect(desktop.getByText(label, { exact: true })).toHaveCount(1);
await expect(desktop.getByText(label, { exact: true })).toBeVisible();
await viewPhones(page);
await expect(
page
.getByRole("img", { name: "Paseo agent chat", exact: true })
.getByText(label, { exact: true }),
).toBeVisible();
}
async function expectPiOverlay(page: Page) {
await expect(page).toHaveTitle("Pi Agent Mobile and Desktop App, Open Source");
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Open source app for the Pi coding agent",
);
await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
"href",
"https://paseo.sh/pi",
);
await expectComposerModel(page, "Pi · Sonnet 4.5");
const section = page.getByRole("region", { name: "Your Pi setup, with a desktop and mobile UI" });
await section.scrollIntoViewIfNeeded();
await expect(section.getByRole("heading", { name: "Bring your extensions" })).toBeVisible();
await expect(
section.getByText(/pi-subagents, pi-ask-user, pi-mcp-adapter and pi-goal-x/),
).toBeVisible();
await expect(section.getByRole("link", { name: "Set up Paseo" })).toHaveAttribute(
"href",
"/docs",
);
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => window.innerWidth),
);
}
for (const width of [1440, 390]) {
test(`Pi has its own content and composer at ${width}px`, async ({ page }) => {
await openAgentPage(page, "pi", width);
await expectPiOverlay(page);
});
test(`an agent without an overlay keeps the shared page at ${width}px`, async ({ page }) => {
await openAgentPage(page, "opencode", width);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(
"Open source app for OpenCode",
);
await expect(
page.getByText(
"Run OpenCode on your machine, drive it from your phone or desktop. Open source on both ends, your code stays local.",
{ exact: true },
),
).toBeVisible();
await expect(
page.getByRole("region", { name: "Your Pi setup, with a desktop and mobile UI" }),
).toHaveCount(0);
await expectComposerModel(page, "GPT-5.6-Sol");
});
}
+2 -49
View File
@@ -24,16 +24,6 @@ test("scrolls mobile plugin carousels without widening the page", async ({ page
await expectPageWithinViewport(page);
});
// Graphite's fixture description ends in raw README HTML with an unbreakable srcset URL.
test("keeps browse grids within a phone's width when a description has an unbreakable word", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/plugins/all");
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expectPageWithinViewport(page);
});
async function expectPageWithinViewport(page: Page) {
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
@@ -98,11 +88,6 @@ test("browses from the directory into a category, a plugin, and its author", asy
"href",
"/plugins/category/git",
);
// The source is a folder in a monorepo; issues are filed on the repository.
await expect(page.getByRole("link", { name: "Report a bug" })).toHaveAttribute(
"href",
"https://github.com/omercnet/paseo-plugins/issues/new",
);
await page.getByRole("link", { name: "Omer Cohen" }).click();
await expect(page).toHaveURL(/\/plugins\/omercnet$/);
@@ -111,9 +96,7 @@ test("browses from the directory into a category, a plugin, and its author", asy
await expect(page.getByRole("link", { name: /Defer/ })).toHaveCount(0);
});
test("ranks plugins on browse pages by installs in a window, by newest, or by latest update", async ({
page,
}) => {
test("ranks plugins on browse pages by installs in a window or by newest", async ({ page }) => {
await page.goto("/plugins/all");
await expect(page.getByRole("heading", { level: 1, name: /^All plugins/ })).toBeVisible();
await expect(page.getByRole("link", { name: "Most installed" })).toHaveAttribute(
@@ -133,21 +116,6 @@ test("ranks plugins on browse pages by installs in a window, by newest, or by la
await expect(page.getByRole("main").getByRole("link", { name: /Base2Tone/ })).toContainText(
"ago",
);
await page.getByRole("link", { name: "Recently updated" }).click();
await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(/^All plugins\s*2$/);
await expect(page.getByRole("main").getByRole("link", { name: /Defer|OMP/ })).toHaveText([
/Defer/,
/OMP/,
]);
});
test("shows each plugin's author with their avatar on browse cards", async ({ page }) => {
await page.goto("/plugins/all");
const card = page.getByRole("main").getByRole("link", { name: /Defer/ });
await expect(card).toContainText("Tom Gringauz");
await expect(card.locator('img[src="https://github.com/tomgrin10.png?size=80"]')).toHaveCount(1);
});
test("filters by search and clears to all plugins", async ({ page }) => {
@@ -609,9 +577,7 @@ test.describe("registry fixture layout", () => {
test("shows the plugin tile on cards when its media has no image", async ({ page }) => {
await page.goto("/plugins/all");
const card = page.getByRole("main").getByRole("link", { name: /launchd Jobs/ });
// The only image is the author's avatar.
await expect(card.locator("img, video")).toHaveCount(1);
await expect(card.locator('img[src^="https://github.com/gpambrozio.png"]')).toHaveCount(1);
await expect(card.locator("img, video")).toHaveCount(0);
await card.click();
await expect(
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
@@ -645,18 +611,6 @@ test.describe("registry fixture layout", () => {
/Defer/,
]);
});
test("lists only plugins updated since they were listed under Recently updated", async ({
page,
}) => {
await openPlugins(page);
await expect(pluginCards(page, "Recently updated")).toHaveText([/Defer/, /OMP/]);
await page
.getByRole("region", { name: "Recently updated" })
.getByRole("link", { name: "See all" })
.click();
await expect(page).toHaveURL(/\/plugins\/all\?sort=updated$/);
});
});
function galleryTiles(links: Element[]) {
@@ -769,7 +723,6 @@ async function expectPluginOverviewSafe(page: Page) {
});
expect(violations).toEqual([]);
await expect(page.getByRole("link", { name: "Source", exact: true })).toHaveCount(0);
await expect(page.getByRole("link", { name: "Report a bug" })).toHaveCount(0);
await expect(page.getByRole("img", { name: /screenshot/ })).toHaveCount(0);
await expect(page.getByRole("heading", { level: 1 })).toHaveText(securityPlugin.name);
await expect(
+37 -37
View File
@@ -84,9 +84,9 @@
"https://raw.githubusercontent.com/alhassanaraouf/paseo-themes/main/packages/paseo-base2tone-theme/preview.png"
],
"submittedAt": "2026-09-24T00:00:00.000Z",
"reviewedAt": "2026-09-24T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-24T00:00:00.000Z",
"updatedAt": "2026-09-24T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Base2Tone\n\n## Behavior\n\nBase2Tone theme for Paseo, ported from the Obsidian theme deathau/Base2Tone-For-Obsidian.md."
},
@@ -117,9 +117,9 @@
"https://raw.githubusercontent.com/dorasto/sayr/main/packages/paseo-plugin/assets/paseo-plugin-2.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Sayr\n\n## Behavior\n\nA paseo plugin for viewing and managing your Sayr tasks"
},
@@ -150,9 +150,9 @@
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/github-board/docs/detail-panel.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# GitHub Board\n\n## Behavior\n\nPaseo plugin: a GitHub sidebar board of your issues, draft and open pull requests, and discussions"
},
@@ -183,9 +183,9 @@
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/herald/docs/timeline-card.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Herald\n\n## Behavior\n\nPaseo plugin: speaks one sentence when an agent needs you, and lists everything waiting in a sidebar panel"
},
@@ -215,9 +215,9 @@
"https://raw.githubusercontent.com/web-platform-tests/wpt/master/media/movie_5.mp4"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# launchd Jobs\n\n## Behavior\n\nPaseo plugin: schedule shell commands on your Mac through launchd, from a sidebar panel"
},
@@ -247,9 +247,9 @@
"https://raw.githubusercontent.com/gpambrozio/paseo-plugins/main/model-pricing/docs/screenshot.png"
],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Model Pricing\n\n## Behavior\n\nPaseo plugin: a sidebar table of what every model costs, across Anthropic, OpenAI, Fireworks AI, Ollama Cloud and OpenRouter"
},
@@ -277,9 +277,9 @@
},
"media": [],
"submittedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Skills\n\n## Behavior\n\nPaseo plugin: lists the skills an agent can use, shows where each comes from, renders its SKILL.md, and invokes it"
},
@@ -309,9 +309,9 @@
"https://raw.githubusercontent.com/hungcuong9125/paseo-prompt-kit/main/docs/images/settings-direct-api.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# PromptKit\n\n## Behavior\n\nPromptKit - rewrite and transform prompts directly from the Paseo composer."
},
@@ -342,9 +342,9 @@
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/agent-monitor/docs/images/agent-monitor-settings.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Agent Monitor\n\n## Behavior\n\nA Paseo plugin that provides a host-wide agent triage roster."
},
@@ -375,9 +375,9 @@
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-beads/docs/images/paseo-beads-compact.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Beads\n\n## Behavior\n\nA dependency-aware Beads work queue for each Paseo workspace."
},
@@ -409,9 +409,9 @@
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-dracula/screenshot-alucard.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Dracula\n\n## Behavior\n\nDracula Classic and Alucard Classic themes for Paseo."
},
@@ -442,9 +442,9 @@
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/fresh-worktrees/docs/images/fresh-worktrees-settings.png"
],
"submittedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Fresh Worktrees\n\n## Behavior\n\nFast-forwards clean local base branches before Paseo creates branch-off worktrees"
},
@@ -475,9 +475,9 @@
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-omp/docs/images/workspace-settings.png"
],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T09:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T09:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# OMP\n\n## Behavior\n\nPaseo integration for OMP, including its direct provider and workspace tooling."
},
@@ -505,9 +505,9 @@
},
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# PR Radar\n\n## Behavior\n\nA viewer-aware delivery queue for pull requests linked to Paseo workspaces."
},
@@ -538,9 +538,9 @@
"https://raw.githubusercontent.com/omercnet/paseo-plugins/main/paseo-shared-browser/docs/images/shared-browser-compact.png"
],
"submittedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-09-18T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Shared Browser\n\n## Behavior\n\nOne real Chromium session per Paseo workspace, shared live across every connected client."
},
@@ -568,9 +568,9 @@
},
"media": [],
"submittedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Tell Agent\n\n## Behavior\n\nSend messages to agents in other workspaces on the same Paseo host."
},
@@ -599,16 +599,16 @@
"https://raw.githubusercontent.com/tomgrin10/paseo-defer/main/docs/defer-popover.png"
],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T15:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T15:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Defer\n\n## Behavior\n\nDefer messages to Paseo coding agents until a chosen time or usage-window reset."
},
{
"id": "tomgrin10/graphite",
"name": "Graphite",
"description": "Workspace-native Graphite stack status and PR action radar for Paseo. <p align=\"center\"> <picture> <source media=\"(prefers-color-scheme: dark)\" srcset=\"https://raw.githubusercontent.com/xpufx/paseo/main/plugins/slash/docs/screenshots/slash-logo-for-dark-bg.png\"> <source media=\"(prefers-color-scheme: light)\" srcset",
"description": "Workspace-native Graphite stack status and PR action radar for Paseo.",
"artifact": {
"kind": "npm",
"package": "paseo-graphite",
@@ -628,9 +628,9 @@
},
"media": [],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"installs": 0,
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
}
@@ -7,14 +7,7 @@ export function agentRouteOptions(slug: string) {
return {
head: () => pageMeta(page.metaTitle, page.metaDescription, `/${slug}`),
component: function AgentLandingPage() {
return (
<LandingPage
title={page.title}
subtitle={page.subtitle}
mockup={page.mockup}
section={page.section}
/>
);
return <LandingPage title={page.title} subtitle={page.subtitle} />;
},
};
}
@@ -6,7 +6,6 @@ import {
DESIGN_WIDTH,
MOCKUP_STATES,
type MockupStateId,
type MockupAgent,
MockupWindow,
ScaledMockup,
} from "~/components/mockup";
@@ -16,7 +15,7 @@ const ALT =
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
export function HeroMockup({ agent }: { agent?: MockupAgent }) {
export function HeroMockup() {
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
return (
@@ -40,7 +39,7 @@ export function HeroMockup({ agent }: { agent?: MockupAgent }) {
label={ALT}
className="ring-1 ring-white/10"
>
<MockupWindow state={state} agent={agent} />
<MockupWindow state={state} />
</ScaledMockup>
</div>
);
@@ -59,7 +59,7 @@ const AGENT_LIST_GRID_STYLE = {
const PHONE_PERSPECTIVE_STYLE = { minHeight: 480, perspective: 700 };
import { CursorFieldProvider } from "~/components/butterfly";
import { CommandDialog } from "~/components/command-dialog";
import { AGENT_PAGES, type AgentPage } from "~/data/agent-pages";
import { AGENT_PAGES } from "~/data/agent-pages";
import {
appStoreUrl,
playStoreUrl,
@@ -91,12 +91,12 @@ import { SponsorSection, SponsorsSection } from "~/components/sponsorship";
import { SiteHeader } from "~/components/site-header";
import "~/styles.css";
interface LandingPageProps extends Pick<AgentPage, "mockup" | "section"> {
interface LandingPageProps {
title: React.ReactNode;
subtitle: React.ReactNode;
}
export function LandingPage({ title, subtitle, mockup, section }: LandingPageProps) {
export function LandingPage({ title, subtitle }: LandingPageProps) {
return (
<CursorFieldProvider>
{/* Hero section with background image */}
@@ -115,15 +115,13 @@ export function LandingPage({ title, subtitle, mockup, section }: LandingPagePro
className="relative px-6 md:px-8 pt-4 md:pt-8 pb-8 md:pb-16"
>
<div className="max-w-7xl mx-auto">
<HeroMockup agent={mockup} />
<HeroMockup />
</div>
</motion.div>
</div>
{section ? <AgentSection section={section} /> : null}
{/* Phone showcase */}
<PhoneShowcase agent={mockup} />
<PhoneShowcase />
{/* Content section */}
<div className="landing-content bg-background">
@@ -146,32 +144,6 @@ export function LandingPage({ title, subtitle, mockup, section }: LandingPagePro
);
}
function AgentSection({ section }: { section: NonNullable<AgentPage["section"]> }) {
return (
<section aria-label={section.title} className="mx-auto max-w-5xl px-6 py-16 md:py-24">
<h2 className="max-w-2xl text-3xl font-medium tracking-tight md:text-4xl">{section.title}</h2>
<p className="mt-4 max-w-2xl text-base leading-relaxed text-muted-foreground">
{section.description}
</p>
<div className="mt-10 grid gap-8 md:grid-cols-3">
{section.items.map((item) => (
<div key={item.title} className="border-t border-white/10 pt-5">
<h3 className="text-lg font-medium">{item.title}</h3>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{item.description}</p>
</div>
))}
</div>
<a
href={section.link.href}
className="mt-8 inline-flex items-center gap-2 text-sm text-white/80 hover:text-white"
>
{section.link.label}
<ArrowRight className="h-4 w-4" />
</a>
</section>
);
}
function Nav() {
return (
<nav className="mb-20 md:mb-24">
@@ -1108,7 +1080,7 @@ function ServerInstallButton() {
);
}
function PhoneShowcase({ agent }: { agent?: AgentPage["mockup"] }) {
function PhoneShowcase() {
const containerRef = React.useRef<HTMLDivElement>(null);
const textInView = useInView(containerRef, { once: true, margin: "-80px" });
@@ -1202,7 +1174,7 @@ function PhoneShowcase({ agent }: { agent?: AgentPage["mockup"] }) {
aria-label="Paseo agent chat"
>
<PhoneFrame time="18:53">
<MobileChat agent={agent} />
<MobileChat />
</PhoneFrame>
</motion.div>
@@ -1,12 +0,0 @@
import type { ComponentType, SVGProps } from "react";
/** Optional branding for the shared desktop and phone composer mockups. */
export interface MockupAgent {
name: string;
Icon: ComponentType<SVGProps<SVGSVGElement>>;
model?: string;
}
export function MockupAgentIcon({ agent, size }: { agent: MockupAgent; size: number }) {
return <agent.Icon width={size} height={size} className="shrink-0" />;
}
@@ -1,4 +1,3 @@
import { MockupAgentIcon, type MockupAgent } from "./agent";
import {
AudioLines,
BookOpen,
@@ -189,10 +188,10 @@ export function PluginBuildTranscript() {
// --- composer ---------------------------------------------------------------
function ControlPill({ children, label }: { children: React.ReactNode; label: string }) {
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
return (
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
{children}
{icon}
{label}
<ChevronDown size={11} className="text-mock-fg-xmuted" />
</span>
@@ -214,11 +213,9 @@ function BusyRing() {
}
export function Composer({
agent,
showProgress = true,
busy = true,
}: {
agent?: MockupAgent;
showProgress?: boolean;
busy?: boolean;
}) {
@@ -246,13 +243,9 @@ export function Composer({
</div>
<div className="flex items-center gap-[17px] pt-[26px]">
<Plus size={15} className="text-mock-fg-muted" />
<ControlPill
label={agent ? `${agent.name} · ${agent.model ?? "GPT-5.6-Sol"}` : "GPT-5.6-Sol"}
>
{agent ? <MockupAgentIcon agent={agent} size={13} /> : MODEL_ICON}
</ControlPill>
<ControlPill label="Medium">{EFFORT_ICON}</ControlPill>
<ControlPill label="Full access">{ACCESS_ICON}</ControlPill>
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
<ControlPill icon={EFFORT_ICON} label="Medium" />
<ControlPill icon={ACCESS_ICON} label="Full access" />
<Zap size={14} className="text-mock-fg-muted" />
<ListTodo size={14} className="text-mock-fg-muted" />
<span className="flex-1" />
@@ -7,4 +7,3 @@ export { MobileSidebar } from "./mobile/sidebar";
export { DEFAULT_MOCKUP_STATE, MOCKUP_STATES, type MockupStateId } from "./states";
export { MockupWindow } from "./window";
export { ScaledMockup } from "./scaled-mockup";
export type { MockupAgent } from "./agent";
@@ -1,4 +1,3 @@
import { MockupAgentIcon, type MockupAgent } from "../agent";
// Mobile agent chat — the counterpart of the desktop chat pane, drawn at phone
// width. Maps to the "agent chat" reference screenshot: header, collapsed slash
// command, muted tool-call rows, an assistant turn, the diff-stat pill, and the
@@ -116,7 +115,7 @@ function Transcript() {
);
}
function Composer({ agent }: { agent?: MockupAgent }) {
function Composer() {
return (
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
<div className="mb-[16px]">
@@ -130,14 +129,8 @@ function Composer({ agent }: { agent?: MockupAgent }) {
<div className="flex items-center gap-[13px] pt-[26px]">
<Plus size={20} className="shrink-0 text-mock-fg-muted" strokeWidth={1.9} />
<span className="flex shrink-0 items-center gap-[5px]">
{agent ? (
<MockupAgentIcon agent={agent} size={19} />
) : (
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
)}
<span className="whitespace-nowrap text-[16px] text-mock-fg">
{agent ? `${agent.name} · ${agent.model ?? "GPT-5.6-Sol"}` : "GPT-5.6-Sol"}
</span>
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
<span className="whitespace-nowrap text-[16px] text-mock-fg">GPT-5.6-Sol</span>
<span className="whitespace-nowrap text-[16px] text-mock-fg-muted">Medium</span>
</span>
<span className="flex-1" />
@@ -150,7 +143,7 @@ function Composer({ agent }: { agent?: MockupAgent }) {
);
}
export function MobileChat({ agent }: { agent?: MockupAgent }) {
export function MobileChat() {
return (
<>
<Header />
@@ -158,7 +151,7 @@ export function MobileChat({ agent }: { agent?: MockupAgent }) {
<div className="min-h-0 flex-1 overflow-hidden pt-[6px]">
<Transcript />
</div>
<Composer agent={agent} />
<Composer />
</>
);
}
@@ -1,4 +1,3 @@
import type { MockupAgent } from "./agent";
import { AnimatePresence, MotionConfig, motion, useReducedMotion } from "framer-motion";
import { AgentTranscript, Composer, PluginBuildTranscript } from "./chat";
import { DESIGN_HEIGHT, DESIGN_WIDTH, RIGHT_PANEL_W } from "./geometry";
@@ -27,7 +26,7 @@ function panelKey(state: MockupStateId): string {
return "changes";
}
function CenterPane({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
function CenterPane({ state }: { state: MockupStateId }) {
if (state === "review") return <DiffReviewPane />;
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
@@ -35,7 +34,7 @@ function CenterPane({ state, agent }: { state: MockupStateId; agent?: MockupAgen
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
</div>
<Composer agent={agent} showProgress={state !== "extend"} busy={state !== "extend"} />
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
</div>
);
}
@@ -60,7 +59,7 @@ function PanelCrossfade({ state }: { state: MockupStateId }) {
}
/** The middle content column: its own header, then the tab strip, then the pane. */
function CenterColumn({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
function CenterColumn({ state }: { state: MockupStateId }) {
return (
<div className="flex min-w-0 flex-1 flex-col">
<TitleBar state={state} />
@@ -74,7 +73,7 @@ function CenterColumn({ state, agent }: { state: MockupStateId; agent?: MockupAg
exit={FADE_OUT}
className="absolute inset-0 flex flex-col"
>
<CenterPane state={state} agent={agent} />
<CenterPane state={state} />
</motion.div>
</AnimatePresence>
</div>
@@ -97,7 +96,7 @@ function DockedPanel({ state }: { state: MockupStateId }) {
);
}
export function MockupWindow({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
export function MockupWindow({ state }: { state: MockupStateId }) {
const reduceMotion = useReducedMotion();
return (
@@ -110,7 +109,7 @@ export function MockupWindow({ state, agent }: { state: MockupStateId; agent?: M
header stops at the divider — it never spans over the right panel. */}
<Sidebar state={state} />
<div className="w-px shrink-0 bg-mock-border" />
<CenterColumn state={state} agent={agent} />
<CenterColumn state={state} />
<AnimatePresence initial={false}>
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
</AnimatePresence>
+1 -36
View File
@@ -1,6 +1,3 @@
import { PiIcon } from "~/components/agent-icons";
import type { MockupAgent } from "~/components/mockup";
// Source of truth for per-agent marketing landing pages.
// To add a new agent, append an entry here and create a 4-line route file at
// `src/routes/<slug>.tsx`. The sitemap (vite.config) reads `AGENT_PAGE_SLUGS`.
@@ -12,14 +9,6 @@ export interface AgentPage {
subtitle: string;
metaTitle: string;
metaDescription: string;
// Omit either field to keep the shared landing page unchanged.
mockup?: MockupAgent;
section?: {
title: string;
description: string;
items: readonly { title: string; description: string }[];
link: { href: string; label: string };
};
}
export const AGENT_PAGES = [
@@ -56,33 +45,9 @@ export const AGENT_PAGES = [
{
slug: "pi",
name: "Pi Agent",
mockup: { name: "Pi", Icon: PiIcon, model: "Sonnet 4.5" },
section: {
title: "Your Pi setup, with a desktop and mobile UI",
description:
"Paseo runs your installed Pi CLI. Start a session from the app, or import one you started in the terminal and continue from there.",
items: [
{
title: "Bring your extensions",
description:
"Paseo supports Pi extensions including pi-subagents, pi-ask-user, pi-mcp-adapter and pi-goal-x. Follow subagents, answer questions and see task progress in the app. Extensions that depend on a custom terminal UI do not carry over.",
},
{
title: "Pick from your Pi models",
description:
"The model picker reads the models available in your Pi setup and uses Pi’s configured model by default. Keep your provider configuration in Pi.",
},
{
title: "Start with Pi on your machine",
description:
"Install and configure Pi on the machine where you run Paseo. Open a workspace in Paseo and choose Pi when you create an agent. Pair your phone to keep working on that machine when you step away.",
},
],
link: { href: "/docs", label: "Set up Paseo" },
},
title: "Open source app for the Pi coding agent",
subtitle:
"Use Pi from your desktop or phone, with the models and extensions you already use. Pi runs on your machine. Paseo gives you a UI for it.",
"Run the Pi coding agent on your machine, drive it from your phone or desktop. Self-hosted and open source.",
metaTitle: "Pi Agent Mobile and Desktop App, Open Source",
metaDescription:
"Open source mobile and desktop app for the Pi coding agent. Launch sessions on your machine, monitor progress, merge from anywhere. Self-hosted.",
+15 -48
View File
@@ -16,9 +16,7 @@ import {
mostInstalled,
newestFirst,
type Plugin,
recentlyUpdated,
searchPlugins,
updatedAgo,
} from "./registry";
import { WindowSwitch } from "./window-switch";
@@ -36,7 +34,7 @@ const TAB_OFF = `${TAB_BASE} border-transparent text-muted-foreground hover:text
const CLEAR_CLASS =
"text-sm text-extra-muted-foreground transition-colors hover:text-muted-foreground";
/** All plugins or one category: sidebar, Most installed / Newest / Recently updated tabs, and the card grid. */
/** All plugins or one category: sidebar, Most installed / Newest tabs, and the card grid. */
export function BrowsePage({
plugins,
installs,
@@ -49,7 +47,8 @@ export function BrowsePage({
query: BrowseQuery;
}) {
const category = query.category ? getCategory(query.category) : null;
const ordered = sortPlugins(plugins, installs, query);
const ordered =
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
// Search ranks by relevance; the tab's order breaks ties.
const matches = query.q ? searchPlugins(ordered, query.q) : ordered;
const results = category ? getPluginsInCategory(matches, category.slug) : matches;
@@ -90,19 +89,21 @@ export function BrowsePage({
</div>
)}
{results.length === 0 && !query.q && (
<p className="text-sm text-muted-foreground">
{query.sort === "updated"
? "No plugins have been updated since they were listed."
: "No plugins in this category yet."}
</p>
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
)}
{results.length > 0 && (
<div className={PLUGIN_GRID_CLASS}>
{results.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<CardDetail plugin={plugin} installs={installs} now={now} query={query} />
</PluginCard>
))}
{results.map((plugin) =>
query.sort === "new" ? (
<PluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</PluginCard>
) : (
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />
</PluginCard>
),
)}
</div>
)}
</PluginSection>
@@ -112,33 +113,6 @@ export function BrowsePage({
);
}
function sortPlugins(
plugins: Plugin[],
installs: Record<string, InstallCounts>,
query: BrowseQuery,
): Plugin[] {
if (query.sort === "new") return newestFirst(plugins);
if (query.sort === "updated") return recentlyUpdated(plugins);
return mostInstalled(plugins, installs, query.window);
}
/** What the card shows beside the author: the date the tab sorts by, or the install count. */
function CardDetail({
plugin,
installs,
now,
query,
}: {
plugin: Plugin;
installs: Record<string, InstallCounts>;
now: string;
query: BrowseQuery;
}) {
if (query.sort === "new") return addedAgo(plugin, now);
if (query.sort === "updated") return updatedAgo(plugin, now);
return <InstallCount count={installs[plugin.id]?.[query.window] ?? 0} />;
}
interface NavEntry {
href: string;
label: string;
@@ -234,13 +208,6 @@ function SortTabs({ query }: { query: BrowseQuery }) {
>
Newest
</a>
<a
href={browseHref({ ...query, sort: "updated" })}
aria-current={query.sort === "updated" ? "page" : undefined}
className={query.sort === "updated" ? TAB_ON : TAB_OFF}
>
Recently updated
</a>
</div>
{windowSwitch && <div className="hidden pb-2 sm:block">{windowSwitch}</div>}
</div>
+3 -3
View File
@@ -5,7 +5,7 @@ export const DOCS_URL = "/docs/plugins";
export const SUBMIT_URL =
"https://github.com/getpaseo/plugins/issues/new?template=submit-plugin.yml";
export type BrowseSort = "installs" | "new" | "updated";
export type BrowseSort = "installs" | "new";
export interface BrowseQuery {
/** Absent for all plugins. */
category?: CategorySlug;
@@ -24,7 +24,7 @@ export function parseSearchTerm(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function parseSort(value: unknown): BrowseSort | undefined {
return value === "new" || value === "updated" ? value : undefined;
return value === "new" ? value : undefined;
}
export function pluginHref(id: string): string {
@@ -37,7 +37,7 @@ export function browseHref({ category, sort, window, q }: BrowseQuery): string {
const path = category ? categoryHref(category) : "/plugins/all";
const params = new URLSearchParams();
if (q) params.set("q", q);
if (sort !== "installs") params.set("sort", sort);
if (sort === "new") params.set("sort", sort);
else if (window !== DEFAULT_WINDOW) params.set("window", window);
const search = params.toString();
return search ? `${path}?${search}` : path;
+6 -15
View File
@@ -1,7 +1,7 @@
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import { authorAvatarUrl, getAuthor, type Plugin } from "./registry";
import type { Plugin } from "./registry";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
@@ -25,15 +25,6 @@ function render(markdown: string) {
function attribute(element: Element, name: string) {
return element.attrs.find((attr) => attr.name === name)?.value;
}
function imageSources(html: string) {
return elements(html)
.filter((node) => node.tagName === "img")
.map((node) => attribute(node, "src"));
}
/** Cards show the author's GitHub avatar beside the plugin's own images. */
function avatarUrl(plugin: Plugin) {
return authorAvatarUrl(getAuthor(plugin));
}
const allowedTags = new Set([
"div",
"p",
@@ -127,7 +118,7 @@ describe("registry images", () => {
createElement(PluginCard, { plugin }),
]) {
expect(
imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)),
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
).toEqual([]);
}
});
@@ -140,8 +131,8 @@ it("uses the first HTTPS screenshot when an earlier screenshot is rejected", ()
icon: undefined,
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([
source,
avatarUrl(plugin),
]);
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual([source]);
});
+22 -6
View File
@@ -35,20 +35,19 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
}
/**
* Gallery card, in three rows under the screenshot: tile and name, description, then author and
* `children`, such as when the plugin was added or its install count.
* What's new and Featured card, in three rows under the screenshot: tile and name, description,
* then author and `children`, such as when the plugin was added or its install count.
*/
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
return (
// Grid items size to their content by default; min-w-0 keeps the card in its column.
<a href={pluginHref(plugin.id)} className="group block min-w-0">
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 truncate text-sm font-medium text-white">{plugin.name}</p>
</div>
<p className="mt-2 line-clamp-2 min-h-10 break-words text-xs leading-5 text-muted-foreground">
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
<p className="mt-2 flex min-w-0 items-center gap-1.5 text-xs text-extra-muted-foreground">
@@ -65,6 +64,23 @@ export function PluginCard({ plugin, children }: { plugin: Plugin; children?: Re
);
}
/** Grid card: screenshot; tile, name, and `children` such as an install count; description. */
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
return (
<a href={pluginHref(plugin.id)} className="group block">
<PluginShot plugin={plugin} />
<div className="mt-3 flex items-center gap-2">
<PluginTile plugin={plugin} size="xs" />
<p className="min-w-0 flex-1 truncate text-sm font-medium text-white">{plugin.name}</p>
<span className="flex-shrink-0 text-xs text-extra-muted-foreground">{children}</span>
</div>
<p className="mt-2 line-clamp-2 min-h-10 text-xs leading-5 text-muted-foreground">
{plugin.description}
</p>
</a>
);
}
/** Ranked row for the directory's Most installed list. */
export function PluginRankRow({
plugin,
@@ -76,7 +76,7 @@ export function PluginSearch({
onSubmit={handleSubmit}
className={className}
>
{scope.sort !== "installs" && <input type="hidden" name="sort" value={scope.sort} />}
{scope.sort === "new" && <input type="hidden" name="sort" value="new" />}
{scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && (
<input type="hidden" name="window" value={scope.window} />
)}
+11 -52
View File
@@ -10,10 +10,7 @@ import {
newestFirst,
type Plugin,
readmeBody,
recentlyUpdated,
reportBugUrl,
searchPlugins,
updatedAgo,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
@@ -79,22 +76,22 @@ describe("plugin registry", () => {
]);
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
});
it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => {
it("lists what's new by first-listing date, not submission or update dates", () => {
const listedFirst = {
...plugin,
id: "acme/listed-first",
submittedAt: "2026-09-30T00:00:00.000Z",
publishedAt: "2026-09-30T06:00:00.000Z",
reviewedAt: "2026-10-02T00:00:00.000Z",
updatedAt: "2026-10-02T00:00:00.000Z",
submittedAt: "2026-09-20",
reviewedAt: "2026-09-21",
updatedAt: "2026-10-02",
publishedAt: "2026-09-02",
};
const listedLater = {
...plugin,
id: "acme/listed-later",
submittedAt: "2026-09-02T00:00:00.000Z",
publishedAt: "2026-09-30T08:00:00.000Z",
reviewedAt: "2026-09-30T08:00:00.000Z",
updatedAt: "2026-09-30T08:00:00.000Z",
submittedAt: "2026-09-01",
reviewedAt: "2026-09-01",
updatedAt: "2026-09-01",
publishedAt: "2026-09-30",
};
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
"acme/listed-later",
@@ -102,33 +99,9 @@ describe("plugin registry", () => {
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(updatedAgo(listedFirst, now)).toBe("yesterday");
expect(addedAgo(listedFirst, now)).toBe("4w ago");
});
it("lists only plugins updated after their first listing as recently updated, latest update first", () => {
const bumped = {
...plugin,
id: "acme/bumped",
publishedAt: "2026-09-10T00:00:00.000Z",
updatedAt: "2026-10-01T09:00:00.000Z",
};
const bumpedLater = {
...plugin,
id: "acme/bumped-later",
publishedAt: "2026-09-20T00:00:00.000Z",
updatedAt: "2026-10-01T17:00:00.000Z",
};
const neverUpdated = {
...plugin,
id: "acme/never-updated",
publishedAt: "2026-10-02T00:00:00.000Z",
updatedAt: "2026-10-02T00:00:00.000Z",
};
expect(recentlyUpdated([bumped, neverUpdated, bumpedLater]).map((p) => p.id)).toEqual([
"acme/bumped-later",
"acme/bumped",
]);
});
it("orders by first listing time and by installs in a window, keeping index order for ties", () => {
it("orders by listing date and by installs in a window, keeping index order for ties", () => {
const older = { ...plugin, id: "acme/older", publishedAt: "2026-09-01" };
const twin = { ...plugin, id: "acme/twin" };
expect(newestFirst([older, plugin, twin]).map((p) => p.id)).toEqual([
@@ -293,20 +266,6 @@ describe("plugin registry", () => {
expect(installCommand(plugin)).toBe("paseo plugin add acme/example");
expect(formatInstalls(1250)).toBe("1.3k");
});
it("reports bugs on the source repository's GitHub issue tracker", () => {
const at = (url: string) => reportBugUrl({ ...plugin, repository: { url } });
expect(at("https://github.com/acme/example")).toBe(
"https://github.com/acme/example/issues/new",
);
expect(at("https://github.com/acme/plugins.git")).toBe(
"https://github.com/acme/plugins/issues/new",
);
expect(at("https://github.com/acme/plugins/tree/HEAD/packages/example")).toBe(
"https://github.com/acme/plugins/issues/new",
);
expect(at("https://gitlab.com/acme/example")).toBeUndefined();
expect(at("javascript:alert(1)")).toBeUndefined();
});
});
/** In-memory stand-in for the website KV namespace, covering the calls the counter makes. */
+3 -32
View File
@@ -1,9 +1,4 @@
import type { z } from "zod";
import {
isGitHubHost,
parseGitHubRemoteIdentity,
parseGitRemoteLocation,
} from "@getpaseo/protocol/git-remote";
import { pluginOverviewUrl } from "@getpaseo/protocol/plugin-overview";
import { pluginMediaKind, type PublishedPluginSchema } from "@getpaseo/protocol/plugin-registry";
import { scoreTextFields } from "@getpaseo/protocol/search/text-match";
@@ -58,14 +53,6 @@ export function npmUrl(plugin: Plugin): string | undefined {
? `https://www.npmjs.com/package/${plugin.artifact.package}`
: undefined;
}
/** A new issue on the plugin's GitHub repository; absent when the source is hosted elsewhere. */
export function reportBugUrl(plugin: Plugin): string | undefined {
const location = parseGitRemoteLocation(plugin.repository.url);
if (!location || !isGitHubHost(location.host)) return undefined;
// A monorepo source points at the plugin's folder; issues belong to the repository.
const repository = parseGitHubRemoteIdentity(location.path.split("/").slice(0, 2).join("/"));
return repository ? `https://github.com/${repository.repo}/issues/new` : undefined;
}
export function authorNpmUrl(author: Author): string | undefined {
return author.npm ? `https://www.npmjs.com/~${author.npm}` : undefined;
}
@@ -119,18 +106,9 @@ export function featuredPlugins(plugins: Plugin[], featured: string[]): Plugin[]
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
return [...new Set(featured)].flatMap((id) => byId.get(id) ?? []);
}
/**
* Most recently listed first. A slow review does not push a plugin down, and an update does not
* bring it back up. Equal times keep index order.
*/
/** Most recently listed first; equal dates keep index order. */
export function newestFirst(plugins: Plugin[]): Plugin[] {
return [...plugins].sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt));
}
/** Plugins with a version approved after their first listing, most recent update first. */
export function recentlyUpdated(plugins: Plugin[]): Plugin[] {
return plugins
.filter((plugin) => Date.parse(plugin.updatedAt) > Date.parse(plugin.publishedAt))
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
}
/** Most installs in the window first; equal counts keep index order. */
export function mostInstalled(
@@ -143,14 +121,7 @@ export function mostInstalled(
}
/** How long ago the plugin was first listed, relative to the loader's clock. */
export function addedAgo(plugin: Plugin, now: string): string {
return ago(plugin.publishedAt, now);
}
/** How long ago the plugin's current version was approved, relative to the loader's clock. */
export function updatedAgo(plugin: Plugin, now: string): string {
return ago(plugin.updatedAt, now);
}
function ago(date: string, now: string): string {
const days = Math.floor((Date.parse(now) - Date.parse(date)) / DAY_MS);
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / DAY_MS);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 14) return `${days}d ago`;
@@ -2,7 +2,7 @@ import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { handlePluginThumbnailRequest, pluginCardScreenshot } from "./thumbnails";
import { PluginCard } from "./plugin-card";
import { NewPluginCard, PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
@@ -29,8 +29,6 @@ const plugin: Plugin = {
installs: 42,
};
const avatar = 'src="https://github.com/acme.png?size=80"';
describe("plugin card thumbnails", () => {
it("uses the first image in the plugin's media, skipping a leading video", () => {
const html = renderToStaticMarkup(
@@ -44,7 +42,7 @@ describe("plugin card thumbnails", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
);
expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]);
expect(html).not.toContain("<img");
expect(html).not.toContain("<video");
expect(html).toContain(">E</div>");
});
@@ -58,14 +56,11 @@ describe("plugin card thumbnails", () => {
expect(html).not.toContain("/plugins/thumb/");
});
it("shows the author's avatar and name beside the card detail", () => {
const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today"));
expect(html).toContain(avatar);
expect(html).toMatch(/>acme<\/span><span aria-hidden="true">·<\/span><span[^>]*>today<\/span>/);
});
it("renders a thumbnail instead of downloading the original screenshot", () => {
const html = renderToStaticMarkup(createElement(PluginCard, { plugin }, "today"));
it.each([
["PluginCard", () => createElement(PluginCard, { plugin }, "today")],
["NewPluginCard", () => createElement(NewPluginCard, { plugin }, "today")],
])("renders a thumbnail instead of downloading the original screenshot (%s)", (_name, card) => {
const html = renderToStaticMarkup(card());
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
@@ -1,6 +1,5 @@
import { createFileRoute, notFound } from "@tanstack/react-router";
import { ExternalLink } from "lucide-react";
import { GitHubIcon } from "~/components/brand-icons";
import { CodeBlock } from "~/components/code-block";
import { PluginOverview, PluginContentLink } from "~/plugins/overview";
import { SiteShell } from "~/components/site-shell";
@@ -15,7 +14,6 @@ import {
npmUrl,
pluginVersion,
readmeBody,
reportBugUrl,
} from "~/plugins";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
@@ -50,8 +48,6 @@ export const Route = createFileRoute("/plugins/$owner_/$slug")({
const META_LINK_CLASS =
"inline-flex items-center gap-1 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
const REPORT_BUG_CLASS =
"inline-flex items-center gap-2 rounded-lg border border-white/12 px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-white/10 hover:text-foreground";
const AUTHOR_LINK_CLASS =
"inline-flex items-center gap-1.5 text-xs text-extra-muted-foreground transition-colors hover:text-muted-foreground";
@@ -60,7 +56,6 @@ function PluginPage() {
const category = getCategory(plugin.categories[0]);
const author = getAuthor(plugin);
const npm = npmUrl(plugin);
const reportBug = reportBugUrl(plugin);
return (
<SiteShell width="default">
@@ -69,16 +64,8 @@ function PluginPage() {
</a>
<h1 className="mt-4 text-3xl font-medium tracking-tight">{plugin.name}</h1>
<p className="mt-3 text-lg leading-relaxed text-white/70">{plugin.description}</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
<div className="w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
{reportBug && (
<PluginContentLink href={reportBug} className={REPORT_BUG_CLASS}>
<GitHubIcon className="h-4 w-4" />
Report a bug
</PluginContentLink>
)}
<div className="mt-6 w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
</div>
<div className="mt-3 flex flex-wrap items-center gap-x-2 gap-y-2 text-xs text-extra-muted-foreground [&>*+*]:before:mr-2 [&>*+*]:before:content-['·'_/_'']">
<AuthorLink author={author} className={AUTHOR_LINK_CLASS}>
+8 -33
View File
@@ -27,8 +27,6 @@ import {
mostInstalled,
newestFirst,
pluginOwner,
recentlyUpdated,
updatedAgo,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
@@ -43,7 +41,7 @@ import {
parseWindow,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { PluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -63,9 +61,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
// Card sections: a swipeable row on phones, a grid from `sm` up.
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
const CARD_ROW_CLASS =
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 *:w-[70%] *:flex-shrink-0 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 sm:*:w-auto lg:grid-cols-4";
"-mx-6 flex gap-4 overflow-x-auto px-6 pb-1 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
const SEE_ALL_CLASS =
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
@@ -81,7 +79,7 @@ export const Route = createFileRoute("/plugins/")({
const sort = parseSort(params.get("sort")) ?? "installs";
const window = parseWindow(params.get("window")) ?? DEFAULT_WINDOW;
const q = parseSearchTerm(params.get("q"));
if (category || sort !== "installs" || q)
if (category || sort === "new" || q)
throw redirect({
href: browseHref({ category: category?.slug, sort, window, q }),
statusCode: 301,
@@ -102,7 +100,6 @@ function PluginsPage() {
const window = Route.useSearch().window ?? DEFAULT_WINDOW;
const featured = featuredPlugins(plugins, featuredIds);
const newest = newestFirst(plugins).slice(0, NEW_COUNT);
const updated = recentlyUpdated(plugins).slice(0, NEW_COUNT);
const top = mostInstalled(plugins, installs, window).slice(0, TOP_COUNT);
const windowHrefs = useMemo(
() => ({
@@ -141,9 +138,9 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<NewPluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</PluginCard>
</NewPluginCard>
))}
</div>
</PluginSection>
@@ -159,35 +156,13 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<NewPluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</PluginCard>
</NewPluginCard>
))}
</div>
</PluginSection>
{updated.length > 0 && (
<PluginSection labelledBy="recently-updated">
<PluginSectionHeader>
<PluginSectionTitle id="recently-updated">Recently updated</PluginSectionTitle>
<a
href={browseHref({ sort: "updated", window: DEFAULT_WINDOW })}
className={SEE_ALL_CLASS}
>
See all
<ChevronRight className="h-3.5 w-3.5" />
</a>
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{updated.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
{updatedAgo(plugin, now)}
</PluginCard>
))}
</div>
</PluginSection>
)}
<PluginSection labelledBy="categories">
<PluginSectionTitle id="categories">Categories</PluginSectionTitle>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">

Some files were not shown because too many files have changed in this diff Show More