mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-22 01:02:41 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802359152e | ||
|
|
196dc3acd3 | ||
|
|
e867773932 | ||
|
|
bfe38d92f6 | ||
|
|
9551b2cc11 | ||
|
|
6f62125ee0 | ||
|
|
8bc5f47b1c | ||
|
|
e1b01df905 | ||
|
|
7321d8a488 | ||
|
|
283a985fb6 | ||
|
|
8d03ccfbf0 | ||
|
|
acb3a8f207 | ||
|
|
a2b040869d | ||
|
|
99e4b53aa0 | ||
|
|
750cb82fbb | ||
|
|
9246813fd2 | ||
|
|
06f0522258 |
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
||||
guards already prevent throttling from causing a false stall.
|
||||
|
||||
### Desktop Linux hidden-window fallback
|
||||
|
||||
On Wayland (seen on ChromeOS Linux) a window created with `show: false` can sit
|
||||
forever without a single compositor frame: the renderer loads and runs, but
|
||||
`ready-to-show` never fires, so the window is never shown and the app looks like
|
||||
it did not start while every process is alive. `showWindowWhenReady`
|
||||
(`packages/desktop/src/window/show-when-ready.ts`) shows the main window when
|
||||
the document finishes loading if the event has not arrived. The fallback is
|
||||
Linux-only; macOS and Windows keep waiting for `ready-to-show`.
|
||||
|
||||
Run the startup regression on Linux with an isolated display:
|
||||
|
||||
```bash
|
||||
xvfb-run -a npm run test:e2e:window-startup --workspace=@getpaseo/desktop
|
||||
```
|
||||
|
||||
### Daemon logs
|
||||
|
||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||
|
||||
@@ -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");
|
||||
@@ -0,0 +1,21 @@
|
||||
# Android workspace memory diagnostic
|
||||
|
||||
Supplemental reproduction for [#4408](https://github.com/getpaseo/paseo/issues/4408), using the production 0.11.2 APK on an Android 16 emulator. This tests an adapted failure: opening the sidebar with 800 projects, each containing one workspace, produced a `LOW_MEMORY` exit after 66–90 seconds. It does not reproduce the reported 18-second cold-start ANR or establish its cause.
|
||||
|
||||
Before running:
|
||||
|
||||
- Use a dedicated emulator with the production APK installed and notifications permission settled. Bind the agent-device session to that emulator before running.
|
||||
- Pair the app with an isolated daemon containing 800 active workspaces, each in its own project. Use Project grouping and leave projects expanded. The observed fixture used existing directories inside a Git checkout, with workspace titles permuted by `(index * 317) % 800`, no agents, and no terminals.
|
||||
- Record the daemon revision, emulator RAM and Java heap settings, and the project/workspace distribution. Device memory pressure controls whether Android kills the app.
|
||||
|
||||
Run from the repository root with the agent-device session, Android serial, and an artifact directory:
|
||||
|
||||
```sh
|
||||
node packages/app/e2e/mobile/workspace-memory/android.mjs workspace-memory emulator-5556 .dev/workspace-memory
|
||||
```
|
||||
|
||||
The test cold-starts the paired app, opens its sidebar, samples memory and the process ID every five seconds for two minutes, and verifies that closing the sidebar responds. A process exit or restart fails the assertion. Memory, exit records, and logs stay in the supplied artifact directory.
|
||||
|
||||
Control: repeat with 800 workspaces grouped into one project. That distribution survived the two-minute automated check and responded to closing the sidebar, with total PSS near 300 MB. The project-per-workspace fixture reached about 2.5 GB PSS before Android recorded `LOW_MEMORY`; its Java heap rose and fell rather than showing the reporter's full-heap ANR. Neither result proves that shared collators would fix the report.
|
||||
|
||||
This diagnostic is deliberately outside CI and contains no production change.
|
||||
@@ -0,0 +1,65 @@
|
||||
// Requires a paired production app and the workspace fixture described in README.md.
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
|
||||
const [, , session, serial, artifacts] = process.argv;
|
||||
assert(session && serial && artifacts, "Pass session, Android serial, and artifact directory");
|
||||
mkdirSync(artifacts, { recursive: true });
|
||||
|
||||
function ad(...args) {
|
||||
return execFileSync("agent-device", [...args, "--session", session], {
|
||||
encoding: "utf8",
|
||||
timeout: 60_000,
|
||||
});
|
||||
}
|
||||
function adb(...args) {
|
||||
return execFileSync("adb", ["-s", serial, ...args], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
});
|
||||
}
|
||||
function pid() {
|
||||
try {
|
||||
return adb("shell", "pidof", "sh.paseo").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
ad("open", "sh.paseo", "--relaunch", "--platform", "android");
|
||||
ad("wait", "text", "Open menu", "15000");
|
||||
assert.equal(
|
||||
JSON.parse(ad("perf", "memory", "sample", "--json")).data.deviceId,
|
||||
serial,
|
||||
"Session and adb serial must address the same device",
|
||||
);
|
||||
const initialPid = pid();
|
||||
assert(initialPid, "App must have a process before opening the sidebar");
|
||||
ad("press", 'label="Open menu"', "--settle");
|
||||
const startedAt = Date.now();
|
||||
const samples = [];
|
||||
try {
|
||||
while (Date.now() - startedAt < 120_000) {
|
||||
const currentPid = pid();
|
||||
samples.push({
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
pid: currentPid,
|
||||
meminfo: currentPid ? adb("shell", "dumpsys", "meminfo", "sh.paseo") : "",
|
||||
});
|
||||
writeFileSync(join(artifacts, "samples.json"), JSON.stringify(samples, null, 2));
|
||||
assert.equal(currentPid, initialPid, "Opening the workspace sidebar must keep the app alive");
|
||||
await delay(5_000);
|
||||
}
|
||||
ad("press", 'label="Close sidebar"', "--settle");
|
||||
ad("wait", "text", "Open menu", "10000");
|
||||
console.log("PASS: app survived two minutes with the sidebar open and responded to Close");
|
||||
} finally {
|
||||
writeFileSync(
|
||||
join(artifacts, "exit-info.txt"),
|
||||
adb("shell", "dumpsys", "activity", "exit-info", "sh.paseo"),
|
||||
);
|
||||
writeFileSync(join(artifacts, "logcat.txt"), adb("logcat", "-d", "-t", "4000"));
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
// Polyfill crypto.randomUUID for React Native before any other imports
|
||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
||||
polyfillCrypto();
|
||||
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
import { polyfillScreenOrientation } from "./src/polyfills/screen-orientation";
|
||||
polyfillScreenOrientation();
|
||||
// Install runtime polyfills before any other module evaluates. Keep this the first import.
|
||||
// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
|
||||
import "./src/polyfills/install-early";
|
||||
|
||||
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||
import "./src/styles/unistyles";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||
import "@/styles/unistyles";
|
||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||
import { PortalProvider } from "@gorhom/portal";
|
||||
@@ -102,7 +103,6 @@ import {
|
||||
useKeyboardActionDispatcher,
|
||||
} from "@/keyboard/keyboard-action-dispatcher-context";
|
||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||
import { polyfillNavigator } from "@/polyfills/navigator";
|
||||
import {
|
||||
getHostRuntimeStore,
|
||||
hasConfiguredLocalDaemonOverride,
|
||||
@@ -138,7 +138,6 @@ import {
|
||||
type WebNotificationClickDetail,
|
||||
} from "@/utils/os-notifications";
|
||||
|
||||
polyfillNavigator();
|
||||
polyfillCrypto();
|
||||
|
||||
export interface HostRuntimeBootstrapState {
|
||||
@@ -625,7 +624,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
||||
surface
|
||||
);
|
||||
|
||||
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
||||
return (
|
||||
<ComposerEditingSessionsProvider>
|
||||
<CommandCenterProvider>{content}</CommandCenterProvider>
|
||||
</ComposerEditingSessionsProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarChrome({
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface MessagePayload {
|
||||
}
|
||||
|
||||
export interface TextReplacement {
|
||||
kind: "initial" | "replace";
|
||||
key: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ export const ar: TranslationResources = {
|
||||
input: {
|
||||
accessibilityLabel: "وكيل الرسائل...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
fullscreen: "ملء الشاشة",
|
||||
exitFullscreen: "الخروج من ملء الشاشة",
|
||||
focusHint: "{{shortcut}}للتركيز",
|
||||
addAttachment: "إضافة مرفق",
|
||||
interruptAgent: "عامل المقاطعة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -125,6 +125,8 @@ export const ja: TranslationResources = {
|
||||
input: {
|
||||
accessibilityLabel: "エージェントにメッセージ...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
fullscreen: "全画面表示",
|
||||
exitFullscreen: "全画面表示を終了",
|
||||
focusHint: "{{shortcut}}でフォーカス",
|
||||
addAttachment: "添付ファイルを追加",
|
||||
interruptAgent: "エージェントを中断",
|
||||
|
||||
@@ -124,6 +124,8 @@ export const ko: TranslationResources = {
|
||||
input: {
|
||||
accessibilityLabel: "에이전트에게 메시지...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
fullscreen: "전체 화면",
|
||||
exitFullscreen: "전체 화면 종료",
|
||||
focusHint: "{{shortcut}}로 포커스",
|
||||
addAttachment: "첨부 추가",
|
||||
interruptAgent: "에이전트 중단",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -125,6 +125,8 @@ export const ru: TranslationResources = {
|
||||
input: {
|
||||
accessibilityLabel: "Написать агенту...",
|
||||
terminalAccessibilityLabel: "Terminal prompt",
|
||||
fullscreen: "Полный экран",
|
||||
exitFullscreen: "Выйти из полноэкранного режима",
|
||||
focusHint: "{{shortcut}}, для фокуса",
|
||||
addAttachment: "Добавить вложение",
|
||||
interruptAgent: "Прервать агента",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Side-effect module imported first by index.ts. Babel hoists imports above
|
||||
// statements, so polyfill calls written in index.ts would run only after every
|
||||
// imported module had evaluated. Modules that read these globals at load time,
|
||||
// such as @xterm/headless reading navigator.userAgent, need them installed here.
|
||||
import { polyfillCrypto } from "./crypto";
|
||||
import { polyfillNavigator } from "./navigator";
|
||||
import { polyfillScreenOrientation } from "./screen-orientation";
|
||||
|
||||
polyfillCrypto();
|
||||
polyfillNavigator();
|
||||
// Polyfill screen.orientation for WebKitGTK desktop runtimes that lack the API.
|
||||
polyfillScreenOrientation();
|
||||
@@ -0,0 +1,56 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { createContext, runInContext } from "node:vm";
|
||||
import ts from "typescript";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
it("installs navigator before the app entry loads its other dependencies", () => {
|
||||
const entry = ts.createSourceFile(
|
||||
"index.ts",
|
||||
readFileSync(new URL("../../index.ts", import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
);
|
||||
const firstImport = entry.statements.find(ts.isImportDeclaration);
|
||||
expect(firstImport?.moduleSpecifier.getText(entry)).toBe('"./src/polyfills/install-early"');
|
||||
const early = ts.createSourceFile(
|
||||
"install-early.ts",
|
||||
readFileSync(new URL("./install-early.ts", import.meta.url), "utf8"),
|
||||
ts.ScriptTarget.Latest,
|
||||
);
|
||||
expect(
|
||||
early.statements.some(
|
||||
(statement) =>
|
||||
ts.isExpressionStatement(statement) &&
|
||||
ts.isCallExpression(statement.expression) &&
|
||||
statement.expression.expression.getText(early) === "polyfillNavigator",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("initializes the native navigator before evaluating the real terminal dependency", () => {
|
||||
const require = createRequire(import.meta.url);
|
||||
const terminalSource = readFileSync(require.resolve("@xterm/headless"), "utf8");
|
||||
const navigatorSource = ts.transpileModule(
|
||||
readFileSync(new URL("./navigator.ts", import.meta.url), "utf8"),
|
||||
{ compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } },
|
||||
).outputText;
|
||||
// Each VM owns its globals, so the native navigator shape never replaces
|
||||
// globals in the test runner or needs a native-module mock.
|
||||
const uninitialized = createContext({
|
||||
navigator: {},
|
||||
window: {},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
});
|
||||
expect(() => runInContext(terminalSource, uninitialized)).toThrow(/includes/);
|
||||
|
||||
const initialized = createContext({
|
||||
navigator: {},
|
||||
window: {},
|
||||
module: { exports: {} },
|
||||
exports: {},
|
||||
});
|
||||
runInContext(navigatorSource, initialized);
|
||||
runInContext("exports.polyfillNavigator()", initialized);
|
||||
expect(() => runInContext(terminalSource, initialized)).not.toThrow();
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,48 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { _electron as electron, expect } from "playwright/test";
|
||||
|
||||
const root = await mkdtemp(path.join(tmpdir(), "paseo-window-startup-"));
|
||||
const modulePath = fileURLToPath(new URL("../dist/window/show-when-ready.js", import.meta.url));
|
||||
|
||||
async function launchWindowWithoutHiddenFrames() {
|
||||
const main = path.join(root, "main.cjs");
|
||||
await writeFile(
|
||||
main,
|
||||
`const { app, BrowserWindow } = require("electron");
|
||||
const { showWindowWhenReady } = require(${JSON.stringify(modulePath)});
|
||||
app.setPath("userData", ${JSON.stringify(path.join(root, "user-data"))});
|
||||
app.whenReady().then(async () => {
|
||||
const win = global.mainWindow = new BrowserWindow({ show: false, paintWhenInitiallyHidden: false });
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
await win.loadURL("data:text/html,<h1>Desktop ready</h1>");
|
||||
global.visibleAfterLoad = win.isVisible();
|
||||
});`,
|
||||
);
|
||||
const env = Object.fromEntries(
|
||||
Object.entries(process.env).filter(([key]) => key !== "ELECTRON_RUN_AS_NODE"),
|
||||
);
|
||||
const displayArgs = process.platform === "linux" ? ["--ozone-platform=x11", "--disable-gpu"] : [];
|
||||
return electron.launch({
|
||||
args: ["--no-sandbox", ...displayArgs, main],
|
||||
env,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectLoadedDesktopIsShown(desktop) {
|
||||
await expect.poll(() => desktop.evaluate(() => global.visibleAfterLoad)).toBe(true);
|
||||
const page = await desktop.firstWindow();
|
||||
await expect(page.getByRole("heading", { name: "Desktop ready" })).toBeVisible();
|
||||
}
|
||||
|
||||
let desktop;
|
||||
try {
|
||||
desktop = await launchWindowWithoutHiddenFrames();
|
||||
await expectLoadedDesktopIsShown(desktop);
|
||||
console.log("Loaded desktop is visible without hidden compositor frames");
|
||||
} finally {
|
||||
await desktop?.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -25,7 +25,8 @@
|
||||
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
|
||||
"test": "install-electron && vitest run --exclude \"e2e/**\"",
|
||||
"typecheck": "tsgo --noEmit -p tsconfig.json",
|
||||
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs"
|
||||
"test:e2e:lifecycle": "npm run build:main && node ./e2e/daemon-lifecycle.e2e.mjs",
|
||||
"test:e2e:window-startup": "npm run build:main && node ./e2e/window-startup.e2e.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@getpaseo/cli": "*",
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
buildStandardContextMenuItems,
|
||||
} from "./window/window-manager.js";
|
||||
import { setupDarwinCompositorWatchdog } from "./window/compositor-watchdog/index.js";
|
||||
import { showWindowWhenReady } from "./window/show-when-ready.js";
|
||||
import { resolveDesktopWindowChromeMode, windowChromeModeArgument } from "./window/chrome.js";
|
||||
import { registerDialogHandlers } from "./features/dialogs.js";
|
||||
import {
|
||||
@@ -773,8 +774,8 @@ async function createWindow(
|
||||
registerBrowserWebviewNavigationGuards(contents);
|
||||
});
|
||||
|
||||
mainWindow.once("ready-to-show", () => {
|
||||
mainWindow.show();
|
||||
showWindowWhenReady(mainWindow, {
|
||||
showOnLoad: process.platform === "linux",
|
||||
});
|
||||
|
||||
if (!app.isPackaged) {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { showWindowWhenReady } from "./show-when-ready.js";
|
||||
|
||||
class Window extends EventEmitter {
|
||||
webContents = new EventEmitter();
|
||||
shows = 0;
|
||||
destroyed = false;
|
||||
show() {
|
||||
this.shows += 1;
|
||||
}
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
close() {
|
||||
this.destroyed = true;
|
||||
this.emit("closed");
|
||||
}
|
||||
}
|
||||
|
||||
describe("showWindowWhenReady", () => {
|
||||
it("shows a loaded Linux window even when no initial frame was painted", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
expect(win.shows).toBe(0);
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(1);
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("shows on the first frame without showing again after load", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
win.emit("ready-to-show");
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves waiting for the initial frame when show on load is disabled", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: false });
|
||||
win.webContents.emit("did-finish-load");
|
||||
expect(win.shows).toBe(0);
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps a closed window closed when readiness arrives later", () => {
|
||||
const win = new Window();
|
||||
showWindowWhenReady(win, { showOnLoad: true });
|
||||
win.close();
|
||||
win.webContents.emit("did-finish-load");
|
||||
win.emit("ready-to-show");
|
||||
expect(win.shows).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
interface ReadyToShowWindow {
|
||||
once(event: "ready-to-show", listener: () => void): unknown;
|
||||
webContents: {
|
||||
once(event: "did-finish-load", listener: () => void): unknown;
|
||||
};
|
||||
show(): void;
|
||||
isDestroyed(): boolean;
|
||||
}
|
||||
|
||||
export function showWindowWhenReady(
|
||||
win: ReadyToShowWindow,
|
||||
options: { showOnLoad: boolean },
|
||||
): void {
|
||||
let shown = false;
|
||||
const show = () => {
|
||||
if (shown || win.isDestroyed()) return;
|
||||
shown = true;
|
||||
win.show();
|
||||
};
|
||||
|
||||
win.once("ready-to-show", show);
|
||||
// On Linux a hidden window can wait for compositor frames until it is shown.
|
||||
// Loading the document is sufficient to reveal it without depending on a frame.
|
||||
if (options.showOnLoad) win.webContents.once("did-finish-load", show);
|
||||
}
|
||||
+133
@@ -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 = {};
|
||||
|
||||
@@ -1943,6 +1943,48 @@ describe("PiRpcAgentSession", () => {
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe("off");
|
||||
});
|
||||
|
||||
test.each(["high", "off", "max"] as const)(
|
||||
"publishes an extension's thinking change to %s before refreshing state",
|
||||
async (level) => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const changes: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "thinking_option_changed") {
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
|
||||
changes.push(event);
|
||||
}
|
||||
});
|
||||
const runtime = pi.latestSession();
|
||||
runtime.state = { ...runtime.state, thinkingLevel: level };
|
||||
runtime.emit({ type: "thinking_level_changed", level });
|
||||
|
||||
expect(changes).toEqual([
|
||||
{ type: "thinking_option_changed", provider: "pi", thinkingOptionId: level },
|
||||
]);
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(level);
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ thinkingOptionId: level });
|
||||
},
|
||||
);
|
||||
|
||||
test.each([undefined, null, "invalid", 123])(
|
||||
"ignores a thinking change with invalid level %s",
|
||||
async (level) => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
const initialThinking = session.describePersistence()?.metadata?.thinkingOptionId;
|
||||
const changes: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "thinking_option_changed") changes.push(event);
|
||||
});
|
||||
|
||||
pi.latestSession().emit({ type: "thinking_level_changed", level });
|
||||
|
||||
expect(changes).toEqual([]);
|
||||
expect(session.describePersistence()?.metadata?.thinkingOptionId).toBe(initialThinking);
|
||||
},
|
||||
);
|
||||
|
||||
test("surfaces state refresh failures after a Pi thinking update", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
onTestFinished(() => session.close());
|
||||
|
||||
@@ -1005,6 +1005,8 @@ function isProcessExitEvent(
|
||||
|
||||
function isPiAgentSessionEvent(event: PiRuntimeEvent): event is PiAgentSessionEvent {
|
||||
switch (event.type) {
|
||||
case "thinking_level_changed":
|
||||
return typeof event.level === "string" && normalizePiThinkingOption(event.level) !== null;
|
||||
case "agent_start":
|
||||
case "turn_start":
|
||||
case "message_start":
|
||||
@@ -2136,6 +2138,15 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "thinking_level_changed":
|
||||
this.state = { ...this.state, thinkingLevel: event.level };
|
||||
this.config.thinkingOptionId = event.level;
|
||||
this.emit({
|
||||
type: "thinking_option_changed",
|
||||
provider: this.provider,
|
||||
thinkingOptionId: event.level,
|
||||
});
|
||||
return;
|
||||
case "agent_start":
|
||||
this.activeTurnStarted = true;
|
||||
this.clearNoTurnBuffers();
|
||||
|
||||
+150
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createPiExtensionHost } from "../index.js";
|
||||
import { readSubagentFixture, verifySubagentFixture } from "../subagent-fixture-test.js";
|
||||
import type { PiAgentMessage } from "../../rpc-types.js";
|
||||
|
||||
describe("@tintinweb/pi-subagents adapter", () => {
|
||||
test("exposes the background output file while the child is running", () => {
|
||||
@@ -22,6 +23,27 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
]);
|
||||
expect(mapping?.childSessions).toEqual([{ id: "call-1", file: "/tmp/child.output" }]);
|
||||
});
|
||||
test("marks a foreground child that wrapped up at its turn limit as completed", () => {
|
||||
const mapping = createPiExtensionHost().mapToolCall({
|
||||
callId: "call-1",
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { agentId: "native-1", status: "steered" },
|
||||
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
|
||||
},
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: "completed" }),
|
||||
]);
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
subAgentType: "Explore",
|
||||
description: "Inspect",
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
});
|
||||
test("maps captured foreground lifecycle live and on replay", async () => {
|
||||
const events = await verifySubagentFixture(
|
||||
readSubagentFixture(new URL("./fixtures/foreground.json", import.meta.url)),
|
||||
@@ -65,6 +87,134 @@ describe("@tintinweb/pi-subagents adapter", () => {
|
||||
call_277179: "completed",
|
||||
});
|
||||
});
|
||||
test.each(["subagent-notification", "subagent-update"])(
|
||||
"completes turn-limited children in a grouped %s",
|
||||
(customType) => {
|
||||
const host = createPiExtensionHost();
|
||||
for (const id of ["1", "2", "3"]) {
|
||||
host.mapToolCall({
|
||||
callId: `call-${id}`,
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: { details: { agentId: `native-${id}`, status: "background" } },
|
||||
});
|
||||
}
|
||||
const mapping = host.mapCustomMessage({
|
||||
role: "custom",
|
||||
customType,
|
||||
content: "Wrapped up (turn limit)",
|
||||
display: true,
|
||||
timestamp: 0,
|
||||
details: {
|
||||
id: "native-1",
|
||||
status: "steered",
|
||||
others: [
|
||||
{ id: "native-2", status: "steered" },
|
||||
{ id: "native-3", status: "running" },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: "completed" }),
|
||||
expect.objectContaining({ id: "call-2", status: "completed" }),
|
||||
expect.objectContaining({ id: "call-3", status: "running" }),
|
||||
]);
|
||||
},
|
||||
);
|
||||
test.each(["get_subagent_result", "steer_subagent"])(
|
||||
"completes a turn-limited child when collected through %s",
|
||||
(toolName) => {
|
||||
const host = createPiExtensionHost();
|
||||
host.mapToolCall({
|
||||
callId: "call-1",
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: { details: { agentId: "native-1", status: "background" } },
|
||||
});
|
||||
const mapping = host.mapToolCall({
|
||||
callId: "followup-1",
|
||||
toolName,
|
||||
args: { agent_id: "native-1" },
|
||||
status: "completed",
|
||||
result: {
|
||||
details: { agentId: "native-1", status: "steered" },
|
||||
content: [{ type: "text", text: "Wrapped up (turn limit)" }],
|
||||
},
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([{ type: "upsert", id: "call-1", status: "completed" }]);
|
||||
expect(mapping?.detail).toEqual({
|
||||
type: "sub_agent",
|
||||
description: undefined,
|
||||
log: "Wrapped up (turn limit)",
|
||||
});
|
||||
},
|
||||
);
|
||||
test.each([
|
||||
["background", "running"],
|
||||
["queued", "running"],
|
||||
["running", "running"],
|
||||
["completed", "completed"],
|
||||
["error", "failed"],
|
||||
["aborted", "canceled"],
|
||||
["stopped", "canceled"],
|
||||
["unknown", "running"],
|
||||
])("preserves the mapping of %s to %s", (status, expected) => {
|
||||
const mapping = createPiExtensionHost().mapToolCall({
|
||||
callId: "call-1",
|
||||
toolName: "Agent",
|
||||
args: { subagent_type: "Explore", prompt: "Inspect" },
|
||||
status: "completed",
|
||||
result: { details: { agentId: "native-1", status } },
|
||||
});
|
||||
expect(mapping?.subagents).toEqual([
|
||||
expect.objectContaining({ id: "call-1", status: expected }),
|
||||
]);
|
||||
});
|
||||
test("completes a turn-limited background child live and on history replay", async () => {
|
||||
const path = new URL("./fixtures/background.json", import.meta.url);
|
||||
const source = readSubagentFixture(path);
|
||||
const notification = source.messages.find((message) => message.role === "custom");
|
||||
const notificationDetails = notification?.details;
|
||||
if (
|
||||
!notificationDetails ||
|
||||
typeof notificationDetails !== "object" ||
|
||||
!("outputFile" in notificationDetails) ||
|
||||
typeof notificationDetails.outputFile !== "string"
|
||||
) {
|
||||
throw new Error("Background fixture is missing its child output file");
|
||||
}
|
||||
const fixture = readSubagentFixture(path, {
|
||||
from: notificationDetails.outputFile,
|
||||
to: fileURLToPath(new URL("./fixtures/child-session.jsonl", import.meta.url)),
|
||||
});
|
||||
function wrapUp(message: PiAgentMessage): PiAgentMessage {
|
||||
if (message.role !== "custom") return message;
|
||||
const details = message.details;
|
||||
if (!details || typeof details !== "object") {
|
||||
throw new Error("Background fixture is missing its notification details");
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: "Wrapped up (turn limit)",
|
||||
details: { ...details, status: "steered" },
|
||||
};
|
||||
}
|
||||
const events = await verifySubagentFixture({
|
||||
...fixture,
|
||||
events: fixture.events.map((event) =>
|
||||
event.type === "message_end" ? { ...event, message: wrapUp(event.message) } : event,
|
||||
),
|
||||
messages: fixture.messages.map(wrapUp),
|
||||
});
|
||||
expect(
|
||||
events
|
||||
.filter((event) => event.event.type === "upsert")
|
||||
.map((event) => (event.event.type === "upsert" ? event.event.status : null)),
|
||||
).toEqual(["running", "running", "completed"]);
|
||||
expect(events.filter((event) => event.event.type === "timeline").length).toBeGreaterThan(0);
|
||||
});
|
||||
test("declines foreign Agent results", () => {
|
||||
expect(
|
||||
createPiExtensionHost().mapToolCall({
|
||||
|
||||
+2
-1
@@ -33,7 +33,8 @@ const Notification = NotificationEntry.extend({
|
||||
others: z.array(NotificationEntry).optional(),
|
||||
});
|
||||
const status = (value: string): "running" | "completed" | "failed" | "canceled" => {
|
||||
if (value === "completed") return "completed";
|
||||
// A soft turn-limit wrap-up has finished; it is not an in-flight steering request.
|
||||
if (value === "completed" || value === "steered") return "completed";
|
||||
if (value === "error") return "failed";
|
||||
if (value === "aborted" || value === "stopped") return "canceled";
|
||||
return "running";
|
||||
|
||||
@@ -160,6 +160,7 @@ export type PiAssistantMessageEvent =
|
||||
| { type: "start" | "text_start" | "text_end" | "thinking_start" | "thinking_end" | "done" };
|
||||
|
||||
export type PiAgentSessionEvent =
|
||||
| { type: "thinking_level_changed"; level: PiThinkingLevel }
|
||||
| { type: "agent_start" }
|
||||
| { type: "turn_start" }
|
||||
| { type: "message_start"; message: PiAgentMessage }
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -23,6 +23,89 @@ afterEach(async () => {
|
||||
}, 60000);
|
||||
|
||||
describe("file explorer", () => {
|
||||
test.each(["..", "..."])(
|
||||
"workspace suggestions resolve exact paths starting with %s",
|
||||
async (prefix) => {
|
||||
const cwd = tmpCwd();
|
||||
const directoryName = `${prefix}docs`;
|
||||
const nestedPath = `${directoryName}/notes.md`;
|
||||
const fileName = `${prefix}notes.md`;
|
||||
try {
|
||||
mkdirSync(path.join(cwd, directoryName));
|
||||
writeFileSync(path.join(cwd, nestedPath), "nested payload\n");
|
||||
writeFileSync(path.join(cwd, fileName), "direct payload\n");
|
||||
const file = await ctx.client.readFile(cwd, nestedPath);
|
||||
expect(new TextDecoder().decode(file.bytes)).toBe("nested payload\n");
|
||||
|
||||
const nestedEntry = { path: nestedPath, kind: "file" };
|
||||
const entries = [
|
||||
nestedEntry,
|
||||
{ path: directoryName, kind: "directory" },
|
||||
{ path: fileName, kind: "file" },
|
||||
];
|
||||
const cases = entries.flatMap((entry) => [
|
||||
{ query: `./${entry.path}`, entry },
|
||||
{ query: path.resolve(cwd, entry.path), entry },
|
||||
]);
|
||||
cases.unshift({ query: nestedPath, entry: nestedEntry });
|
||||
for (const { query, entry } of cases) {
|
||||
const response = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query,
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.entries).toEqual([entry]);
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("workspace suggestions exclude exact parent and sibling paths", async () => {
|
||||
const directory = tmpCwd();
|
||||
const cwd = path.join(directory, "workspace");
|
||||
const sibling = path.join(directory, "workspace-other");
|
||||
try {
|
||||
mkdirSync(cwd);
|
||||
mkdirSync(sibling);
|
||||
writeFileSync(path.join(cwd, "visible.txt"), "inside payload\n");
|
||||
writeFileSync(path.join(sibling, "outside.txt"), "outside payload\n");
|
||||
for (const query of [
|
||||
"./..",
|
||||
"../workspace-other/outside.txt",
|
||||
path.join(sibling, "outside.txt"),
|
||||
]) {
|
||||
const response = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query,
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: true,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(response.error).toBeNull();
|
||||
expect(response.entries).toEqual([]);
|
||||
}
|
||||
const control = await ctx.client.getDirectorySuggestions({
|
||||
cwd,
|
||||
query: "./visible.txt",
|
||||
limit: 1,
|
||||
includeFiles: true,
|
||||
includeDirectories: false,
|
||||
matchMode: "suffix",
|
||||
});
|
||||
expect(control.error).toBeNull();
|
||||
expect(control.entries).toEqual([{ path: "visible.txt", kind: "file" }]);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ description: "a long ASCII name", fileName: `${"a".repeat(236)}.txt` },
|
||||
{ description: "an ordinary name", fileName: "notes.txt" },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,35 @@ describe("path equivalence", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ root: "/repo", candidate: "/repo/..notes.txt", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/..drafts/notes.md", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/...drafts/notes.md", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/docs/..notes.txt", inside: true },
|
||||
{ root: "/repo", candidate: "/repo", inside: true },
|
||||
{ root: "/repo", candidate: "/repo/../sibling/notes.md", inside: false },
|
||||
{ root: "/repo", candidate: "/repo/..drafts/../../outside.txt", inside: false },
|
||||
{ root: "/repo", candidate: "/repo-other/notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "c:/repo/..notes.txt", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\..drafts\\notes.md", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\...drafts\\notes.md", inside: true },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo\\..\\sibling\\notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "C:\\Repo-other\\notes.md", inside: false },
|
||||
{ root: "C:\\Repo", candidate: "D:\\Repo\\..notes.txt", inside: false },
|
||||
{
|
||||
root: String.raw`\\server\share\repo`,
|
||||
candidate: String.raw`\\server\share\repo\..drafts\notes.md`,
|
||||
inside: true,
|
||||
},
|
||||
{
|
||||
root: String.raw`\\?\C:\Repo`,
|
||||
candidate: "C:\\Repo\\..notes.txt",
|
||||
inside: true,
|
||||
},
|
||||
])("checks named path containment for $candidate", ({ root, candidate, inside }) => {
|
||||
expect(isPathInsideRoot(root, candidate)).toBe(inside);
|
||||
});
|
||||
|
||||
test("preserves the casing of Windows relative suffixes", () => {
|
||||
expect(getRealpathAwareRelativePath("C:\\Repo\\.git", "c:\\repo\\.git\\HEAD")).toBe("HEAD");
|
||||
expect(
|
||||
|
||||
@@ -114,10 +114,11 @@ function getRelativePathInsideRoot(root: string, candidate: string): string | nu
|
||||
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
||||
const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
||||
|
||||
if (
|
||||
comparableRelative !== "" &&
|
||||
(comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative))
|
||||
) {
|
||||
const isOutsideRoot =
|
||||
comparableRelative === ".." ||
|
||||
comparableRelative.startsWith(`..${platformPath.sep}`) ||
|
||||
platformPath.isAbsolute(comparableRelative);
|
||||
if (isOutsideRoot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user