Compare commits

...
Author SHA1 Message Date
paseo-bot[bot] 494d88093b feat(website): add agent landing overlays with Pi content 2026-10-12 16:34:26 +02:00
Mohamed Boudraandpaseo-bot[bot] 8bc5f47b1c Add a Report a bug button to plugin detail pages (#6721)
Links to a new issue on the plugin's GitHub repository, from the registry's repository URL. Monorepo folder URLs resolve to the repository; non-GitHub sources show no button.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 20:51:22 +07:00
Mohamed Boudraandpaseo-bot[bot] e1b01df905 Keep gallery cards inside their grid column on phones (#6722)
A card whose description has an unbreakable word, such as xpufx/slash's raw
README HTML, sized its grid column to that word and widened browse pages to
564px at a 390px viewport. The card now has min-width 0 and its description
wraps long words.

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 20:22:34 +07:00
Mohamed Boudraandpaseo-bot[bot] 7321d8a488 List new plugins by first listing, add Recently updated, show authors on grid cards (#6718)
* List new plugins by first listing, add Recently updated, and show authors on grid cards

What's new and the Newest tab sort by publishedAt, which the registry now keeps
at the first listing. Recently updated lists plugins whose current version was
approved after their first listing, on the directory and as a browse tab. Browse
and author grid cards show the author's avatar and name like the directory cards.

* Rename the card image helper so it does not shadow a test parameter

---------

Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 19:47:02 +07:00
paseo-bot[bot]andTeraYang 283a985fb6 fix(app): keep Android status icons readable as themes change (#6707)
* fix(app): sync Android system bar icon style with app theme

With edge-to-edge enabled the app draws under the Android system bars,
but the bars keep their default light icon style regardless of theme,
so the status bar clock/battery/icons are invisible on light themes
(white on white). Call SystemBars.setStyle from the appearance provider
whenever the theme is applied: dark icons on light themes, light icons
on dark themes, auto following the system for adaptive themes and
plugin-contributed themes.

Refs #1272 (runtime only; the splash screen phase is not covered).

* fix(app): keep Android status icons readable as themes change

Keep system-bar contrast subscribed to the resolved app theme so automatic appearance changes update the icons as well as the app surface. Add a native regression covering explicit and automatic themes.

Co-authored-by: TeraYang <4110825+terayang@users.noreply.github.com>

* fix(app): map system-bar appearance through color scheme

Unistyles reserves style mappings for React Native style objects. Keep SystemBars style selection inside its appearance adapter and map the colorScheme prop instead.

---------

Co-authored-by: TeraYang <4110825+terayang@users.noreply.github.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 14:04:41 +02:00
Mohamed Boudraandpaseo-bot[bot] 8d03ccfbf0 Sort What's new plugins by submission time (#6713)
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 18:47:48 +07:00
Mohamed Boudraandpaseo-bot[bot] acb3a8f207 Add Vercel Sandbox to related projects (#6708)
Co-authored-by: paseo-bot[bot] <paseo-bot[bot]@users.noreply.github.com>
2026-10-12 18:08:34 +07:00
paseo-bot[bot]andkywoo26 a2b040869d fix(claude): count input reported late in the context meter (#6703)
* fix(claude): read cumulative input usage from message_delta

The context meter took input_tokens only from message_start. Backends behind a
translating gateway report 0 there and the real cumulative counts on
message_delta, so contextWindowUsedTokens collapsed to output_tokens.

Keep the message_start counters per field and let a counter present on
message_delta replace it, the same rule the Anthropic SDK MessageStream uses.
A missing counter keeps the message_start value, so a delta without cache
fields does not drop cache_read_input_tokens.

Closes #4294

* test(claude): ignore a message_delta that precedes its message_start

A canceled request can leave its terminal message_delta queued ahead of the
next turn. Pin that such a straggler neither emits usage_updated nor leaks
into the next request's counters.

* test(claude): take the message_delta usage object as a single parameter

Keep createMessageDeltaEvent(outputTokens) for existing call sites and add
createMessageDeltaUsageEvent(usage) for the tests that set input counters on
the delta, instead of a second positional parameter.

* test(claude): verify late input usage through the real provider process

Compare start-side and delta-side counters through two real provider turns against a local Messages API fixture, including omitted cache counters. The delta cases fail on the baseline with output-only context totals.

Co-authored-by: kywoo26 <kywoo26@naver.com>

* test(claude): always close the context usage fixture

---------

Co-authored-by: kywoo26 <kywoo26@naver.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 12:59:46 +02:00
99e4b53aa0 fix(app): restore outline jumps to prompts rendered by plugins (#6690)
* fix(app): jump the chat outline to prompts a timeline plugin draws

A timeline transformer replaces a stream item with plugin rows whose ids
are "<pluginId>/<itemId>". The chat outline, chat find and history reveal
look a message up by the source item's id, so once a plugin drew the
prompts the outline found no row: the rail showed the prompts and
clicking one did nothing, for mounted, virtualized and unloaded prompts
alike.

Projected plugin rows now carry the id of the item they replaced, and
getStreamItemMessageId answers with it. Row wrappers, the virtualized
index lookup, the visible-message set and history reveal all read that
function, so they now find the plugin row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(app): verify outline jumps to virtualized plugin prompts

Replacing every prompt keeps all loaded rows mounted, so the original plugin regression never exercised a virtualized target. Retain newer native prompts and verify that a loaded unmounted plugin prompt lands without fetching.

Co-authored-by: luopeihuan <luopeihuan@roundreddot.com>

* test(app): retain outline navigation screenshots

---------

Co-authored-by: luopeihuan <luopeihuan@roundreddot.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 12:10:54 +02:00
Mohamed Boudra 750cb82fbb Keep long mobile drafts readable with fullscreen editing (#6531)
* feat(app): add fullscreen editing to the compact composer

* fix(app): preserve read-only terminal command previews

Static command previews must render their published source; the live editor buffer is updated after render and has no mounted input to repaint read-only content.

* fix(app): import React for composer editing scope rendering

Workspace pane unit tests use the classic JSX transform, so the editing-scope provider needs React in scope when mounted.

* fix(app): preserve fullscreen composer controls and editing

Keep the fullscreen frame inside the visual viewport, gate hidden autocomplete keys, apply replacement commands to local line measurement, and return to inline before starting voice so its controls stay accessible.

* test(app): cover draft replacement through the production composer

Replace the measurement-hook fixture with profile switching and typing through the real composer. Keep measurement state inside its production surface; Android manual red/green evidence covers the native replacement flow.
2026-10-12 16:26:01 +07:00
paseo-bot[bot] 9246813fd2 Preserve spaces in project folder names for Changes and worktrees (#6686)
* fix(workspaces): preserve spaces in project directory names

Git path output and app workspace descriptors each stripped significant path whitespace. Preserve directory names while retaining single-record Git output validation and workspace separator normalization.

* test(workspaces): capture Changes after files appear

* test(workspaces): release fixtures when setup fails

---------

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-10-12 10:25:18 +02:00
Elowen 06f0522258 fix(server): invalidate worktree listings after archive (#6545)
* fix(server): invalidate worktree listings after archive

* fix(server): clear archived worktrees across cached checkout roots
2026-10-12 08:58:54 +02:00
93 changed files with 3328 additions and 515 deletions
@@ -25,6 +25,12 @@ 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,
@@ -105,6 +111,60 @@ 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,6 +4,10 @@ import {
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
selectComposerDraftRange,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -53,3 +57,48 @@ 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,3 +1,5 @@
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,
@@ -5,7 +7,27 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
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 { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function composerHeight(page: Page): Promise<number> {
@@ -133,3 +155,300 @@ 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,6 +6,15 @@ 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;
@@ -196,3 +205,52 @@ 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,13 +9,12 @@ import {
expectTerminalPreviewCommand,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
seedTerminalProfiles,
launchTrigger,
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
@@ -42,17 +41,18 @@ 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,4 +97,51 @@ 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();
});
});
});
@@ -0,0 +1,64 @@
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") });
});
@@ -0,0 +1,62 @@
// 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,3 +316,133 @@ 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,6 +47,65 @@ 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,
@@ -60,7 +119,6 @@ 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",
@@ -73,40 +131,53 @@ export async function withTimelinePlugin(
}
: {},
});
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
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",
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;
}
},
});
} 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.");
}
@@ -142,3 +213,21 @@ 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 } from "./presentation";
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
import { buildAgentStreamRenderModel } from "./model";
const runtime = {
@@ -29,7 +29,7 @@ const runtime = {
} as unknown as PluginClientRuntime;
function installProbe(
itemType: "assistant_message" | "tool_call" | "reasoning",
itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
condition = "true",
): InstalledPlugin {
const clientBundle = `(function() {
@@ -160,6 +160,23 @@ 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,10 +26,14 @@ 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.
* 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.
*/
export function getStreamItemMessageId(item: StreamItem): string {
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
if (item.kind === "plugin") return item.sourceId ?? item.id;
return item.id;
}
/**
+6 -1
View File
@@ -1,3 +1,4 @@
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
@@ -625,7 +626,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
return <CommandCenterProvider>{content}</CommandCenterProvider>;
return (
<ComposerEditingSessionsProvider>
<CommandCenterProvider>{content}</CommandCenterProvider>
</ComposerEditingSessionsProvider>
);
}
function SidebarChrome({
+5 -1
View File
@@ -21,6 +21,7 @@ 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[];
@@ -107,7 +108,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
if (!hasAppliedAppearance) return null;
return (
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
<ContributedThemesContext.Provider value={value}>
<AppearanceSystemBars />
{children}
</ContributedThemesContext.Provider>
);
}
@@ -0,0 +1,11 @@
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,
}));
@@ -0,0 +1,3 @@
export function AppearanceSystemBars() {
return null;
}
@@ -56,6 +56,16 @@ 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(() => {
@@ -76,6 +86,32 @@ 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>();
@@ -105,12 +141,14 @@ 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,52 +69,56 @@ 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,
}));
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],
);
const handleChangeText = useCallback(
(nextText: string) => {
@@ -57,31 +57,35 @@ 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,6 +1,7 @@
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,6 +2,7 @@ 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";
@@ -17,6 +18,7 @@ import Animated, {
import { useKeyboardShift } from "@/keyboard/shift";
import {
resolveCenteredClearance,
resolveInlineComposerCapacity,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
@@ -71,12 +73,13 @@ 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: capacity.value,
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
}));
return (
+17 -5
View File
@@ -1,8 +1,10 @@
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import type { ReactNode } from "react";
import { useState, useCallback, useMemo, 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 {
@@ -14,23 +16,33 @@ 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}>
<View style={styles.centered} onLayout={measureViewport}>
<View style={styles.form}>
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<View style={styles.centeredComposer}>{composer}</View>
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
{overlay}
</View>
</View>
);
}
return (
<View style={styles.surface}>
<View style={styles.surface} onLayout={measureViewport}>
<View style={styles.content}>{content}</View>
<View style={styles.composer}>{composer}</View>
<View style={[styles.composer, constraint]}>{composer}</View>
{overlay}
</View>
);
@@ -1,11 +1,24 @@
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,3 +50,13 @@ 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,6 +115,7 @@ 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(),
}));
@@ -123,6 +124,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(nextText: string) => {
textReplacementRevisionRef.current += 1;
setTextReplacement({
kind: "replace",
key: `${draftKey}:${textReplacementRevisionRef.current}`,
text: nextText,
});
@@ -0,0 +1,52 @@
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();
},
);
});
@@ -0,0 +1,142 @@
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);
}
+11 -4
View File
@@ -1,3 +1,4 @@
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
@@ -1003,8 +1004,10 @@ function ComposerAutocompleteBinding({
cursorIndex: Math.min(cursorIndex, userInput.length),
onAutocompleteApplied: () => inputRef.current?.focus(),
});
useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [
autocomplete.onKeyPress,
const handleKeyPress = autocomplete.onKeyPress;
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
show,
handleKeyPress,
]);
const selectOption = autocomplete.onSelectOption;
const onSelect = useCallback(
@@ -1336,6 +1339,8 @@ 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);
@@ -2364,7 +2369,7 @@ function ComposerContentImpl({
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{sendErrorNode}
{!isFullscreen && sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<ComposerAutocompleteBinding
@@ -2372,7 +2377,7 @@ function ComposerContentImpl({
cursor={cursor}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
show={mode.showAutocomplete}
show={mode.showAutocomplete && !isFullscreen}
ref={autocompleteRef}
configuration={autocompleteConfiguration}
/>
@@ -2387,6 +2392,8 @@ function ComposerContentImpl({
<RenderProfile id="MessageInput">
<StableMessageInput
ref={messageInputRef}
editingSession={editingSession}
submissionError={sendErrorNode}
value={textSource.getSnapshot()}
onChangeText={setUserInput}
onSubmit={handleSubmit}
@@ -0,0 +1,57 @@
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,
},
}));
@@ -0,0 +1,77 @@
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,6 +7,8 @@ interface ComposerHeightArgs {
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
onRenderedLinesChange?: (lines: number) => void;
presentationKey?: boolean;
}
export function useComposerHeight({
+15 -4
View File
@@ -7,6 +7,8 @@ interface ComposerHeightArgs {
textareaRef: RefObject<HTMLElement | null>;
minHeight: number;
maxHeight: number;
onRenderedLinesChange?: (lines: number) => void;
presentationKey?: boolean;
}
const COPIED_STYLES = [
@@ -36,11 +38,13 @@ 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 });
paramsRef.current = { getText, minHeight, maxHeight };
const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange });
paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange };
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
const setBoundedHeight = useCallback((nextHeight: number) => {
@@ -65,6 +69,13 @@ 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],
@@ -101,7 +112,7 @@ export function useComposerHeight({
useLayoutEffect(() => {
measure(getText());
}, [maxHeight, minHeight, getText, measure]);
}, [maxHeight, minHeight, getText, measure, presentationKey]);
useEffect(() => {
const source = textareaRef.current;
@@ -115,7 +126,7 @@ export function useComposerHeight({
});
observer.observe(source);
return () => observer.disconnect();
}, [measure, textareaRef]);
}, [measure, textareaRef, presentationKey]);
const onTextChange = useCallback(
(_previousText: string, nextText: string) => measure(nextText),
+470 -163
View File
@@ -1,3 +1,8 @@
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
@@ -56,6 +61,7 @@ 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 {
@@ -77,6 +83,7 @@ import {
applyDictationTranscript,
computeCanStartDictation,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -106,6 +113,8 @@ export interface ComposerKeyPressEvent {
}
export interface MessageInputProps {
editingSession?: ComposerEditingSession;
submissionError?: React.ReactNode;
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
@@ -206,6 +215,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
>;
interface TextAreaHandle {
value?: string;
scrollHeight?: number;
clientHeight?: number;
offsetHeight?: number;
@@ -215,7 +225,7 @@ interface TextAreaHandle {
style?: {
height?: string;
overflowY?: string;
} & Record<string, unknown>;
};
}
function AttachButtonIcon({
@@ -433,7 +443,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
}
interface PasteImagesEffectArgs {
getWebTextArea: () => TextAreaHandle | null;
textarea: HTMLElement | null;
isConnected: boolean;
disabled: boolean;
isDictating: boolean;
@@ -443,7 +453,7 @@ interface PasteImagesEffectArgs {
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const {
getWebTextArea,
textarea,
isConnected,
disabled,
isDictating,
@@ -454,19 +464,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
useEffect(() => {
if (!isWeb || !onAddImages) 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;
}
if (!textarea) return;
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
@@ -493,14 +491,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
disposed = true;
textarea.removeEventListener?.("paste", handlePaste);
};
}, [
disabled,
getWebTextArea,
isConnected,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
]);
}, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
}
function useAutoFocusOnWebEffect(
@@ -628,6 +619,7 @@ function FocusHint({
interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textReplacementKey: string;
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: EditingTextInputProps["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
@@ -643,6 +635,9 @@ 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;
@@ -654,6 +649,23 @@ 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}>
@@ -664,12 +676,28 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
);
}
return (
<View style={styles.textInputScrollWrapper}>
<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}
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
initialValue={props.value}
onChangeText={props.onChangeText}
onChangeText={handleTextChange}
placeholder={props.placeholder}
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
@@ -843,6 +871,7 @@ interface ToggleRealtimeVoiceContext {
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
exitFullscreen: () => void;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
@@ -858,6 +887,7 @@ 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);
@@ -872,6 +902,7 @@ interface StartDictationContext {
canStartDictation: () => boolean;
toast: { error: (msg: string) => void };
startDictation: () => Promise<void>;
exitFullscreen: () => void;
}
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
@@ -882,6 +913,7 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis
if (!ctx.canStartDictation()) {
return;
}
ctx.exitFullscreen();
await ctx.startDictation();
}
@@ -983,7 +1015,8 @@ function computeIsDictationStartEnabled(
return (isReadyForDictation ?? isConnected) && !disabled;
}
function resolveMaxInputHeight(windowHeight: number): number {
function resolveMaxInputHeight(windowHeight: number, compact: boolean): number {
if (compact) return DEFAULT_MAX_INPUT_HEIGHT;
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));
}
@@ -1007,9 +1040,12 @@ function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
mountedTextArea?: TextAreaHandle | null,
): ComposerInputSnapshot {
const text = current?.getText() ?? fallbackText;
const textArea = getWebTextAreaImpl(current);
// 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 start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
return { text, selection: { start, end } };
@@ -1139,6 +1175,28 @@ 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 {
@@ -1186,11 +1244,15 @@ 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 maxInputHeight = resolveMaxInputHeight(windowHeight);
const editingSession = useComposerEditingSession(inputMode, props.editingSession);
const isFullscreen = useComposerFullscreen(editingSession);
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const toast = useToast();
const voice = useVoiceOptional();
@@ -1207,27 +1269,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const webTextareaRef = useRef<HTMLElement | null>(null);
const getLiveText = useCallback(() => valueRef.current, []);
const composerHeight = useComposerHeight({
getText: getLiveText,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
});
const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight;
const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined;
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
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 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;
@@ -1235,6 +1289,82 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
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 replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
updateComposerHeightForText?.(valueRef.current, nextText);
@@ -1412,8 +1542,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canStartDictation,
toast,
startDictation,
exitFullscreen,
}),
[canStartDictation, dictationUnavailableMessage, startDictation, toast],
[canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen],
);
const handleVoicePress = useCallback(
@@ -1485,10 +1616,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleStopRealtimeVoice,
toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
exitFullscreen,
});
}, [
disabled,
handleStopRealtimeVoice,
exitFullscreen,
isAgentRunning,
isConnected,
t,
@@ -1572,10 +1705,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea]);
}, [getWebTextArea, isFullscreen]);
usePasteImagesEffect({
getWebTextArea,
textarea: webTextArea,
isConnected,
disabled,
isDictating,
@@ -1596,16 +1729,30 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const shouldHandleWebKeyPress = isWeb;
const shouldSubmitOnEnter = isWeb && !isCompact;
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, {
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,
onKeyPressCallback,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
@@ -1613,8 +1760,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
disabled,
handleAlternateSendAction,
handleDefaultSendAction,
});
}
],
);
const primaryActionKind = resolvePrimaryActionKind({
hasSendableContent: hasSendableComposerContent({
@@ -1678,16 +1825,18 @@ 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);
}, [onFocusChange]);
}, [isFullscreen, onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
@@ -1735,8 +1884,12 @@ 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, composerHeightStyle],
[composerHeightStyle, mode.isMonospace],
() => [
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
],
[composerHeightStyle, mode.isMonospace, isFullscreen],
);
// Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height.
@@ -1780,107 +1933,192 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
);
return (
<View
ref={rootRef}
style={styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
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}
/>
{/* 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()) : "",
})}
),
[
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")}
/>
</RenderProfile>
{leftContent}
</View>
{/* 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>
{/* 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}
</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}
@@ -1903,6 +2141,62 @@ 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>
);
},
@@ -1947,6 +2241,19 @@ 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",
@@ -0,0 +1,146 @@
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,6 +4,7 @@ import {
computeCanStartDictation,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -316,3 +317,26 @@ 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",
);
});
});
+9 -1
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 } from "@/composer/types";
import type { MessagePayload, TextReplacement } from "@/composer/types";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type SendBehavior = ActiveTurnBehavior | "queue";
@@ -192,3 +192,11 @@ 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,6 +96,35 @@ 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,6 +10,7 @@ export interface MessagePayload {
}
export interface TextReplacement {
kind: "initial" | "replace";
key: string;
text: string;
}
+2
View File
@@ -124,6 +124,8 @@ export const ar: TranslationResources = {
input: {
accessibilityLabel: "وكيل الرسائل...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "ملء الشاشة",
exitFullscreen: "الخروج من ملء الشاشة",
focusHint: "{{shortcut}}للتركيز",
addAttachment: "إضافة مرفق",
interruptAgent: "عامل المقاطعة",
+2
View File
@@ -120,6 +120,8 @@ 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,6 +125,8 @@ 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,6 +126,8 @@ 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,6 +125,8 @@ export const ja: TranslationResources = {
input: {
accessibilityLabel: "エージェントにメッセージ...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "全画面表示",
exitFullscreen: "全画面表示を終了",
focusHint: "{{shortcut}}でフォーカス",
addAttachment: "添付ファイルを追加",
interruptAgent: "エージェントを中断",
+2
View File
@@ -124,6 +124,8 @@ export const ko: TranslationResources = {
input: {
accessibilityLabel: "에이전트에게 메시지...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "전체 화면",
exitFullscreen: "전체 화면 종료",
focusHint: "{{shortcut}}로 포커스",
addAttachment: "첨부 추가",
interruptAgent: "에이전트 중단",
+2
View File
@@ -125,6 +125,8 @@ 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,6 +125,8 @@ export const ru: TranslationResources = {
input: {
accessibilityLabel: "Написать агенту...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "Полный экран",
exitFullscreen: "Выйти из полноэкранного режима",
focusHint: "{{shortcut}}, для фокуса",
addAttachment: "Добавить вложение",
interruptAgent: "Прервать агента",
+2
View File
@@ -124,6 +124,8 @@ export const zhCN: TranslationResources = {
input: {
accessibilityLabel: "给 Agent 发消息...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
focusHint: "{{shortcut}} 聚焦",
addAttachment: "添加附件",
interruptAgent: "中断 Agent",
@@ -104,6 +104,7 @@ 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(
() => ({ key: launchFocusKey, text: terminalComposerValue }),
() => ({ kind: "replace" as const, key: launchFocusKey, text: terminalComposerValue }),
[launchFocusKey, terminalComposerValue],
);
@@ -1,3 +1,4 @@
import { ComposerEditingScope } from "@/composer/editing-session";
import React, { useMemo, type ComponentType } from "react";
import invariant from "tiny-invariant";
import {
@@ -156,7 +157,12 @@ export function WorkspacePaneContent({
>
<PaneProvider value={stablePaneContextValue}>
<PaneFocusProvider value={paneFocusValue}>
<Component key={key} />
<ComposerEditingScope
tabId={paneContextValue.tabId}
targetKey={JSON.stringify(paneContextValue.target)}
>
<Component key={key} />
</ComposerEditingScope>
</PaneFocusProvider>
</PaneProvider>
</RenderProfile>
@@ -1,3 +1,4 @@
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";
@@ -753,14 +754,20 @@ function WorkspaceContentProviders({
function WorkspacePanelContent({
launcher,
content,
tabIds,
workspaceKey,
}: {
workspaceKey: string;
launcher: NewTabLauncher;
content: ReactNode;
tabIds: readonly string[];
}) {
return (
<NewTabLauncherProvider value={launcher}>
<View style={styles.content}>{content}</View>
</NewTabLauncherProvider>
<WorkspaceComposerEditingSessionsProvider workspaceKey={workspaceKey} tabIds={tabIds}>
<NewTabLauncherProvider value={launcher}>
<View style={styles.content}>{content}</View>
</NewTabLauncherProvider>
</WorkspaceComposerEditingSessionsProvider>
);
}
@@ -3491,6 +3498,8 @@ function WorkspaceScreenContent({
const workspacePanelContent = (
<WorkspacePanelContent
launcher={newTabLauncher}
workspaceKey={`${normalizedServerId}:${normalizedWorkspaceId}`}
tabIds={uiTabs.map((tab) => tab.tabId)}
content={isMobile ? content : desktopContent}
/>
);
+2
View File
@@ -804,6 +804,8 @@ 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,6 +2,10 @@ 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",
+2 -3
View File
@@ -13,11 +13,10 @@ export function normalizeWorkspaceOpaqueId(value: string | null | undefined): st
}
export function normalizeWorkspacePath(value: string | null | undefined): string | null {
const trimmed = trimNonEmpty(value);
if (!trimmed) {
if (typeof value !== "string" || value.trim().length === 0) {
return null;
}
const withUnixSeparators = trimmed.replace(/\\/g, "/");
const withUnixSeparators = value.replace(/\\/g, "/");
if (withUnixSeparators === "/") {
return withUnixSeparators;
}
@@ -0,0 +1,133 @@
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,13 +2135,15 @@ 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: {
output_tokens: outputTokens,
},
usage,
},
session_id: "session-1",
};
@@ -2887,6 +2889,119 @@ 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,30 +1855,59 @@ function extractContextWindowSize(modelUsage: unknown): number | undefined {
return maxContextWindow;
}
function readStreamRequestInputTokens(event: Record<string, unknown>): number | undefined {
const messageUsage = toObjectRecord(toObjectRecord(event.message)?.usage);
if (!messageUsage) {
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) {
return undefined;
}
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 value;
}
function readStreamRequestInputUsage(
event: Record<string, unknown>,
): StreamRequestInputUsage | undefined {
const usage = toObjectRecord(toObjectRecord(event.message)?.usage);
if (!usage) {
return undefined;
}
return inputTokens + cacheCreationInputTokens + cacheReadInputTokens;
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,
};
}
function readStreamRequestOutputTokens(event: Record<string, unknown>): number | undefined {
@@ -1958,7 +1987,7 @@ function readClaudeParentToolUseId(message: SDKMessage): string | null {
class ClaudeContextUsageState {
private contextWindowMaxTokens: number | undefined;
private streamRequestInputTokens: number | undefined;
private streamRequestInputUsage: StreamRequestInputUsage | undefined;
private streamRequestOutputTokens: number | undefined;
private compactedContextWindowUsedTokens: number | undefined;
private completedResultTurns = 0;
@@ -1968,7 +1997,7 @@ class ClaudeContextUsageState {
}
beginTurn(): void {
this.streamRequestInputTokens = undefined;
this.streamRequestInputUsage = undefined;
this.streamRequestOutputTokens = undefined;
this.compactedContextWindowUsedTokens = undefined;
}
@@ -1992,11 +2021,11 @@ class ClaudeContextUsageState {
}
const eventType = readTrimmedString(streamEvent.type);
if (eventType === "message_start") {
const inputTokens = readStreamRequestInputTokens(streamEvent);
if (typeof inputTokens !== "number") {
const inputUsage = readStreamRequestInputUsage(streamEvent);
if (!inputUsage) {
return null;
}
this.streamRequestInputTokens = inputTokens;
this.streamRequestInputUsage = inputUsage;
this.streamRequestOutputTokens = 0;
} else if (eventType === "message_delta") {
const outputTokens = readStreamRequestOutputTokens(streamEvent);
@@ -2004,6 +2033,12 @@ class ClaudeContextUsageState {
return null;
}
this.streamRequestOutputTokens = outputTokens;
if (this.streamRequestInputUsage) {
this.streamRequestInputUsage = mergeStreamDeltaInputUsage(
this.streamRequestInputUsage,
streamEvent,
);
}
} else {
return null;
}
@@ -2050,13 +2085,15 @@ class ClaudeContextUsageState {
}
private streamUsedTokens(): number | undefined {
if (
typeof this.streamRequestInputTokens !== "number" ||
typeof this.streamRequestOutputTokens !== "number"
) {
const inputUsage = this.streamRequestInputUsage;
if (!inputUsage || typeof this.streamRequestOutputTokens !== "number") {
return undefined;
}
const usedTokens = this.streamRequestInputTokens + this.streamRequestOutputTokens;
const usedTokens =
inputUsage.inputTokens +
inputUsage.cacheCreationInputTokens +
inputUsage.cacheReadInputTokens +
this.streamRequestOutputTokens;
return usedTokens > 0 ? usedTokens : undefined;
}
@@ -2075,7 +2112,7 @@ class ClaudeContextUsageState {
}
buildCompactionUsageEvent(postTokens: number | undefined): AgentStreamEvent {
this.streamRequestInputTokens = undefined;
this.streamRequestInputUsage = 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"
"getSnapshot" | "listWorktrees" | "resolveRepoRoot" | "invalidateWorktrees"
>;
findWorkspaceIdForCwd?: ArchiveDependencies["findWorkspaceIdForCwd"];
listActiveWorkspaces?: ArchiveDependencies["listActiveWorkspaces"];
@@ -0,0 +1,73 @@
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");
});
@@ -55,6 +55,7 @@ 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,6 +246,7 @@ 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,10 +1,13 @@
import { execFileSync, spawn } from "node:child_process";
import { execFile, 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,
@@ -54,6 +57,32 @@ 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 },
@@ -248,6 +277,78 @@ 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,7 +140,8 @@ function createArchiveDeps(input: ArchiveDepsInput): ArchiveTestDependencies {
github: createGitHubServiceStub(),
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
} as unknown as Pick<WorkspaceGitService, "getSnapshot">,
invalidateWorktrees: () => {},
} as unknown as Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">,
agentManager: {
listAgents: () => [],
getAgent: () => null,
@@ -617,28 +618,37 @@ describe("archiveByScope", () => {
expect(deps.emitWorkspaceUpdatesForWorkspaceIds).not.toHaveBeenCalled();
});
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");
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;
const result = await archiveByScope(
createArchiveDeps({
paseoHome,
activeWorkspaces: [],
}),
{
const result = await archiveByScope(dependencies, {
scope: { kind: "worktree", targetPath: worktree.worktreePath },
requestId: "req-zero-records",
},
);
});
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
});
assertArchiveResult(result, {
archivedWorkspaceIds: [],
removedDirectory: true,
});
expect(existsSync(worktree.worktreePath)).toBe(false);
expect(invalidateWorktrees).toHaveBeenCalledTimes(1);
},
);
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">;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "invalidateWorktrees">;
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,6 +408,7 @@ async function maybeRemoveDirectory(
paseoHome: dependencies.paseoHome,
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
});
dependencies.workspaceGitService.invalidateWorktrees();
dependencies.github.invalidate({ cwd: backing.path });
return true;
} catch (error) {
@@ -1819,6 +1819,46 @@ 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,6 +241,7 @@ 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>;
@@ -918,6 +919,13 @@ 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,6 +2017,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2088,6 +2089,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2164,6 +2166,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -2240,6 +2243,7 @@ describe("handlePaseoWorktreeArchiveRequest worktree scope", () => {
workspaceGitService: {
getSnapshot: vi.fn(async () => null),
listWorktrees: vi.fn(async () => []),
invalidateWorktrees: () => {},
},
agentManager: {
listAgents: () => [],
@@ -501,7 +501,10 @@ export async function handlePaseoWorktreeArchiveRequest(
"emitWorkspaceUpdatesForWorkspaceIds" | "workspaceGitService"
> & {
emit: EmitSessionMessage;
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
workspaceGitService: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
>;
emitWorkspaceUpdatesForWorkspaceIds: (workspaceIds: Iterable<string>) => Promise<void>;
},
msg: Extract<SessionInboundMessage, { type: "paseo_worktree_archive_request" }>,
@@ -93,7 +93,10 @@ export interface ArchiveCommandDependencies extends Omit<
ArchiveDependencies,
"workspaceGitService"
> {
workspaceGitService: Pick<WorkspaceGitService, "getSnapshot" | "listWorktrees">;
workspaceGitService: Pick<
WorkspaceGitService,
"getSnapshot" | "listWorktrees" | "invalidateWorktrees"
>;
}
export interface ArchiveCommandInput {
@@ -0,0 +1,26 @@
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,18 +1,9 @@
import { resolve } from "node:path";
export function parseGitRevParsePath(stdout: string): string | null {
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("--")) {
// 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("--")) {
return null;
}
+69
View File
@@ -59,3 +59,72 @@ 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");
});
}
+49 -2
View File
@@ -24,6 +24,16 @@ 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),
@@ -88,6 +98,11 @@ 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$/);
@@ -96,7 +111,9 @@ 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 or by newest", async ({ page }) => {
test("ranks plugins on browse pages by installs in a window, by newest, or by latest update", 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(
@@ -116,6 +133,21 @@ test("ranks plugins on browse pages by installs in a window or by newest", async
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 }) => {
@@ -577,7 +609,9 @@ 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/ });
await expect(card.locator("img, video")).toHaveCount(0);
// 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 card.click();
await expect(
page.getByRole("link", { name: "launchd Jobs video 1" }).locator("video"),
@@ -611,6 +645,18 @@ 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[]) {
@@ -723,6 +769,7 @@ 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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-24T00:00:00.000Z",
"publishedAt": "2026-09-24T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-24T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-22T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-19T00:00:00.000Z",
"publishedAt": "2026-09-19T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-19T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-22T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-18T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-18T00: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-03T00:00:00.000Z",
"reviewedAt": "2026-10-03T09:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-10-03T09: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-18T00:00:00.000Z",
"publishedAt": "2026-09-18T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-18T00: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-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-17T00:00:00.000Z",
"publishedAt": "2026-09-17T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-17T00: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-03T00:00:00.000Z",
"reviewedAt": "2026-10-03T15:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-10-03T15: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.",
"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",
"artifact": {
"kind": "npm",
"package": "paseo-graphite",
@@ -628,9 +628,9 @@
},
"media": [],
"submittedAt": "2026-09-22T00:00:00.000Z",
"reviewedAt": "2026-10-03T00:00:00.000Z",
"reviewedAt": "2026-09-22T00:00:00.000Z",
"publishedAt": "2026-09-22T00:00:00.000Z",
"updatedAt": "2026-10-03T00:00:00.000Z",
"updatedAt": "2026-09-22T00:00:00.000Z",
"installs": 0,
"readme": "# Graphite\n\n## Behavior\n\nWorkspace-native Graphite stack status and PR action radar for Paseo."
}
@@ -7,7 +7,14 @@ export function agentRouteOptions(slug: string) {
return {
head: () => pageMeta(page.metaTitle, page.metaDescription, `/${slug}`),
component: function AgentLandingPage() {
return <LandingPage title={page.title} subtitle={page.subtitle} />;
return (
<LandingPage
title={page.title}
subtitle={page.subtitle}
mockup={page.mockup}
section={page.section}
/>
);
},
};
}
@@ -6,6 +6,7 @@ import {
DESIGN_WIDTH,
MOCKUP_STATES,
type MockupStateId,
type MockupAgent,
MockupWindow,
ScaledMockup,
} from "~/components/mockup";
@@ -15,7 +16,7 @@ const ALT =
const PILL_TRANSITION = { duration: 0.34, ease: [0.22, 0.61, 0.36, 1] as const };
export function HeroMockup() {
export function HeroMockup({ agent }: { agent?: MockupAgent }) {
const [state, setState] = useState<MockupStateId>(DEFAULT_MOCKUP_STATE);
return (
@@ -39,7 +40,7 @@ export function HeroMockup() {
label={ALT}
className="ring-1 ring-white/10"
>
<MockupWindow state={state} />
<MockupWindow state={state} agent={agent} />
</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 } from "~/data/agent-pages";
import { AGENT_PAGES, type AgentPage } 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 {
interface LandingPageProps extends Pick<AgentPage, "mockup" | "section"> {
title: React.ReactNode;
subtitle: React.ReactNode;
}
export function LandingPage({ title, subtitle }: LandingPageProps) {
export function LandingPage({ title, subtitle, mockup, section }: LandingPageProps) {
return (
<CursorFieldProvider>
{/* Hero section with background image */}
@@ -115,13 +115,15 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
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 />
<HeroMockup agent={mockup} />
</div>
</motion.div>
</div>
{section ? <AgentSection section={section} /> : null}
{/* Phone showcase */}
<PhoneShowcase />
<PhoneShowcase agent={mockup} />
{/* Content section */}
<div className="landing-content bg-background">
@@ -144,6 +146,32 @@ export function LandingPage({ title, subtitle }: LandingPageProps) {
);
}
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">
@@ -1080,7 +1108,7 @@ function ServerInstallButton() {
);
}
function PhoneShowcase() {
function PhoneShowcase({ agent }: { agent?: AgentPage["mockup"] }) {
const containerRef = React.useRef<HTMLDivElement>(null);
const textInView = useInView(containerRef, { once: true, margin: "-80px" });
@@ -1174,7 +1202,7 @@ function PhoneShowcase() {
aria-label="Paseo agent chat"
>
<PhoneFrame time="18:53">
<MobileChat />
<MobileChat agent={agent} />
</PhoneFrame>
</motion.div>
@@ -0,0 +1,12 @@
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,3 +1,4 @@
import { MockupAgentIcon, type MockupAgent } from "./agent";
import {
AudioLines,
BookOpen,
@@ -188,10 +189,10 @@ export function PluginBuildTranscript() {
// --- composer ---------------------------------------------------------------
function ControlPill({ icon, label }: { icon: React.ReactNode; label: string }) {
function ControlPill({ children, label }: { children: React.ReactNode; label: string }) {
return (
<span className="flex items-center gap-[6px] text-[12px] text-mock-fg-muted">
{icon}
{children}
{label}
<ChevronDown size={11} className="text-mock-fg-xmuted" />
</span>
@@ -213,9 +214,11 @@ function BusyRing() {
}
export function Composer({
agent,
showProgress = true,
busy = true,
}: {
agent?: MockupAgent;
showProgress?: boolean;
busy?: boolean;
}) {
@@ -243,9 +246,13 @@ export function Composer({
</div>
<div className="flex items-center gap-[17px] pt-[26px]">
<Plus size={15} className="text-mock-fg-muted" />
<ControlPill icon={MODEL_ICON} label="GPT-5.6-Sol" />
<ControlPill icon={EFFORT_ICON} label="Medium" />
<ControlPill icon={ACCESS_ICON} label="Full access" />
<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>
<Zap size={14} className="text-mock-fg-muted" />
<ListTodo size={14} className="text-mock-fg-muted" />
<span className="flex-1" />
@@ -7,3 +7,4 @@ 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,3 +1,4 @@
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
@@ -115,7 +116,7 @@ function Transcript() {
);
}
function Composer() {
function Composer({ agent }: { agent?: MockupAgent }) {
return (
<div className="shrink-0 px-[18px] pt-[10px] pb-[30px]">
<div className="mb-[16px]">
@@ -129,8 +130,14 @@ function Composer() {
<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]">
<CodexIcon size={19} className="shrink-0 text-mock-fg" />
<span className="whitespace-nowrap text-[16px] text-mock-fg">GPT-5.6-Sol</span>
{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>
<span className="whitespace-nowrap text-[16px] text-mock-fg-muted">Medium</span>
</span>
<span className="flex-1" />
@@ -143,7 +150,7 @@ function Composer() {
);
}
export function MobileChat() {
export function MobileChat({ agent }: { agent?: MockupAgent }) {
return (
<>
<Header />
@@ -151,7 +158,7 @@ export function MobileChat() {
<div className="min-h-0 flex-1 overflow-hidden pt-[6px]">
<Transcript />
</div>
<Composer />
<Composer agent={agent} />
</>
);
}
@@ -1,3 +1,4 @@
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";
@@ -26,7 +27,7 @@ function panelKey(state: MockupStateId): string {
return "changes";
}
function CenterPane({ state }: { state: MockupStateId }) {
function CenterPane({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
if (state === "review") return <DiffReviewPane />;
return (
<div className="flex min-h-0 flex-1 flex-col bg-mock-surface0">
@@ -34,7 +35,7 @@ function CenterPane({ state }: { state: MockupStateId }) {
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-hidden px-[27px] pb-[6px]">
{state === "extend" ? <PluginBuildTranscript /> : <AgentTranscript />}
</div>
<Composer showProgress={state !== "extend"} busy={state !== "extend"} />
<Composer agent={agent} showProgress={state !== "extend"} busy={state !== "extend"} />
</div>
);
}
@@ -59,7 +60,7 @@ function PanelCrossfade({ state }: { state: MockupStateId }) {
}
/** The middle content column: its own header, then the tab strip, then the pane. */
function CenterColumn({ state }: { state: MockupStateId }) {
function CenterColumn({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
return (
<div className="flex min-w-0 flex-1 flex-col">
<TitleBar state={state} />
@@ -73,7 +74,7 @@ function CenterColumn({ state }: { state: MockupStateId }) {
exit={FADE_OUT}
className="absolute inset-0 flex flex-col"
>
<CenterPane state={state} />
<CenterPane state={state} agent={agent} />
</motion.div>
</AnimatePresence>
</div>
@@ -96,7 +97,7 @@ function DockedPanel({ state }: { state: MockupStateId }) {
);
}
export function MockupWindow({ state }: { state: MockupStateId }) {
export function MockupWindow({ state, agent }: { state: MockupStateId; agent?: MockupAgent }) {
const reduceMotion = useReducedMotion();
return (
@@ -109,7 +110,7 @@ export function MockupWindow({ state }: { state: MockupStateId }) {
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} />
<CenterColumn state={state} agent={agent} />
<AnimatePresence initial={false}>
{hasRightPanel(state) ? <DockedPanel key="right-panel" state={state} /> : null}
</AnimatePresence>
+36 -1
View File
@@ -1,3 +1,6 @@
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`.
@@ -9,6 +12,14 @@ 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 = [
@@ -45,9 +56,33 @@ 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:
"Run the Pi coding agent on your machine, drive it from your phone or desktop. Self-hosted and open source.",
"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.",
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.",
+48 -15
View File
@@ -16,7 +16,9 @@ import {
mostInstalled,
newestFirst,
type Plugin,
recentlyUpdated,
searchPlugins,
updatedAgo,
} from "./registry";
import { WindowSwitch } from "./window-switch";
@@ -34,7 +36,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 tabs, and the card grid. */
/** All plugins or one category: sidebar, Most installed / Newest / Recently updated tabs, and the card grid. */
export function BrowsePage({
plugins,
installs,
@@ -47,8 +49,7 @@ export function BrowsePage({
query: BrowseQuery;
}) {
const category = query.category ? getCategory(query.category) : null;
const ordered =
query.sort === "new" ? newestFirst(plugins) : mostInstalled(plugins, installs, query.window);
const ordered = sortPlugins(plugins, installs, query);
// 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;
@@ -89,21 +90,19 @@ export function BrowsePage({
</div>
)}
{results.length === 0 && !query.q && (
<p className="text-sm text-muted-foreground">No plugins in this category yet.</p>
<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>
)}
{results.length > 0 && (
<div className={PLUGIN_GRID_CLASS}>
{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>
),
)}
{results.map((plugin) => (
<PluginCard key={plugin.id} plugin={plugin}>
<CardDetail plugin={plugin} installs={installs} now={now} query={query} />
</PluginCard>
))}
</div>
)}
</PluginSection>
@@ -113,6 +112,33 @@ 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;
@@ -208,6 +234,13 @@ 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";
export type BrowseSort = "installs" | "new" | "updated";
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 : undefined;
return value === "new" || value === "updated" ? 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 === "new") params.set("sort", sort);
if (sort !== "installs") params.set("sort", sort);
else if (window !== DEFAULT_WINDOW) params.set("window", window);
const search = params.toString();
return search ? `${path}?${search}` : path;
+15 -6
View File
@@ -1,7 +1,7 @@
import registry from "../../e2e/registry.fixture.json";
import { PluginTile } from "./plugin-tile";
import { PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
import { authorAvatarUrl, getAuthor, type Plugin } from "./registry";
import React, { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { parseFragment, type DefaultTreeAdapterMap } from "parse5";
@@ -25,6 +25,15 @@ 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",
@@ -118,7 +127,7 @@ describe("registry images", () => {
createElement(PluginCard, { plugin }),
]) {
expect(
elements(renderToStaticMarkup(component)).filter((node) => node.tagName === "img"),
imageSources(renderToStaticMarkup(component)).filter((src) => src !== avatarUrl(plugin)),
).toEqual([]);
}
});
@@ -131,8 +140,8 @@ it("uses the first HTTPS screenshot when an earlier screenshot is rejected", ()
icon: undefined,
media: ["data:image/png;base64,AAAA", source],
} as Plugin;
const nodes = elements(renderToStaticMarkup(createElement(PluginCard, { plugin })));
expect(
nodes.filter((node) => node.tagName === "img").map((node) => attribute(node, "src")),
).toEqual([source]);
expect(imageSources(renderToStaticMarkup(createElement(PluginCard, { plugin })))).toEqual([
source,
avatarUrl(plugin),
]);
});
+6 -22
View File
@@ -35,19 +35,20 @@ function PluginShot({ plugin }: { plugin: Plugin }) {
}
/**
* 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.
* 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.
*/
export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
export function PluginCard({ plugin, children }: { plugin: Plugin; children?: ReactNode }) {
const author = getAuthor(plugin);
return (
<a href={pluginHref(plugin.id)} className="group block w-[70%] flex-shrink-0 sm:w-auto">
// 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">
<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 text-xs leading-5 text-muted-foreground">
<p className="mt-2 line-clamp-2 min-h-10 break-words 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">
@@ -64,23 +65,6 @@ export function NewPluginCard({ plugin, children }: { plugin: Plugin; children?:
);
}
/** 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 === "new" && <input type="hidden" name="sort" value="new" />}
{scope.sort !== "installs" && <input type="hidden" name="sort" value={scope.sort} />}
{scope.sort === "installs" && scope.window !== DEFAULT_WINDOW && (
<input type="hidden" name="window" value={scope.window} />
)}
+52 -11
View File
@@ -10,7 +10,10 @@ import {
newestFirst,
type Plugin,
readmeBody,
recentlyUpdated,
reportBugUrl,
searchPlugins,
updatedAgo,
} from "./registry";
import { readInstallCounts, recordInstall } from "./installs";
import { handlePluginRegistryRequest, loadRegistryIndex, loadRegistryPlugin } from "./published";
@@ -76,22 +79,22 @@ describe("plugin registry", () => {
]);
expect(featuredPlugins(listed, ["acme/not-published"])).toEqual([]);
});
it("lists what's new by first-listing date, not submission or update dates", () => {
it("lists what's new by first listing time, so neither a slow review nor an update moves a plugin", () => {
const listedFirst = {
...plugin,
id: "acme/listed-first",
submittedAt: "2026-09-20",
reviewedAt: "2026-09-21",
updatedAt: "2026-10-02",
publishedAt: "2026-09-02",
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",
};
const listedLater = {
...plugin,
id: "acme/listed-later",
submittedAt: "2026-09-01",
reviewedAt: "2026-09-01",
updatedAt: "2026-09-01",
publishedAt: "2026-09-30",
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",
};
expect(newestFirst([listedFirst, listedLater]).map((p) => p.id)).toEqual([
"acme/listed-later",
@@ -99,9 +102,33 @@ describe("plugin registry", () => {
]);
const now = "2026-10-03T12:00:00.000Z";
expect(addedAgo(listedLater, now)).toBe("3d ago");
expect(addedAgo(listedFirst, now)).toBe("4w ago");
expect(updatedAgo(listedFirst, now)).toBe("yesterday");
});
it("orders by listing date and by installs in a window, keeping index order for ties", () => {
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", () => {
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([
@@ -266,6 +293,20 @@ 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. */
+32 -3
View File
@@ -1,4 +1,9 @@
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";
@@ -53,6 +58,14 @@ 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;
}
@@ -106,9 +119,18 @@ 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; equal dates keep index order. */
/**
* 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.
*/
export function newestFirst(plugins: Plugin[]): Plugin[] {
return [...plugins].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt));
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));
}
/** Most installs in the window first; equal counts keep index order. */
export function mostInstalled(
@@ -121,7 +143,14 @@ 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 {
const days = Math.floor((Date.parse(now) - Date.parse(plugin.publishedAt)) / DAY_MS);
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);
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 { NewPluginCard, PluginCard } from "./plugin-card";
import { PluginCard } from "./plugin-card";
import type { Plugin } from "./registry";
const source = "https://cdn.jsdelivr.net/npm/paseo-example@1.2.3/preview.png";
@@ -29,6 +29,8 @@ 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(
@@ -42,7 +44,7 @@ describe("plugin card thumbnails", () => {
const html = renderToStaticMarkup(
createElement(PluginCard, { plugin: { ...plugin, media: [video] } }),
);
expect(html).not.toContain("<img");
expect(html.match(/<img [^>]*>/g)).toEqual([expect.stringContaining(avatar)]);
expect(html).not.toContain("<video");
expect(html).toContain(">E</div>");
});
@@ -56,11 +58,14 @@ describe("plugin card thumbnails", () => {
expect(html).not.toContain("/plugins/thumb/");
});
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());
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"));
expect(html).toContain(
`src="/plugins/thumb/592/${encodeURIComponent(source)}?plugin=acme%2Fexample"`,
);
@@ -1,5 +1,6 @@
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";
@@ -14,6 +15,7 @@ import {
npmUrl,
pluginVersion,
readmeBody,
reportBugUrl,
} from "~/plugins";
import { AuthorAvatar, AuthorLink } from "~/plugins/author-link";
import { InstallCount } from "~/plugins/install-count";
@@ -48,6 +50,8 @@ 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";
@@ -56,6 +60,7 @@ function PluginPage() {
const category = getCategory(plugin.categories[0]);
const author = getAuthor(plugin);
const npm = npmUrl(plugin);
const reportBug = reportBugUrl(plugin);
return (
<SiteShell width="default">
@@ -64,8 +69,16 @@ 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 w-fit max-w-full">
<CodeBlock size="sm">{installCommand(plugin)}</CodeBlock>
<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>
<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}>
+33 -8
View File
@@ -27,6 +27,8 @@ import {
mostInstalled,
newestFirst,
pluginOwner,
recentlyUpdated,
updatedAgo,
} from "~/plugins";
import { ContributeSection } from "~/plugins/contribute-links";
import { PluginsHero } from "~/plugins/hero";
@@ -41,7 +43,7 @@ import {
parseWindow,
} from "~/plugins/links";
import { InstallCount } from "~/plugins/install-count";
import { NewPluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginCard, PluginRankRow } from "~/plugins/plugin-card";
import { PluginSection, PluginSectionHeader, PluginSectionTitle } from "~/plugins/section";
import { WindowSwitch } from "~/plugins/window-switch";
import "~/styles.css";
@@ -61,9 +63,9 @@ const CATEGORY_ICONS: Record<CategorySlug, LucideIcon> = {
utils: Wrench,
};
// What's new and Featured cards: a swipeable row on phones, a grid from `sm` up.
// Card sections: 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 sm:mx-0 sm:grid sm:grid-cols-2 sm:px-0 lg:grid-cols-4";
"-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";
const SEE_ALL_CLASS =
"inline-flex items-center gap-0.5 text-sm text-muted-foreground transition-colors hover:text-foreground";
@@ -79,7 +81,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 === "new" || q)
if (category || sort !== "installs" || q)
throw redirect({
href: browseHref({ category: category?.slug, sort, window, q }),
statusCode: 301,
@@ -100,6 +102,7 @@ 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(
() => ({
@@ -138,9 +141,9 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{featured.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
<PluginCard key={plugin.id} plugin={plugin}>
<InstallCount count={installs[plugin.id]?.all ?? 0} />
</NewPluginCard>
</PluginCard>
))}
</div>
</PluginSection>
@@ -156,13 +159,35 @@ function PluginsPage() {
</PluginSectionHeader>
<div className={CARD_ROW_CLASS}>
{newest.map((plugin) => (
<NewPluginCard key={plugin.id} plugin={plugin}>
<PluginCard key={plugin.id} plugin={plugin}>
{addedAgo(plugin, now)}
</NewPluginCard>
</PluginCard>
))}
</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">
+5 -4
View File
@@ -30,7 +30,8 @@ us, so read a plugin's source before installing it.
## Hosting and infrastructure
| Project | What it does |
| ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
| [Paseo Self-hosted](https://github.com/blockfeed/paseo-selfhosted) | Packages the Paseo web UI and a local daemon as a Docker deployment. |
| [Devbox Fleet](https://github.com/omrihaviv/devbox-fleet) | Provisions and maintains per-developer GCP devboxes with Paseo, coding agents, and Tailscale access. |
| Project | What it does |
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| [Paseo Self-hosted](https://github.com/blockfeed/paseo-selfhosted) | Packages the Paseo web UI and a local daemon as a Docker deployment. |
| [Devbox Fleet](https://github.com/omrihaviv/devbox-fleet) | Provisions and maintains per-developer GCP devboxes with Paseo, coding agents, and Tailscale access. |
| [Paseo Vercel Sandbox](https://github.com/vercel-labs/paseo-vercel-sandbox) | Creates persistent Vercel Sandboxes running a Paseo daemon with Codex, Claude Code, OpenCode, or Pi through Vercel AI Gateway. Pair each sandbox as a remote Paseo host. |