Compare commits

..
234 changed files with 2794 additions and 11031 deletions
+1
View File
@@ -252,6 +252,7 @@
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
-16
View File
@@ -311,22 +311,6 @@ 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
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
await row.click();
await expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -683,7 +683,7 @@ test("discarding a staged rename restores its source path", async ({ page }) =>
const panel = page.getByTestId("working-diff-panel").filter({ visible: true });
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
const toggleTestId = await renamedToggle.getAttribute("data-testid");
expect(toggleTestId).not.toBeNull();
@@ -25,12 +25,6 @@ import {
expectReadingStreamedMarkdown,
} from "../support/helpers/chat-outline";
import { createCreationScenario } from "../support/helpers/creation";
import {
attachPromptTimelineEvidence,
expectPromptsDrawnByPlugin,
expectVirtualizedPluginPromptVisible,
withPromptTimelinePlugin,
} from "../support/helpers/plugin-timeline";
import {
expectTimelineAtMaximumScrollWithPromptVisible,
expectTimelinePromptNotMounted,
@@ -111,60 +105,6 @@ test.describe("desktop chat outline", () => {
}
});
test("jumps to prompts a timeline plugin draws", async ({ page }) => {
test.setTimeout(180_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
try {
await withPromptTimelinePlugin(async () => {
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await expectPromptsDrawnByPlugin(page);
await expectTimelinePromptNotMounted(page, agent.prompts[39]);
// Unloaded, then loaded, then near the newest edge.
await clickChatOutlineRowEdge(page, 40);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
await attachPromptTimelineEvidence(page, test.info());
await clickChatOutlineRowEdge(page, 45);
await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
await clickChatOutlineRowEdge(page, 78);
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
await expectPromptsDrawnByPlugin(page);
});
} finally {
await agent.cleanup();
}
});
test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
page,
}) => {
test.setTimeout(180_000);
const agent = await seedLongMockAgentTimeline({ turns: 80 });
const prompt = agent.prompts[29]!;
try {
await withPromptTimelinePlugin(async () => {
const history = await holdOlderHistoryPages(page, agent);
await page.setViewportSize(WIDE_VIEWPORT);
await openAgentTimeline(page, agent);
await expectChatOutlinePrompts(page, 80);
await scrollThroughOlderHistoryPages(page, 2, history);
await scrollTimelineToNewestLoadedEdge(page);
await expectTimelinePromptNotMounted(page, prompt);
const requestsBeforeJump = history.requestCount();
await clickChatOutlineRowEdge(page, 30);
await expectTimelinePromptLandedBelowTop(page, prompt);
await expectVirtualizedPluginPromptVisible(page, prompt);
await attachPromptTimelineEvidence(page, test.info());
expect(history.requestCount()).toBe(requestsBeforeJump);
}, [prompt]);
} finally {
await agent.cleanup();
}
});
test.describe("with the whole conversation loaded", () => {
let agent: LongTimelineAgent;
@@ -1,96 +0,0 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
const labels = {
long: {
title: "workspace-with-a-very-long-release-and-investigation-name",
project: "project-with-a-very-long-customer-integration-name",
},
short: { title: "main", project: "Paseo" },
};
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
try {
await workspace.client.renameProject(workspace.projectId, names.project);
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Header layout check",
modeId: "load-test",
model: "e2e-fast-stream",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
await expect(
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
).toHaveCount(1);
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
const title = page.getByTestId("workspace-header-title");
const project = page.getByTestId("workspace-header-subtitle");
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
const trigger = page.getByRole("button", { name: /Switch tabs/ });
const measure = async () => ({
title: await title.boundingBox(),
project: await project.boundingBox(),
badge: await badge.boundingBox(),
icon: await badge.locator("svg").boundingBox(),
trigger: await trigger.boundingBox(),
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
});
await page.screenshot({ path: info.outputPath("header.png") });
await expect(async () => {
const bounds = await measure();
expect(bounds.title).not.toBeNull();
expect(bounds.project).not.toBeNull();
expect(bounds.badge).not.toBeNull();
expect(bounds.icon).not.toBeNull();
expect(bounds.trigger).not.toBeNull();
// Approved geometry: 12px outside margin, 46px leading island, 8px title gap.
expect(bounds.title!.x).toBe(66);
expect(bounds.project!.x).toBe(bounds.title!.x);
expect(bounds.trigger!.width).toBe(32);
expect(bounds.trigger!.height).toBe(32);
// The action frame starts 7px inside its island; preserve the 8px gap before it.
const contentRight = bounds.trigger!.x - 15;
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1);
}
expect(bounds.project!.width).toBeGreaterThan(20);
expect(bounds.icon!.width).toBe(12);
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
expect(bounds.titleTruncated).toBe(long);
expect(bounds.projectTruncated).toBe(long);
}).toPass({ timeout: 5_000 });
await info.attach("header-bounds", {
body: JSON.stringify(await measure(), null, 2),
contentType: "application/json",
});
}
for (const width of [320, 390]) {
for (const kind of ["long", "short"] as const) {
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 844 });
const workspace = await openNamedWorkspace(page, labels[kind]);
try {
await expectHeaderContentsFit(page, info, kind === "long");
} finally {
await workspace.cleanup();
}
});
}
}
@@ -1,104 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
selectComposerDraftRange,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// The popover has no semantic role; its rows are React Native Pressables.
function fileSuggestions(page: Page) {
return page.getByTestId("composer-autocomplete-popover");
}
async function selectReadmeMention(page: Page): Promise<void> {
await fillComposerDraft(page, "@README");
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
await expect(fileSuggestions(page).getByText("example.com", { exact: true })).toHaveCount(0);
await composerLocator(page).press("Enter");
await expectComposerDraft(page, '"README.md"');
}
async function sendEmailIntact(page: Page, email: string): Promise<void> {
await fillComposerDraft(page, email);
await expect(fileSuggestions(page)).not.toBeVisible();
await composerLocator(page).press("Enter");
await expectComposerDraft(page, "");
await expect(page.getByText(email, { exact: true })).toBeVisible();
}
for (const email of [
"user <12345+user@noreply.example.com>",
"alice@example.com",
"noreply+@example.com",
]) {
test(`Enter sends ${email} intact and still completes file mentions`, async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-email-",
title: "Email mention regression",
repo: { files: [{ path: "example.com", content: "A file matching the email domain.\n" }] },
});
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await test.step("complete a workspace file mention with Enter", async () => {
await selectReadmeMention(page);
});
await test.step("send an email without offering or inserting workspace files", async () => {
await sendEmailIntact(page, email);
});
} finally {
await agent.cleanup();
}
});
}
test("fullscreen mention keys edit the draft while inline keys still complete files", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-mention-",
title: "Fullscreen mention keys",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
const draft = "First line\nSecond line\n@README";
await fillComposerDraft(page, draft);
await expect(fileSuggestions(page).getByText("README.md", { exact: true })).toBeVisible();
await enterComposerFullscreen(page);
await expect(fileSuggestions(page)).not.toBeVisible();
await selectComposerDraftRange(page, draft.length, draft.length);
await composerLocator(page).press("ArrowUp");
await expect
.poll(() =>
composerLocator(page).evaluate((element) => {
const input = element as HTMLTextAreaElement;
return input.selectionStart === input.selectionEnd && input.selectionStart < 23;
}),
)
.toBe(true);
await selectComposerDraftRange(page, draft.length, draft.length);
await composerLocator(page).press("Enter");
await expectComposerDraft(page, `${draft}\n`);
await exitComposerFullscreen(page);
await selectReadmeMention(page);
const commandDraft = "/cl\nSecond line\nThird line";
await fillComposerDraft(page, commandDraft);
await selectComposerDraftRange(page, 2, 2);
await composerLocator(page).press("ArrowRight");
await expect(fileSuggestions(page).getByText("/clear", { exact: true }).first()).toBeVisible();
await enterComposerFullscreen(page);
await expect(fileSuggestions(page)).not.toBeVisible();
await composerLocator(page).press("Enter");
await expectComposerDraft(page, "/cl\n\nSecond line\nThird line");
} finally {
await agent.cleanup();
}
});
@@ -1,5 +1,3 @@
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
import { seedWorkspace } from "../support/helpers/seed-client";
import { expect, test, type Page } from "../support/fixtures";
import {
expectNearBottom,
@@ -7,27 +5,7 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
import {
attachComposerTestImage,
pasteComposerTestImage,
enterComposerFullscreen,
exitComposerFullscreen,
resizeComposerViewport,
selectComposerDraftRange,
expectComposerDraftSelection,
expectComposerFullscreenControls,
sendComposerWithButton,
queueComposerWithButton,
configureComposerQueueSend,
activateRetainedComposerTab,
recordVisibleComposerGeometry,
expectNoCollapsedVisibleComposer,
expectAttachmentPill,
expectQueuedMessageButton,
startRunningMockAgent,
composerLocator,
expectComposerVisible,
} from "../support/helpers/composer";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
async function composerHeight(page: Page): Promise<number> {
@@ -155,300 +133,3 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
await agent.cleanup();
}
});
async function writeCompactDraft(page: Page, text: string): Promise<void> {
await composerLocator(page).fill(text);
await expect(composerLocator(page)).toHaveValue(text);
}
test("compact long drafts stop growing before the header", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-cap-",
title: "Composer cap",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "A long draft line\n".repeat(40));
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
} finally {
await agent.cleanup();
}
});
test("compact fullscreen editing preserves the live draft and selection and stays open after send", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-",
title: "Fullscreen composer",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await attachComposerTestImage(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await writeCompactDraft(page, "First line\nSecond line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await writeCompactDraft(
page,
"A sentence with enough words to wrap across several rendered lines in a narrow input. ".repeat(
4,
),
);
await expect(page.getByRole("button", { name: "Fullscreen", exact: true })).toBeVisible();
await selectComposerDraftRange(page, 4, 17);
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(0);
await writeCompactDraft(page, "Edited fullscreen draft\nSecond line\nThird line");
await selectComposerDraftRange(page, 3, 12);
await exitComposerFullscreen(page);
await expect(composerLocator(page)).toHaveValue(
"Edited fullscreen draft\nSecond line\nThird line",
);
await expectComposerDraftSelection(page, 3, 12);
await expectAttachmentPill(page, "composer-image-attachment-pill");
await expect(composerLocator(page)).toBeFocused();
await enterComposerFullscreen(page);
await writeCompactDraft(page, "Short draft");
await expectComposerFullscreenControls(page);
await selectComposerDraftRange(page, 0, 5);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await expectComposerDraftSelection(page, 0, 5);
await expect(composerLocator(page)).toHaveValue("Short draft");
await expect(composerLocator(page)).toBeFocused();
await resizeComposerViewport(page, "portrait");
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
} finally {
await agent.cleanup();
}
});
test("fullscreen submit rejection retains the editable draft and supports retry", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-fullscreen-retry-",
title: "Fullscreen retry",
featureValues: { mockPromptRejections: 1 },
});
const draft = "First line\nSecond line\nThird line";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await sendComposerWithButton(page);
await expect(page.getByTestId("composer-fullscreen").getByRole("alert")).toHaveText(
"Requested mock prompt rejection",
);
await expect(composerLocator(page)).toHaveValue(draft);
await expect(composerLocator(page)).toBeEditable();
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
await sendComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
} finally {
await agent.cleanup();
}
});
test("fullscreen Send keeps the configured queue behavior while an agent runs", async ({
page,
}) => {
await configureComposerQueueSend(page);
await resizeComposerViewport(page, "portrait");
const agent = await startRunningMockAgent(page, {
prefix: "composer-fullscreen-queue-",
model: "one-minute-stream",
prompt: "Stay running for fullscreen queue test.",
});
try {
await writeCompactDraft(page, "Queued first line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectComposerFullscreenControls(page);
await queueComposerWithButton(page);
await expect(composerLocator(page)).toHaveValue("");
await expectComposerFullscreenControls(page);
await exitComposerFullscreen(page);
await expectQueuedMessageButton(page);
} finally {
await agent.cleanup();
}
});
test("image paste stays attached to the active editor across fullscreen entry and exit", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-paste-remount-",
title: "Composer paste",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
expect(await pasteComposerTestImage(page)).toBe(true);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(1);
await enterComposerFullscreen(page);
expect(await pasteComposerTestImage(page)).toBe(true);
await exitComposerFullscreen(page);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(2);
expect(await pasteComposerTestImage(page)).toBe(true);
await expect(page.getByTestId("composer-image-attachment-pill")).toHaveCount(3);
} finally {
await agent.cleanup();
}
});
test("retained agent tabs preserve fullscreen editing and paste when switched away and back", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-fullscreen-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "First retained chat",
});
const second = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Second retained chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await resizeComposerViewport(page, "portrait");
const draft = "Live first line\nLive second line\nLive third line";
await writeCompactDraft(page, draft);
await enterComposerFullscreen(page);
await selectComposerDraftRange(page, 4, 17);
await activateRetainedComposerTab(page, first.title);
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await writeCompactDraft(page, "Other tab draft");
await shrinkVisibleViewport(page);
await activateRetainedComposerTab(page, second.title);
await expectComposerFullscreenControls(page);
await expect(composerLocator(page)).toHaveValue(draft);
await expectFullscreenInsideVisibleViewport(page);
await expectComposerDraftSelection(page, 4, 17);
await expect(composerLocator(page)).toBeFocused();
expect(await pasteComposerTestImage(page)).toBe(true);
await exitComposerFullscreen(page);
await expectAttachmentPill(page, "composer-image-attachment-pill");
} finally {
await workspace.cleanup();
}
});
test("retained inline composers keep usable capacity through hidden tab geometry", async ({
page,
}) => {
const workspace = await seedWorkspace({ repoPrefix: "composer-retained-capacity-" });
try {
const first = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "First capacity chat",
});
const second = await createMockIdleAgent(workspace.client, {
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Second capacity chat",
});
await openWorkspaceWithAgents(page, [first, second]);
await resizeComposerViewport(page, "portrait");
const draft = "Retained long line\n".repeat(40);
await writeCompactDraft(page, draft);
await recordVisibleComposerGeometry(page);
await activateRetainedComposerTab(page, first.title);
await writeCompactDraft(page, "Other visible draft");
await activateRetainedComposerTab(page, second.title);
await expect(composerLocator(page)).toHaveValue(draft);
await expect.poll(() => composerHeight(page)).toBeLessThanOrEqual(160);
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeVisible();
await expectNoCollapsedVisibleComposer(page);
} finally {
await workspace.cleanup();
}
});
async function shrinkVisibleViewport(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Emulation.setPageScaleFactor", { pageScaleFactor: 2 });
await expect.poll(() => page.evaluate(() => window.visualViewport!.height)).toBe(422);
await cdp.detach();
}
async function panVisibleViewport(page: Page): Promise<void> {
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: 4, y: 300 }],
});
for (let y = 290; y >= 100; y -= 10) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: 4, y }],
});
}
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetTop)).toBeGreaterThan(0);
await cdp.detach();
}
async function expectFullscreenInsideVisibleViewport(page: Page): Promise<void> {
await expect
.poll(() =>
page.getByTestId("composer-fullscreen").evaluate((element) => {
const viewport = window.visualViewport!;
const frame = element.getBoundingClientRect();
const send = element.querySelector('[aria-label="Send message"]')!.getBoundingClientRect();
return {
frameFits:
Math.abs(frame.top - viewport.offsetTop) < 1 &&
Math.abs(frame.left - viewport.offsetLeft) < 1 &&
Math.abs(frame.height - viewport.height) < 1 &&
Math.abs(frame.width - viewport.width) < 1,
sendFits:
send.top >= viewport.offsetTop &&
send.bottom <= viewport.offsetTop + viewport.height &&
send.left >= viewport.offsetLeft &&
send.right <= viewport.offsetLeft + viewport.width,
};
}),
)
.toEqual({ frameFits: true, sendFits: true });
}
test("fullscreen Send follows the visible viewport when it shrinks and pans", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "composer-visible-viewport-",
title: "Visible viewport composer",
});
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await writeCompactDraft(page, "First line\nSecond line\nThird line");
await enterComposerFullscreen(page);
await expectFullscreenInsideVisibleViewport(page);
await shrinkVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
await panVisibleViewport(page);
await expectFullscreenInsideVisibleViewport(page);
} finally {
await agent.cleanup();
}
});
@@ -96,64 +96,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
title: "Non-ASCII file link",
initialPrompt: "Show the report link",
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
});
try {
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "报告");
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText("Report opened", { exact: true }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
} finally {
await session.cleanup();
}
});
for (const order of ["raw-first", "href-first"] as const) {
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "percent-link-cache-",
title: "Distinct file links",
initialPrompt: "Show both filenames",
featureValues: {
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
},
});
try {
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
await openAgentRoute(page, session);
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
for (const link of links) {
await returnToAssistant(page, session.agentId);
if (link === "raw") {
await openAssistantInlinePath(page, "literal%20.md");
await expectFileTabOpen(page, "literal%20.md");
await expectPreviewText(page, "Literal filename");
} else {
await openAssistantMarkdownLink(page, "Decoded");
await expectFileTabOpen(page, "literal .md");
await expectPreviewText(page, "Decoded filename");
}
}
} finally {
await session.cleanup();
}
});
}
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -815,24 +757,3 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
await page.getByRole("link", { name, exact: true }).first().click();
}
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
}
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
await page.getByText(token, { exact: true }).click();
}
async function expectPreviewText(page: Page, text: string): Promise<void> {
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText(text, { exact: true }),
).toBeVisible();
}
@@ -6,15 +6,6 @@ import {
selectWorkspaceIsolation,
} from "../support/helpers/new-workspace";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import {
composerLocator,
expectComposerVisible,
fillComposerDraft,
enterComposerFullscreen,
resizeComposerViewport,
expectComposerFullscreenControls,
} from "../support/helpers/composer";
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
type WebSocketMessage = string | Buffer;
@@ -205,52 +196,3 @@ test.describe("New Workspace dictation submit", () => {
}
});
});
async function startDictationWithShortcut(page: Page): Promise<void> {
await composerLocator(page).press("Control+d");
}
test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "fullscreen-dictation-",
title: "Fullscreen dictation shortcut",
});
await installSyntheticMicrophone(page);
const harness = await installDictationFailureHarness(page);
const draft = "First line\nSecond line\nThird line";
try {
await resizeComposerViewport(page, "portrait");
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await fillComposerDraft(page, draft);
await enterComposerFullscreen(page);
await resizeComposerViewport(page, "landscape");
await expectComposerFullscreenControls(page);
await startDictationWithShortcut(page);
await harness.waitForAudio();
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
await expect(
page.getByRole("button", { name: "Insert transcription", exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Insert transcription and send", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue(draft);
await resizeComposerViewport(page, "portrait");
await enterComposerFullscreen(page);
await resizeComposerViewport(page, "landscape");
await composerLocator(page).press("Control+Shift+d");
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
await expect(
page.getByText("Realtime voice is disabled in this test.", { exact: true }),
).toBeVisible();
await expect(composerLocator(page)).toHaveValue(draft);
} finally {
await agent.cleanup();
}
});
@@ -9,12 +9,13 @@ import {
expectTerminalPreviewCommand,
expectWorkspaceOpensWithTerminalTab,
fillTerminalPrompt,
launchTrigger,
seedTerminalProfiles,
selectLaunchOption,
submitTerminalLaunch,
terminalLaunchSubmit,
terminalPromptInput,
type TerminalProfile,
type TerminalProfileSeed,
} from "../support/helpers/new-workspace-launch";
// Someone who wants a terminal agent instead of a chat: they pick a profile
@@ -41,18 +42,17 @@ const BARE_PROFILE: TerminalProfile = {
args: ["-c", "echo bare-launch-static-line; exec cat"],
};
test.use({
e2eDaemonConfig: { version: 1, daemon: { terminalProfiles: [PROMPT_PROFILE, BARE_PROFILE] } },
});
test.describe("New workspace: launching a terminal", () => {
let workspace: SeededWorkspace;
let profileSeed: TerminalProfileSeed;
test.beforeEach(async () => {
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
});
test.afterEach(async () => {
await profileSeed.restore();
await workspace?.cleanup();
});
@@ -97,51 +97,4 @@ test.describe("New workspace: launching a terminal", () => {
await expectTerminalOutputContains(page, "bare-launch-static-line");
});
});
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
page,
}) => {
await gotoAppShell(page);
await waitForSidebarHydration(page);
await openNewWorkspaceComposer(page, {
projectKey: workspace.projectKey,
projectDisplayName: workspace.projectDisplayName,
});
await page.setViewportSize({ width: 390, height: 844 });
const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
const selectProfile = async (profile: TerminalProfile) => {
await launchTrigger(page).click();
const option = page.getByRole("menuitem").filter({ hasText: profile.name });
await option.click();
await expect(option).not.toBeVisible();
};
const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
await test.step("restore a multiline prompt after a read-only profile", async () => {
await selectProfile(PROMPT_PROFILE);
await fillTerminalPrompt(page, prompt);
await expect(fullscreen).toBeVisible();
await selectProfile(BARE_PROFILE);
await expect(terminalPromptInput(page)).toHaveCount(0);
await selectProfile(PROMPT_PROFILE);
await expect(terminalPromptInput(page)).toHaveValue(prompt);
await expect(fullscreen).toBeVisible();
});
await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
await fillTerminalPrompt(page, "Short draft");
await expect(fullscreen).toHaveCount(0);
await selectProfile(BARE_PROFILE);
await selectProfile(PROMPT_PROFILE);
await expect(terminalPromptInput(page)).toHaveValue("Short draft");
await expect(fullscreen).toHaveCount(0);
await terminalPromptInput(page).press("End");
await terminalPromptInput(page).press("Shift+Enter");
await terminalPromptInput(page).pressSequentially("Second");
await terminalPromptInput(page).press("Shift+Enter");
await terminalPromptInput(page).pressSequentially("Third");
await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
await expect(fullscreen).toBeVisible();
});
});
});
@@ -1,64 +0,0 @@
import { execFileSync } from "node:child_process";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test as base } from "../support/fixtures";
import { gotoWorkspace } from "../support/helpers/launcher";
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
const test = base.extend<{ projectWithTrailingSpace: string }>({
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
try {
const repoPath = path.join(root, "repo ");
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
execFileSync(
"git",
[
"-c",
"user.name=Paseo Test",
"-c",
"user.email=test@getpaseo.local",
"-c",
"commit.gpgsign=false",
"commit",
"--allow-empty",
"-m",
"initial",
],
{ cwd: repoPath, stdio: "pipe" },
);
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
const added = await client.addProject(repoPath);
expect(added.error).toBeNull();
const projectId = added.project!.projectId;
try {
const created = await client.createWorkspace({
source: { kind: "directory", path: repoPath, projectId },
});
expect(created.error).toBeNull();
await provide(created.workspace!.id);
} finally {
await client.removeProject(projectId);
}
} finally {
await rm(root, { recursive: true, force: true });
}
},
});
test.skip(
process.platform === "win32",
"Windows does not preserve trailing spaces in folder names",
);
test("Changes shows files in a project whose folder ends in a space", async ({
page,
projectWithTrailingSpace,
}, testInfo) => {
await gotoWorkspace(page, projectWithTrailingSpace);
await openChangesTreePanel(page);
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
await page.screenshot({ path: testInfo.outputPath("changes.png") });
});
@@ -1,4 +1,3 @@
import type { Page, TestInfo } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { test, expect } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -7,31 +6,6 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
async function openTabSwitcher(page: Page) {
await page.getByRole("button", { name: /Switch tabs/ }).click();
const footer = page.getByRole("button", { name: "New terminal", exact: true });
await expect(footer).toBeVisible();
// Visibility alone passes while the sheet is still travelling up from below the viewport.
await expect
.poll(async () => {
const bounds = await footer.boundingBox();
return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
}
async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
await page.screenshot({ path: testInfo.outputPath(name + ".png") });
}
async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
await openTabSwitcher(page);
await page.getByRole("button", { name: kind, exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
@@ -46,7 +20,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
test.describe("Workspace session rename (compact tab switcher)", () => {
test("renames an agent session from the switcher's per-session actions sheet", async ({
page,
}, testInfo) => {
}) => {
test.setTimeout(120_000);
await page.setViewportSize(MOBILE_VIEWPORT);
@@ -62,10 +36,8 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
timeout: 30_000,
});
await captureScreen(page, testInfo, "compact-chat-header");
// Open the sessions dropdown (a bottom sheet on compact).
await openTabSwitcher(page);
await page.getByRole("button", { name: /Switch tabs/ }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{
timeout: 10_000,
@@ -93,58 +65,12 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
timeout: 15_000,
});
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
await captureScreen(page, testInfo, "renamed-tab-in-switcher");
} finally {
await session.cleanup();
}
});
});
test("switcher footer creates tabs and returns to the existing chat", async ({
page,
}, testInfo) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "compact-header-actions-",
title: "Existing chat",
});
try {
await openAgentRoute(page, session);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await createTabFromSwitcher(page, "New terminal");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
await captureScreen(page, testInfo, "compact-terminal-header");
await createTabFromSwitcher(page, "New agent");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await expect(
page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
).toHaveCount(1);
await captureScreen(page, testInfo, "compact-draft-header");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await captureScreen(page, testInfo, "switcher-three-tabs");
await page.getByText("Existing chat", { exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 10, y: 10 } });
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await captureScreen(page, testInfo, "returned-chat-header");
} finally {
await session.cleanup();
}
});
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"terminal-surface\"" 10000
press "id=\"terminal-surface\""
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
ad keyboard dismiss || true
ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad press 'id="workspace-header-menu-trigger"' --settle
ad wait 'id="workspace-header-new-agent"' 10000
ad press 'id="workspace-header-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
@@ -1,62 +0,0 @@
// Open Settings > Appearance in a native Android app loaded from this checkout.
// AGENT_DEVICE_STATE_DIR=<state-dir> node packages/app/e2e/mobile/system-bars/android.mjs <session> <adb-serial> <app-id>
// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
const [, , session, serial, appId] = process.argv;
assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
function ad(...args) {
return execFileSync("agent-device", [...args, "--session", session], {
encoding: "utf8",
timeout: 60_000,
});
}
function windowState() {
const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
encoding: "utf8",
timeout: 10_000,
});
assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
assert(regions, "Android must report the status-bar appearance region");
return regions.includes("LIGHT_STATUS_BARS");
}
async function expectDarkIcons(expected, context) {
const deadline = Date.now() + 5_000;
do {
if (windowState() === expected) {
console.log(`PASS: ${context}`);
return;
}
await delay(100);
} while (Date.now() < deadline);
assert.equal(windowState(), expected, context);
}
function selectTheme(theme) {
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
assert(current, "Start on Settings > Appearance");
ad("press", `label="Theme: ${current}"`, "--settle");
ad("press", `label="${theme}"`, "--settle");
ad("wait", "text", `Theme: ${theme}`, "5000");
}
ad("settings", "appearance", "light");
selectTheme("Light");
await expectDarkIcons(true, "Light app theme uses dark status icons");
selectTheme("Dark");
await expectDarkIcons(false, "Dark app theme uses light status icons");
selectTheme("System");
await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
ad("settings", "appearance", "dark");
await expectDarkIcons(
false,
"System theme follows an OS change to dark without reopening settings",
);
ad("settings", "appearance", "light");
await expectDarkIcons(true, "System theme follows an OS change back to light");
@@ -1,21 +0,0 @@
# 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.
@@ -1,65 +0,0 @@
// 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,133 +316,3 @@ export async function openGithubWorkspace(
},
};
}
const COMPOSER_TEST_IMAGE_BASE64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
export async function attachComposerTestImage(page: Page): Promise<void> {
await attachImageFromMenu(page, {
name: "fullscreen.png",
mimeType: "image/png",
buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"),
});
}
export async function pasteComposerTestImage(page: Page): Promise<boolean> {
return composerInput(page).evaluate((input, base64) => {
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
const clipboardData = new DataTransfer();
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
input.dispatchEvent(event);
return event.defaultPrevented;
}, COMPOSER_TEST_IMAGE_BASE64);
}
export async function enterComposerFullscreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Fullscreen", exact: true }).click();
}
export async function exitComposerFullscreen(page: Page): Promise<void> {
await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click();
}
export async function resizeComposerViewport(
page: Page,
orientation: "portrait" | "landscape" | "short",
): Promise<void> {
const sizes = {
portrait: { width: 390, height: 844 },
landscape: { width: 844, height: 390 },
short: { width: 390, height: 390 },
};
await page.setViewportSize(sizes[orientation]);
}
export async function selectComposerDraftRange(
page: Page,
start: number,
end: number,
): Promise<void> {
await composerInput(page).focus();
await composerInput(page).evaluate(
(element, selection) => {
const input = element as HTMLTextAreaElement;
input.setSelectionRange(selection.start, selection.end);
input.dispatchEvent(new Event("select", { bubbles: true }));
},
{ start, end },
);
}
export async function expectComposerDraftSelection(
page: Page,
start: number,
end: number,
): Promise<void> {
await expect
.poll(() =>
composerInput(page).evaluate((element) => {
const input = element as HTMLTextAreaElement;
return [input.selectionStart, input.selectionEnd];
}),
)
.toEqual([start, end]);
}
export async function expectComposerFullscreenControls(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
await expect(composerInput(page)).toHaveCount(1);
}
export async function sendComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Send message", exact: true }).click();
}
export async function queueComposerWithButton(page: Page): Promise<void> {
await page.getByRole("button", { name: "Queue message", exact: true }).click();
}
export async function configureComposerQueueSend(page: Page): Promise<void> {
await page.addInitScript(() =>
localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })),
);
}
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
* This exercises retained activity changes, not a claim that covered chrome is clickable.
*/
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
await page
.getByTestId("workspace-tab-switcher-trigger")
.evaluate((element) => (element as HTMLElement).click());
const option = page.getByText(title, { exact: true }).filter({ visible: true }).last();
await expect(option).toBeVisible();
await option.evaluate((element) => (element as HTMLElement).click());
}
export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
await page.evaluate(() => {
const frames: { input: number; root: number }[] = [];
Reflect.set(window, "__composerGeometryFrames", frames);
let running = true;
const tick = () => {
if (!running) return;
for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) {
if (root.getClientRects().length === 0) continue;
const input = root.querySelector("textarea");
if (input)
frames.push({
input: input.getBoundingClientRect().height,
root: root.getBoundingClientRect().height,
});
}
requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
Reflect.set(window, "__stopComposerGeometry", () => {
running = false;
});
});
}
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
const frames = await page.evaluate(() => {
Reflect.get(window, "__stopComposerGeometry")?.();
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
});
expect(frames.length).toBeGreaterThan(0);
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
}
@@ -30,6 +30,7 @@ async function capture(
name: string,
subject: Locator = headerButtons(page),
) {
await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
@@ -79,7 +80,6 @@ export async function withButtonAuthorExample(
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
@@ -88,13 +88,7 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
await expect(headerButtons(page)).toHaveCount(0);
await capture(
page,
info,
"02-compact-actions",
page.getByRole("button", { name: "Refresh context", exact: true }),
);
await capture(page, info, "02-compact-actions");
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
@@ -134,8 +128,7 @@ export async function withButtonAuthorExample(
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
await expect(headerButtons(page)).toHaveCount(0);
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await headerButtons(page).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
@@ -150,7 +143,6 @@ export async function withButtonAuthorExample(
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
@@ -202,6 +202,31 @@ async function expectHeaderTextTruncated(page: Page) {
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
async function holdButton(page: Page, button: Locator) {
await button.hover();
await page.mouse.down();
}
async function openCompactOverflowWithHeaderChrome(page: Page) {
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
const action = page.getByRole("button", { name: "Deploy application", exact: true });
const overflow = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true });
await expect(action).toHaveCSS("border-top-width", "0px");
await expect(overflow).toHaveCSS("border-top-width", "0px");
await hamburger.hover();
const highlight = await hamburger.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await action.hover();
await expect(action).toHaveCSS("background-color", highlight);
await holdButton(page, overflow);
await expect(overflow).toHaveCSS("background-color", highlight);
await expectSurfaceVisible(page);
await page.mouse.up();
}
export async function withButtonShowcase(
page: Page,
run: (buttons: {
@@ -383,7 +408,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -401,12 +426,39 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await expectSurfaceVisible(page);
await openCompactOverflowWithHeaderChrome(page);
await expect(
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Header checks");
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await expectSurfaceVisible(
page,
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await showcase.setTitle("Compact checks");
await press(page, "Header checks");
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Open menu", exact: true }),
).toBeInViewport();
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
@@ -47,65 +47,6 @@ export default function contribute(client) {
return () => {};
}`;
const PROMPT_PLUGIN_ID = "timeline-prompt-e2e";
function promptClientSource(prompts?: readonly string[]): string {
return `import React from "react";
import { Text, View } from "react-native";
import { z } from "zod";
const selectedPrompts = ${JSON.stringify(prompts ?? null)};
function PromptCard({ item }) {
return <View testID="plugin-prompt"><Text>{item.data.text}</Text></View>;
}
export default function contribute(client) {
client.addTimelineTransformer({
id: "prompt",
query: { itemType: "user_message" },
transform({ item }) {
if (selectedPrompts && !selectedPrompts.includes(item.text)) return undefined;
return { items: [{ type: "plugin", kind: "prompt", version: 1, data: { text: item.text } }] };
},
});
client.addTimelineRenderer({
kind: "prompt", version: 1,
schema: z.object({ text: z.string() }),
Component: PromptCard,
});
return () => {};
}`;
}
async function withDirectoryPlugin(options: {
pluginId: string;
source: string;
run: () => Promise<void>;
}): Promise<void> {
const { pluginId, source, run } = options;
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: pluginId, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), source);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await run();
} finally {
await pluginClient.removePlugin(pluginId);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await rm(directory, { recursive: true, force: true });
}
}
export async function withTimelinePlugin(
page: Page,
info: TestInfo,
@@ -119,6 +60,7 @@ export async function withTimelinePlugin(
JSON.stringify({ toolCallDetailLevel: "overview" }),
);
});
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
const agent = await seedMockAgentWorkspace({
repoPrefix: "timeline-plugin-",
title: "Timeline plugin regression",
@@ -131,53 +73,40 @@ export async function withTimelinePlugin(
}
: {},
});
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
const previous = await pluginClient.getDaemonConfig();
try {
await withDirectoryPlugin({
pluginId: PLUGIN_ID,
source: CLIENT_SOURCE,
run: async () => {
try {
await openAgentRoute(page, agent);
await expect(
page.getByRole("textbox", { name: "Message agent..." }).first(),
).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
}
},
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
);
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
await pluginClient.installDirectoryPlugin(directory);
await openAgentRoute(page, agent);
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
await run(agent);
await info.attach("timeline-plugin", {
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
contentType: "image/png",
});
} catch (error) {
await info.attach("timeline-before-cleanup", {
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
contentType: "image/png",
});
throw error;
} finally {
await pluginClient.removePlugin(PLUGIN_ID);
await pluginClient.patchDaemonConfig({
pluginsEnabled: previous.config.pluginsEnabled ?? false,
});
await pluginClient.close();
await agent.cleanup();
await rm(directory, { recursive: true, force: true });
}
}
/** Draws all prompts, or only the selected prompts, as plugin cards. */
export async function withPromptTimelinePlugin(
run: () => Promise<void>,
prompts?: readonly string[],
): Promise<void> {
await withDirectoryPlugin({
pluginId: PROMPT_PLUGIN_ID,
source: promptClientSource(prompts),
run,
});
}
export async function expectPromptsDrawnByPlugin(page: Page): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(timeline.getByTestId("plugin-prompt").first()).toBeVisible({ timeout: 30_000 });
await expect(timeline.getByTestId("user-message")).toHaveCount(0);
}
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
}
@@ -213,21 +142,3 @@ export async function expectBothConsecutiveTools(page: Page): Promise<void> {
await grep.scrollIntoViewIfNeeded();
await expect(grep).toBeVisible();
}
/** The row belongs to the virtualized history, not the always-mounted recent window. */
export async function expectVirtualizedPluginPromptVisible(
page: Page,
prompt: string,
): Promise<void> {
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
await expect(
timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
).toBeVisible();
}
export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise<void> {
await info.attach("plugin-outline-jump", {
body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
contentType: "image/png",
});
}
+7 -3
View File
@@ -1,6 +1,10 @@
// 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";
// 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();
// 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, getStreamItemMessageId } from "./presentation";
import { createStreamPresentation } from "./presentation";
import { buildAgentStreamRenderModel } from "./model";
const runtime = {
@@ -29,7 +29,7 @@ const runtime = {
} as unknown as PluginClientRuntime;
function installProbe(
itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
itemType: "assistant_message" | "tool_call" | "reasoning",
condition = "true",
): InstalledPlugin {
const clientBundle = `(function() {
@@ -160,23 +160,6 @@ describe("stream presentation through installed plugins", () => {
},
);
it("addresses a prompt a plugin replaced by the prompt's own id", () => {
const harness = streamHarness(installedTransform(installProbe("user_message")));
const rendered = rows(
harness.send({
type: "timeline",
provider: "claude",
item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" },
}),
);
const source = rows(harness.source());
expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
// The chat outline jumps to the source id; the row it lands on is the plugin's.
expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
});
it("finishes one message before streaming a different message ID", () => {
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
harness.send(assistant("First\n\nBody", "first"));
@@ -26,14 +26,10 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
/**
* The source message a display row belongs to. Every assistant message is split into
* Markdown blocks, so an assistant row id never equals its message id; anything that
* addresses a message — find, scroll-to-message, history reveal — asks for this. A
* plugin row a timeline transformer projected answers for the item it replaced, whose
* id is what the chat outline and find hold.
* addresses a message — find, scroll-to-message, history reveal — asks for this.
*/
export function getStreamItemMessageId(item: StreamItem): string {
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
if (item.kind === "plugin") return item.sourceId ?? item.id;
return item.id;
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
}
/**
@@ -42,7 +42,6 @@ import {
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -275,20 +274,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
const topOverlayInset = useStreamTopOverlayInset();
// The list is inverted, so its content's bottom padding is the visual top.
const listContentContainerStyle = useMemo(
() => [
baseListContentContainerStyle,
{
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
},
],
[
baseListContentContainerStyle,
streamKeyboardInset.contentContainerPaddingBottom,
topOverlayInset,
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
],
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
);
const listInsetProps = useMemo(
() =>
@@ -1,14 +0,0 @@
import { createContext, useContext } from "react";
import { isNative } from "@/constants/platform";
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
export const supportsStreamTopOverlay = isNative;
const StreamTopOverlayContext = createContext(0);
/** Visual-top clearance supplied by the containing layout, in points. */
export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
export function useStreamTopOverlayInset(): number {
return useContext(StreamTopOverlayContext);
}
+4 -7
View File
@@ -1,4 +1,3 @@
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
import "@/styles/unistyles";
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
import { PortalProvider } from "@gorhom/portal";
@@ -36,7 +35,7 @@ import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
@@ -103,6 +102,7 @@ import {
useKeyboardActionDispatcher,
} from "@/keyboard/keyboard-action-dispatcher-context";
import { polyfillCrypto } from "@/polyfills/crypto";
import { polyfillNavigator } from "@/polyfills/navigator";
import {
getHostRuntimeStore,
hasConfiguredLocalDaemonOverride,
@@ -138,6 +138,7 @@ import {
type WebNotificationClickDetail,
} from "@/utils/os-notifications";
polyfillNavigator();
polyfillCrypto();
export interface HostRuntimeBootstrapState {
@@ -624,11 +625,7 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
surface
);
return (
<ComposerEditingSessionsProvider>
<CommandCenterProvider>{content}</CommandCenterProvider>
</ComposerEditingSessionsProvider>
);
return <CommandCenterProvider>{content}</CommandCenterProvider>;
}
function SidebarChrome({
+1 -5
View File
@@ -21,7 +21,6 @@ import {
} from "@/plugins/themes";
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
import { applyAppearance } from "./apply";
import { AppearanceSystemBars } from "./system-bars";
interface ContributedThemes {
options: PluginThemeOption[];
@@ -108,10 +107,7 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
if (!hasAppliedAppearance) return null;
return (
<ContributedThemesContext.Provider value={value}>
<AppearanceSystemBars />
{children}
</ContributedThemesContext.Provider>
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
);
}
@@ -1,11 +0,0 @@
import { SystemBars } from "react-native-edge-to-edge";
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
function ThemeSystemBars({ colorScheme }: Pick<Theme, "colorScheme">) {
return <SystemBars style={colorScheme === "light" ? "dark" : "light"} />;
}
export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({
colorScheme: theme.colorScheme,
}));
@@ -1,3 +0,0 @@
export function AppearanceSystemBars() {
return null;
}
@@ -417,128 +417,6 @@ describe("parseAssistantFileLink", () => {
}
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
});
it("decodes percent-encoded relative markdown hrefs", () => {
// markdown-it percent-encodes non-ASCII characters in hrefs, so a link
// written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
expect(
parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
path: "/Users/test/project/docs/reports/开户赠金.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded spaces in relative markdown hrefs", () => {
expect(
parseAssistantFileLink("docs/my%20file.md#L5", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/my%20file.md#L5",
path: "/Users/test/project/docs/my file.md",
lineStart: 5,
lineEnd: undefined,
});
});
it("decodes percent-encoded absolute hrefs with line suffixes", () => {
expect(
parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
path: "/tmp/开户.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("decodes percent-encoded home-relative hrefs", () => {
expect(
parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "~/notes/%E5%BC%80%E6%88%B7.md",
path: "~/notes/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded Windows hrefs", () => {
expect(
parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
workspaceRoot: "C:/repo",
decodeHref: true,
}),
).toEqual({
raw: "C:/repo/my%20dir/app.tsx#L8",
path: "C:/repo/my dir/app.tsx",
lineStart: 8,
lineEnd: undefined,
});
});
it("keeps literal percent signs in raw paths that are not hrefs", () => {
// Inline-code and linkified tokens are raw file paths: a real file named
// "my%20file.md" must not be decoded into "my file.md".
expect(
parseAssistantFileLink("docs/my%20file.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/my%20file.md:3", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/my%20file.md:3",
path: "/tmp/my%20file.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("keeps non-ASCII characters in raw absolute paths", () => {
// `new URL` percent-encodes non-ASCII, so raw paths must not be routed
// through URL parsing — `/tmp/开户.md` must round-trip unchanged.
expect(
parseAssistantFileLink("/tmp/开户.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md",
path: "/tmp/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/开户.md#L4", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md#L4",
path: "/tmp/开户.md",
lineStart: 4,
lineEnd: undefined,
});
});
});
describe("normalizeInlinePathTarget", () => {
@@ -591,23 +469,3 @@ describe("normalizeInlinePathTarget", () => {
});
});
});
describe("encoded filename line markers", () => {
it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
"keeps encoded line syntax in the filename %s",
(href) => {
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
}),
).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
})?.lineStart,
).toBeUndefined();
},
);
});
+38 -98
View File
@@ -80,13 +80,6 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/**
* True only when `value` is a markdown href — the renderer percent-encodes
* non-ASCII and space characters, so the path must be decoded. Raw path
* text (inline code, linkified tokens) is not encoded; decoding it would
* send a real file named `my%20file.md` to `my file.md`.
*/
decodeHref?: boolean;
}
export type AssistantFileLinkClassification =
@@ -241,20 +234,13 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
};
}
function parseAssistantInlinePathLink(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
const inlinePathTarget = parseInlinePathToken(value);
if (!inlinePathTarget) {
return null;
}
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
const normalizedPath = normalizePathToken(inlinePathTarget.path);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -291,7 +277,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -322,19 +308,32 @@ export function parseAssistantFileLink(
return null;
}
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
if (inlinePathTarget) {
return inlinePathTarget;
}
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
if (windowsTarget) {
return windowsTarget;
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw: value,
path: normalizedPath,
...lines,
};
}
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
});
if (relativeTarget) {
return relativeTarget;
@@ -344,33 +343,19 @@ export function parseAssistantFileLink(
return null;
}
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
// normalize before decode. Raw paths must not: `new URL` percent-encodes
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
// still-encoded name.
let pathPart: string;
let hashPart: string;
if (options.decodeHref) {
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
hashPart = parsedUrl.hash;
} else {
const hashIndex = trimmed.indexOf("#");
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
const normalizedPath = normalizePathToken(pathPart);
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
const lines = parseLineFragment(hashPart);
const lines = parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
}
@@ -401,42 +386,11 @@ export function isFileLookingAssistantToken(value: string): boolean {
return isPlausibleAssistantLocalPath(path);
}
function parseWindowsFileLink(
trimmed: string,
raw: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (!windowsPathMatch) {
return null;
}
let pathPart = windowsPathMatch[1] ?? "";
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw,
path: normalizedPath,
...lines,
};
}
function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
const parsed = parseLocalPathParts(value, options);
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
@@ -468,11 +422,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
} | null {
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?")) {
return null;
@@ -486,19 +436,14 @@ function parseLocalPathParts(
return null;
}
// Split literal line markers before decoding; encoded punctuation belongs
// to the filename. Raw inline-code/path text stays untouched.
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
return null;
}
return {
path: pathPart,
path: inlinePathTarget.path,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
@@ -510,17 +455,12 @@ function parseLocalPathParts(
return null;
}
let decodedBeforeHash = beforeHash;
if (options.decodeHref) {
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
if (!isPlausibleAssistantLocalPath(beforeHash)) {
return null;
}
return {
path: decodedBeforeHash,
path: beforeHash,
lines: fragmentLines,
};
}
@@ -605,14 +545,14 @@ function isExternalHref(value: string): boolean {
function isAmbiguousWorkspaceCandidate(
value: string,
target: InlinePathTarget,
options: AssistantHrefParseOptions = {},
workspaceRoot?: string,
): boolean {
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
const parsed = parseLocalPathParts(value, options);
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return false;
}
@@ -1,8 +1,4 @@
import { describe, expect, it } from "vitest";
import MarkdownIt from "markdown-it";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
classifyForResolution,
fetchDaemonResolution,
@@ -18,25 +14,6 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
try {
await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
expect(resolution.kind).toBe("resolved");
if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
throw new Error("Expected a directly resolved file link");
}
expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
});
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
@@ -75,27 +52,6 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
};
describe("classifyForResolution", () => {
it.each([
["C:/repo/my%20file.md", undefined],
["C:\\repo\\my%20file.md", undefined],
["C:/repo/my%20file.md:12", 12],
["C:\\repo\\my%20file.md:12", 12],
])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
for (const text of [href, undefined]) {
const result = classifyForResolution(
{ href, text, sourceType: "inline-code" },
{ workspaceRoot: "C:/repo" },
);
expect(result).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
});
}
expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
});
});
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
@@ -209,46 +165,6 @@ describe("classifyForResolution", () => {
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
it("decodes percent-encoded markdown hrefs", () => {
const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my file.md",
lineStart: undefined,
lineEnd: undefined,
},
},
});
});
it("keeps literal percent signs in inline-code paths", () => {
const result = classifyForResolution(
{
href: "docs/my%20file.md",
text: "docs/my%20file.md",
sourceType: "inline-code",
},
CONTEXT,
);
expect(result).toEqual({
kind: "needsLookup",
ambiguousQuery: "docs/my%20file.md",
token: "docs/my%20file.md",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
},
});
});
});
describe("fetchDaemonResolution", () => {
@@ -108,15 +108,13 @@ export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const tokenInfo = getAssistantFileLinkTokenInfo(source);
const token = tokenInfo.token.trim();
const token = getAssistantFileLinkToken(source).trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -150,26 +148,14 @@ export function classifyForResolution(
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
return getAssistantFileLinkTokenInfo(source).token;
}
// Markdown link hrefs are percent-encoded by the renderer; inline-code and
// linkified text are raw file paths that must not be decoded.
function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
token: string;
fromHref: boolean;
} {
if (source.sourceType === "inline-code") {
return { token: source.text?.trim() || source.href, fromHref: false };
}
if (isLinkifiedSource(source)) {
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return { token: text, fromHref: false };
return text;
}
}
return { token: source.href, fromHref: true };
return source.href;
}
export function getAmbiguousSuggestionQuery(
@@ -29,21 +29,14 @@ export interface AssistantFileLinkActions {
canResolveFile(source: AssistantFileLinkSource): boolean;
}
interface AssistantFileLinkLookupKey {
query: string;
path: string;
lineStart: number | null;
lineEnd: number | null;
}
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
AssistantFileLinkLookupKey | null,
string,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
@@ -66,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
resolution,
token: resolution.token,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -186,7 +179,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
resolution: capturedResolution,
token: capturedResolution.token,
});
const run = async () => {
@@ -252,19 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
token: string;
}): AssistantFileLinkQueryKey {
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
{
query: input.resolution.ambiguousQuery,
path: input.resolution.target.path,
lineStart: input.resolution.target.lineStart ?? null,
lineEnd: input.resolution.target.lineEnd ?? null,
},
];
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
}
async function dispatchResolvedLink(input: {
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => {
it("assigns safe clearance to the footer independently of decorative padding", () => {
@@ -46,3 +49,17 @@ describe("getCompactSheetSafeAreaPadding", () => {
).toEqual({});
});
});
describe("getBottomSheetVisibleContentHeight", () => {
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
const layout = {
containerHeight: 874,
contentPosition: 88,
handleHeight: 24,
keyboardHeight: 344,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
});
});
@@ -10,6 +10,28 @@ export interface CompactSheetSafeAreaPadding {
footerPaddingBottom?: number;
}
interface BottomSheetVisibleContentHeightInput {
containerHeight: number;
contentPosition: number;
handleHeight: number;
keyboardHeight: number;
isKeyboardVisible: boolean;
}
export function getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
handleHeight,
keyboardHeight,
isKeyboardVisible,
}: BottomSheetVisibleContentHeightInput): number {
"worklet";
return Math.max(
0,
containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
);
}
export function getCompactSheetSafeAreaPadding({
isCompact,
isKeyboardVisible,
@@ -12,16 +12,22 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import {
KEYBOARD_STATUS,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
@@ -201,6 +207,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
@@ -253,6 +263,40 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleContentStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
if (containerHeight < 0 || handleHeight < 0) {
return { height: 0 };
}
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
const contentPosition =
initialDetentPosition == null
? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition);
const keyboardState = animatedKeyboardState.get();
return {
height: getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
handleHeight,
keyboardHeight: keyboardState.heightWithinContainer,
isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN,
}),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export function SheetHeaderView({
header,
onClose,
@@ -427,6 +471,8 @@ export interface AdaptiveModalSheetProps {
bodyStyle?: StyleProp<ViewStyle>;
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
contextBridge?: ContextBridge | null;
}
@@ -447,6 +493,7 @@ export function AdaptiveModalSheet({
presentation,
contentStyle,
bodyStyle,
sizeContentToCurrentSnapPoint = true,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
@@ -472,15 +519,11 @@ export function AdaptiveModalSheet({
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom],
);
const footerView = useMemo(
() =>
footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const footerView = footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null;
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
@@ -585,6 +628,7 @@ export function AdaptiveModalSheet({
</View>
)}
</View>
{footerView}
</>
);
@@ -606,7 +650,11 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
) : (
sheetContent
)}
</IsolatedBottomSheetModal>
);
}
@@ -1,99 +0,0 @@
import { useCallback, useId, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 24;
interface FloatingHeaderLayoutProps {
header: ReactNode;
/** Float only when the content supports visual-top clearance. */
floating: boolean;
children: (topOverlayInset: number) => ReactNode;
}
/**
* Stacks a header above content, or floats it over the content behind a fade. Children keep the
* same tree position in both modes, so toggling `floating` never remounts them.
*/
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []);
return (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none" onLayout={handleHeaderLayout}>
<FloatingHeaderFade />
{header}
</View>
) : null}
</View>
);
}
function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={1} />
<Stop offset="50%" stopColor={color} stopOpacity={0.97} />
<Stop offset="75%" stopColor={color} stopOpacity={0.8} />
<Stop offset="100%" stopColor={color} stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg);
const workspaceSurfaceColorMapping = (theme: Theme) => ({
color: theme.colors.surfaceWorkspace,
});
function FloatingHeaderFade() {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={styles.fade} pointerEvents="none">
<ThemedFloatingHeaderFadeSvg
gradientId={gradientId}
uniProps={workspaceSurfaceColorMapping}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
},
content: {
flex: 1,
minHeight: 0,
},
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
},
fade: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: -FADE_OVERHANG,
},
});
@@ -6,11 +6,10 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { CompactHeader } from "./compact-header";
import { useIsCompactFormFactor } from "@/constants/layout";
interface BackHeaderProps {
title?: string;
titleAccessory?: ReactNode;
rightContent?: ReactNode;
onBack?: () => void;
}
@@ -19,15 +18,7 @@ function goBack(): void {
router.back();
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="back" title={title} onBack={onBack} actions={rightContent} />;
}
return <DesktopBackHeader title={title} rightContent={rightContent} onBack={onBack} />;
}
function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => {
@@ -51,6 +42,7 @@ function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && <ScreenTitle>{title}</ScreenTitle>}
{titleAccessory}
</>
}
right={rightContent}
@@ -64,7 +56,10 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
backButton: {
padding: theme.spacing[2],
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
@@ -1,168 +0,0 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const ISLAND_PADDING = 7;
const NO_SHORTCUT: ShortcutKey[] = [];
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
navigation: "menu" | "back";
/** Back button action. Defaults to navigating back. */
onBack?: () => void;
title?: string;
titleTestID?: string;
/** A line under the title. */
subtitle?: ReactNode;
/** Show a placeholder instead of the title while it loads. */
loading?: boolean;
/** Icon actions inherit the island chrome through the shared button primitives. */
actions?: ReactNode;
}
/**
* The phone header: the navigation button and the screen's actions sit in islands, with the title
* left-aligned between them. It has no background of its own, so it reads the same in flow and floating
* over content (see `FloatingHeaderLayout`). Its height matches the old flat header, so anything
* offset by the header height still lines up.
*/
export function CompactHeader({
navigation,
onBack,
title,
titleTestID,
subtitle,
loading = false,
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const rootStyle = useMemo(
() => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
[insets.top],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
<View style={styles.titleGroup}>
<CompactHeaderTitle
loading={loading}
title={title}
titleTestID={titleTestID}
subtitle={subtitle}
/>
</View>
{actions ? <View style={styles.island}>{actions}</View> : null}
</View>
</View>
</IconButtonChromeProvider>
);
}
function CompactHeaderTitle({
loading,
title,
titleTestID,
subtitle,
}: Pick<CompactHeaderProps, "title" | "titleTestID" | "subtitle"> & { loading: boolean }) {
if (loading) {
return <View style={styles.titleSkeleton} />;
}
return (
<>
{title ? (
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
{title}
</Text>
) : null}
{subtitle}
</>
);
}
function BackButton({ onBack }: { onBack?: () => void }) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
if (onBack) {
onBack();
return;
}
router.back();
}, [onBack]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("common.actions.back")}
tooltipKeys={NO_SHORTCUT}
tooltipSide="right"
testID="header-back-button"
accessibilityRole="button"
accessibilityLabel={t("common.actions.back")}
>
<ThemedArrowLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
titleGroup: {
flex: 1,
minWidth: 0,
// Both title lines receive the remaining width; text alignment does not size the slot.
alignItems: "stretch",
},
island: {
flexDirection: "row",
alignItems: "center",
padding: ISLAND_PADDING,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
...theme.shadow.sm,
},
title: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
},
titleSkeleton: {
width: 140,
maxWidth: "100%",
height: 22,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
opacity: 0.25,
},
}));
@@ -1,4 +1,4 @@
import { type ReactElement, type ReactNode } from "react";
import { useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -7,7 +7,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
useIconButtonChromeStyle,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
@@ -46,11 +46,17 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null;
const combinedStyle = useIconButtonChromeStyle({
size: "large",
disabled: Boolean(disabled),
style,
});
const combinedStyle = useMemo(
() =>
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
iconButtonChromeStyle({
size: "large",
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -1,10 +1,16 @@
import type { ReactNode } from "react";
import { StyleSheet } from "react-native-unistyles";
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { CompactHeader } from "./compact-header";
import { SidebarMenuToggle } from "./sidebar-menu-toggle";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -12,11 +18,130 @@ interface MenuHeaderProps {
borderless?: boolean;
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ color }: { color: string }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
const shortLineStyle = useMemo(
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }],
[color],
);
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo(
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
[],
);
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
) : (
<PanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
);
}
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const isMobile = useIsCompactFormFactor();
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
const placeholderStyle = useMemo(
() => [headerIconSlotStyle.slot, resolvedStyle],
[resolvedStyle],
);
if (!isMobile && !ownsTopLeft) {
return null;
}
if (!isMobile && hasTopLeftWindowControls) {
return (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -33,7 +158,31 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
left: {
gap: theme.spacing[2],
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
@@ -1,167 +0,0 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import {
extraMutedIconColorMapping,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
const ThemedPanelLeft = withUnistyles(PanelLeft);
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ extraMuted }: { extraMuted: boolean }) {
const color = extraMuted ? styles.mobileMenuLineExtraMuted : styles.mobileMenuLineMuted;
const lineStyle = useMemo(() => [styles.mobileMenuLine, color], [color]);
const shortLineStyle = useMemo(
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, color],
[color],
);
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { t } = useTranslation();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo(
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
[],
);
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon extraMuted={extraMutedIdleIcon} />
) : (
<ThemedPanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={extraMutedIdleIcon ? extraMutedIconColorMapping : mutedIconColorMapping}
/>
)}
</HeaderToggleButton>
);
}
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const isMobile = useIsCompactFormFactor();
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
const placeholderStyle = useMemo(
() => [headerIconSlotStyle.slot, resolvedStyle],
[resolvedStyle],
);
if (!isMobile && !ownsTopLeft) {
return null;
}
if (!isMobile && hasTopLeftWindowControls) {
return (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineMuted: {
backgroundColor: theme.colors.foregroundMuted,
},
mobileMenuLineExtraMuted: {
backgroundColor: theme.colors.foregroundExtraMuted,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
+30 -25
View File
@@ -1,13 +1,5 @@
import { router } from "expo-router";
import {
CircleGauge,
FolderPlus,
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -34,7 +26,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -45,7 +37,6 @@ import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -289,25 +280,30 @@ function FooterIconButton({
onPress,
testID,
label,
icon,
icon: Icon,
iconSizeAdjustment = 0,
shortcutKeys,
theme,
}: {
onPress: () => void;
testID: string;
label: string;
icon: LucideIcon;
icon: typeof FolderPlus;
/** Only for a glyph that reads larger than the others at the same size. */
iconSizeAdjustment?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
const isCompact = useIsCompactFormFactor();
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
style={styles.footerIconButton(isCompact)}
testID={testID}
nativeID={testID}
collapsable={false}
@@ -317,11 +313,9 @@ function FooterIconButton({
onPress={onPress}
>
{({ hovered }) => (
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
<Icon
size={iconSize + iconSizeAdjustment}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
@@ -334,10 +328,12 @@ function FooterIconButton({
}
function SidebarHostPicker({
theme,
label,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
label: string;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
@@ -381,6 +377,7 @@ function SidebarHostPicker({
icon={Server}
// Server's two boxes fill more of the square than the other glyphs.
iconSizeAdjustment={-1}
theme={theme}
/>
</HostPicker>
);
@@ -402,6 +399,7 @@ function IconTooltipContent({
}
function SidebarFooter({
theme,
handleOpenProject,
handleSettings,
labels,
@@ -409,6 +407,7 @@ function SidebarFooter({
handleOpenHostSettings,
onBeforeNavigate,
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleSettings: () => void;
labels: {
@@ -437,9 +436,11 @@ function SidebarFooter({
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<SidebarUsageIcon label={labels.usage} />
<SidebarUsageIcon label={labels.usage} theme={theme} />
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
@@ -452,6 +453,7 @@ function SidebarFooter({
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
@@ -459,7 +461,7 @@ function SidebarFooter({
);
}
function SidebarUsageIcon({ label }: { label: string }) {
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
const openUsage = useOpenSidebarUsage();
return (
<FooterIconButton
@@ -467,6 +469,7 @@ function SidebarUsageIcon({ label }: { label: string }) {
testID="sidebar-usage-icon"
label={label}
icon={CircleGauge}
theme={theme}
/>
);
}
@@ -609,6 +612,7 @@ function MobileSidebar({
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -785,6 +789,7 @@ function DesktopSidebar({
<SidebarCalloutSlot />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -952,14 +957,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5],
gap: 2,
},
footerIconButton: {
width: density.iconButton,
height: density.iconButton,
footerIconButton: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
}),
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -391,43 +391,6 @@ describe("splitHtmlishMarkdown", () => {
);
});
it("keeps table rows after a comparison that only looks like a tag", () => {
const source = [
"| ID | Note | Status |",
"|---|---|---|",
"| a-1 | accepts 1<size<2 only | open |",
"| a-2 | second row | open |",
"",
"Later x -> y.",
].join("\n");
expect(splitHtmlishMarkdown(source)).toEqual([{ kind: "markdown", text: source }]);
});
it.each(["See <https://example.com/docs> for more.", "Stop when a<b", "Keep </ b> and a <b"])(
"keeps %j literal because it is not HTML",
(source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
},
);
it.each([
"Call As<Integer>() before limits<DWORD>::max()",
"<Kbd Data-Key='K'>Ctrl</Kbd> then <Foo/>",
])("echoes unknown tags in %j as written", (source) => {
expect(normalizeHtmlishMarkdown(source)).toBe(source);
});
it("rebuilds an unknown tag that a code span splits", () => {
expect(normalizeHtmlishMarkdown('<Tip title="`x`">hi</Tip>')).toBe("`x`<tip title>hi</Tip>");
});
it("still reads tags and comments next to literal angle brackets", () => {
expect(normalizeHtmlishMarkdown("1<n<2 <b>bold</b><!-- hidden --> 3<4")).toBe(
"1<n<2 **bold** 3<4",
);
});
it("falls back to inert markdown when details are unclosed", () => {
const source = "<details><summary>Open</summary>Still open";
@@ -29,12 +29,6 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
const BACKTICK_RUN_RE = /`+/g;
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/i;
// CommonMark open and closing tags (spec "Raw HTML"); comments are matched separately.
const HTML_ATTRIBUTE = String.raw`\s+[A-Za-z_:][\w.:-]*(?:\s*=\s*(?:[^\s"'=<>\x60]+|'[^']*'|"[^"]*"))?`;
const HTML_TAG_RE = new RegExp(
String.raw`<(?:[A-Za-z][A-Za-z0-9-]*(?:${HTML_ATTRIBUTE})*\s*\/?|\/[A-Za-z][A-Za-z0-9-]*\s*)>`,
"y",
);
const VOID_HTML_TAGS = new Set(["br", "img", "source"]);
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
b: ["**", "**"],
@@ -379,16 +373,14 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
}
const children = tokens.slice(index + 1, closeIndex);
const close = tokens[closeIndex];
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
output += renderHtmlTag(token, children, closeRaw);
output += renderHtmlTag(token, children);
index = closeIndex;
}
return output;
}
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
if (token.name === "a") {
return renderLinkToken(token, children);
}
@@ -408,7 +400,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: str
if (token.name === "p" || token.name === "div") {
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
}
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
}
function renderTableTokens(tokens: HtmlToken[]): string {
@@ -614,15 +606,7 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
let stripNextLeadingLineBreak = false;
let skippedLength = 0;
let writtenLength = 0;
let chunkStart = 0;
let parser!: Parser;
// Unknown tags render as text, so keep them as written: the parser lowercases names
// and rewrites attributes. A tag split by a protected range has no single slice.
const readRawTag = (): string | null =>
parser.startIndex < chunkStart
? null
: source.slice(parser.startIndex + skippedLength, parser.endIndex + 1 + skippedLength);
parser = new Parser(
{
onopentag(name, attributes, isImplied) {
@@ -636,7 +620,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: false,
selfClosing,
attributes,
raw: readRawTag() ?? renderStartTag(name, attributes),
raw: renderStartTag(name, attributes),
});
},
onclosetag(name, isImplied) {
@@ -649,7 +633,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
closing: true,
selfClosing: false,
attributes: {},
raw: readRawTag() ?? `</${name}>`,
raw: `</${name}>`,
});
},
ontext(value) {
@@ -676,8 +660,6 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
);
return {
write(chunk) {
chunkStart = writtenLength;
writtenLength += chunk.length;
parser.write(chunk);
},
skip(length) {
@@ -716,58 +698,7 @@ function escapeAttribute(value: string): string {
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
const fencedRanges = getFencedCodeRanges(source);
const codeRanges = mergeProtectedRanges([
...fencedRanges,
...getInlineCodeRanges(source, fencedRanges),
]);
return mergeProtectedRanges([...codeRanges, ...getLiteralAngleBracketRanges(source, codeRanges)]);
}
/**
* htmlparser2 reads any `<` followed by a letter as a tag and keeps collecting attributes up
* to the next `>`, so `1<n<2` in a table cell swallows the rows after it and an autolink loses
* its slashes. Hand it only what CommonMark would read as raw HTML; every other `<` is text.
*/
function getLiteralAngleBracketRanges(
source: string,
codeRanges: ProtectedMarkdownRange[],
): ProtectedMarkdownRange[] {
const ranges: ProtectedMarkdownRange[] = [];
let codeIndex = 0;
// Reused across comment openers so unclosed comments stay linear.
let commentClose = -1;
let index = source.indexOf("<");
while (index !== -1) {
while ((codeRanges[codeIndex]?.end ?? Infinity) <= index) {
codeIndex += 1;
}
const code = codeRanges[codeIndex];
if (code && code.start <= index) {
index = source.indexOf("<", code.end);
continue;
}
let htmlEnd: number | null;
if (source.startsWith("<!--", index)) {
if (commentClose !== source.length && commentClose < index + 2) {
const close = source.indexOf("-->", index + 2);
commentClose = close === -1 ? source.length : close;
}
htmlEnd = commentClose === source.length ? null : commentClose + 3;
} else {
HTML_TAG_RE.lastIndex = index;
htmlEnd = HTML_TAG_RE.test(source) ? HTML_TAG_RE.lastIndex : null;
}
if (htmlEnd === null) {
ranges.push({ start: index, end: index + 1 });
htmlEnd = index + 1;
}
index = source.indexOf("<", htmlEnd);
}
return ranges;
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
}
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
@@ -126,6 +126,9 @@ export function ProjectEditSheet({
onClose={handleClose}
footer={footer}
desktopMaxWidth={440}
// Bound the compact scroller to the live snap height so the footer stays
// on screen instead of being pushed past the bottom of the sheet.
sizeContentToCurrentSnapPoint
testID="project-edit-sheet"
>
<Field
@@ -30,7 +30,6 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -115,7 +114,6 @@ import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { DensityIcon } from "@/components/ui/density-icon";
import { PressHighlight } from "@/components/ui/press-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -824,10 +822,9 @@ function NewWorkspaceGhostRow({
{({ hovered, pressed }) => (
<>
<View style={styles.newWorkspaceGhostIconSlot}>
<DensityIcon
icon={Plus}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
<ThemedPlus
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</View>
<Text
@@ -2522,7 +2519,7 @@ const styles = StyleSheet.create((theme) => ({
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
// pressed fills stay the same box as every other row in the sidebar.
newWorkspaceGhostRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[4],
@@ -2542,27 +2539,27 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it.
newWorkspaceGhostIconSlot: {
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
},
projectRow: {
position: "relative",
minHeight: density.rowHeight,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -2603,7 +2600,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -2684,7 +2681,7 @@ const styles = StyleSheet.create((theme) => ({
right: theme.spacing[2],
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -2,11 +2,16 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density, densityFontSize } from "@/styles/density";
import type { Theme } from "@/styles/theme";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function PinnedSectionHeader({
collapsed,
@@ -18,6 +23,7 @@ export function PinnedSectionHeader({
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
return (
<Pressable
@@ -31,11 +37,7 @@ export function PinnedSectionHeader({
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
) : null}
</>
)}
@@ -45,7 +47,7 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
minHeight: 36,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
@@ -56,7 +58,7 @@ const styles = StyleSheet.create((theme) => ({
},
title: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "sm"),
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -4,8 +4,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
getProjectStatusBadgeContent,
@@ -36,8 +35,9 @@ const STATUS_BADGE_OFFSET = -4;
//
// The filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots.
const DensityProjectIconView = withUnistyles(ProjectIconView);
const projectIconProps = densityIconProps("md", () => ({}));
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
@@ -230,12 +230,12 @@ function ProjectIcon({
projectViewKey: string;
}) {
return (
<DensityProjectIconView
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={projectViewKey}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
uniProps={projectIconProps}
/>
);
}
@@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => {
return {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
// only as tall as the icon puts it above the title and the kebab — which is why the
// workspace status indicator shares the same `rowLineHeight`.
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
projectLeadingVisualSlot: {
width: densityIconBox("md"),
height: density.rowLineHeight,
width: theme.iconSize.md,
height: LEADING_SLOT_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -284,8 +284,8 @@ const styles = StyleSheet.create((theme) => {
// Anchors the corner badge to the icon rather than to the taller slot.
projectIconBox: {
position: "relative",
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIconFallbackText: {
fontSize: 9,
@@ -2,11 +2,19 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, ChevronUp } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronUp = withUnistyles(ChevronUp);
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -54,11 +62,17 @@ export function SidebarGroupToggleRow({
{({ hovered, pressed }) => (
<>
<View style={styles.iconSlot}>
<DensityIcon
icon={expanded ? ChevronUp : ChevronDown}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
/>
{expanded ? (
<ThemedChevronUp
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<ThemedChevronDown
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</View>
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
{label}
@@ -72,7 +86,7 @@ export function SidebarGroupToggleRow({
const styles = StyleSheet.create((theme) => ({
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
row: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -92,21 +106,21 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it rather than two points to their left.
iconSlot: {
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
@@ -1,12 +1,22 @@
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
import {
useCallback,
useMemo,
useState,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { Shortcut } from "@/components/ui/shortcut";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
@@ -39,7 +49,7 @@ interface SidebarHeaderRowProps {
}
export function SidebarHeaderRow({
icon,
icon: Icon,
label,
onPress,
isActive = false,
@@ -54,8 +64,9 @@ export function SidebarHeaderRow({
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
const isHighlighted = isHovered || isActive;
const iconSize = variant === "header" ? "md" : "sm";
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
let right = trailing ?? null;
if (right === null && shortcutKeys && isHovered) {
@@ -80,11 +91,10 @@ export function SidebarHeaderRow({
aria-selected={isActive}
style={styles.button}
>
{icon ? (
<DensityIcon
icon={icon}
{ThemedIcon ? (
<ThemedIcon
size={iconSize}
color={isHighlighted ? "foreground" : "foregroundMuted"}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
@@ -130,7 +140,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
// Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator.
minHeight: density.tightRowHeight,
minHeight: 28,
borderRadius: theme.borderRadius.lg,
},
rowHighlighted: {
@@ -142,17 +152,17 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: density.tightRowHeight,
minHeight: 28,
paddingVertical: theme.spacing[1],
// Match the project rows' inner padding so the icons align on one vertical
// edge with the list below.
paddingHorizontal: theme.spacing[2],
},
iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
label: {
flexShrink: 1,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -14,14 +14,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -32,10 +32,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const ThemedActivity = withUnistyles(Activity);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedGift = withUnistyles(Gift);
const ThemedKeyboard = withUnistyles(Keyboard);
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
}
export function SidebarHelpMenu() {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -103,16 +106,15 @@ export function SidebarHelpMenu() {
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
style={styles.trigger(isCompact)}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered }) => (
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
<ThemedCircleHelp
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
@@ -182,14 +184,14 @@ export function SidebarHelpMenu() {
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
trigger: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
}),
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
}));
});
// One mapping per bucket, resolved through the status-dot producer so a group header and
// the rows under it cannot disagree about what "failed" looks like.
const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
const needsInputColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
}));
const failedGroupIconProps = densityIconProps("sm", (theme) => ({
});
const failedColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
}));
const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
});
const attentionColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
}));
const runningGroupIconProps = densityIconProps("sm", (theme) => ({
});
const runningColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
}));
});
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({
return <StatusGroupIcon bucket={leading.bucket} />;
}
if (collapsed) {
return <ThemedChevronRight uniProps={mutedGroupIconProps} />;
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
}
return <ThemedChevronDown uniProps={mutedGroupIconProps} />;
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
}
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
switch (bucket) {
case "needs_input":
return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
case "failed":
return <ThemedCircleX uniProps={failedGroupIconProps} />;
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
case "attention":
return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
case "running":
return <ThemedCircleDot uniProps={runningGroupIconProps} />;
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
case "done":
return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
}
}
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusWorkspaceListContainer: {},
statusGroupRow: {
minHeight: density.rowHeight,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupLeadingVisualSlot: {
position: "relative",
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -16,7 +16,6 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -28,16 +27,19 @@ import {
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
import { DensityIcon } from "@/components/ui/density-icon";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0,
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
});
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
export function SidebarWorkspaceRowFrame({
workspace,
@@ -239,15 +241,15 @@ function WorkspaceStatusIndicator({
) : null;
}
let KindIcon: typeof Monitor;
if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
else KindIcon = Folder;
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<DensityIcon icon={KindIcon} size="sm" color="foregroundMuted" />
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View>
);
@@ -480,8 +482,8 @@ const styles = StyleSheet.create((theme) => ({
},
workspaceStatusDot: {
position: "relative",
width: densityIconBox("md"),
height: density.rowLineHeight,
width: theme.iconSize.md,
height: 20,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -513,9 +515,9 @@ const styles = StyleSheet.create((theme) => ({
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: density.rowLineHeight,
lineHeight: 20,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -4,7 +4,6 @@ import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -486,7 +485,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -160,16 +160,3 @@ describe("combobox above-search ordering", () => {
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
});
});
describe("search-only option text", () => {
it("finds hidden search text while keeping title matches first", () => {
const bodyMatch = { id: "pr:7", label: "Fix parsing", searchText: "Release notes for 2024" };
const titleMatch = { id: "pr:8", label: "2024 release" };
expect(filterAndRankComboboxOptions([bodyMatch, titleMatch], "2024")).toEqual([
titleMatch,
bodyMatch,
]);
expect(filterAndRankComboboxOptions([bodyMatch], "unrelated")).toEqual([]);
expect(filterAndRankComboboxOptions([bodyMatch], "parsing 2024")).toEqual([bodyMatch]);
});
});
@@ -10,22 +10,18 @@ export interface ComboboxOptionModel {
id: string;
label: string;
description?: string;
/** Additional text to match without displaying it in the option row. */
searchText?: string;
kind?: ComboboxOptionKind;
}
const SECONDARY_TEXT_FALLBACK_TIER = 99;
const DESCRIPTION_FALLBACK_TIER = 99;
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
const best = scoreTextFields(search, [opt.label, opt.id]);
if (best) return best;
const secondaryFields = opt.searchText
? [opt.label, opt.id, opt.description ?? "", opt.searchText]
: [opt.description ?? ""];
const secondaryScore = scoreTextFields(search, secondaryFields);
if (!secondaryScore) return null;
return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER };
if (!opt.description) return null;
const descriptionScore = scoreTextFields(search, [opt.description]);
if (!descriptionScore) return null;
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
}
export interface BuildVisibleComboboxOptionsInput {
+43 -55
View File
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Check, File, Folder, Search } from "lucide-react-native";
@@ -50,7 +50,6 @@ import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
import {
@@ -949,19 +948,9 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const { footer, safeAreaBottom } = props;
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
[footer, safeAreaBottom],
);
const footerStyle = useMemo(
() => [styles.footer, { paddingBottom: safeAreaBottom }],
[safeAreaBottom],
);
const footerView = useMemo(
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
[footer, footerStyle],
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
[props.safeAreaBottom],
);
const body = props.hasChildren ? (
@@ -994,47 +983,46 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<SheetVisibleFrame footer={footerView}>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</View>
</SheetVisibleFrame>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</View>
</IsolatedBottomSheetModal>
);
}
@@ -1,51 +0,0 @@
import { useMemo, type ComponentType } from "react";
import { withUnistyles } from "react-native-unistyles";
import { densityIconSize, type DensityIconSize } from "@/styles/density";
import type { Theme } from "@/styles/theme";
type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>;
type IconColor = {
[K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never;
}[keyof Theme["colors"]];
function IconGlyph({
icon: Icon,
size,
color,
strokeWidth,
}: {
icon: IconComponent;
size: number;
color: string;
strokeWidth?: number;
}) {
return <Icon size={size} color={color} strokeWidth={strokeWidth} />;
}
const ThemedIconGlyph = withUnistyles(IconGlyph);
/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */
export function DensityIcon({
icon,
size,
sizeAdjustment = 0,
color,
strokeWidth,
}: {
icon: IconComponent;
size: DensityIconSize;
/** Only for a glyph that reads larger than others at the same size. */
sizeAdjustment?: number;
color: IconColor;
strokeWidth?: number;
}) {
const uniProps = useMemo(
() => (theme: Theme, rt: { breakpoint?: string }) => ({
size: densityIconSize(rt, size) + sizeAdjustment,
color: theme.colors[color],
}),
[size, sizeAdjustment, color],
);
return <ThemedIconGlyph icon={icon} strokeWidth={strokeWidth} uniProps={uniProps} />;
}
@@ -1,4 +1,3 @@
import { createContext, useCallback, useContext } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -9,8 +8,6 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/
export type IconButtonChromeSize = "large" | "small";
const SMALL_ICON_BUTTON_SIZE = 20;
/** Width and height of a large icon button on compact layouts. */
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
export interface IconButtonChromeState {
@@ -30,53 +27,20 @@ interface IconButtonChromeOptions {
state?: IconButtonChromeState;
compact?: boolean;
disabled?: boolean;
/** A circular hitbox and highlight, for buttons inside a pill-shaped container. */
round?: boolean;
style?: StyleProp<ViewStyle>;
}
const IconButtonChromeContext = createContext<"default" | "header-island">("default");
/** A container owns the chrome of its icon actions, including custom menu and badge triggers. */
export const IconButtonChromeProvider = IconButtonChromeContext.Provider;
/** The same state callback works with Pressable, TooltipTrigger, and DropdownMenuTrigger. */
export function useIconButtonChromeStyle({
size,
compact,
disabled,
round,
style,
active,
}: Omit<IconButtonChromeOptions, "state"> & { active?: boolean }) {
const scope = useContext(IconButtonChromeContext);
return useCallback(
(state: IconButtonChromeState) =>
iconButtonChromeStyle({
size: scope === "header-island" ? "large" : size,
compact,
disabled,
round: scope === "header-island" || round,
style,
state: { ...state, active: active || state.active },
}),
[scope, size, compact, disabled, round, style, active],
);
}
/** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */
export function iconButtonChromeStyle({
size,
state,
compact = false,
disabled = false,
round = false,
style,
}: IconButtonChromeOptions): StyleProp<ViewStyle> {
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
return [
resolveIconButtonFrame(size, compact),
round ? styles.round : null,
style,
highlighted ? styles.highlighted : null,
disabled ? styles.disabled : null,
@@ -103,11 +67,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
const styles = StyleSheet.create((theme) => ({
large: {
width: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
padding: 0,
@@ -140,9 +104,6 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
round: {
borderRadius: theme.borderRadius.full,
},
highlighted: {
backgroundColor: theme.colors.interactionHighlight,
},
@@ -152,5 +152,3 @@ export function useIsolatedBottomSheetVisibility({
handleSheetDismiss,
};
}
export { SheetVisibleFrame } from "./visible-frame";
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout";
describe("getBottomSheetVisibleContentHeight", () => {
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
const layout = {
containerHeight: 874,
contentPosition: 88,
handleHeight: 24,
keyboardHeight: 344,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
});
});
// Below the lowest snap the frame must travel with the sheet, not shrink to the screen edge.
describe("sheet footer motion", () => {
it("keeps the frame height fixed while opening and panning below the lowest snap", () => {
const layout = {
containerHeight: 1000,
handleHeight: 24,
keyboardHeight: 0,
isKeyboardVisible: false,
lowestDetentPosition: 400,
};
const heights = [1000, 800, 600, 400].map((contentPosition) =>
getBottomSheetVisibleContentHeight({ ...layout, contentPosition }),
);
expect(heights).toEqual([576, 576, 576, 576]);
});
});
it("resizes above the lowest snap and never returns negative or unmeasured heights", () => {
const layout = {
containerHeight: 1000,
handleHeight: 24,
keyboardHeight: 0,
isKeyboardVisible: false,
lowestDetentPosition: 400,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100 })).toBe(876);
expect(
getBottomSheetVisibleContentHeight({
...layout,
contentPosition: 100,
keyboardHeight: 950,
isKeyboardVisible: true,
}),
).toBe(0);
expect(
getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, handleHeight: -999 }),
).toBe(0);
expect(
getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, containerHeight: -999 }),
).toBe(0);
});
@@ -1,25 +0,0 @@
interface BottomSheetVisibleContentHeightInput {
containerHeight: number;
contentPosition: number;
lowestDetentPosition?: number;
handleHeight: number;
keyboardHeight: number;
isKeyboardVisible: boolean;
}
export function getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
lowestDetentPosition,
handleHeight,
keyboardHeight,
isKeyboardVisible,
}: BottomSheetVisibleContentHeightInput): number {
"worklet";
if (containerHeight < 0 || handleHeight < 0) return 0;
const position = Math.min(contentPosition, lowestDetentPosition ?? contentPosition);
return Math.max(
0,
containerHeight - position - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
);
}
@@ -1,42 +0,0 @@
import type { ReactNode } from "react";
import { View, StyleSheet } from "react-native";
import { KEYBOARD_STATUS, useBottomSheetInternal } from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout";
/** Fixed-detent sheet content. The footer shares the sheet's translation below its lowest snap. */
export function SheetVisibleFrame({
children,
footer,
}: {
children: ReactNode;
footer?: ReactNode;
}) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
const keyboard = animatedKeyboardState.get();
return {
height: getBottomSheetVisibleContentHeight({
containerHeight,
handleHeight,
contentPosition: animatedPosition.get(),
lowestDetentPosition: animatedDetentsState.get().detents?.[0],
keyboardHeight: keyboard.heightWithinContainer,
isKeyboardVisible: keyboard.status === KEYBOARD_STATUS.SHOWN,
}),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.frame, visibleStyle]}>
<View style={styles.body}>{children}</View>
{footer}
</Animated.View>
);
}
const styles = StyleSheet.create({
frame: { minHeight: 0, overflow: "hidden" },
body: { flex: 1, minHeight: 0 },
});
@@ -16,7 +16,6 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
useIconButtonChromeStyle,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
@@ -61,8 +60,6 @@ interface ToolbarButtonCommonProps extends Omit<
tooltip?: ReactNode;
selected?: boolean;
compact?: boolean;
/** A circular highlight, for buttons inside a pill-shaped container. */
round?: boolean;
shortcut?: ShortcutKey[][] | null;
tooltipSide?: "left" | "right" | "top" | "bottom";
style?: StyleProp<ViewStyle>;
@@ -84,7 +81,6 @@ export function ToolbarButton({
tooltip: tooltipBody,
selected = false,
compact = false,
round = false,
shortcut,
tooltipSide = "bottom",
style,
@@ -94,14 +90,17 @@ export function ToolbarButton({
testID,
...props
}: ToolbarButtonProps) {
const buttonStyle = useIconButtonChromeStyle({
size: "small",
compact,
active: selected,
disabled: Boolean(disabled),
round,
style,
});
const buttonStyle = useMemo(
() => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) =>
iconButtonChromeStyle({
size: "small",
compact,
state: { ...state, active: selected },
disabled: Boolean(disabled),
style,
}),
[compact, disabled, selected, style],
);
const accessibilityState = useMemo(
() => ({ disabled: Boolean(disabled), selected }),
[disabled, selected],
@@ -56,16 +56,6 @@ afterEach(() => {
function noop() {}
function createHandleRecorder() {
const handleRef = createRef<EditingTextInputHandle>();
const received: (EditingTextInputHandle | null)[] = [];
function assignHandle(handle: EditingTextInputHandle | null) {
received.push(handle);
handleRef.current = handle;
}
return { handleRef, received, assignHandle };
}
describe("EditingTextInputNative", () => {
it("uses the bottom-sheet input only inside a bottom sheet", () => {
act(() => {
@@ -86,32 +76,6 @@ describe("EditingTextInputNative", () => {
expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" });
});
it("keeps its editing handle and selection when native editing rerenders the leaf", () => {
const { handleRef, received, assignHandle } = createHandleRecorder();
act(() =>
root?.render(<EditingTextInput ref={assignHandle} initialValue="initial" multiline />),
);
const input = container?.querySelector("textarea");
if (!input) throw new Error("Expected native editing surface");
const handle = handleRef.current;
act(() => {
handle?.focus();
const setValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
if (!setValue) throw new Error("Expected input value setter");
setValue.call(input, "native edit");
input.setSelectionRange(2, 5);
input.dispatchEvent(new InputEvent("input", { bubbles: true, data: "native edit" }));
});
expect(handleRef.current).toBe(handle);
expect(received).toEqual([handle]);
expect(handle?.getText()).toBe("native edit");
expect([input.selectionStart, input.selectionEnd]).toEqual([2, 5]);
expect(document.activeElement).toBe(input);
act(() => root?.unmount());
root = null;
expect(received).toEqual([handle, null]);
});
it("clears text via clear() when replaceText receives an empty string", () => {
const handleRef = createRef<EditingTextInputHandle>();
@@ -141,14 +105,12 @@ describe("EditingTextInputNative", () => {
);
});
const grownInput = container?.querySelector("input");
const handle = handleRef.current;
act(() => {
handleRef.current?.reset();
});
expect(container?.querySelector("input")).not.toBe(grownInput);
expect(handleRef.current).toBe(handle);
});
it("restores focus after replacing a reset native input", () => {
@@ -69,56 +69,52 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
setReplacement((current) => ({ ...current, autoFocus }));
}, []);
useImperativeHandle(
ref,
() => ({
focus: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(true);
return;
}
inputRef.current?.focus();
},
blur: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(false);
return;
}
inputRef.current?.blur();
},
isFocused: () => inputRef.current?.isFocused?.() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
return;
}
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
reset: () => {
textRef.current = "";
const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText("");
} else {
inputRef.current?.clear?.();
}
isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}),
[setReplacementFocus],
);
useImperativeHandle(ref, () => ({
focus: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(true);
return;
}
inputRef.current?.focus();
},
blur: () => {
if (isAwaitingReplacementRef.current) {
setReplacementFocus(false);
return;
}
inputRef.current?.blur();
},
isFocused: () => inputRef.current?.isFocused?.() ?? false,
getText: () => textRef.current,
replaceText: (nextText, selection) => {
textRef.current = nextText;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText(nextText, selection);
return;
}
if (nextText === "") {
inputRef.current?.clear?.();
return;
}
inputRef.current?.setNativeProps?.({
text: nextText,
...(selection ? { selection } : {}),
});
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
},
reset: () => {
textRef.current = "";
const autoFocus = inputRef.current?.isFocused?.() ?? false;
if (inputRef.current?.replaceText) {
inputRef.current.replaceText("");
} else {
inputRef.current?.clear?.();
}
isAwaitingReplacementRef.current = true;
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
},
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
}));
const handleChangeText = useCallback(
(nextText: string) => {
@@ -57,35 +57,31 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
onChangeTextRef.current?.(nextText);
}, []);
useImperativeHandle(
ref,
() => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => document.activeElement === inputRef.current,
getText: () => {
const input = inputRef.current as WebTextInputElement | null;
const nextText = input?.value ?? textRef.current;
textRef.current = nextText;
return nextText;
},
replaceText: (nextText, selection) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = nextText;
if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end);
}
},
reset: () => {
textRef.current = "";
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = "";
},
getNativeRef: () => inputRef.current,
}),
[],
);
useImperativeHandle(ref, () => ({
focus: () => inputRef.current?.focus(),
blur: () => inputRef.current?.blur(),
isFocused: () => document.activeElement === inputRef.current,
getText: () => {
const input = inputRef.current as WebTextInputElement | null;
const nextText = input?.value ?? textRef.current;
textRef.current = nextText;
return nextText;
},
replaceText: (nextText, selection) => {
textRef.current = nextText;
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = nextText;
if (selection && typeof input?.setSelectionRange === "function") {
input.setSelectionRange(selection.start, selection.end);
}
},
reset: () => {
textRef.current = "";
const input = inputRef.current as WebTextInputElement | null;
if (input && "value" in input) input.value = "";
},
getNativeRef: () => inputRef.current,
}));
return (
<TextInput
@@ -1,7 +1,6 @@
import type { TextInputProps } from "react-native";
import type { NativePastedFile } from "@/composer/native-pasted-image";
/** Stable for the mounted editor lifetime; reset() owns any inner input replacement. */
export interface EditingTextInputHandle {
focus(): void;
blur(): void;
@@ -298,6 +298,7 @@ export function CompactModelSheet({
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
@@ -345,6 +346,7 @@ export function CompactModelSheet({
onClose={closeModelBrowser}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint
presentation="push"
contentStyle={styles.sheetBody}
testID="agent-controls-model-browser-sheet"
@@ -2,7 +2,6 @@ import { ComposerDockBackground } from "./internal/background";
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
import { KeyboardTranslateView } from "@/keyboard/shift";
@@ -18,7 +17,6 @@ import Animated, {
import { useKeyboardShift } from "@/keyboard/shift";
import {
resolveCenteredClearance,
resolveInlineComposerCapacity,
updateComposerCapacity,
type ComposerCapacity,
} from "./internal/capacity";
@@ -73,13 +71,12 @@ function ComposerViewport({
/** Bound the complete composer (including its controls), not just the text input. */
function ComposerViewportContent({ style, ...props }: ViewProps) {
const compact = useIsCompactFormFactor();
const capacity = useContext(ViewportCapacity);
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
const constraint = useAnimatedStyle(() => ({
// Only viewport changes and keyboard start/end events trigger layout. Motion
// stays in KeyboardTranslateView and never changes this constraint per frame.
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
maxHeight: capacity.value,
}));
return (
+5 -17
View File
@@ -1,10 +1,8 @@
export { ComposerDockBackground } from "./internal/background";
import { ScrollView } from "@/components/ui/scroll-view";
import { useState, useCallback, useMemo, type ReactNode } from "react";
import type { ReactNode } from "react";
import { View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { resolveInlineComposerCapacity } from "./internal/capacity";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
interface ComposerDockProps {
@@ -16,33 +14,23 @@ export function ComposerDock({
children: [content, composer, overlay],
centered = false,
}: ComposerDockProps) {
const compact = useIsCompactFormFactor();
const [availableHeight, setAvailableHeight] = useState<number>();
const constraint = useMemo(
() => ({ maxHeight: resolveInlineComposerCapacity(availableHeight, compact) }),
[availableHeight, compact],
);
const measureViewport = useCallback((event: import("react-native").LayoutChangeEvent) => {
const { height } = event.nativeEvent.layout;
if (height > 0) setAvailableHeight(height);
}, []);
if (centered) {
return (
<View style={styles.centered} onLayout={measureViewport}>
<View style={styles.centered}>
<View style={styles.form}>
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
{content}
</ScrollView>
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
<View style={styles.centeredComposer}>{composer}</View>
{overlay}
</View>
</View>
);
}
return (
<View style={styles.surface} onLayout={measureViewport}>
<View style={styles.surface}>
<View style={styles.content}>{content}</View>
<View style={[styles.composer, constraint]}>{composer}</View>
<View style={styles.composer}>{composer}</View>
{overlay}
</View>
);
@@ -1,24 +1,11 @@
import { describe, expect, it } from "vitest";
import {
resolveCenteredClearance,
resolveInlineComposerCapacity,
resolveComposerCapacity,
updateComposerCapacity,
} from "./capacity";
describe("composer viewport", () => {
it("caps the complete compact composer at sixty percent of keyboard-reserved space", () => {
const open = updateComposerCapacity(undefined, {
height: 582,
bottomInset: 24,
safeAreaBottom: 0,
centered: false,
keyboardShift: 308,
});
expect(resolveInlineComposerCapacity(open.capacity, true)).toBe(147);
expect(resolveInlineComposerCapacity(open.capacity, false)).toBe(245);
expect(resolveInlineComposerCapacity(undefined, true)).toBeUndefined();
});
it("preserves the editing capacity when the keyboard closes", () => {
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
@@ -50,13 +50,3 @@ export function updateComposerCapacity(
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
};
}
/** Compact editing leaves conversation space above the complete composer. */
export function resolveInlineComposerCapacity(
capacity: number | undefined,
compact: boolean,
): number | undefined {
"worklet";
if (capacity === undefined) return undefined;
return compact ? capacity * 0.6 : capacity;
}
@@ -115,7 +115,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
const isHydrated = hydratedDraftKey === draftKey;
const textReplacementRevisionRef = useRef(0);
const [textReplacement, setTextReplacement] = useState<TextReplacement>(() => ({
kind: "initial",
key: `${draftKey}:0`,
text: textSource.getSnapshot(),
}));
@@ -124,7 +123,6 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
(nextText: string) => {
textReplacementRevisionRef.current += 1;
setTextReplacement({
kind: "replace",
key: `${draftKey}:${textReplacementRevisionRef.current}`,
text: nextText,
});
@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import { ComposerEditingSession } from "./index";
function leaveLiveDraft(session: ComposerEditingSession) {
session.captureForRemount({
snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } },
focused: true,
replacementKey: "draft:applied",
});
}
describe("composer editing remount handoff", () => {
it("keeps live text and selection when remounting from an initial draft publication", () => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" });
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
expect(handoff?.focused).toBe(true);
});
it("keeps local edits when the last applied replacement is still current", () => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({
kind: "replace",
key: "draft:applied",
text: "old replacement",
});
expect(handoff?.snapshot).toEqual({
text: "Unpublished live draft",
selection: { start: 4, end: 17 },
});
});
it.each(["New explicit draft", ""])(
"honors an explicit replacement made while the editor is absent: %j",
(text) => {
const session = new ComposerEditingSession();
leaveLiveDraft(session);
const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text });
expect(handoff?.snapshot).toEqual({
text,
selection: { start: text.length, end: text.length },
});
expect(handoff?.focused).toBe(true);
expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull();
},
);
});
@@ -1,142 +0,0 @@
import React, {
createContext,
useContext,
useEffect,
useMemo,
useState,
useSyncExternalStore,
type ReactNode,
} from "react";
import type { TextReplacement } from "@/composer/types";
import type { ComposerInputMode } from "@/composer/input-mode";
export interface ComposerEditingHandoff {
snapshot: { text: string; selection: { start: number; end: number } };
focused: boolean;
replacementKey: string;
}
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
export class ComposerEditingSession {
private fullscreen = false;
private handoff: ComposerEditingHandoff | null = null;
private listeners = new Set<() => void>();
getFullscreen = () => this.fullscreen;
subscribe = (listener: () => void) => {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
};
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
this.captureForRemount(handoff);
if (fullscreen === this.fullscreen) return;
this.fullscreen = fullscreen;
for (const listener of this.listeners) listener();
}
captureForRemount(handoff: ComposerEditingHandoff) {
this.handoff = handoff;
}
peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null {
const handoff = this.handoff;
if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey)
return handoff;
// A replacement command issued since capture supersedes the old editor's live buffer.
return {
snapshot: {
text: replacement.text,
selection: { start: replacement.text.length, end: replacement.text.length },
},
focused: handoff.focused,
replacementKey: replacement.key,
};
}
takeHandoff(replacement: TextReplacement) {
const handoff = this.peekHandoff(replacement);
this.handoff = null;
return handoff;
}
}
class ComposerEditingSessions {
private workspaces = new Map<
string,
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
>();
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
let workspace = this.workspaces.get(workspaceKey);
if (!workspace) {
workspace = new Map();
this.workspaces.set(workspaceKey, workspace);
}
let tab = workspace.get(tabId);
if (!tab || tab.targetKey !== targetKey) {
tab = { targetKey, sessions: new Map() };
workspace.set(tabId, tab);
}
let session = tab.sessions.get(purpose);
if (!session) {
session = new ComposerEditingSession();
tab.sessions.set(purpose, session);
}
return session;
}
retain(workspaceKey: string, tabIds: readonly string[]) {
const workspace = this.workspaces.get(workspaceKey);
if (!workspace) return;
const retained = new Set(tabIds);
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
}
}
const SessionsContext = createContext<ComposerEditingSessions | null>(null);
const WorkspaceContext = createContext<string | null>(null);
const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null);
/** App-container ownership survives replacement of both the gesture shell and tab layout. */
export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) {
const [sessions] = useState(() => new ComposerEditingSessions());
return <SessionsContext.Provider value={sessions}>{children}</SessionsContext.Provider>;
}
export function WorkspaceComposerEditingSessionsProvider({
workspaceKey,
tabIds,
children,
}: {
workspaceKey: string;
tabIds: readonly string[];
children: ReactNode;
}) {
const sessions = useContext(SessionsContext);
useEffect(() => sessions?.retain(workspaceKey, tabIds), [sessions, workspaceKey, tabIds]);
return <WorkspaceContext.Provider value={workspaceKey}>{children}</WorkspaceContext.Provider>;
}
export function ComposerEditingScope({
tabId,
targetKey,
children,
}: {
tabId: string;
targetKey: string;
children: ReactNode;
}) {
const scope = useMemo(() => ({ tabId, targetKey }), [tabId, targetKey]);
return <ScopeContext.Provider value={scope}>{children}</ScopeContext.Provider>;
}
export function useComposerEditingSession(
purpose: ComposerInputMode,
provided?: ComposerEditingSession,
): ComposerEditingSession {
const sessions = useContext(SessionsContext);
const scope = useContext(ScopeContext);
const workspaceKey = useContext(WorkspaceContext);
const [local] = useState(() => provided ?? new ComposerEditingSession());
if (provided) return provided;
return sessions && scope && workspaceKey
? sessions.get(workspaceKey, scope.tabId, scope.targetKey, purpose)
: local;
}
export function useComposerFullscreen(session: ComposerEditingSession): boolean {
return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen);
}
+4 -11
View File
@@ -1,4 +1,3 @@
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
@@ -1004,10 +1003,8 @@ function ComposerAutocompleteBinding({
cursorIndex: Math.min(cursorIndex, userInput.length),
onAutocompleteApplied: () => inputRef.current?.focus(),
});
const handleKeyPress = autocomplete.onKeyPress;
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
show,
handleKeyPress,
useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [
autocomplete.onKeyPress,
]);
const selectOption = autocomplete.onSelectOption;
const onSelect = useCallback(
@@ -1339,8 +1336,6 @@ function ComposerContentImpl({
const nextPendingFileId = useRef(0);
const isUploadingFile = pendingFiles.length > 0;
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
const editingSession = useComposerEditingSession(inputMode);
const isFullscreen = useComposerFullscreen(editingSession);
const [sendError, setSendError] = useState<string | null>(null);
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
@@ -2369,7 +2364,7 @@ function ComposerContentImpl({
<View style={inputAreaContainerStyle}>
<View style={styles.inputAreaContent}>
{queueList}
{!isFullscreen && sendErrorNode}
{sendErrorNode}
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
<ComposerAutocompleteBinding
@@ -2377,7 +2372,7 @@ function ComposerContentImpl({
cursor={cursor}
inputRef={messageInputRef}
anchorRef={messageInputContainerRef}
show={mode.showAutocomplete && !isFullscreen}
show={mode.showAutocomplete}
ref={autocompleteRef}
configuration={autocompleteConfiguration}
/>
@@ -2392,8 +2387,6 @@ function ComposerContentImpl({
<RenderProfile id="MessageInput">
<StableMessageInput
ref={messageInputRef}
editingSession={editingSession}
submissionError={sendErrorNode}
value={textSource.getSnapshot()}
onChangeText={setUserInput}
onSubmit={handleSubmit}
@@ -1,57 +0,0 @@
import { useEffect, type ReactNode } from "react";
import { Portal } from "@gorhom/portal";
import { BackHandler, View, useWindowDimensions } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal";
import { useKeyboardShift } from "@/keyboard/shift";
/** Same-window host keeps the IME attached while escaping the dock's clipping. */
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
useEffect(() => {
if (!active) return;
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
onExit();
return true;
});
return () => handler.remove();
}, [active, onExit]);
const insets = useSafeAreaInsets();
const { height } = useWindowDimensions();
const { layoutShift } = useKeyboardShift();
const frame = useAnimatedStyle(() => ({
height: Math.max(0, height - layoutShift.value),
paddingTop: insets.top,
paddingBottom: insets.bottom,
paddingLeft: insets.left,
paddingRight: insets.right,
}));
return (
<Portal hostName={DEFAULT_FLOATING_PANEL_PORTAL_HOST}>
<View style={[styles.background, !active && styles.hidden]}>
<Animated.View style={[{ width: "100%" }, frame]}>{children}</Animated.View>
</View>
</Portal>
);
}
const styles = StyleSheet.create((theme) => ({
hidden: { display: "none" },
background: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
right: 0,
backgroundColor: theme.colors.surface1,
},
}));
@@ -1,77 +0,0 @@
import { useMemo, useLayoutEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { View } from "react-native";
import {
getOverlayRoot,
useOverlayLayer,
OverlayLayerProvider,
useWebOverlayRegistration,
} from "@/lib/overlay-root";
export function ComposerFullscreenHost({
children,
onExit,
active,
}: {
children: ReactNode;
onExit: () => void;
active: boolean;
}) {
const layer = useOverlayLayer("modal");
const registration = useWebOverlayRegistration({
active,
layer,
onKeyDown: (event) => {
if (event.key !== "Escape") return false;
event.preventDefault();
onExit();
return true;
},
});
const [viewport, setViewport] = useState(readVisibleViewport);
useLayoutEffect(() => {
if (!active) return;
const update = () => setViewport(readVisibleViewport());
const visualViewport = window.visualViewport;
update();
visualViewport?.addEventListener("resize", update);
visualViewport?.addEventListener("scroll", update);
window.addEventListener("resize", update);
return () => {
visualViewport?.removeEventListener("resize", update);
visualViewport?.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, [active]);
const frameStyle = useMemo(
() => ({
display: active ? ("flex" as const) : ("none" as const),
position: "absolute" as const,
top: viewport.top,
left: viewport.left,
width: viewport.width,
zIndex: layer,
pointerEvents: "auto" as const,
height: viewport.height,
}),
[active, layer, viewport],
);
return createPortal(
<OverlayLayerProvider layer={layer}>
<View ref={registration} style={frameStyle}>
{children}
</View>
</OverlayLayerProvider>,
getOverlayRoot(),
);
}
function readVisibleViewport() {
const viewport = window.visualViewport;
return {
top: viewport?.offsetTop ?? 0,
left: viewport?.offsetLeft ?? 0,
width: viewport?.width ?? window.innerWidth,
height: viewport?.height ?? window.innerHeight,
};
}
@@ -7,8 +7,6 @@ interface ComposerHeightArgs {
textareaRef: RefObject<unknown>;
minHeight: number;
maxHeight: number;
onRenderedLinesChange?: (lines: number) => void;
presentationKey?: boolean;
}
export function useComposerHeight({
+4 -15
View File
@@ -7,8 +7,6 @@ interface ComposerHeightArgs {
textareaRef: RefObject<HTMLElement | null>;
minHeight: number;
maxHeight: number;
onRenderedLinesChange?: (lines: number) => void;
presentationKey?: boolean;
}
const COPIED_STYLES = [
@@ -38,13 +36,11 @@ export function useComposerHeight({
textareaRef,
minHeight,
maxHeight,
onRenderedLinesChange,
presentationKey,
}: ComposerHeightArgs): ComposerHeightResult {
const [height, setHeight] = useState(minHeight);
const heightRef = useRef(minHeight);
const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange });
paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange };
const paramsRef = useRef({ getText, minHeight, maxHeight });
paramsRef.current = { getText, minHeight, maxHeight };
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
const setBoundedHeight = useCallback((nextHeight: number) => {
@@ -69,13 +65,6 @@ export function useComposerHeight({
}
mirror.style.width = `${sourceWidth}px`;
mirror.value = text.endsWith("\n") ? `${text} ` : text;
const padding =
parseFloat(computedStyle.paddingTop) + parseFloat(computedStyle.paddingBottom);
const lineHeight = parseFloat(computedStyle.lineHeight);
if (lineHeight > 0)
paramsRef.current.onRenderedLinesChange?.(
Math.round((mirror.scrollHeight - padding) / lineHeight),
);
setBoundedHeight(mirror.scrollHeight);
},
[setBoundedHeight, textareaRef],
@@ -112,7 +101,7 @@ export function useComposerHeight({
useLayoutEffect(() => {
measure(getText());
}, [maxHeight, minHeight, getText, measure, presentationKey]);
}, [maxHeight, minHeight, getText, measure]);
useEffect(() => {
const source = textareaRef.current;
@@ -126,7 +115,7 @@ export function useComposerHeight({
});
observer.observe(source);
return () => observer.disconnect();
}, [measure, textareaRef, presentationKey]);
}, [measure, textareaRef]);
const onTextChange = useCallback(
(_previousText: string, nextText: string) => measure(nextText),
+152 -459
View File
@@ -1,8 +1,3 @@
import {
useComposerEditingSession,
useComposerFullscreen,
type ComposerEditingSession,
} from "@/composer/editing-session";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
@@ -61,7 +56,6 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
import { RenderProfile } from "@/utils/render-profiler";
import { useComposerHeight } from "./height";
import { ComposerInputPresentation } from "./presentation";
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
import type { NativePastedFile } from "@/composer/native-pasted-image";
import {
@@ -83,7 +77,6 @@ import {
applyDictationTranscript,
computeCanStartDictation,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -113,8 +106,6 @@ export interface ComposerKeyPressEvent {
}
export interface MessageInputProps {
editingSession?: ComposerEditingSession;
submissionError?: React.ReactNode;
value: string;
onChangeText: (text: string) => void;
onSubmit: (payload: MessagePayload) => void;
@@ -215,7 +206,6 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
>;
interface TextAreaHandle {
value?: string;
scrollHeight?: number;
clientHeight?: number;
offsetHeight?: number;
@@ -225,7 +215,7 @@ interface TextAreaHandle {
style?: {
height?: string;
overflowY?: string;
};
} & Record<string, unknown>;
}
function AttachButtonIcon({
@@ -443,7 +433,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
}
interface PasteImagesEffectArgs {
textarea: HTMLElement | null;
getWebTextArea: () => TextAreaHandle | null;
isConnected: boolean;
disabled: boolean;
isDictating: boolean;
@@ -453,7 +443,7 @@ interface PasteImagesEffectArgs {
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
const {
textarea,
getWebTextArea,
isConnected,
disabled,
isDictating,
@@ -464,7 +454,19 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
useEffect(() => {
if (!isWeb || !onAddImages) return;
if (!textarea) return;
const textarea = getWebTextArea() as
| (TextAreaHandle & {
addEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void;
removeEventListener?: (type: string, listener: (e: ClipboardEvent) => void) => void;
})
| null;
if (
!textarea ||
typeof textarea.addEventListener !== "function" ||
typeof textarea.removeEventListener !== "function"
) {
return;
}
let disposed = false;
const handlePaste = (event: ClipboardEvent) => {
@@ -491,7 +493,14 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
disposed = true;
textarea.removeEventListener?.("paste", handlePaste);
};
}, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
}, [
disabled,
getWebTextArea,
isConnected,
isDictating,
isRealtimeVoiceForCurrentAgent,
onAddImages,
]);
}
function useAutoFocusOnWebEffect(
@@ -619,7 +628,6 @@ function FocusHint({
interface ComposerTextSurfaceProps {
readOnly: boolean;
value: string;
textReplacementKey: string;
textInputRef: React.Ref<ComposerTextInputHandle>;
textInputStyle: EditingTextInputProps["style"];
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
@@ -635,9 +643,6 @@ interface ComposerTextSurfaceProps {
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
onPasteError: (message: string) => void;
fullscreen: boolean;
compact: boolean;
onRenderedLinesChange: (lines: number) => void;
focusHintVisible: boolean;
focusInputKeys: ShortcutChord | null | undefined;
focusHintLabel: string;
@@ -649,23 +654,6 @@ interface ComposerTextSurfaceProps {
* state of this composer rather than a second one.
*/
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
const { onChangeText, onRenderedLinesChange } = props;
const [measurementText, setMeasurementText] = useState(props.value);
useEffect(() => {
if (!isWeb) setMeasurementText(props.value);
}, [props.value, props.textReplacementKey]);
const handleTextChange = useCallback(
(text: string) => {
if (!isWeb) setMeasurementText(text);
onChangeText(text);
},
[onChangeText, setMeasurementText],
);
const measureLines = useCallback(
(event: import("react-native").TextLayoutEvent) =>
onRenderedLinesChange(event.nativeEvent.lines.length),
[onRenderedLinesChange],
);
if (props.readOnly) {
return (
<View style={styles.textInputScrollWrapper}>
@@ -676,28 +664,12 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
);
}
return (
<View
style={[
styles.textInputScrollWrapper,
props.fullscreen && styles.fullscreenTextSurface,
props.compact && !props.fullscreen && styles.compactTextSurface,
]}
>
{!isWeb && props.compact && !props.fullscreen ? (
<Text
accessible={false}
pointerEvents="none"
style={[props.textInputStyle, styles.lineMeasurement]}
onTextLayout={measureLines}
>
{`${measurementText}\u200b`}
</Text>
) : null}
<View style={styles.textInputScrollWrapper}>
<ComposerTextInput
ref={props.textInputRef}
dataSet={COMPOSER_INPUT_DATASET}
initialValue={props.value}
onChangeText={handleTextChange}
onChangeText={props.onChangeText}
placeholder={props.placeholder}
accessibilityLabel={props.accessibilityLabel}
onFocus={props.onFocus}
@@ -871,7 +843,6 @@ interface ToggleRealtimeVoiceContext {
handleStopRealtimeVoice: () => Promise<unknown> | void;
toast: { error: (msg: string) => void };
interruptBeforeVoiceMessage: string;
exitFullscreen: () => void;
}
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
@@ -887,7 +858,6 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
return;
}
ctx.exitFullscreen();
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
console.error("[MessageInput] Failed to start realtime voice", error);
const message = extractErrorMessage(error);
@@ -902,7 +872,6 @@ interface StartDictationContext {
canStartDictation: () => boolean;
toast: { error: (msg: string) => void };
startDictation: () => Promise<void>;
exitFullscreen: () => void;
}
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
@@ -913,7 +882,6 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis
if (!ctx.canStartDictation()) {
return;
}
ctx.exitFullscreen();
await ctx.startDictation();
}
@@ -1015,8 +983,7 @@ function computeIsDictationStartEnabled(
return (isReadyForDictation ?? isConnected) && !disabled;
}
function resolveMaxInputHeight(windowHeight: number, compact: boolean): number {
if (compact) return DEFAULT_MAX_INPUT_HEIGHT;
function resolveMaxInputHeight(windowHeight: number): number {
if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT;
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
}
@@ -1040,12 +1007,9 @@ function getComposerInputSnapshot(
current: ComposerTextInputHandle | null,
fallbackText: string,
fallbackSelection: ComposerInputSnapshot["selection"],
mountedTextArea?: TextAreaHandle | null,
): ComposerInputSnapshot {
// The inner DOM ref can detach before the outer editing handle. Keep the mounted
// element available until this owner's ref callback captures the remount handoff.
const textArea = mountedTextArea ?? getWebTextAreaImpl(current);
const text = textArea?.value ?? current?.getText() ?? fallbackText;
const text = current?.getText() ?? fallbackText;
const textArea = getWebTextAreaImpl(current);
const start = textArea?.selectionStart ?? fallbackSelection.start;
const end = textArea?.selectionEnd ?? fallbackSelection.end;
return { text, selection: { start, end } };
@@ -1175,28 +1139,6 @@ function extractErrorMessage(error: unknown): string | null {
return null;
}
function resolvePresentationSendDisabled(input: {
fullscreen: boolean;
disabled: boolean;
hasText: boolean;
attachments: ComposerAttachment[];
hasExternalContent: boolean;
allowEmpty: boolean;
}): boolean {
return (
input.disabled || (input.fullscreen && !input.allowEmpty && !hasSendableComposerContent(input))
);
}
function shouldShowFullscreenControl(
compact: boolean,
readOnly: boolean,
overlayVisible: boolean,
renderedLines: number,
) {
return compact && !readOnly && !overlayVisible && renderedLines > 2;
}
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
function MessageInput(props, ref) {
const {
@@ -1244,15 +1186,11 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
textReplacement,
submitLabel,
} = resolveMessageInputProps(props);
const { isActiveComposer } = useComposerKeyboardScope();
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const { height: windowHeight } = useWindowDimensions();
const editingSession = useComposerEditingSession(inputMode, props.editingSession);
const isFullscreen = useComposerFullscreen(editingSession);
const [renderedLines, setRenderedLines] = useState(1);
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
const maxInputHeight = resolveMaxInputHeight(windowHeight);
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
const toast = useToast();
const voice = useVoiceOptional();
@@ -1269,102 +1207,34 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const inputWrapperRef = useRef<View | null>(null);
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
const isInputFocusedRef = useRef(false);
const handoff = editingSession.peekHandoff(textReplacement);
const focusIntentRef = useRef(Boolean(handoff?.focused));
const valueRef = useRef(handoff?.snapshot.text ?? value);
const selectionRef = useRef(
handoff?.snapshot.selection ?? { start: value.length, end: value.length },
);
const activeComposerRef = useRef(isActiveComposer);
activeComposerRef.current = isActiveComposer;
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
const valueRef = useRef(value);
const selectionRef = useRef({ start: value.length, end: value.length });
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
const textReplacementRef = useRef(textReplacement);
textReplacementRef.current = textReplacement;
const webTextareaRef = useRef<HTMLElement | null>(null);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
if (hasLiveTextRef.current === nextHasLiveText) return;
hasLiveTextRef.current = nextHasLiveText;
setHasLiveText(nextHasLiveText);
}, []);
const assignTextInput = useCallback(
(input: ComposerTextInputHandle | null) => {
const previous = textInputRef.current;
if (!input && previous) {
editingSession.captureForRemount({
snapshot: getComposerInputSnapshot(
previous,
valueRef.current,
selectionRef.current,
webTextareaRef.current,
),
focused: previous.isFocused() || isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
}
textInputRef.current = input;
if (isWeb) {
const element = getWebTextAreaImpl(input) as HTMLElement | null;
webTextareaRef.current = element;
setWebTextArea(element);
}
if (!input) return;
const transfer = editingSession.takeHandoff(textReplacementRef.current);
if (transfer) {
appliedTextReplacementKeyRef.current = textReplacementRef.current.key;
valueRef.current = transfer.snapshot.text;
updateLiveTextPresence(transfer.snapshot.text);
selectionRef.current = transfer.snapshot.selection;
input.replaceText(transfer.snapshot.text, transfer.snapshot.selection);
if (transfer.focused && activeComposerRef.current) input.focus();
}
},
[editingSession, updateLiveTextPresence],
);
useLayoutEffect(() => {
if (isActiveComposer && focusIntentRef.current) textInputRef.current?.focus();
}, [isActiveComposer]);
const getLiveText = useCallback(() => valueRef.current, []);
const composerHeight = useComposerHeight({
getText: getLiveText,
textareaRef: webTextareaRef,
minHeight: MIN_INPUT_HEIGHT,
maxHeight: maxInputHeight,
onRenderedLinesChange: setRenderedLines,
presentationKey: isFullscreen,
});
const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight;
const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined;
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
const resetComposerHeight = measuredComposerHeight?.reset;
const changePresentation = useCallback(
(fullscreen: boolean) => {
if (editingSession.getFullscreen() === fullscreen) return;
const snapshot = getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
);
editingSession.changePresentation(fullscreen, {
snapshot,
focused: textInputRef.current?.isFocused() ?? isInputFocusedRef.current,
replacementKey: appliedTextReplacementKeyRef.current,
});
valueRef.current = snapshot.text;
selectionRef.current = snapshot.selection;
},
[editingSession],
);
const exitFullscreen = useCallback(() => changePresentation(false), [changePresentation]);
const handleComposerLayout = useCallback(
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
[onHeightChange],
);
const updateLiveTextPresence = useCallback((text: string) => {
const nextHasLiveText = text.trim().length > 0;
if (hasLiveTextRef.current === nextHasLiveText) return;
hasLiveTextRef.current = nextHasLiveText;
setHasLiveText(nextHasLiveText);
}, []);
const replaceText = useCallback(
(nextText: string, selection?: { start: number; end: number }) => {
updateComposerHeightForText?.(valueRef.current, nextText);
@@ -1542,9 +1412,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
canStartDictation,
toast,
startDictation,
exitFullscreen,
}),
[canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen],
[canStartDictation, dictationUnavailableMessage, startDictation, toast],
);
const handleVoicePress = useCallback(
@@ -1616,12 +1485,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
handleStopRealtimeVoice,
toast,
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
exitFullscreen,
});
}, [
disabled,
handleStopRealtimeVoice,
exitFullscreen,
isAgentRunning,
isConnected,
t,
@@ -1705,10 +1572,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
if (isWeb) {
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
}
}, [getWebTextArea, isFullscreen]);
}, [getWebTextArea]);
usePasteImagesEffect({
textarea: webTextArea,
getWebTextArea,
isConnected,
disabled,
isDictating,
@@ -1729,30 +1596,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const shouldHandleWebKeyPress = isWeb;
const shouldSubmitOnEnter = isWeb && !isCompact;
const handleDesktopKeyPress = useCallback(
(event: WebTextInputKeyPressEvent) => {
if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, {
onKeyPressCallback,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
isSubmitLoading,
disabled,
handleAlternateSendAction,
handleDefaultSendAction,
});
},
[
shouldHandleWebKeyPress,
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
if (!shouldHandleWebKeyPress) return;
handleDesktopKeyPressImpl(event, {
onKeyPressCallback,
shouldSubmitOnEnter,
input: getComposerInputSnapshot(
textInputRef.current,
valueRef.current,
selectionRef.current,
),
submitOnEnter: shouldSubmitOnEnter,
isAgentRunning,
onQueue,
isSubmitDisabled,
@@ -1760,8 +1613,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
disabled,
handleAlternateSendAction,
handleDefaultSendAction,
],
);
});
}
const primaryActionKind = resolvePrimaryActionKind({
hasSendableContent: hasSendableComposerContent({
@@ -1825,18 +1678,16 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
);
const handleInputFocus = useCallback(() => {
focusIntentRef.current = true;
isInputFocusedRef.current = true;
setIsInputFocused(true);
onFocusChange?.(true);
}, [onFocusChange]);
const handleInputBlur = useCallback(() => {
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
isInputFocusedRef.current = false;
setIsInputFocused(false);
onFocusChange?.(false);
}, [isFullscreen, onFocusChange]);
}, [onFocusChange]);
const handlePasteError = useCallback(
(message: string) => {
@@ -1884,12 +1735,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
// `fontFamily` here is silently dropped while every other property lands.
// An inline style outranks both classes. See docs/unistyles.md.
const textInputStyle = useMemo(
() => [
styles.textInput,
mode.isMonospace && styles.textInputMonospace,
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
],
[composerHeightStyle, mode.isMonospace, isFullscreen],
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle],
[composerHeightStyle, mode.isMonospace],
);
// Static content has no textarea to mirror, so it grows with its own text
// instead of the measured input height.
@@ -1933,192 +1780,107 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
);
const sendButton = useMemo(
() => (
<PrimaryAction
kind={isFullscreen ? "send" : primaryActionKind}
activeActionContent={activeActionContent}
shouldShow
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
isSendButtonDisabled={resolvePresentationSendDisabled({
fullscreen: isFullscreen,
disabled: isSendButtonDisabled,
hasText: hasLiveText,
attachments,
hasExternalContent,
allowEmpty: allowEmptySubmit,
})}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}
return (
<View
ref={rootRef}
style={styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
/>
),
[
isFullscreen,
primaryActionKind,
activeActionContent,
canPressLoadingButton,
onSubmitLoadingPress,
handleDefaultSendAction,
isSendButtonDisabled,
hasLiveText,
attachments,
hasExternalContent,
allowEmptySubmit,
submitAccessibilityLabel,
sendButtonCombinedStyle,
isSubmitLoading,
submitIcon,
submitLabel,
submitButtonTestID,
buttonIconSize,
sendTooltipLabel,
],
);
const textSurface = useMemo(
() => (
<RenderProfile id="ComposerTextSurface">
<ComposerTextSurface
readOnly={readOnly}
value={
readOnly
? value
: resolveComposerSurfaceText(
valueRef.current,
textReplacement,
appliedTextReplacementKeyRef.current,
)
}
textReplacementKey={textReplacement.key}
textInputRef={assignTextInput}
textInputStyle={textInputStyle}
readOnlyTextStyle={readOnlyTextStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
accessibilityLabel={t(mode.accessibilityLabelKey)}
onChangeText={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
fullscreen={isFullscreen}
compact={isCompact}
onRenderedLinesChange={setRenderedLines}
focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
</RenderProfile>
),
[
readOnly,
textReplacement,
editingSession,
isActiveComposer,
assignTextInput,
textInputStyle,
readOnlyTextStyle,
placeholder,
t,
mode.accessibilityLabelKey,
handleInputChange,
handleInputFocus,
handleInputBlur,
isDictating,
isRealtimeVoiceForCurrentAgent,
disabled,
isComposerScrollEnabled,
shouldHandleWebKeyPress,
handleDesktopKeyPress,
handleSelectionChange,
onPasteImages,
handlePasteError,
isFullscreen,
isCompact,
isInputFocused,
value,
focusInputKeys,
],
);
const toolbar = useMemo(
() => (
<View style={styles.buttonRow}>
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
visible={mode.showAttachments}
isConnected={isConnected}
disabled={disabled}
attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
{/* Regular input */}
<View
ref={inputWrapperRef}
style={inputWrapperCombinedStyle}
pointerEvents={surfacePresentation.input.pointerEvents}
>
{attachmentSlot}
{/* Text input */}
<RenderProfile id="ComposerTextSurface">
<ComposerTextSurface
readOnly={readOnly}
value={value}
textInputRef={textInputRef}
textInputStyle={textInputStyle}
readOnlyTextStyle={readOnlyTextStyle}
placeholder={placeholder ?? t("composer.placeholders.fallback")}
accessibilityLabel={t(mode.accessibilityLabelKey)}
onChangeText={handleInputChange}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
scrollEnabled={isComposerScrollEnabled}
autoFocus={false}
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
onSelectionChange={handleSelectionChange}
onPasteImages={onPasteImages}
onPasteError={handlePasteError}
focusHintVisible={isWeb && !isInputFocused && !value}
focusInputKeys={focusInputKeys}
focusHintLabel={t("composer.input.focusHint", {
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
})}
/>
{leftContent}
</View>
</RenderProfile>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<VoiceButtonTooltip
visible={mode.showVoice}
onVoicePress={handleVoicePress}
isDictationStartEnabled={isDictationStartEnabled}
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
voiceButtonStyle={voiceButtonStyle}
renderVoiceButtonIcon={renderVoiceButtonIcon}
voiceTooltipText={voiceTooltipText}
isRealtimeVoiceForCurrentAgent={isRealtimeVoiceForCurrentAgent}
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{rightContent}
{sendButton}
{/* Button row */}
<View style={styles.buttonRow}>
{/* Toolbar left: attachment button + agent controls */}
<View style={styles.leftButtonGroup}>
<AttachmentDropdown
visible={mode.showAttachments}
isConnected={isConnected}
disabled={disabled}
attachButtonStyle={attachButtonStyle}
renderAttachButtonIcon={renderAttachButtonIcon}
attachmentMenuItems={attachmentMenuItems}
addAttachmentLabel={t("composer.input.addAttachment")}
/>
{leftContent}
</View>
{/* Right: voice button, contextual button (realtime/send/cancel) */}
<View style={styles.rightButtonGroup}>
{beforeVoiceContent}
<VoiceButtonTooltip
visible={mode.showVoice}
onVoicePress={handleVoicePress}
isDictationStartEnabled={isDictationStartEnabled}
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
voiceButtonStyle={voiceButtonStyle}
renderVoiceButtonIcon={renderVoiceButtonIcon}
voiceTooltipText={voiceTooltipText}
isRealtimeVoiceForCurrentAgent={isRealtimeVoiceForCurrentAgent}
voiceMuteToggleKeys={voiceMuteToggleKeys}
dictationToggleKeys={dictationToggleKeys}
/>
{rightContent}
<PrimaryAction
kind={primaryActionKind}
activeActionContent={activeActionContent}
shouldShow
canPressLoadingButton={canPressLoadingButton}
onSubmitLoadingPress={onSubmitLoadingPress}
onDefaultSendAction={handleDefaultSendAction}
isSendButtonDisabled={isSendButtonDisabled}
submitAccessibilityLabel={submitAccessibilityLabel}
sendButtonCombinedStyle={sendButtonCombinedStyle}
isSubmitLoading={isSubmitLoading}
submitIcon={submitIcon}
submitLabel={submitLabel}
submitButtonTestID={submitButtonTestID}
buttonIconSize={buttonIconSize}
sendKeys={DEFAULT_SEND_KEYS}
sendTooltipLabel={sendTooltipLabel}
/>
</View>
</View>
</View>
),
[
mode.showAttachments,
isConnected,
disabled,
attachButtonStyle,
renderAttachButtonIcon,
attachmentMenuItems,
t,
leftContent,
beforeVoiceContent,
mode.showVoice,
handleVoicePress,
isDictationStartEnabled,
voiceButtonAccessibilityLabel,
voiceButtonStyle,
renderVoiceButtonIcon,
voiceTooltipText,
isRealtimeVoiceForCurrentAgent,
voiceMuteToggleKeys,
dictationToggleKeys,
rightContent,
sendButton,
],
);
const voiceOverlay = useMemo(
() => (
<View
style={overlayContainerStyle}
pointerEvents={surfacePresentation.overlay.pointerEvents}
@@ -2141,62 +1903,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
onRealtimeVoiceStop={handleRealtimeVoiceStop}
/>
</View>
),
[
overlayContainerStyle,
surfacePresentation.overlay.pointerEvents,
showDictationOverlay,
showRealtimeOverlay,
voice,
dictationVolume,
dictationDuration,
isDictating,
isDictationProcessing,
dictationStatus,
dictationError,
handleCancelRecording,
handleAcceptRecording,
handleAcceptAndSendRecording,
handleRetryFailedRecording,
handleDiscardFailedRecording,
handleRealtimeVoiceStop,
],
);
const enterFullscreen = useCallback(() => changePresentation(true), [changePresentation]);
return (
<View
ref={rootRef}
style={styles.container}
testID="message-input-root"
onLayout={handleComposerLayout}
>
<MessageInputAutoFocus
enabled={autoFocus}
autoFocusKey={autoFocusKey}
textInputRef={textInputRef}
/>
<ComposerInputPresentation
fullscreen={isFullscreen}
iconSize={buttonIconSize}
active={isActiveComposer}
surface={textSurface}
toolbar={toolbar}
send={sendButton}
error={props.submissionError}
onExit={exitFullscreen}
inputWrapperRef={inputWrapperRef}
inputWrapperStyle={inputWrapperCombinedStyle}
inputPointerEvents={surfacePresentation.input.pointerEvents}
attachmentSlot={attachmentSlot}
showFullscreenButton={shouldShowFullscreenControl(
isCompact,
readOnly,
showOverlay,
renderedLines,
)}
onEnter={enterFullscreen}
overlay={voiceOverlay}
/>
</View>
);
},
@@ -2241,19 +1947,6 @@ const styles = StyleSheet.create((theme: Theme) => ({
inputWrapperReadOnly: {
borderStyle: "dotted",
},
compactTextSurface: { paddingRight: 36 },
fullscreenTextSurface: { flex: 1, minHeight: 0 },
fullscreenTextInput: { flex: 1, height: "100%", textAlignVertical: "top" },
lineMeasurement: {
position: "absolute",
top: 0,
left: 0,
right: 36,
opacity: 0,
maxHeight: undefined,
minHeight: undefined,
height: undefined,
},
textInputScrollWrapper: {
flexShrink: 1,
position: "relative",
@@ -1,146 +0,0 @@
import { useCallback, useMemo, type ReactNode, type Ref } from "react";
import { View, type ViewProps } from "react-native";
import { Maximize2, Minimize2 } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { isWeb } from "@/constants/platform";
import { StyleSheet } from "react-native-unistyles";
import { ComposerFullscreenHost } from "./fullscreen-host";
function keepEditorFocused(event: import("react-native").PointerEvent) {
if (isWeb) event.preventDefault();
}
/** Both presentations receive the same editing surface and send capability. */
export function ComposerInputPresentation({
fullscreen,
iconSize,
active,
surface,
toolbar,
send,
error,
onExit,
inputWrapperRef,
inputWrapperStyle,
inputPointerEvents,
attachmentSlot,
showFullscreenButton,
onEnter,
overlay,
}: {
fullscreen: boolean;
iconSize: number;
active: boolean;
surface: ReactNode;
toolbar: ReactNode;
send: ReactNode;
error: ReactNode;
onExit: () => void;
inputWrapperRef: Ref<View>;
inputWrapperStyle: ViewProps["style"];
inputPointerEvents: ViewProps["pointerEvents"];
attachmentSlot: ReactNode;
showFullscreenButton: boolean;
onEnter: () => void;
overlay: ReactNode;
}) {
const { t } = useTranslation();
// ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox.
// Compensate for that difference to align visible ink with Send, not just boxes.
const railAdjustment = iconSize / 12;
const maximizeIcon = useCallback(
(color: string) => <Maximize2 color={color} size={iconSize} />,
[iconSize],
);
const minimizeIcon = useCallback(
(color: string) => <Minimize2 color={color} size={iconSize} />,
[iconSize],
);
const enterStyle = useMemo(
() => [styles.fullscreenButton, { right: -6 + railAdjustment }],
[railAdjustment],
);
const exitStyle = useMemo(
() => [styles.iconButton, { marginRight: railAdjustment }],
[railAdjustment],
);
if (!fullscreen)
return (
<>
<View ref={inputWrapperRef} style={inputWrapperStyle} pointerEvents={inputPointerEvents}>
{attachmentSlot}
<View style={styles.inlineTextSurface}>
{surface}
{showFullscreenButton ? (
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.fullscreen")}
leftIcon={maximizeIcon}
onPointerDown={keepEditorFocused}
onPress={onEnter}
style={enterStyle}
hitSlop={8}
>
{null}
</Button>
) : null}
</View>
{toolbar}
</View>
{overlay}
</>
);
return (
<ComposerFullscreenHost onExit={onExit} active={active}>
<View style={styles.fullscreen} testID="composer-fullscreen">
<View style={styles.exitRow}>
<Button
variant="ghost"
size="xs"
accessibilityLabel={t("composer.input.exitFullscreen")}
onPointerDown={keepEditorFocused}
onPress={onExit}
style={exitStyle}
leftIcon={minimizeIcon}
hitSlop={8}
>
{null}
</Button>
</View>
{surface}
{error}
<View style={styles.sendRow}>{send}</View>
</View>
</ComposerFullscreenHost>
);
}
const styles = StyleSheet.create((theme) => ({
inlineTextSurface: { flexShrink: 1, position: "relative" },
iconButton: {
width: 28,
height: 28,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
},
fullscreenButton: {
position: "absolute",
top: 0,
width: 28,
height: 28,
padding: 0,
borderWidth: 0,
borderRadius: theme.borderRadius.full,
},
fullscreen: {
flex: 1,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[4],
gap: theme.spacing[3],
},
exitRow: { alignItems: "flex-end", flexShrink: 0 },
sendRow: { flexShrink: 0, alignItems: "flex-end" },
}));
@@ -4,7 +4,6 @@ import {
computeCanStartDictation,
resolveActiveSendBehavior,
resolveComposerSurfacePresentation,
resolveComposerSurfaceText,
runAlternateSendAction,
runDefaultSendAction,
runMessageInputKeyboardAction,
@@ -317,26 +316,3 @@ describe("stopRealtimeVoice", () => {
expect(calls).toEqual(["cancel agent", "stop voice"]);
});
});
describe("composer surface text", () => {
it("measures a new nonempty replacement before it reaches the editing handle", () => {
const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" };
expect(resolveComposerSurfaceText("Short draft", replacement, "draft:1")).toBe(
replacement.text,
);
});
it("keeps live typing once the replacement is applied", () => {
const replacement = { kind: "replace" as const, key: "draft:2", text: "First\nSecond\nThird" };
expect(resolveComposerSurfaceText("Edited live draft", replacement, "draft:2")).toBe(
"Edited live draft",
);
});
it("uses the live handoff when its replacement has already been applied", () => {
const replacement = { kind: "initial" as const, key: "draft:0", text: "Stale publication" };
expect(resolveComposerSurfaceText("Live remount handoff", replacement, "draft:0")).toBe(
"Live remount handoff",
);
});
});
+1 -9
View File
@@ -1,6 +1,6 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { ActiveTurnBehavior } from "@getpaseo/protocol/messages";
import type { MessagePayload, TextReplacement } from "@/composer/types";
import type { MessagePayload } from "@/composer/types";
import type { MessageInputKeyboardActionKind } from "@/keyboard/actions";
export type SendBehavior = ActiveTurnBehavior | "queue";
@@ -192,11 +192,3 @@ export async function stopRealtimeVoice(ctx: StopRealtimeVoiceContext): Promise<
await ctx.voice.stopVoice();
}
export function resolveComposerSurfaceText(
liveText: string,
replacement: TextReplacement,
appliedReplacementKey: string,
): string {
return replacement.key === appliedReplacementKey ? liveText : replacement.text;
}
@@ -96,35 +96,6 @@ describe("ComposerTextInput web IME composition", () => {
expect(document.activeElement).toBe(mounted.textarea);
});
it("keeps its editing handle and selection across a rerender during composition", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
const handle = mounted.inputRef.current;
act(() => {
handle?.focus();
dispatchComposition(mounted.textarea, "compositionstart");
typeFromIme(mounted.textarea, "你好");
mounted.textarea.setSelectionRange(1, 2);
mounted.root.render(
<ComposerTextInput
ref={mounted.inputRef}
initialValue="stale publication"
multiline
onChangeText={recorder.onChangeText}
placeholder="updated presentation"
/>,
);
});
expect(mounted.inputRef.current).toBe(handle);
expect(handle?.getNativeRef()).toBe(mounted.textarea);
expect([mounted.textarea.selectionStart, mounted.textarea.selectionEnd]).toEqual([1, 2]);
expect(document.activeElement).toBe(mounted.textarea);
expect(recorder.changes).toEqual([]);
act(() => dispatchComposition(mounted.textarea, "compositionend"));
expect(recorder.changes).toEqual(["你好"]);
expect(handle?.getText()).toBe("你好");
});
it("keeps locally typed text when its parent rerenders with a stale value", () => {
const recorder = createTextRecorder();
const mounted = mountInput(recorder.onChangeText);
-1
View File
@@ -10,7 +10,6 @@ export interface MessagePayload {
}
export interface TextReplacement {
kind: "initial" | "replace";
key: string;
text: string;
}
+1 -6
View File
@@ -33,18 +33,13 @@ export {
getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform";
/** Compact layouts are the breakpoints below `md`. */
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
return breakpoint === "xs" || breakpoint === "sm";
}
/**
* Reactive hook — re-renders the component when the breakpoint changes.
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
*/
export function useIsCompactFormFactor(): boolean {
const { rt } = useUnistyles();
return isCompactBreakpoint(rt.breakpoint);
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
-2
View File
@@ -124,8 +124,6 @@ export const ar: TranslationResources = {
input: {
accessibilityLabel: "وكيل الرسائل...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "ملء الشاشة",
exitFullscreen: "الخروج من ملء الشاشة",
focusHint: "{{shortcut}}للتركيز",
addAttachment: "إضافة مرفق",
interruptAgent: "عامل المقاطعة",
-2
View File
@@ -120,8 +120,6 @@ export const en = {
input: {
accessibilityLabel: "Message agent...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "Fullscreen",
exitFullscreen: "Exit fullscreen",
focusHint: "{{shortcut}} to focus",
addAttachment: "Add attachment",
interruptAgent: "Interrupt agent",
-2
View File
@@ -125,8 +125,6 @@ export const es: TranslationResources = {
input: {
accessibilityLabel: "Agente de mensajes...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "Pantalla completa",
exitFullscreen: "Salir de pantalla completa",
focusHint: "{{shortcut}}para enfocar",
addAttachment: "Agregar archivo adjunto",
interruptAgent: "agente de interrupción",
-2
View File
@@ -126,8 +126,6 @@ export const fr: TranslationResources = {
input: {
accessibilityLabel: "Écrire à l’agent…",
terminalAccessibilityLabel: "Prompt du terminal",
fullscreen: "Plein écran",
exitFullscreen: "Quitter le plein écran",
focusHint: "{{shortcut}} pour saisir",
addAttachment: "Ajouter une pièce jointe",
interruptAgent: "Interrompre l’agent",
-2
View File
@@ -125,8 +125,6 @@ export const ja: TranslationResources = {
input: {
accessibilityLabel: "エージェントにメッセージ...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "全画面表示",
exitFullscreen: "全画面表示を終了",
focusHint: "{{shortcut}}でフォーカス",
addAttachment: "添付ファイルを追加",
interruptAgent: "エージェントを中断",
-2
View File
@@ -124,8 +124,6 @@ export const ko: TranslationResources = {
input: {
accessibilityLabel: "에이전트에게 메시지...",
terminalAccessibilityLabel: "Terminal prompt",
fullscreen: "전체 화면",
exitFullscreen: "전체 화면 종료",
focusHint: "{{shortcut}}로 포커스",
addAttachment: "첨부 추가",
interruptAgent: "에이전트 중단",

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