mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-23 17:52:49 +00:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
802359152e | ||
|
|
196dc3acd3 | ||
|
|
e867773932 | ||
|
|
bfe38d92f6 | ||
|
|
9551b2cc11 | ||
|
|
6f62125ee0 | ||
|
|
8bc5f47b1c | ||
|
|
e1b01df905 | ||
|
|
7321d8a488 | ||
|
|
283a985fb6 | ||
|
|
8d03ccfbf0 | ||
|
|
acb3a8f207 | ||
|
|
a2b040869d | ||
|
|
99e4b53aa0 | ||
|
|
750cb82fbb | ||
|
|
9246813fd2 | ||
|
|
06f0522258 | ||
|
|
9b92b2e25c | ||
|
|
a310444c67 | ||
|
|
c364b16d40 | ||
|
|
dc7bc8db12 | ||
|
|
40f0393064 | ||
|
|
166aabd57b | ||
|
|
b852ae920b | ||
|
|
b3693b79d1 | ||
|
|
0458988825 | ||
|
|
4bd31e1a2e | ||
|
|
1939ee79ed | ||
|
|
8d9e7d3ad3 | ||
|
|
114389383d | ||
|
|
804335bc00 | ||
|
|
e2fd5a9c57 | ||
|
|
b412374f0c | ||
|
|
a3f75aa9e4 | ||
|
|
46b1284177 | ||
|
|
a5dba3c992 | ||
|
|
dd0350bc2b | ||
|
|
cfb18f16d4 | ||
|
|
7f9b65d464 | ||
|
|
7e66657bf0 | ||
|
|
446749102e | ||
|
|
e61009d5fb | ||
|
|
ee0d901efb | ||
|
|
d5513472ed | ||
|
|
1228c01a10 | ||
|
|
563c014dc1 | ||
|
|
01f9bc8291 | ||
|
|
d0728cd1ed | ||
|
|
0ba4d2526e | ||
|
|
7868f5caf6 | ||
|
|
0147edaaac | ||
|
|
bd78e5efa4 | ||
|
|
e81d5d02b1 | ||
|
|
b63a325be3 | ||
|
|
6ec6633425 | ||
|
|
b26b9234c0 |
@@ -252,7 +252,6 @@
|
|||||||
"packages/app/src/components/download-toast.tsx",
|
"packages/app/src/components/download-toast.tsx",
|
||||||
"packages/app/src/components/draggable-list.native.tsx",
|
"packages/app/src/components/draggable-list.native.tsx",
|
||||||
"packages/app/src/components/headers/back-header.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/headers/screen-header.tsx",
|
||||||
"packages/app/src/components/host-status-dot.tsx",
|
"packages/app/src/components/host-status-dot.tsx",
|
||||||
"packages/app/src/components/hosts/host-picker.tsx",
|
"packages/app/src/components/hosts/host-picker.tsx",
|
||||||
|
|||||||
@@ -311,6 +311,22 @@ frames non-stop, pinning ProMotion displays at 120Hz forever and draining the
|
|||||||
battery while the app is idle — so do not re-add it. The probe's visibility
|
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.
|
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
|
### Daemon logs
|
||||||
|
|
||||||
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
Check `$PASEO_HOME/daemon.log` for daemon logs. The default level is `info`; set
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const fs = require("node:fs");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
const pkg = require("./package.json");
|
const pkg = require("./package.json");
|
||||||
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
|
||||||
|
const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera");
|
||||||
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
const withAndroidProfileable = require("./plugins/with-android-profileable");
|
||||||
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
|
||||||
const withPasteInput = require("./plugins/with-paste-input");
|
const withPasteInput = require("./plugins/with-paste-input");
|
||||||
@@ -145,6 +146,7 @@ export default {
|
|||||||
withAndroidScroll,
|
withAndroidScroll,
|
||||||
[withAndroidAsyncStorageSize, 64],
|
[withAndroidAsyncStorageSize, 64],
|
||||||
...buildProfile.cameraPlugins,
|
...buildProfile.cameraPlugins,
|
||||||
|
withAndroidOptionalCamera,
|
||||||
[
|
[
|
||||||
"expo-splash-screen",
|
"expo-splash-screen",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { createRequire } from "node:module";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
// Load the copies the Expo CLI uses for prebuild, not older hoisted ones.
|
||||||
|
const requireFromExpo = createRequire(require.resolve("expo/package.json"));
|
||||||
|
const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json"));
|
||||||
|
const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config");
|
||||||
|
const { compileModsAsync } = requireFromExpoCli(
|
||||||
|
"@expo/config-plugins/build/plugins/mod-compiler.js",
|
||||||
|
);
|
||||||
|
|
||||||
|
interface ManifestEntry {
|
||||||
|
$: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFdroidFlag = process.env.PASEO_FDROID_BUILD;
|
||||||
|
|
||||||
|
async function resolveAndroidManifest() {
|
||||||
|
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] });
|
||||||
|
await compileModsAsync(exp, {
|
||||||
|
projectRoot: __dirname,
|
||||||
|
introspect: true,
|
||||||
|
platforms: ["android"],
|
||||||
|
assertMissingModProviders: false,
|
||||||
|
});
|
||||||
|
return exp._internal.modResults.android.manifest.manifest;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Android app config", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
if (originalFdroidFlag === undefined) {
|
||||||
|
delete process.env.PASEO_FDROID_BUILD;
|
||||||
|
} else {
|
||||||
|
process.env.PASEO_FDROID_BUILD = originalFdroidFlag;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Google Play", "0"],
|
||||||
|
["F-Droid", "1"],
|
||||||
|
])(
|
||||||
|
"%s build installs on devices without a camera",
|
||||||
|
async (_build, fdroidFlag) => {
|
||||||
|
process.env.PASEO_FDROID_BUILD = fdroidFlag;
|
||||||
|
const manifest = await resolveAndroidManifest();
|
||||||
|
const permissions = (manifest["uses-permission"] ?? []).map(
|
||||||
|
(entry: ManifestEntry) => entry.$["android:name"],
|
||||||
|
);
|
||||||
|
const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$);
|
||||||
|
|
||||||
|
expect(permissions).toContain("android.permission.CAMERA");
|
||||||
|
expect(features).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ "android:name": "android.hardware.camera", "android:required": "false" },
|
||||||
|
{ "android:name": "android.hardware.camera.autofocus", "android:required": "false" },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ test("keeps the selected workspace visible in Light", async ({ page }, testInfo)
|
|||||||
await row.click();
|
await row.click();
|
||||||
|
|
||||||
await expect(row).toHaveAttribute("aria-selected", "true");
|
await expect(row).toHaveAttribute("aria-selected", "true");
|
||||||
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
|
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
|
||||||
await page.screenshot({
|
await page.screenshot({
|
||||||
path: testInfo.outputPath("light-selected-workspace.png"),
|
path: testInfo.outputPath("light-selected-workspace.png"),
|
||||||
fullPage: true,
|
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 panel = page.getByTestId("working-diff-panel").filter({ visible: true });
|
||||||
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
const renamedHeader = diffHeaderForPath(panel, "src/zz-renamed.ts");
|
||||||
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +1, -0");
|
await expect(renamedHeader).toHaveAccessibleName("src/zz-renamed.ts, +0, -0");
|
||||||
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
const renamedToggle = renamedHeader.getByTestId(/^diff-file-\d+-toggle$/);
|
||||||
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
const toggleTestId = await renamedToggle.getAttribute("data-testid");
|
||||||
expect(toggleTestId).not.toBeNull();
|
expect(toggleTestId).not.toBeNull();
|
||||||
|
|||||||
@@ -25,6 +25,12 @@ import {
|
|||||||
expectReadingStreamedMarkdown,
|
expectReadingStreamedMarkdown,
|
||||||
} from "../support/helpers/chat-outline";
|
} from "../support/helpers/chat-outline";
|
||||||
import { createCreationScenario } from "../support/helpers/creation";
|
import { createCreationScenario } from "../support/helpers/creation";
|
||||||
|
import {
|
||||||
|
attachPromptTimelineEvidence,
|
||||||
|
expectPromptsDrawnByPlugin,
|
||||||
|
expectVirtualizedPluginPromptVisible,
|
||||||
|
withPromptTimelinePlugin,
|
||||||
|
} from "../support/helpers/plugin-timeline";
|
||||||
import {
|
import {
|
||||||
expectTimelineAtMaximumScrollWithPromptVisible,
|
expectTimelineAtMaximumScrollWithPromptVisible,
|
||||||
expectTimelinePromptNotMounted,
|
expectTimelinePromptNotMounted,
|
||||||
@@ -105,6 +111,60 @@ test.describe("desktop chat outline", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("jumps to prompts a timeline plugin draws", async ({ page }) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||||
|
try {
|
||||||
|
await withPromptTimelinePlugin(async () => {
|
||||||
|
await page.setViewportSize(WIDE_VIEWPORT);
|
||||||
|
await openAgentTimeline(page, agent);
|
||||||
|
await expectChatOutlinePrompts(page, 80);
|
||||||
|
await expectPromptsDrawnByPlugin(page);
|
||||||
|
await expectTimelinePromptNotMounted(page, agent.prompts[39]);
|
||||||
|
|
||||||
|
// Unloaded, then loaded, then near the newest edge.
|
||||||
|
await clickChatOutlineRowEdge(page, 40);
|
||||||
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[39]);
|
||||||
|
await attachPromptTimelineEvidence(page, test.info());
|
||||||
|
await clickChatOutlineRowEdge(page, 45);
|
||||||
|
await expectTimelinePromptLandedBelowTop(page, agent.prompts[44]);
|
||||||
|
await clickChatOutlineRowEdge(page, 78);
|
||||||
|
await expectTimelineAtMaximumScrollWithPromptVisible(page, agent.prompts[77]);
|
||||||
|
await expectPromptsDrawnByPlugin(page);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("jumps to a loaded virtualized prompt a timeline plugin draws without fetching", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
test.setTimeout(180_000);
|
||||||
|
const agent = await seedLongMockAgentTimeline({ turns: 80 });
|
||||||
|
const prompt = agent.prompts[29]!;
|
||||||
|
try {
|
||||||
|
await withPromptTimelinePlugin(async () => {
|
||||||
|
const history = await holdOlderHistoryPages(page, agent);
|
||||||
|
await page.setViewportSize(WIDE_VIEWPORT);
|
||||||
|
await openAgentTimeline(page, agent);
|
||||||
|
await expectChatOutlinePrompts(page, 80);
|
||||||
|
await scrollThroughOlderHistoryPages(page, 2, history);
|
||||||
|
await scrollTimelineToNewestLoadedEdge(page);
|
||||||
|
await expectTimelinePromptNotMounted(page, prompt);
|
||||||
|
const requestsBeforeJump = history.requestCount();
|
||||||
|
|
||||||
|
await clickChatOutlineRowEdge(page, 30);
|
||||||
|
await expectTimelinePromptLandedBelowTop(page, prompt);
|
||||||
|
await expectVirtualizedPluginPromptVisible(page, prompt);
|
||||||
|
await attachPromptTimelineEvidence(page, test.info());
|
||||||
|
expect(history.requestCount()).toBe(requestsBeforeJump);
|
||||||
|
}, [prompt]);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test.describe("with the whole conversation loaded", () => {
|
test.describe("with the whole conversation loaded", () => {
|
||||||
let agent: LongTimelineAgent;
|
let agent: LongTimelineAgent;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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,7 +1,14 @@
|
|||||||
|
import type { Locator } from "@playwright/test";
|
||||||
import { writeFile } from "node:fs/promises";
|
import { writeFile } from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
|
||||||
|
import {
|
||||||
|
fillComposerDraft,
|
||||||
|
expectComposerDraft,
|
||||||
|
attachFileFromMenu,
|
||||||
|
} from "../support/helpers/composer";
|
||||||
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
|
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
const APP_SETTINGS_KEY = "@paseo:app-settings";
|
||||||
@@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) {
|
|||||||
await composerChangesPill(page).click();
|
await composerChangesPill(page).click();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedChangedAgent(repoPrefix: string) {
|
async function seedChangedAgent(
|
||||||
|
repoPrefix: string,
|
||||||
|
featureValues?: Record<string, unknown>,
|
||||||
|
files: Array<{ path: string; content: string }> = [],
|
||||||
|
) {
|
||||||
const workspace = await seedMockAgentWorkspace({
|
const workspace = await seedMockAgentWorkspace({
|
||||||
repoPrefix,
|
repoPrefix,
|
||||||
|
featureValues,
|
||||||
title: "Composer diff stat",
|
title: "Composer diff stat",
|
||||||
repo: {
|
repo: {
|
||||||
withRemote: true,
|
withRemote: true,
|
||||||
// Exclude the local remote before the daemon starts observing this checkout.
|
// Exclude the local remote before the daemon starts observing this checkout.
|
||||||
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
|
files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -156,3 +168,691 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
|
|||||||
await workspace.cleanup();
|
await workspace.cleanup();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function tapFirstAddedDiffLine(
|
||||||
|
page: Page,
|
||||||
|
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||||
|
) {
|
||||||
|
const body = surface.getByTestId("diff-file-0-body");
|
||||||
|
await expect(body).toBeVisible({ timeout: 30000 });
|
||||||
|
const bounds = await body.boundingBox();
|
||||||
|
if (!bounds) throw new Error("Expanded diff body has no bounds");
|
||||||
|
const fontSize = await surface
|
||||||
|
.getByTestId("git-diff-canvas")
|
||||||
|
.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
|
||||||
|
await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openReviewDiff(page: Page, workspace: Awaited<ReturnType<typeof seedChangedAgent>>) {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||||
|
await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 });
|
||||||
|
if (await page.getByTestId("workspace-tab-working_diff").count()) {
|
||||||
|
await selectDiffTab(page);
|
||||||
|
} else {
|
||||||
|
await revealComposerChangesInExplorer(page);
|
||||||
|
await openComposerDiff(page);
|
||||||
|
}
|
||||||
|
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveReviewComment(page: Page, body: string) {
|
||||||
|
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
|
||||||
|
await page.getByRole("button", { name: "Save review comment", exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancelReviewComment(page: Page) {
|
||||||
|
await page.getByRole("button", { name: "Cancel review comment", exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("review comments", () => {
|
||||||
|
test.use({ hasTouch: true });
|
||||||
|
|
||||||
|
test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("compact-review-comment-");
|
||||||
|
try {
|
||||||
|
await test.step("Open a compact sheet with reachable input and actions", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await tapFirstAddedDiffLine(page);
|
||||||
|
await expectCompactCommentSheet(page);
|
||||||
|
});
|
||||||
|
await test.step("Save, discard an edit, then save an updated comment", async () => {
|
||||||
|
await saveReviewComment(page, " Please simplify this. ");
|
||||||
|
await expectSavedReview(page, "Please simplify this.");
|
||||||
|
await editSavedReview(page, "Please simplify this.");
|
||||||
|
await fillReviewComment(page, "Discard this edit");
|
||||||
|
await cancelReviewComment(page);
|
||||||
|
await expectSavedReview(page, "Please simplify this.");
|
||||||
|
await editSavedReview(page, "Please simplify this.");
|
||||||
|
await saveReviewComment(page, "Updated comment");
|
||||||
|
await expectSavedReview(page, "Updated comment");
|
||||||
|
});
|
||||||
|
await test.step("Discard a new comment and retain the saved one after reload", async () => {
|
||||||
|
await tapFirstAddedDiffLine(page);
|
||||||
|
await fillReviewComment(page, "Discard new comment");
|
||||||
|
await cancelReviewComment(page);
|
||||||
|
await expectReviewAbsent(page, "Discard new comment");
|
||||||
|
await reloadReview(page);
|
||||||
|
await expectSavedReview(page, "Updated comment");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("wide review comments retain inline editing", async ({ page }) => {
|
||||||
|
const workspace = await seedChangedAgent("wide-review-comment-");
|
||||||
|
try {
|
||||||
|
await test.step("Save and cancel an inline edit on a wide screen", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await tapFirstAddedDiffLine(page);
|
||||||
|
await expectInlineCommentEditor(page);
|
||||||
|
await saveReviewComment(page, "Wide comment");
|
||||||
|
await expectSavedReview(page, "Wide comment");
|
||||||
|
await editSavedReview(page, "Wide comment");
|
||||||
|
await expectInlineCommentEditor(page);
|
||||||
|
await cancelReviewComment(page);
|
||||||
|
await expectSavedReview(page, "Wide comment");
|
||||||
|
});
|
||||||
|
await test.step("The existing composer still sends the review", async () => {
|
||||||
|
await sendReviewThroughComposer(page, workspace.agentId);
|
||||||
|
await selectDiffTab(page);
|
||||||
|
await expectReviewAbsent(page, "Wide comment");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function sendReviewThroughComposer(page: Page, agentId: string) {
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${agentId}`).click();
|
||||||
|
await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible();
|
||||||
|
await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review");
|
||||||
|
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||||
|
await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addSavedReview(
|
||||||
|
page: Page,
|
||||||
|
body: string,
|
||||||
|
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||||
|
) {
|
||||||
|
await tapFirstAddedDiffLine(page, surface);
|
||||||
|
await saveReviewComment(page, body);
|
||||||
|
await expectSavedReview(page, body, surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectSavedReview(
|
||||||
|
page: Page,
|
||||||
|
body: string,
|
||||||
|
surface: Locator = page.getByTestId("working-diff-panel"),
|
||||||
|
) {
|
||||||
|
await expect(surface.getByText(body, { exact: true })).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editSavedReview(page: Page, expectedBody: string) {
|
||||||
|
await page
|
||||||
|
.getByTestId("working-diff-panel")
|
||||||
|
.getByRole("button", { name: "Edit review comment", exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendFeedback(page: Page, count: number) {
|
||||||
|
await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectFeedbackSent(page: Page, recipient: string) {
|
||||||
|
await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe("send review feedback", () => {
|
||||||
|
test.use({ hasTouch: true });
|
||||||
|
|
||||||
|
test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("review-feedback-single-");
|
||||||
|
try {
|
||||||
|
await test.step("Save a review without changing the chat draft", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await selectAgentTab(page, workspace.agentId);
|
||||||
|
await fillComposerDraft(page, "Keep this thought");
|
||||||
|
await attachFileFromMenu(page, {
|
||||||
|
name: "unrelated.txt",
|
||||||
|
mimeType: "text/plain",
|
||||||
|
buffer: Buffer.from("Unrelated draft attachment"),
|
||||||
|
});
|
||||||
|
await selectDiffTab(page);
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await addSavedReview(page, "Please simplify this.");
|
||||||
|
});
|
||||||
|
await test.step("Send directly and stay on the diff", async () => {
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await expectFeedbackSent(page, "Composer diff stat");
|
||||||
|
await expectReviewAbsent(page, "Please simplify this.");
|
||||||
|
await captureFeedback(page, "single");
|
||||||
|
});
|
||||||
|
await test.step("Return to the untouched composer draft", async () => {
|
||||||
|
await showWideLayout(page);
|
||||||
|
await selectAgentTab(page, workspace.agentId);
|
||||||
|
await expectComposerDraft(page, "Keep this thought");
|
||||||
|
await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
test("feedback pending ownership follows the draft across Diff and compact Changes", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("review-shared-pending-");
|
||||||
|
const gate = await gateNextAgentMessage(page);
|
||||||
|
try {
|
||||||
|
await test.step("Send feedback from the Diff tab and hold its real request", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await addSavedReview(page, "One draft across surfaces");
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await gate.waitForRequest();
|
||||||
|
});
|
||||||
|
await test.step("The compact Changes surface observes the same pending operation", async () => {
|
||||||
|
await revealCompactChanges(page);
|
||||||
|
await expect(
|
||||||
|
compactReviewSurface(page).getByRole("button", {
|
||||||
|
name: "Sending feedback (1)",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(gate.requestCount()).toBe(1);
|
||||||
|
gate.accept();
|
||||||
|
await expect(
|
||||||
|
compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unsaved review survives presentation changes and acknowledgement before Save", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("review-editor-ownership-");
|
||||||
|
const gate = await gateNextAgentMessage(page);
|
||||||
|
try {
|
||||||
|
await test.step("Carry unsaved inline text into the compact sheet and back", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await tapFirstAddedDiffLine(page);
|
||||||
|
await fillReviewComment(page, "Body across presentations");
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await expectReviewEditorBody(page, "Body across presentations");
|
||||||
|
await showWideLayout(page);
|
||||||
|
await expectReviewEditorBody(page, "Body across presentations");
|
||||||
|
await saveReviewComment(page, "Body across presentations");
|
||||||
|
});
|
||||||
|
await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => {
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await gate.waitForRequest();
|
||||||
|
await editSavedReview(page, "Body across presentations");
|
||||||
|
await fillReviewComment(page, "Keep this unsaved edit");
|
||||||
|
gate.accept();
|
||||||
|
await expectFeedbackSent(page, "Composer diff stat");
|
||||||
|
await expectReviewEditorBody(page, "Keep this unsaved edit");
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await expectReviewEditorBody(page, "Keep this unsaved edit");
|
||||||
|
await saveReviewComment(page, "Keep this unsaved edit");
|
||||||
|
await expectSavedReview(page, "Keep this unsaved edit");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("saved feedback preserves navigation across multiple diff files", async ({ page }) => {
|
||||||
|
const workspace = await seedMultiFileReview();
|
||||||
|
try {
|
||||||
|
await test.step("Save compact feedback and retain both floating actions", async () => {
|
||||||
|
await openCompactReviewExplorer(page, workspace);
|
||||||
|
await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page));
|
||||||
|
await expectReviewNavigationReachable(page);
|
||||||
|
});
|
||||||
|
await test.step("Jump to another file without covering the feedback action", async () => {
|
||||||
|
await jumpToReviewFile(page, "z-last.ts");
|
||||||
|
await scrollReviewToLastLine(page);
|
||||||
|
await expectReviewNavigationReachable(page);
|
||||||
|
await captureFeedback(page, "navigation-compact");
|
||||||
|
});
|
||||||
|
await test.step("Wide file navigation and feedback remain reachable", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await navigateWideReviewFile(page, "README.md");
|
||||||
|
await expectFeedbackReachable(page);
|
||||||
|
await expectSavedReview(page, "Keep file navigation available");
|
||||||
|
await captureFeedback(page, "navigation-wide");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("feedback sends only matching comments and preserves orphaned comments", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedMultiFileReview();
|
||||||
|
try {
|
||||||
|
await test.step("A saved comment whose diff disappears has no sendable context", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await addSavedReview(page, "Retain this orphan");
|
||||||
|
await setReadmeDiff(workspace, false);
|
||||||
|
await expectUnmatchedFeedback(page);
|
||||||
|
});
|
||||||
|
await test.step("Send the matching subset with an accurate count", async () => {
|
||||||
|
await addSavedReview(page, "Send this matching comment");
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await expectFeedbackSent(page, "Composer diff stat");
|
||||||
|
await expectReviewAbsent(page, "Send this matching comment");
|
||||||
|
await expectUnmatchedFeedback(page);
|
||||||
|
});
|
||||||
|
await test.step("Restoring the missing diff reveals the unsent comment", async () => {
|
||||||
|
await setReadmeDiff(workspace, true);
|
||||||
|
await expectSavedReview(page, "Retain this orphan");
|
||||||
|
await expectFeedbackReachable(page);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => {
|
||||||
|
const workspace = await seedChangedAgent("review-feedback-zero-");
|
||||||
|
try {
|
||||||
|
await test.step("Close the agent tab while keeping the review", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await addSavedReview(page, "Keep until an agent is opened");
|
||||||
|
await closeAgentTab(page, workspace.agentId);
|
||||||
|
});
|
||||||
|
await test.step("The disabled action explains why in wide and compact layouts", async () => {
|
||||||
|
await expectFeedbackUnavailable(page);
|
||||||
|
await captureFeedback(page, "zero-wide");
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await expectFeedbackUnavailable(page);
|
||||||
|
await expectSavedReview(page, "Keep until an agent is opened");
|
||||||
|
await captureFeedback(page, "zero-compact");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("review-feedback-multiple-");
|
||||||
|
const other = await seedChangedAgent("review-feedback-other-");
|
||||||
|
try {
|
||||||
|
const second = await createReviewRecipient(workspace, "Second reviewer");
|
||||||
|
const historical = await createReviewRecipient(workspace, "Closed historical agent");
|
||||||
|
await test.step("Open two local tabs and a tab in another workspace", async () => {
|
||||||
|
await openReviewDiff(page, other);
|
||||||
|
await addSavedReview(page, "Other workspace review");
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await closeAgentTab(page, historical.id);
|
||||||
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id });
|
||||||
|
await selectDiffTab(page);
|
||||||
|
await addSavedReview(page, "Review for second agent");
|
||||||
|
});
|
||||||
|
await test.step("The wide menu includes only local open agents", async () => {
|
||||||
|
await captureWideFeedbackAppearance(page);
|
||||||
|
});
|
||||||
|
await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => {
|
||||||
|
await openFeedbackWithKeyboard(page);
|
||||||
|
await dismissRecipientMenu(page);
|
||||||
|
await expectFeedbackFocused(page);
|
||||||
|
await reopenFeedbackWithKeyboard(page);
|
||||||
|
await dismissRecipientMenu(page);
|
||||||
|
await expectFeedbackFocused(page);
|
||||||
|
});
|
||||||
|
await test.step("Choose the second agent in the compact menu and stay on the diff", async () => {
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await captureFeedback(page, "cta-compact");
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await expectRecipientMenu(page);
|
||||||
|
await captureFeedback(page, "menu-compact");
|
||||||
|
await chooseFeedbackRecipient(page, "Second reviewer");
|
||||||
|
await expectFeedbackSent(page, "Second reviewer");
|
||||||
|
await expectReviewAbsent(page, "Review for second agent");
|
||||||
|
});
|
||||||
|
await test.step("The other workspace's saved review remains", async () => {
|
||||||
|
await openReviewDiff(page, other);
|
||||||
|
await expectSavedReview(page, "Other workspace review");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
await other.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed feedback remains editable and retries through the real provider", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 });
|
||||||
|
try {
|
||||||
|
await test.step("A provider rejection retains the saved review", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await addSavedReview(page, "Initial feedback");
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await expectFeedbackFailure(page);
|
||||||
|
await expectSavedReview(page, "Initial feedback");
|
||||||
|
await captureFeedback(page, "failure");
|
||||||
|
});
|
||||||
|
await test.step("Edit the retained feedback and retry successfully", async () => {
|
||||||
|
await editSavedReview(page, "Initial feedback");
|
||||||
|
await saveReviewComment(page, "Corrected feedback");
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await expectFeedbackSent(page, "Composer diff stat");
|
||||||
|
await expectReviewAbsent(page, "Corrected feedback");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("pending feedback blocks repeat submission and preserves edits and additions", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspace = await seedChangedAgent("review-feedback-pending-");
|
||||||
|
const gate = await gateNextAgentMessage(page);
|
||||||
|
try {
|
||||||
|
await test.step("Hold the real request while feedback is pending", async () => {
|
||||||
|
await openReviewDiff(page, workspace);
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await addSavedReview(page, "Sent version");
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
const request = await gate.waitForRequest();
|
||||||
|
expect(request.agentId).toBe(workspace.agentId);
|
||||||
|
expect(request.text).toBe("Please address this code review.");
|
||||||
|
expect(request.attachments).toMatchObject([
|
||||||
|
{
|
||||||
|
type: "review",
|
||||||
|
cwd: workspace.cwd,
|
||||||
|
comments: [
|
||||||
|
{
|
||||||
|
filePath: "README.md",
|
||||||
|
lineNumber: 2,
|
||||||
|
body: "Sent version",
|
||||||
|
context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await expectFeedbackPending(page);
|
||||||
|
});
|
||||||
|
await test.step("Save an edit and another comment before acknowledgement", async () => {
|
||||||
|
await editSavedReview(page, "Sent version");
|
||||||
|
await saveReviewComment(page, "Edited during send");
|
||||||
|
await addSavedReview(page, "Added during send");
|
||||||
|
await expectFeedbackPending(page);
|
||||||
|
expect(gate.requestCount()).toBe(1);
|
||||||
|
await captureFeedback(page, "pending");
|
||||||
|
});
|
||||||
|
await test.step("A real acknowledgement preserves both new versions", async () => {
|
||||||
|
gate.accept();
|
||||||
|
await expectFeedbackSent(page, "Composer diff stat");
|
||||||
|
await expectSavedReview(page, "Edited during send");
|
||||||
|
await expectSavedReview(page, "Added during send");
|
||||||
|
await expectReviewAbsent(page, "Sent version");
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await workspace.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function showCompactLayout(page: Page) {
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
}
|
||||||
|
async function showWideLayout(page: Page) {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 900 });
|
||||||
|
}
|
||||||
|
async function selectAgentTab(page: Page, agentId: string) {
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } });
|
||||||
|
}
|
||||||
|
async function selectDiffTab(page: Page) {
|
||||||
|
await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } });
|
||||||
|
}
|
||||||
|
async function fillReviewComment(page: Page, body: string) {
|
||||||
|
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
|
||||||
|
}
|
||||||
|
async function expectReviewAbsent(page: Page, body: string) {
|
||||||
|
await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function reloadReview(page: Page) {
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 });
|
||||||
|
}
|
||||||
|
async function expectInlineCommentEditor(page: Page) {
|
||||||
|
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
|
||||||
|
await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
async function expectCompactCommentSheet(page: Page) {
|
||||||
|
await expect(page.getByTestId("review-comment-sheet")).toBeVisible();
|
||||||
|
await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 });
|
||||||
|
for (const action of ["Save", "Cancel"])
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `${action} review comment`, exact: true }),
|
||||||
|
).toBeInViewport({ ratio: 1 });
|
||||||
|
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Save review comment", exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
}
|
||||||
|
function feedbackButton(page: Page) {
|
||||||
|
return page.getByRole("button", { name: "Send feedback (1)", exact: true });
|
||||||
|
}
|
||||||
|
async function openFeedbackWithKeyboard(page: Page) {
|
||||||
|
await feedbackButton(page).focus();
|
||||||
|
await expectFeedbackFocused(page);
|
||||||
|
await reopenFeedbackWithKeyboard(page);
|
||||||
|
}
|
||||||
|
async function reopenFeedbackWithKeyboard(page: Page) {
|
||||||
|
await page.keyboard.press("Enter");
|
||||||
|
await expectRecipientMenu(page);
|
||||||
|
const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true });
|
||||||
|
await recipient.focus();
|
||||||
|
await expect(recipient).toBeFocused();
|
||||||
|
}
|
||||||
|
async function expectFeedbackFocused(page: Page) {
|
||||||
|
await expect(feedbackButton(page)).toBeFocused();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureWideFeedbackAppearance(page: Page) {
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
await page.emulateMedia({ colorScheme });
|
||||||
|
await expectSavedReview(page, "Review for second agent");
|
||||||
|
await captureFeedback(page, `cta-wide-${colorScheme}-closed`);
|
||||||
|
await sendFeedback(page, 1);
|
||||||
|
await expectRecipientMenu(page);
|
||||||
|
await captureFeedback(page, `cta-wide-${colorScheme}-open`);
|
||||||
|
await dismissRecipientMenu(page);
|
||||||
|
}
|
||||||
|
await page.emulateMedia({ colorScheme: "light" });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureFeedback(page: Page, name: string) {
|
||||||
|
await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeAgentTab(page: Page, agentId: string) {
|
||||||
|
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" });
|
||||||
|
await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click();
|
||||||
|
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
|
||||||
|
}
|
||||||
|
async function expectFeedbackUnavailable(page: Page) {
|
||||||
|
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
async function createReviewRecipient(
|
||||||
|
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||||
|
title: string,
|
||||||
|
) {
|
||||||
|
return workspace.client.createAgent({
|
||||||
|
provider: "mock",
|
||||||
|
cwd: workspace.cwd,
|
||||||
|
workspaceId: workspace.workspaceId,
|
||||||
|
title,
|
||||||
|
modeId: "load-test",
|
||||||
|
model: "e2e-fast-stream",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function expectRecipientMenu(page: Page) {
|
||||||
|
for (const name of ["Composer diff stat", "Second reviewer"]) {
|
||||||
|
await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 });
|
||||||
|
}
|
||||||
|
await expect(page.getByRole("menuitem")).toHaveCount(2);
|
||||||
|
}
|
||||||
|
async function dismissRecipientMenu(page: Page) {
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
await expect(page.getByRole("menuitem")).toHaveCount(0);
|
||||||
|
}
|
||||||
|
async function chooseFeedbackRecipient(page: Page, name: string) {
|
||||||
|
await page.getByRole("menuitem", { name, exact: true }).click();
|
||||||
|
}
|
||||||
|
async function expectFeedbackFailure(page: Page) {
|
||||||
|
await expect(
|
||||||
|
page.getByText("Requested mock prompt rejection", { exact: true }).first(),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled();
|
||||||
|
}
|
||||||
|
async function expectFeedbackPending(page: Page, count = 1) {
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }),
|
||||||
|
).toBeDisabled();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMultiFileReview() {
|
||||||
|
const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [
|
||||||
|
{ path: "z-last.ts", content: "export const final = 0;\n" },
|
||||||
|
]);
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.cwd, "z-last.ts"),
|
||||||
|
Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") +
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||||
|
return workspace;
|
||||||
|
}
|
||||||
|
async function expectFeedbackReachable(page: Page) {
|
||||||
|
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport(
|
||||||
|
{ ratio: 1 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function expectReviewNavigationReachable(page: Page) {
|
||||||
|
await expectFeedbackReachable(page);
|
||||||
|
const jump = page.getByRole("button", { name: "Jump to file", exact: true });
|
||||||
|
await expect(jump).toBeInViewport({ ratio: 1 });
|
||||||
|
const navigationBounds = await jump.boundingBox();
|
||||||
|
const feedbackBounds = await page
|
||||||
|
.getByRole("button", { name: "Send feedback (1)", exact: true })
|
||||||
|
.boundingBox();
|
||||||
|
if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds");
|
||||||
|
expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y);
|
||||||
|
}
|
||||||
|
async function jumpToReviewFile(page: Page, name: string) {
|
||||||
|
await page.getByRole("button", { name: "Jump to file", exact: true }).click();
|
||||||
|
const sheet = page.getByTestId("changes-jump-to-file-sheet");
|
||||||
|
await page
|
||||||
|
.getByTestId("changes-file-tree")
|
||||||
|
.filter({ visible: true })
|
||||||
|
.getByText(name, { exact: true })
|
||||||
|
.click();
|
||||||
|
await expect(sheet).toHaveCount(0);
|
||||||
|
await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport();
|
||||||
|
}
|
||||||
|
async function navigateWideReviewFile(page: Page, name: string) {
|
||||||
|
const explorer = await ensureExplorerSidebar(page);
|
||||||
|
await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click();
|
||||||
|
await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setReadmeDiff(
|
||||||
|
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||||
|
changed: boolean,
|
||||||
|
) {
|
||||||
|
await writeFile(
|
||||||
|
path.join(workspace.cwd, "README.md"),
|
||||||
|
changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n",
|
||||||
|
);
|
||||||
|
await workspace.client.checkoutRefresh(workspace.cwd);
|
||||||
|
}
|
||||||
|
async function expectUnmatchedFeedback(page: Page) {
|
||||||
|
await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled();
|
||||||
|
await expect(
|
||||||
|
page.getByText("Saved comments no longer match this diff.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactReviewSurface(page: Page) {
|
||||||
|
return page.getByTestId("explorer-content-area").filter({ visible: true });
|
||||||
|
}
|
||||||
|
async function openCompactReviewExplorer(
|
||||||
|
page: Page,
|
||||||
|
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
|
||||||
|
) {
|
||||||
|
await showWideLayout(page);
|
||||||
|
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
|
||||||
|
await selectAgentTab(page, workspace.agentId);
|
||||||
|
await showCompactLayout(page);
|
||||||
|
await composerChangesPill(page).click();
|
||||||
|
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reviewFileHeader(surface: Locator, name: string) {
|
||||||
|
return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scrollReviewToLastLine(page: Page) {
|
||||||
|
const surface = compactReviewSurface(page);
|
||||||
|
const bounds = await surface.boundingBox();
|
||||||
|
if (!bounds) throw new Error("Compact review has no bounds");
|
||||||
|
await page.mouse.move(bounds.x + 120, bounds.y + 200);
|
||||||
|
await page.mouse.wheel(0, 10000);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const body = await surface.getByTestId("diff-file-1-body").boundingBox();
|
||||||
|
const feedback = await page
|
||||||
|
.getByRole("button", { name: "Send feedback (1)", exact: true })
|
||||||
|
.boundingBox();
|
||||||
|
if (!body || !feedback) return false;
|
||||||
|
return body.y + body.height <= feedback.y;
|
||||||
|
})
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revealCompactChanges(page: Page) {
|
||||||
|
await page.getByTestId("workspace-explorer-toggle").first().click();
|
||||||
|
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
|
||||||
|
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
|
||||||
|
}
|
||||||
|
async function expectReviewEditorBody(page: Page, body: string) {
|
||||||
|
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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,3 +1,5 @@
|
|||||||
|
import { createMockIdleAgent, openWorkspaceWithAgents } from "../support/helpers/archive-tab";
|
||||||
|
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||||
import { expect, test, type Page } from "../support/fixtures";
|
import { expect, test, type Page } from "../support/fixtures";
|
||||||
import {
|
import {
|
||||||
expectNearBottom,
|
expectNearBottom,
|
||||||
@@ -5,7 +7,27 @@ import {
|
|||||||
waitForScrollableChat,
|
waitForScrollableChat,
|
||||||
} from "../support/helpers/agent-bottom-anchor";
|
} from "../support/helpers/agent-bottom-anchor";
|
||||||
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
import { awaitAssistantMessage } from "../support/helpers/agent-stream";
|
||||||
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
|
import {
|
||||||
|
attachComposerTestImage,
|
||||||
|
pasteComposerTestImage,
|
||||||
|
enterComposerFullscreen,
|
||||||
|
exitComposerFullscreen,
|
||||||
|
resizeComposerViewport,
|
||||||
|
selectComposerDraftRange,
|
||||||
|
expectComposerDraftSelection,
|
||||||
|
expectComposerFullscreenControls,
|
||||||
|
sendComposerWithButton,
|
||||||
|
queueComposerWithButton,
|
||||||
|
configureComposerQueueSend,
|
||||||
|
activateRetainedComposerTab,
|
||||||
|
recordVisibleComposerGeometry,
|
||||||
|
expectNoCollapsedVisibleComposer,
|
||||||
|
expectAttachmentPill,
|
||||||
|
expectQueuedMessageButton,
|
||||||
|
startRunningMockAgent,
|
||||||
|
composerLocator,
|
||||||
|
expectComposerVisible,
|
||||||
|
} from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
|
|
||||||
async function composerHeight(page: Page): Promise<number> {
|
async function composerHeight(page: Page): Promise<number> {
|
||||||
@@ -133,3 +155,300 @@ test("composer growth keeps a bottom-pinned chat at the bottom", async ({ page }
|
|||||||
await agent.cleanup();
|
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,6 +96,64 @@ async function seedAgentWithFileLink(input: LinkedFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test.describe("CodeMirror workspace file editing", () => {
|
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 }) => {
|
test("jumps to each relative reference to the same file", async ({ page }) => {
|
||||||
const session = await seedMockAgentWorkspace({
|
const session = await seedMockAgentWorkspace({
|
||||||
repoPrefix: "relative-reference-qa-",
|
repoPrefix: "relative-reference-qa-",
|
||||||
@@ -757,3 +815,24 @@ test.describe("CodeMirror workspace file editing", () => {
|
|||||||
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
|
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,6 +6,15 @@ import {
|
|||||||
selectWorkspaceIsolation,
|
selectWorkspaceIsolation,
|
||||||
} from "../support/helpers/new-workspace";
|
} from "../support/helpers/new-workspace";
|
||||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
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";
|
import { waitForSidebarHydration } from "../support/helpers/workspace-ui";
|
||||||
|
|
||||||
type WebSocketMessage = string | Buffer;
|
type WebSocketMessage = string | Buffer;
|
||||||
@@ -196,3 +205,52 @@ test.describe("New Workspace dictation submit", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function startDictationWithShortcut(page: Page): Promise<void> {
|
||||||
|
await composerLocator(page).press("Control+d");
|
||||||
|
}
|
||||||
|
|
||||||
|
test("dictation shortcut leaves retained fullscreen and exposes recording controls", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const agent = await seedMockAgentWorkspace({
|
||||||
|
repoPrefix: "fullscreen-dictation-",
|
||||||
|
title: "Fullscreen dictation shortcut",
|
||||||
|
});
|
||||||
|
await installSyntheticMicrophone(page);
|
||||||
|
const harness = await installDictationFailureHarness(page);
|
||||||
|
const draft = "First line\nSecond line\nThird line";
|
||||||
|
try {
|
||||||
|
await resizeComposerViewport(page, "portrait");
|
||||||
|
await openAgentRoute(page, agent);
|
||||||
|
await expectComposerVisible(page);
|
||||||
|
await fillComposerDraft(page, draft);
|
||||||
|
await enterComposerFullscreen(page);
|
||||||
|
await resizeComposerViewport(page, "landscape");
|
||||||
|
await expectComposerFullscreenControls(page);
|
||||||
|
await startDictationWithShortcut(page);
|
||||||
|
await harness.waitForAudio();
|
||||||
|
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||||
|
await expect(page.getByRole("button", { name: "Cancel dictation", exact: true })).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Insert transcription", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("button", { name: "Insert transcription and send", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
|
||||||
|
await expect(composerLocator(page)).toBeEditable();
|
||||||
|
await expect(composerLocator(page)).toHaveValue(draft);
|
||||||
|
await resizeComposerViewport(page, "portrait");
|
||||||
|
await enterComposerFullscreen(page);
|
||||||
|
await resizeComposerViewport(page, "landscape");
|
||||||
|
await composerLocator(page).press("Control+Shift+d");
|
||||||
|
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toHaveCount(0);
|
||||||
|
await expect(
|
||||||
|
page.getByText("Realtime voice is disabled in this test.", { exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(composerLocator(page)).toHaveValue(draft);
|
||||||
|
} finally {
|
||||||
|
await agent.cleanup();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -9,13 +9,12 @@ import {
|
|||||||
expectTerminalPreviewCommand,
|
expectTerminalPreviewCommand,
|
||||||
expectWorkspaceOpensWithTerminalTab,
|
expectWorkspaceOpensWithTerminalTab,
|
||||||
fillTerminalPrompt,
|
fillTerminalPrompt,
|
||||||
seedTerminalProfiles,
|
launchTrigger,
|
||||||
selectLaunchOption,
|
selectLaunchOption,
|
||||||
submitTerminalLaunch,
|
submitTerminalLaunch,
|
||||||
terminalLaunchSubmit,
|
terminalLaunchSubmit,
|
||||||
terminalPromptInput,
|
terminalPromptInput,
|
||||||
type TerminalProfile,
|
type TerminalProfile,
|
||||||
type TerminalProfileSeed,
|
|
||||||
} from "../support/helpers/new-workspace-launch";
|
} from "../support/helpers/new-workspace-launch";
|
||||||
|
|
||||||
// Someone who wants a terminal agent instead of a chat: they pick a profile
|
// Someone who wants a terminal agent instead of a chat: they pick a profile
|
||||||
@@ -42,17 +41,18 @@ const BARE_PROFILE: TerminalProfile = {
|
|||||||
args: ["-c", "echo bare-launch-static-line; exec cat"],
|
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", () => {
|
test.describe("New workspace: launching a terminal", () => {
|
||||||
let workspace: SeededWorkspace;
|
let workspace: SeededWorkspace;
|
||||||
let profileSeed: TerminalProfileSeed;
|
|
||||||
|
|
||||||
test.beforeEach(async () => {
|
test.beforeEach(async () => {
|
||||||
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
|
workspace = await seedWorkspace({ repoPrefix: "launch-terminal-" });
|
||||||
profileSeed = await seedTerminalProfiles([PROMPT_PROFILE, BARE_PROFILE]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.afterEach(async () => {
|
test.afterEach(async () => {
|
||||||
await profileSeed.restore();
|
|
||||||
await workspace?.cleanup();
|
await workspace?.cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -97,4 +97,51 @@ test.describe("New workspace: launching a terminal", () => {
|
|||||||
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
await expectTerminalOutputContains(page, "bare-launch-static-line");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("switching terminal profiles restores the prompt and its fullscreen affordance", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await gotoAppShell(page);
|
||||||
|
await waitForSidebarHydration(page);
|
||||||
|
await openNewWorkspaceComposer(page, {
|
||||||
|
projectKey: workspace.projectKey,
|
||||||
|
projectDisplayName: workspace.projectDisplayName,
|
||||||
|
});
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
const prompt = "First replacement line\nSecond replacement line\nThird replacement line";
|
||||||
|
const selectProfile = async (profile: TerminalProfile) => {
|
||||||
|
await launchTrigger(page).click();
|
||||||
|
const option = page.getByRole("menuitem").filter({ hasText: profile.name });
|
||||||
|
await option.click();
|
||||||
|
await expect(option).not.toBeVisible();
|
||||||
|
};
|
||||||
|
const fullscreen = page.getByRole("button", { name: "Fullscreen", exact: true });
|
||||||
|
|
||||||
|
await test.step("restore a multiline prompt after a read-only profile", async () => {
|
||||||
|
await selectProfile(PROMPT_PROFILE);
|
||||||
|
await fillTerminalPrompt(page, prompt);
|
||||||
|
await expect(fullscreen).toBeVisible();
|
||||||
|
await selectProfile(BARE_PROFILE);
|
||||||
|
await expect(terminalPromptInput(page)).toHaveCount(0);
|
||||||
|
await selectProfile(PROMPT_PROFILE);
|
||||||
|
await expect(terminalPromptInput(page)).toHaveValue(prompt);
|
||||||
|
await expect(fullscreen).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
await test.step("restore the locally edited prompt and measure subsequent typing", async () => {
|
||||||
|
await fillTerminalPrompt(page, "Short draft");
|
||||||
|
await expect(fullscreen).toHaveCount(0);
|
||||||
|
await selectProfile(BARE_PROFILE);
|
||||||
|
await selectProfile(PROMPT_PROFILE);
|
||||||
|
await expect(terminalPromptInput(page)).toHaveValue("Short draft");
|
||||||
|
await expect(fullscreen).toHaveCount(0);
|
||||||
|
await terminalPromptInput(page).press("End");
|
||||||
|
await terminalPromptInput(page).press("Shift+Enter");
|
||||||
|
await terminalPromptInput(page).pressSequentially("Second");
|
||||||
|
await terminalPromptInput(page).press("Shift+Enter");
|
||||||
|
await terminalPromptInput(page).pressSequentially("Third");
|
||||||
|
await expect(terminalPromptInput(page)).toHaveValue("Short draft\nSecond\nThird");
|
||||||
|
await expect(fullscreen).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { expect, test as base } from "../support/fixtures";
|
||||||
|
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||||
|
import { openChangesTreePanel } from "../support/helpers/workspace-tabs";
|
||||||
|
|
||||||
|
const test = base.extend<{ projectWithTrailingSpace: string }>({
|
||||||
|
projectWithTrailingSpace: async ({ e2eWorkerClient: client }, provide) => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "paseo-project-path-spaces-"));
|
||||||
|
try {
|
||||||
|
const repoPath = path.join(root, "repo ");
|
||||||
|
execFileSync("git", ["init", "-b", "main", repoPath], { stdio: "pipe" });
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
[
|
||||||
|
"-c",
|
||||||
|
"user.name=Paseo Test",
|
||||||
|
"-c",
|
||||||
|
"user.email=test@getpaseo.local",
|
||||||
|
"-c",
|
||||||
|
"commit.gpgsign=false",
|
||||||
|
"commit",
|
||||||
|
"--allow-empty",
|
||||||
|
"-m",
|
||||||
|
"initial",
|
||||||
|
],
|
||||||
|
{ cwd: repoPath, stdio: "pipe" },
|
||||||
|
);
|
||||||
|
await writeFile(path.join(repoPath, "visible-change.txt"), "a change\n");
|
||||||
|
const added = await client.addProject(repoPath);
|
||||||
|
expect(added.error).toBeNull();
|
||||||
|
const projectId = added.project!.projectId;
|
||||||
|
try {
|
||||||
|
const created = await client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: repoPath, projectId },
|
||||||
|
});
|
||||||
|
expect(created.error).toBeNull();
|
||||||
|
await provide(created.workspace!.id);
|
||||||
|
} finally {
|
||||||
|
await client.removeProject(projectId);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
test.skip(
|
||||||
|
process.platform === "win32",
|
||||||
|
"Windows does not preserve trailing spaces in folder names",
|
||||||
|
);
|
||||||
|
|
||||||
|
test("Changes shows files in a project whose folder ends in a space", async ({
|
||||||
|
page,
|
||||||
|
projectWithTrailingSpace,
|
||||||
|
}, testInfo) => {
|
||||||
|
await gotoWorkspace(page, projectWithTrailingSpace);
|
||||||
|
await openChangesTreePanel(page);
|
||||||
|
await expect(page.getByText("visible-change.txt", { exact: true }).first()).toBeVisible();
|
||||||
|
await expect(page.getByText("Not a git repository", { exact: true })).toHaveCount(0);
|
||||||
|
await page.screenshot({ path: testInfo.outputPath("changes.png") });
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { Page, TestInfo } from "@playwright/test";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { test, expect } from "../support/fixtures";
|
import { test, expect } from "../support/fixtures";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -6,6 +7,31 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
|
|||||||
|
|
||||||
const MOBILE_VIEWPORT = { width: 390, height: 844 };
|
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> {
|
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
|
||||||
const result = await client.fetchAgents({ scope: "active" });
|
const result = await client.fetchAgents({ scope: "active" });
|
||||||
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
|
||||||
@@ -20,7 +46,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
|
|||||||
test.describe("Workspace session rename (compact tab switcher)", () => {
|
test.describe("Workspace session rename (compact tab switcher)", () => {
|
||||||
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
test("renames an agent session from the switcher's per-session actions sheet", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}, testInfo) => {
|
||||||
test.setTimeout(120_000);
|
test.setTimeout(120_000);
|
||||||
|
|
||||||
await page.setViewportSize(MOBILE_VIEWPORT);
|
await page.setViewportSize(MOBILE_VIEWPORT);
|
||||||
@@ -36,8 +62,10 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
|||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await captureScreen(page, testInfo, "compact-chat-header");
|
||||||
|
|
||||||
// Open the sessions dropdown (a bottom sheet on compact).
|
// Open the sessions dropdown (a bottom sheet on compact).
|
||||||
await page.getByRole("button", { name: /Switch tabs/ }).click();
|
await openTabSwitcher(page);
|
||||||
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
|
||||||
{
|
{
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
@@ -65,12 +93,58 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
|
|||||||
await renameModalSubmit(page, modalPrefix).click();
|
await renameModalSubmit(page, modalPrefix).click();
|
||||||
|
|
||||||
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
await expect(input).toHaveCount(0, { timeout: 15_000 });
|
||||||
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
|
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
|
||||||
timeout: 15_000,
|
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.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 {
|
} finally {
|
||||||
await session.cleanup();
|
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
|
env APP_ID=sh.paseo.debug
|
||||||
|
|
||||||
open "${APP_ID}"
|
open "${APP_ID}"
|
||||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||||
press "id=\"workspace-header-menu-trigger\""
|
press "id=\"workspace-tab-switcher-trigger\""
|
||||||
press "id=\"workspace-header-new-terminal\""
|
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||||
press "id=\"terminal-keyboard-toggle\""
|
press "id=\"terminal-keyboard-toggle\""
|
||||||
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
|
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
|
env APP_ID=sh.paseo.debug
|
||||||
|
|
||||||
open "${APP_ID}"
|
open "${APP_ID}"
|
||||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||||
press "id=\"workspace-header-menu-trigger\""
|
press "id=\"workspace-tab-switcher-trigger\""
|
||||||
press "id=\"workspace-header-new-terminal\""
|
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||||
wait "id=\"terminal-surface\"" 10000
|
wait "id=\"terminal-surface\"" 10000
|
||||||
press "id=\"terminal-surface\""
|
press "id=\"terminal-surface\""
|
||||||
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
|
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
|
env APP_ID=sh.paseo.debug
|
||||||
|
|
||||||
open "${APP_ID}"
|
open "${APP_ID}"
|
||||||
wait "id=\"workspace-header-menu-trigger\"" 20000
|
wait "id=\"workspace-tab-switcher-trigger\"" 20000
|
||||||
press "id=\"workspace-header-menu-trigger\""
|
press "id=\"workspace-tab-switcher-trigger\""
|
||||||
press "id=\"workspace-header-new-terminal\""
|
press "id=\"workspace-tab-switcher-new-terminal\""
|
||||||
wait "id=\"terminal-keyboard-toggle\"" 10000
|
wait "id=\"terminal-keyboard-toggle\"" 10000
|
||||||
press "id=\"terminal-keyboard-toggle\""
|
press "id=\"terminal-keyboard-toggle\""
|
||||||
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
|
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
|
ad wait "text=\"${AGENT_TITLE}\"" 10000
|
||||||
run_host_scenario chat
|
run_host_scenario chat
|
||||||
ad keyboard dismiss || true
|
ad keyboard dismiss || true
|
||||||
ad press 'id="workspace-header-menu-trigger"' --settle
|
ad press 'id="workspace-tab-switcher-trigger"' --settle
|
||||||
ad wait 'id="workspace-header-new-agent"' 10000
|
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
|
||||||
ad press 'id="workspace-header-new-agent"' --settle
|
ad press 'id="workspace-tab-switcher-new-agent"' --settle
|
||||||
ad wait 'text="New Agent"' 10000
|
ad wait 'text="New Agent"' 10000
|
||||||
run_host_scenario workspace-draft
|
run_host_scenario workspace-draft
|
||||||
ad keyboard dismiss || true
|
ad keyboard dismiss || true
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// Open Settings > Appearance in a native Android app loaded from this checkout.
|
||||||
|
// AGENT_DEVICE_STATE_DIR=<state-dir> node packages/app/e2e/mobile/system-bars/android.mjs <session> <adb-serial> <app-id>
|
||||||
|
// Requires English UI and a dedicated emulator. Leaves System theme and light OS appearance.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
|
||||||
|
const [, , session, serial, appId] = process.argv;
|
||||||
|
assert(session && serial && appId, "Pass the agent-device session, adb serial, and app ID");
|
||||||
|
|
||||||
|
function ad(...args) {
|
||||||
|
return execFileSync("agent-device", [...args, "--session", session], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function windowState() {
|
||||||
|
const state = execFileSync("adb", ["-s", serial, "shell", "dumpsys", "window"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
assert(state.match(/mCurrentFocus=.*\n/)?.[0].includes(`${appId}/`), "App must be focused");
|
||||||
|
const regions = state.match(/mLastStatusBarAppearanceRegions=\s*\n([^\n]+)/)?.[1];
|
||||||
|
assert(regions, "Android must report the status-bar appearance region");
|
||||||
|
return regions.includes("LIGHT_STATUS_BARS");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectDarkIcons(expected, context) {
|
||||||
|
const deadline = Date.now() + 5_000;
|
||||||
|
do {
|
||||||
|
if (windowState() === expected) {
|
||||||
|
console.log(`PASS: ${context}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await delay(100);
|
||||||
|
} while (Date.now() < deadline);
|
||||||
|
assert.equal(windowState(), expected, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectTheme(theme) {
|
||||||
|
const current = ad("snapshot", "-i").match(/"Theme: ([^"]+)"/)?.[1];
|
||||||
|
assert(current, "Start on Settings > Appearance");
|
||||||
|
ad("press", `label="Theme: ${current}"`, "--settle");
|
||||||
|
ad("press", `label="${theme}"`, "--settle");
|
||||||
|
ad("wait", "text", `Theme: ${theme}`, "5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
ad("settings", "appearance", "light");
|
||||||
|
selectTheme("Light");
|
||||||
|
await expectDarkIcons(true, "Light app theme uses dark status icons");
|
||||||
|
selectTheme("Dark");
|
||||||
|
await expectDarkIcons(false, "Dark app theme uses light status icons");
|
||||||
|
selectTheme("System");
|
||||||
|
await expectDarkIcons(true, "System theme starts with dark icons on light OS appearance");
|
||||||
|
ad("settings", "appearance", "dark");
|
||||||
|
await expectDarkIcons(
|
||||||
|
false,
|
||||||
|
"System theme follows an OS change to dark without reopening settings",
|
||||||
|
);
|
||||||
|
ad("settings", "appearance", "light");
|
||||||
|
await expectDarkIcons(true, "System theme follows an OS change back to light");
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# Android workspace memory diagnostic
|
||||||
|
|
||||||
|
Supplemental reproduction for [#4408](https://github.com/getpaseo/paseo/issues/4408), using the production 0.11.2 APK on an Android 16 emulator. This tests an adapted failure: opening the sidebar with 800 projects, each containing one workspace, produced a `LOW_MEMORY` exit after 66–90 seconds. It does not reproduce the reported 18-second cold-start ANR or establish its cause.
|
||||||
|
|
||||||
|
Before running:
|
||||||
|
|
||||||
|
- Use a dedicated emulator with the production APK installed and notifications permission settled. Bind the agent-device session to that emulator before running.
|
||||||
|
- Pair the app with an isolated daemon containing 800 active workspaces, each in its own project. Use Project grouping and leave projects expanded. The observed fixture used existing directories inside a Git checkout, with workspace titles permuted by `(index * 317) % 800`, no agents, and no terminals.
|
||||||
|
- Record the daemon revision, emulator RAM and Java heap settings, and the project/workspace distribution. Device memory pressure controls whether Android kills the app.
|
||||||
|
|
||||||
|
Run from the repository root with the agent-device session, Android serial, and an artifact directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
node packages/app/e2e/mobile/workspace-memory/android.mjs workspace-memory emulator-5556 .dev/workspace-memory
|
||||||
|
```
|
||||||
|
|
||||||
|
The test cold-starts the paired app, opens its sidebar, samples memory and the process ID every five seconds for two minutes, and verifies that closing the sidebar responds. A process exit or restart fails the assertion. Memory, exit records, and logs stay in the supplied artifact directory.
|
||||||
|
|
||||||
|
Control: repeat with 800 workspaces grouped into one project. That distribution survived the two-minute automated check and responded to closing the sidebar, with total PSS near 300 MB. The project-per-workspace fixture reached about 2.5 GB PSS before Android recorded `LOW_MEMORY`; its Java heap rose and fell rather than showing the reporter's full-heap ANR. Neither result proves that shared collators would fix the report.
|
||||||
|
|
||||||
|
This diagnostic is deliberately outside CI and contains no production change.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Requires a paired production app and the workspace fixture described in README.md.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { setTimeout as delay } from "node:timers/promises";
|
||||||
|
|
||||||
|
const [, , session, serial, artifacts] = process.argv;
|
||||||
|
assert(session && serial && artifacts, "Pass session, Android serial, and artifact directory");
|
||||||
|
mkdirSync(artifacts, { recursive: true });
|
||||||
|
|
||||||
|
function ad(...args) {
|
||||||
|
return execFileSync("agent-device", [...args, "--session", session], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function adb(...args) {
|
||||||
|
return execFileSync("adb", ["-s", serial, ...args], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 15_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function pid() {
|
||||||
|
try {
|
||||||
|
return adb("shell", "pidof", "sh.paseo").trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ad("open", "sh.paseo", "--relaunch", "--platform", "android");
|
||||||
|
ad("wait", "text", "Open menu", "15000");
|
||||||
|
assert.equal(
|
||||||
|
JSON.parse(ad("perf", "memory", "sample", "--json")).data.deviceId,
|
||||||
|
serial,
|
||||||
|
"Session and adb serial must address the same device",
|
||||||
|
);
|
||||||
|
const initialPid = pid();
|
||||||
|
assert(initialPid, "App must have a process before opening the sidebar");
|
||||||
|
ad("press", 'label="Open menu"', "--settle");
|
||||||
|
const startedAt = Date.now();
|
||||||
|
const samples = [];
|
||||||
|
try {
|
||||||
|
while (Date.now() - startedAt < 120_000) {
|
||||||
|
const currentPid = pid();
|
||||||
|
samples.push({
|
||||||
|
elapsedMs: Date.now() - startedAt,
|
||||||
|
pid: currentPid,
|
||||||
|
meminfo: currentPid ? adb("shell", "dumpsys", "meminfo", "sh.paseo") : "",
|
||||||
|
});
|
||||||
|
writeFileSync(join(artifacts, "samples.json"), JSON.stringify(samples, null, 2));
|
||||||
|
assert.equal(currentPid, initialPid, "Opening the workspace sidebar must keep the app alive");
|
||||||
|
await delay(5_000);
|
||||||
|
}
|
||||||
|
ad("press", 'label="Close sidebar"', "--settle");
|
||||||
|
ad("wait", "text", "Open menu", "10000");
|
||||||
|
console.log("PASS: app survived two minutes with the sidebar open and responded to Close");
|
||||||
|
} finally {
|
||||||
|
writeFileSync(
|
||||||
|
join(artifacts, "exit-info.txt"),
|
||||||
|
adb("shell", "dumpsys", "activity", "exit-info", "sh.paseo"),
|
||||||
|
);
|
||||||
|
writeFileSync(join(artifacts, "logcat.txt"), adb("logcat", "-d", "-t", "4000"));
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ type WebSocketMessage = string | Buffer;
|
|||||||
interface SendAgentMessageRequest {
|
interface SendAgentMessageRequest {
|
||||||
type: "send_agent_message_request";
|
type: "send_agent_message_request";
|
||||||
requestId: string;
|
requestId: string;
|
||||||
|
text: unknown;
|
||||||
|
attachments: unknown;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
/** Absent when the client sends into an idle agent. */
|
/** Absent when the client sends into an idle agent. */
|
||||||
activeTurnBehavior?: string;
|
activeTurnBehavior?: string;
|
||||||
@@ -29,6 +31,8 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
|
|||||||
return {
|
return {
|
||||||
type: "send_agent_message_request",
|
type: "send_agent_message_request",
|
||||||
requestId: request.requestId,
|
requestId: request.requestId,
|
||||||
|
text: request.text,
|
||||||
|
attachments: request.attachments,
|
||||||
agentId: request.agentId,
|
agentId: request.agentId,
|
||||||
activeTurnBehavior:
|
activeTurnBehavior:
|
||||||
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
|
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
|
||||||
@@ -74,6 +78,7 @@ export async function gateNextAgentMessage(page: Page) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
waitForRequest,
|
waitForRequest,
|
||||||
|
requestCount: () => requests.length,
|
||||||
accept(index = 0) {
|
accept(index = 0) {
|
||||||
const heldMessage = heldMessages[index];
|
const heldMessage = heldMessages[index];
|
||||||
if (!serverSocket || !heldMessage) {
|
if (!serverSocket || !heldMessage) {
|
||||||
|
|||||||
@@ -316,3 +316,133 @@ export async function openGithubWorkspace(
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const COMPOSER_TEST_IMAGE_BASE64 =
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==";
|
||||||
|
|
||||||
|
export async function attachComposerTestImage(page: Page): Promise<void> {
|
||||||
|
await attachImageFromMenu(page, {
|
||||||
|
name: "fullscreen.png",
|
||||||
|
mimeType: "image/png",
|
||||||
|
buffer: Buffer.from(COMPOSER_TEST_IMAGE_BASE64, "base64"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pasteComposerTestImage(page: Page): Promise<boolean> {
|
||||||
|
return composerInput(page).evaluate((input, base64) => {
|
||||||
|
const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0));
|
||||||
|
const clipboardData = new DataTransfer();
|
||||||
|
clipboardData.items.add(new File([bytes], "pasted.png", { type: "image/png" }));
|
||||||
|
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
|
||||||
|
input.dispatchEvent(event);
|
||||||
|
return event.defaultPrevented;
|
||||||
|
}, COMPOSER_TEST_IMAGE_BASE64);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function enterComposerFullscreen(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Fullscreen", exact: true }).click();
|
||||||
|
}
|
||||||
|
export async function exitComposerFullscreen(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Exit fullscreen", exact: true }).click();
|
||||||
|
}
|
||||||
|
export async function resizeComposerViewport(
|
||||||
|
page: Page,
|
||||||
|
orientation: "portrait" | "landscape" | "short",
|
||||||
|
): Promise<void> {
|
||||||
|
const sizes = {
|
||||||
|
portrait: { width: 390, height: 844 },
|
||||||
|
landscape: { width: 844, height: 390 },
|
||||||
|
short: { width: 390, height: 390 },
|
||||||
|
};
|
||||||
|
await page.setViewportSize(sizes[orientation]);
|
||||||
|
}
|
||||||
|
export async function selectComposerDraftRange(
|
||||||
|
page: Page,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await composerInput(page).focus();
|
||||||
|
await composerInput(page).evaluate(
|
||||||
|
(element, selection) => {
|
||||||
|
const input = element as HTMLTextAreaElement;
|
||||||
|
input.setSelectionRange(selection.start, selection.end);
|
||||||
|
input.dispatchEvent(new Event("select", { bubbles: true }));
|
||||||
|
},
|
||||||
|
{ start, end },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function expectComposerDraftSelection(
|
||||||
|
page: Page,
|
||||||
|
start: number,
|
||||||
|
end: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
composerInput(page).evaluate((element) => {
|
||||||
|
const input = element as HTMLTextAreaElement;
|
||||||
|
return [input.selectionStart, input.selectionEnd];
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.toEqual([start, end]);
|
||||||
|
}
|
||||||
|
export async function expectComposerFullscreenControls(page: Page): Promise<void> {
|
||||||
|
await expect(page.getByRole("button", { name: "Exit fullscreen", exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByTestId("composer-fullscreen").getByRole("button")).toHaveCount(2);
|
||||||
|
await expect(composerInput(page)).toHaveCount(1);
|
||||||
|
}
|
||||||
|
export async function sendComposerWithButton(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Send message", exact: true }).click();
|
||||||
|
}
|
||||||
|
export async function queueComposerWithButton(page: Page): Promise<void> {
|
||||||
|
await page.getByRole("button", { name: "Queue message", exact: true }).click();
|
||||||
|
}
|
||||||
|
export async function configureComposerQueueSend(page: Page): Promise<void> {
|
||||||
|
await page.addInitScript(() =>
|
||||||
|
localStorage.setItem("@paseo:app-settings", JSON.stringify({ sendBehavior: "queue" })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive the real tab chooser's commands even when fullscreen covers its pointer surface.
|
||||||
|
* This exercises retained activity changes, not a claim that covered chrome is clickable.
|
||||||
|
*/
|
||||||
|
export async function activateRetainedComposerTab(page: Page, title: string): Promise<void> {
|
||||||
|
await page
|
||||||
|
.getByTestId("workspace-tab-switcher-trigger")
|
||||||
|
.evaluate((element) => (element as HTMLElement).click());
|
||||||
|
const option = page.getByText(title, { exact: true }).filter({ visible: true }).last();
|
||||||
|
await expect(option).toBeVisible();
|
||||||
|
await option.evaluate((element) => (element as HTMLElement).click());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recordVisibleComposerGeometry(page: Page): Promise<void> {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
const frames: { input: number; root: number }[] = [];
|
||||||
|
Reflect.set(window, "__composerGeometryFrames", frames);
|
||||||
|
let running = true;
|
||||||
|
const tick = () => {
|
||||||
|
if (!running) return;
|
||||||
|
for (const root of document.querySelectorAll('[data-testid="message-input-root"]')) {
|
||||||
|
if (root.getClientRects().length === 0) continue;
|
||||||
|
const input = root.querySelector("textarea");
|
||||||
|
if (input)
|
||||||
|
frames.push({
|
||||||
|
input: input.getBoundingClientRect().height,
|
||||||
|
root: root.getBoundingClientRect().height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
Reflect.set(window, "__stopComposerGeometry", () => {
|
||||||
|
running = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function expectNoCollapsedVisibleComposer(page: Page): Promise<void> {
|
||||||
|
const frames = await page.evaluate(() => {
|
||||||
|
Reflect.get(window, "__stopComposerGeometry")?.();
|
||||||
|
return Reflect.get(window, "__composerGeometryFrames") as { input: number; root: number }[];
|
||||||
|
});
|
||||||
|
expect(frames.length).toBeGreaterThan(0);
|
||||||
|
expect(frames.filter((frame) => frame.root < 42 || frame.input < 42)).toEqual([]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,6 @@ async function capture(
|
|||||||
name: string,
|
name: string,
|
||||||
subject: Locator = headerButtons(page),
|
subject: Locator = headerButtons(page),
|
||||||
) {
|
) {
|
||||||
await expect(headerButtons(page)).toHaveCount(1);
|
|
||||||
await expect(subject).toBeInViewport();
|
await expect(subject).toBeInViewport();
|
||||||
await waitForSettledPosition(subject);
|
await waitForSettledPosition(subject);
|
||||||
const file = info.outputPath(`${name}.png`);
|
const file = info.outputPath(`${name}.png`);
|
||||||
@@ -80,6 +79,7 @@ export async function withButtonAuthorExample(
|
|||||||
refreshFromHeaderAndComposer: () =>
|
refreshFromHeaderAndComposer: () =>
|
||||||
test.step("icon-only header action and labeled composer action update in place", async () => {
|
test.step("icon-only header action and labeled composer action update in place", async () => {
|
||||||
await exampleCommand(page, "action");
|
await exampleCommand(page, "action");
|
||||||
|
await expect(headerButtons(page)).toHaveCount(1);
|
||||||
await headerButtons(page).click();
|
await headerButtons(page).click();
|
||||||
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
|
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
|
||||||
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
|
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
|
||||||
@@ -88,7 +88,13 @@ export async function withButtonAuthorExample(
|
|||||||
).toContainText("Refreshed · 2");
|
).toContainText("Refreshed · 2");
|
||||||
await capture(page, info, "01-actions");
|
await capture(page, info, "01-actions");
|
||||||
await page.setViewportSize(COMPACT);
|
await page.setViewportSize(COMPACT);
|
||||||
await capture(page, info, "02-compact-actions");
|
await expect(headerButtons(page)).toHaveCount(0);
|
||||||
|
await capture(
|
||||||
|
page,
|
||||||
|
info,
|
||||||
|
"02-compact-actions",
|
||||||
|
page.getByRole("button", { name: "Refresh context", exact: true }),
|
||||||
|
);
|
||||||
}),
|
}),
|
||||||
useMenusAndToggleButtons: () =>
|
useMenusAndToggleButtons: () =>
|
||||||
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
|
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
|
||||||
@@ -128,7 +134,8 @@ export async function withButtonAuthorExample(
|
|||||||
await capture(page, info, "05-disabled");
|
await capture(page, info, "05-disabled");
|
||||||
await exampleCommand(page, "enable");
|
await exampleCommand(page, "enable");
|
||||||
await page.setViewportSize(COMPACT);
|
await page.setViewportSize(COMPACT);
|
||||||
await headerButtons(page).click();
|
await expect(headerButtons(page)).toHaveCount(0);
|
||||||
|
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
|
||||||
).toBeInViewport();
|
).toBeInViewport();
|
||||||
@@ -143,6 +150,7 @@ export async function withButtonAuthorExample(
|
|||||||
inspectLiveWorkspaceDetails: () =>
|
inspectLiveWorkspaceDetails: () =>
|
||||||
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
|
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
|
||||||
await exampleCommand(page, "popover");
|
await exampleCommand(page, "popover");
|
||||||
|
await expect(headerButtons(page)).toHaveCount(1);
|
||||||
await headerButtons(page).click();
|
await headerButtons(page).click();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
page.getByRole("button", { name: "Close workspace details", exact: true }),
|
||||||
|
|||||||
@@ -202,31 +202,6 @@ async function expectHeaderTextTruncated(page: Page) {
|
|||||||
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
|
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(
|
export async function withButtonShowcase(
|
||||||
page: Page,
|
page: Page,
|
||||||
run: (buttons: {
|
run: (buttons: {
|
||||||
@@ -408,7 +383,7 @@ export async function withButtonShowcase(
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
openCompactSheets: () =>
|
openCompactSheets: () =>
|
||||||
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
|
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
|
||||||
await page.setViewportSize(COMPACT);
|
await page.setViewportSize(COMPACT);
|
||||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||||
await expect(
|
await expect(
|
||||||
@@ -426,39 +401,12 @@ export async function withButtonShowcase(
|
|||||||
"Button showcase with a long workspace title",
|
"Button showcase with a long workspace title",
|
||||||
);
|
);
|
||||||
await expectHeaderTextTruncated(page);
|
await expectHeaderTextTruncated(page);
|
||||||
await expectSurfaceVisible(page);
|
|
||||||
await openCompactOverflowWithHeaderChrome(page);
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("menuitem", { name: "Production locked", exact: true }),
|
page.getByRole("toolbar", { name: "Workspace actions", 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);
|
).toHaveCount(0);
|
||||||
await expectSurfaceVisible(
|
await expect(
|
||||||
page,
|
page.getByRole("button", { name: "Open menu", exact: true }),
|
||||||
page.getByRole("menuitem", { name: "Run checks", exact: true }),
|
).toBeInViewport();
|
||||||
);
|
|
||||||
await choose(page, "Run checks");
|
|
||||||
await showcase.setTitle("Button showcase");
|
await showcase.setTitle("Button showcase");
|
||||||
await press(page, "Composer status");
|
await press(page, "Composer status");
|
||||||
await expectDetails(page);
|
await expectDetails(page);
|
||||||
|
|||||||
@@ -47,6 +47,65 @@ export default function contribute(client) {
|
|||||||
return () => {};
|
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(
|
export async function withTimelinePlugin(
|
||||||
page: Page,
|
page: Page,
|
||||||
info: TestInfo,
|
info: TestInfo,
|
||||||
@@ -60,7 +119,6 @@ export async function withTimelinePlugin(
|
|||||||
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
JSON.stringify({ toolCallDetailLevel: "overview" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-timeline-plugin-"));
|
|
||||||
const agent = await seedMockAgentWorkspace({
|
const agent = await seedMockAgentWorkspace({
|
||||||
repoPrefix: "timeline-plugin-",
|
repoPrefix: "timeline-plugin-",
|
||||||
title: "Timeline plugin regression",
|
title: "Timeline plugin regression",
|
||||||
@@ -73,40 +131,53 @@ export async function withTimelinePlugin(
|
|||||||
}
|
}
|
||||||
: {},
|
: {},
|
||||||
});
|
});
|
||||||
const pluginClient = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
|
||||||
const previous = await pluginClient.getDaemonConfig();
|
|
||||||
try {
|
try {
|
||||||
await writeFile(
|
await withDirectoryPlugin({
|
||||||
path.join(directory, "paseo-plugin.json"),
|
pluginId: PLUGIN_ID,
|
||||||
JSON.stringify({ id: PLUGIN_ID, requirements: pluginRequirements }),
|
source: CLIENT_SOURCE,
|
||||||
);
|
run: async () => {
|
||||||
await writeFile(path.join(directory, "index.client.tsx"), CLIENT_SOURCE);
|
try {
|
||||||
await pluginClient.patchDaemonConfig({ pluginsEnabled: true });
|
await openAgentRoute(page, agent);
|
||||||
await pluginClient.installDirectoryPlugin(directory);
|
await expect(
|
||||||
await openAgentRoute(page, agent);
|
page.getByRole("textbox", { name: "Message agent..." }).first(),
|
||||||
await expect(page.getByRole("textbox", { name: "Message agent..." }).first()).toBeVisible();
|
).toBeVisible();
|
||||||
await run(agent);
|
await run(agent);
|
||||||
await info.attach("timeline-plugin", {
|
await info.attach("timeline-plugin", {
|
||||||
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
|
body: await page.screenshot({ path: info.outputPath("timeline-plugin.png") }),
|
||||||
contentType: "image/png",
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await info.attach("timeline-before-cleanup", {
|
||||||
|
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
await info.attach("timeline-before-cleanup", {
|
|
||||||
body: await page.screenshot({ path: info.outputPath("timeline-before-cleanup.png") }),
|
|
||||||
contentType: "image/png",
|
|
||||||
});
|
|
||||||
throw error;
|
|
||||||
} finally {
|
} finally {
|
||||||
await pluginClient.removePlugin(PLUGIN_ID);
|
|
||||||
await pluginClient.patchDaemonConfig({
|
|
||||||
pluginsEnabled: previous.config.pluginsEnabled ?? false,
|
|
||||||
});
|
|
||||||
await pluginClient.close();
|
|
||||||
await agent.cleanup();
|
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> {
|
export async function requestPluginTimeline(agent: MockAgentWorkspace): Promise<void> {
|
||||||
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
|
await agent.client.sendAgentMessage(agent.agentId, "Exercise timeline transforms.");
|
||||||
}
|
}
|
||||||
@@ -142,3 +213,21 @@ export async function expectBothConsecutiveTools(page: Page): Promise<void> {
|
|||||||
await grep.scrollIntoViewIfNeeded();
|
await grep.scrollIntoViewIfNeeded();
|
||||||
await expect(grep).toBeVisible();
|
await expect(grep).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The row belongs to the virtualized history, not the always-mounted recent window. */
|
||||||
|
export async function expectVirtualizedPluginPromptVisible(
|
||||||
|
page: Page,
|
||||||
|
prompt: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const timeline = page.locator('[data-testid="agent-chat-scroll"]:visible').first();
|
||||||
|
await expect(
|
||||||
|
timeline.locator("[data-index]").getByTestId("plugin-prompt").filter({ hasText: prompt }),
|
||||||
|
).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function attachPromptTimelineEvidence(page: Page, info: TestInfo): Promise<void> {
|
||||||
|
await info.attach("plugin-outline-jump", {
|
||||||
|
body: await page.screenshot({ path: info.outputPath("plugin-outline-jump.png") }),
|
||||||
|
contentType: "image/png",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
// Polyfill crypto.randomUUID for React Native before any other imports
|
// Install runtime polyfills before any other module evaluates. Keep this the first import.
|
||||||
import { polyfillCrypto } from "./src/polyfills/crypto";
|
// oxlint-disable-next-line import/no-unassigned-import -- Polyfills install on import.
|
||||||
polyfillCrypto();
|
import "./src/polyfills/install-early";
|
||||||
|
|
||||||
// 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.
|
// Configure Unistyles before Expo Router pulls in any components using StyleSheet.
|
||||||
import "./src/styles/unistyles";
|
import "./src/styles/unistyles";
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
const { withAndroidManifest } = require("expo/config-plugins");
|
||||||
|
|
||||||
|
// Requesting CAMERA implies these features are required, which hides the app
|
||||||
|
// on Google Play for devices without a camera. Pairing works without QR scanning.
|
||||||
|
const OPTIONAL_CAMERA_FEATURES = ["android.hardware.camera", "android.hardware.camera.autofocus"];
|
||||||
|
|
||||||
|
function withAndroidOptionalCamera(config) {
|
||||||
|
return withAndroidManifest(config, (modConfig) => {
|
||||||
|
const manifest = modConfig.modResults.manifest;
|
||||||
|
const features = manifest["uses-feature"] ?? [];
|
||||||
|
for (const name of OPTIONAL_CAMERA_FEATURES) {
|
||||||
|
const existing = features.find((feature) => feature.$["android:name"] === name);
|
||||||
|
if (existing) {
|
||||||
|
existing.$["android:required"] = "false";
|
||||||
|
} else {
|
||||||
|
features.push({ $: { "android:name": name, "android:required": "false" } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
manifest["uses-feature"] = features;
|
||||||
|
return modConfig;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withAndroidOptionalCamera;
|
||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
type UserMessageItem,
|
type UserMessageItem,
|
||||||
} from "@/types/stream";
|
} from "@/types/stream";
|
||||||
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
import { transformTimelineItem, type TimelineItemTransform } from "@/plugins/timeline/model";
|
||||||
import { createStreamPresentation } from "./presentation";
|
import { createStreamPresentation, getStreamItemMessageId } from "./presentation";
|
||||||
import { buildAgentStreamRenderModel } from "./model";
|
import { buildAgentStreamRenderModel } from "./model";
|
||||||
|
|
||||||
const runtime = {
|
const runtime = {
|
||||||
@@ -29,7 +29,7 @@ const runtime = {
|
|||||||
} as unknown as PluginClientRuntime;
|
} as unknown as PluginClientRuntime;
|
||||||
|
|
||||||
function installProbe(
|
function installProbe(
|
||||||
itemType: "assistant_message" | "tool_call" | "reasoning",
|
itemType: "user_message" | "assistant_message" | "tool_call" | "reasoning",
|
||||||
condition = "true",
|
condition = "true",
|
||||||
): InstalledPlugin {
|
): InstalledPlugin {
|
||||||
const clientBundle = `(function() {
|
const clientBundle = `(function() {
|
||||||
@@ -160,6 +160,23 @@ describe("stream presentation through installed plugins", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it("addresses a prompt a plugin replaced by the prompt's own id", () => {
|
||||||
|
const harness = streamHarness(installedTransform(installProbe("user_message")));
|
||||||
|
const rendered = rows(
|
||||||
|
harness.send({
|
||||||
|
type: "timeline",
|
||||||
|
provider: "claude",
|
||||||
|
item: { type: "user_message", messageId: "prompt-1", text: "Earlier prompt" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const source = rows(harness.source());
|
||||||
|
|
||||||
|
expect(source).toMatchObject([{ kind: "user_message", id: "prompt-1" }]);
|
||||||
|
expect(rendered).toMatchObject([{ kind: "plugin", id: "probe-plugin/prompt-1/0" }]);
|
||||||
|
// The chat outline jumps to the source id; the row it lands on is the plugin's.
|
||||||
|
expect(rendered.map(getStreamItemMessageId)).toEqual(["prompt-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("finishes one message before streaming a different message ID", () => {
|
it("finishes one message before streaming a different message ID", () => {
|
||||||
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
|
const harness = streamHarness(installedTransform(installProbe("assistant_message")));
|
||||||
harness.send(assistant("First\n\nBody", "first"));
|
harness.send(assistant("First\n\nBody", "first"));
|
||||||
|
|||||||
@@ -26,10 +26,14 @@ function retainItems(previous: StreamItem[], next: StreamItem[]): StreamItem[] {
|
|||||||
/**
|
/**
|
||||||
* The source message a display row belongs to. Every assistant message is split into
|
* 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
|
* Markdown blocks, so an assistant row id never equals its message id; anything that
|
||||||
* addresses a message — find, scroll-to-message, history reveal — asks for this.
|
* addresses a message — find, scroll-to-message, history reveal — asks for this. A
|
||||||
|
* plugin row a timeline transformer projected answers for the item it replaced, whose
|
||||||
|
* id is what the chat outline and find hold.
|
||||||
*/
|
*/
|
||||||
export function getStreamItemMessageId(item: StreamItem): string {
|
export function getStreamItemMessageId(item: StreamItem): string {
|
||||||
return item.kind === "assistant_message" ? (item.blockGroupId ?? item.id) : item.id;
|
if (item.kind === "assistant_message") return item.blockGroupId ?? item.id;
|
||||||
|
if (item.kind === "plugin") return item.sourceId ?? item.id;
|
||||||
|
return item.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
type HistoryStartPaginationInput,
|
type HistoryStartPaginationInput,
|
||||||
type HistoryStartPaginationTransition,
|
type HistoryStartPaginationTransition,
|
||||||
} from "./history-start-pagination";
|
} from "./history-start-pagination";
|
||||||
|
import { useStreamTopOverlayInset } from "./top-overlay";
|
||||||
import {
|
import {
|
||||||
createHistoryStartSettleScheduler,
|
createHistoryStartSettleScheduler,
|
||||||
type HistoryStartSettleScheduler,
|
type HistoryStartSettleScheduler,
|
||||||
@@ -274,12 +275,20 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
|
|||||||
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
|
||||||
? undefined
|
? undefined
|
||||||
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
|
: 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(
|
const listContentContainerStyle = useMemo(
|
||||||
() => [
|
() => [
|
||||||
baseListContentContainerStyle,
|
baseListContentContainerStyle,
|
||||||
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
|
{
|
||||||
|
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
baseListContentContainerStyle,
|
||||||
|
streamKeyboardInset.contentContainerPaddingBottom,
|
||||||
|
topOverlayInset,
|
||||||
],
|
],
|
||||||
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
|
|
||||||
);
|
);
|
||||||
const listInsetProps = useMemo(
|
const listInsetProps = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createContext, useContext } from "react";
|
||||||
|
import { isNative } from "@/constants/platform";
|
||||||
|
|
||||||
|
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
|
||||||
|
export const supportsStreamTopOverlay = isNative;
|
||||||
|
|
||||||
|
const StreamTopOverlayContext = createContext(0);
|
||||||
|
|
||||||
|
/** Visual-top clearance supplied by the containing layout, in points. */
|
||||||
|
export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
|
||||||
|
|
||||||
|
export function useStreamTopOverlayInset(): number {
|
||||||
|
return useContext(StreamTopOverlayContext);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { ComposerEditingSessionsProvider } from "@/composer/editing-session";
|
||||||
import "@/styles/unistyles";
|
import "@/styles/unistyles";
|
||||||
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
import { BottomSheetModalProvider } from "@gorhom/bottom-sheet";
|
||||||
import { PortalProvider } from "@gorhom/portal";
|
import { PortalProvider } from "@gorhom/portal";
|
||||||
@@ -35,7 +36,7 @@ import { ChangelogHost } from "@/changelog";
|
|||||||
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
|
||||||
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
|
||||||
import { LeftSidebar } from "@/components/left-sidebar";
|
import { LeftSidebar } from "@/components/left-sidebar";
|
||||||
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
|
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
|
||||||
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
import { DesktopWindowControls } from "@/components/desktop/window-controls";
|
||||||
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
|
||||||
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
|
||||||
@@ -102,7 +103,6 @@ import {
|
|||||||
useKeyboardActionDispatcher,
|
useKeyboardActionDispatcher,
|
||||||
} from "@/keyboard/keyboard-action-dispatcher-context";
|
} from "@/keyboard/keyboard-action-dispatcher-context";
|
||||||
import { polyfillCrypto } from "@/polyfills/crypto";
|
import { polyfillCrypto } from "@/polyfills/crypto";
|
||||||
import { polyfillNavigator } from "@/polyfills/navigator";
|
|
||||||
import {
|
import {
|
||||||
getHostRuntimeStore,
|
getHostRuntimeStore,
|
||||||
hasConfiguredLocalDaemonOverride,
|
hasConfiguredLocalDaemonOverride,
|
||||||
@@ -138,7 +138,6 @@ import {
|
|||||||
type WebNotificationClickDetail,
|
type WebNotificationClickDetail,
|
||||||
} from "@/utils/os-notifications";
|
} from "@/utils/os-notifications";
|
||||||
|
|
||||||
polyfillNavigator();
|
|
||||||
polyfillCrypto();
|
polyfillCrypto();
|
||||||
|
|
||||||
export interface HostRuntimeBootstrapState {
|
export interface HostRuntimeBootstrapState {
|
||||||
@@ -625,7 +624,11 @@ function AppContainer({ children, chromeEnabled: chromeEnabledOverride }: AppCon
|
|||||||
surface
|
surface
|
||||||
);
|
);
|
||||||
|
|
||||||
return <CommandCenterProvider>{content}</CommandCenterProvider>;
|
return (
|
||||||
|
<ComposerEditingSessionsProvider>
|
||||||
|
<CommandCenterProvider>{content}</CommandCenterProvider>
|
||||||
|
</ComposerEditingSessionsProvider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarChrome({
|
function SidebarChrome({
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "@/plugins/themes";
|
} from "@/plugins/themes";
|
||||||
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
|
import { PLUGIN_THEME_NAMES, PLUGIN_THEME_PREFERENCE, THEME_TO_UNISTYLES } from "@/styles/theme";
|
||||||
import { applyAppearance } from "./apply";
|
import { applyAppearance } from "./apply";
|
||||||
|
import { AppearanceSystemBars } from "./system-bars";
|
||||||
|
|
||||||
interface ContributedThemes {
|
interface ContributedThemes {
|
||||||
options: PluginThemeOption[];
|
options: PluginThemeOption[];
|
||||||
@@ -107,7 +108,10 @@ export function AppearanceProvider({ children }: { children: ReactNode }) {
|
|||||||
if (!hasAppliedAppearance) return null;
|
if (!hasAppliedAppearance) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContributedThemesContext.Provider value={value}>{children}</ContributedThemesContext.Provider>
|
<ContributedThemesContext.Provider value={value}>
|
||||||
|
<AppearanceSystemBars />
|
||||||
|
{children}
|
||||||
|
</ContributedThemesContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { SystemBars } from "react-native-edge-to-edge";
|
||||||
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
|
|
||||||
|
function ThemeSystemBars({ colorScheme }: Pick<Theme, "colorScheme">) {
|
||||||
|
return <SystemBars style={colorScheme === "light" ? "dark" : "light"} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AppearanceSystemBars = withUnistyles(ThemeSystemBars, (theme) => ({
|
||||||
|
colorScheme: theme.colorScheme,
|
||||||
|
}));
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function AppearanceSystemBars() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -417,6 +417,128 @@ describe("parseAssistantFileLink", () => {
|
|||||||
}
|
}
|
||||||
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
|
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", () => {
|
describe("normalizeInlinePathTarget", () => {
|
||||||
@@ -469,3 +591,23 @@ 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();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -80,6 +80,13 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
|
|||||||
|
|
||||||
export interface AssistantHrefParseOptions {
|
export interface AssistantHrefParseOptions {
|
||||||
workspaceRoot?: string;
|
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 =
|
export type AssistantFileLinkClassification =
|
||||||
@@ -234,13 +241,20 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
|
function parseAssistantInlinePathLink(
|
||||||
|
value: string,
|
||||||
|
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||||
|
): InlinePathTarget | null {
|
||||||
const inlinePathTarget = parseInlinePathToken(value);
|
const inlinePathTarget = parseInlinePathToken(value);
|
||||||
if (!inlinePathTarget) {
|
if (!inlinePathTarget) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPath = normalizePathToken(inlinePathTarget.path);
|
let pathPart = inlinePathTarget.path;
|
||||||
|
if (options.decodeHref) {
|
||||||
|
pathPart = safeDecodeURIComponent(pathPart);
|
||||||
|
}
|
||||||
|
const normalizedPath = normalizePathToken(pathPart);
|
||||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -277,7 +291,7 @@ export function classifyAssistantFileLink(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
|
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
|
||||||
return {
|
return {
|
||||||
kind: "ambiguousFileCandidate",
|
kind: "ambiguousFileCandidate",
|
||||||
target,
|
target,
|
||||||
@@ -308,32 +322,19 @@ export function parseAssistantFileLink(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
|
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
|
||||||
if (inlinePathTarget) {
|
if (inlinePathTarget) {
|
||||||
return inlinePathTarget;
|
return inlinePathTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
|
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
|
||||||
if (windowsPathMatch) {
|
if (windowsTarget) {
|
||||||
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
|
return windowsTarget;
|
||||||
if (!normalizedPath) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
|
|
||||||
if (!lines) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
raw: value,
|
|
||||||
path: normalizedPath,
|
|
||||||
...lines,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
|
||||||
workspaceRoot: options.workspaceRoot,
|
workspaceRoot: options.workspaceRoot,
|
||||||
|
decodeHref: options.decodeHref,
|
||||||
});
|
});
|
||||||
if (relativeTarget) {
|
if (relativeTarget) {
|
||||||
return relativeTarget;
|
return relativeTarget;
|
||||||
@@ -343,19 +344,33 @@ export function parseAssistantFileLink(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let parsedUrl: URL;
|
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
|
||||||
try {
|
// normalize before decode. Raw paths must not: `new URL` percent-encodes
|
||||||
parsedUrl = new URL(trimmed, "http://paseo.invalid");
|
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
|
||||||
} catch {
|
// still-encoded name.
|
||||||
return null;
|
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) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
|
const normalizedPath = normalizePathToken(pathPart);
|
||||||
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = parseLineFragment(parsedUrl.hash);
|
const lines = parseLineFragment(hashPart);
|
||||||
if (!lines) {
|
if (!lines) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -386,11 +401,42 @@ export function isFileLookingAssistantToken(value: string): boolean {
|
|||||||
return isPlausibleAssistantLocalPath(path);
|
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(
|
function parseWorkspaceRelativeFileLink(
|
||||||
value: string,
|
value: string,
|
||||||
options: AssistantHrefParseOptions,
|
options: AssistantHrefParseOptions,
|
||||||
): InlinePathTarget | null {
|
): InlinePathTarget | null {
|
||||||
const parsed = parseLocalPathParts(value);
|
const parsed = parseLocalPathParts(value, options);
|
||||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -422,7 +468,11 @@ function parseWorkspaceRelativeFileLink(
|
|||||||
|
|
||||||
function parseLocalPathParts(
|
function parseLocalPathParts(
|
||||||
value: string,
|
value: string,
|
||||||
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
|
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
|
||||||
|
): {
|
||||||
|
path: string;
|
||||||
|
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
|
||||||
|
} | null {
|
||||||
const normalized = normalizePathToken(value);
|
const normalized = normalizePathToken(value);
|
||||||
if (!normalized || normalized.includes("?")) {
|
if (!normalized || normalized.includes("?")) {
|
||||||
return null;
|
return null;
|
||||||
@@ -436,14 +486,19 @@ function parseLocalPathParts(
|
|||||||
return null;
|
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);
|
const inlinePathTarget = parseInlinePathToken(beforeHash);
|
||||||
if (inlinePathTarget) {
|
if (inlinePathTarget) {
|
||||||
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
|
let pathPart = inlinePathTarget.path;
|
||||||
|
if (options.decodeHref) {
|
||||||
|
pathPart = safeDecodeURIComponent(pathPart);
|
||||||
|
}
|
||||||
|
if (!isPlausibleAssistantLocalPath(pathPart)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: inlinePathTarget.path,
|
path: pathPart,
|
||||||
lines: {
|
lines: {
|
||||||
lineStart: inlinePathTarget.lineStart,
|
lineStart: inlinePathTarget.lineStart,
|
||||||
lineEnd: inlinePathTarget.lineEnd,
|
lineEnd: inlinePathTarget.lineEnd,
|
||||||
@@ -455,12 +510,17 @@ function parseLocalPathParts(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isPlausibleAssistantLocalPath(beforeHash)) {
|
let decodedBeforeHash = beforeHash;
|
||||||
|
if (options.decodeHref) {
|
||||||
|
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
|
||||||
|
}
|
||||||
|
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
|
||||||
|
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
path: beforeHash,
|
path: decodedBeforeHash,
|
||||||
lines: fragmentLines,
|
lines: fragmentLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -545,14 +605,14 @@ function isExternalHref(value: string): boolean {
|
|||||||
function isAmbiguousWorkspaceCandidate(
|
function isAmbiguousWorkspaceCandidate(
|
||||||
value: string,
|
value: string,
|
||||||
target: InlinePathTarget,
|
target: InlinePathTarget,
|
||||||
workspaceRoot?: string,
|
options: AssistantHrefParseOptions = {},
|
||||||
): boolean {
|
): boolean {
|
||||||
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
|
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
|
||||||
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseLocalPathParts(value);
|
const parsed = parseLocalPathParts(value, options);
|
||||||
if (!parsed || isAbsolutePath(parsed.path)) {
|
if (!parsed || isAbsolutePath(parsed.path)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
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 {
|
import {
|
||||||
classifyForResolution,
|
classifyForResolution,
|
||||||
fetchDaemonResolution,
|
fetchDaemonResolution,
|
||||||
@@ -14,6 +18,25 @@ const CONTEXT: AssistantFileLinkContext = {
|
|||||||
workspaceRoot: "/Users/test/project",
|
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(
|
function resolvedSuggestions(
|
||||||
entries: DirectorySuggestionResult["entries"],
|
entries: DirectorySuggestionResult["entries"],
|
||||||
): DirectorySuggestionResult {
|
): DirectorySuggestionResult {
|
||||||
@@ -52,6 +75,27 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("classifyForResolution", () => {
|
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", () => {
|
it("returns the directFile target synchronously", () => {
|
||||||
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
|
||||||
|
|
||||||
@@ -165,6 +209,46 @@ describe("classifyForResolution", () => {
|
|||||||
|
|
||||||
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
|
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", () => {
|
describe("fetchDaemonResolution", () => {
|
||||||
|
|||||||
@@ -108,13 +108,15 @@ export function classifyForResolution(
|
|||||||
source: AssistantFileLinkSource,
|
source: AssistantFileLinkSource,
|
||||||
context: AssistantFileLinkContext,
|
context: AssistantFileLinkContext,
|
||||||
): AssistantFileLinkResolution {
|
): AssistantFileLinkResolution {
|
||||||
const token = getAssistantFileLinkToken(source).trim();
|
const tokenInfo = getAssistantFileLinkTokenInfo(source);
|
||||||
|
const token = tokenInfo.token.trim();
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return { kind: "resolved", value: { kind: "ignored" } };
|
return { kind: "resolved", value: { kind: "ignored" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const classification = classifyAssistantFileLink(token, {
|
const classification = classifyAssistantFileLink(token, {
|
||||||
workspaceRoot: context.workspaceRoot,
|
workspaceRoot: context.workspaceRoot,
|
||||||
|
decodeHref: tokenInfo.fromHref,
|
||||||
});
|
});
|
||||||
if (!classification) {
|
if (!classification) {
|
||||||
return { kind: "resolved", value: { kind: "ignored" } };
|
return { kind: "resolved", value: { kind: "ignored" } };
|
||||||
@@ -148,14 +150,26 @@ export function classifyForResolution(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
|
||||||
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
|
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)) {
|
||||||
const text = source.text?.trim();
|
const text = source.text?.trim();
|
||||||
if (text && isFileLookingAssistantToken(text)) {
|
if (text && isFileLookingAssistantToken(text)) {
|
||||||
return text;
|
return { token: text, fromHref: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return source.href;
|
return { token: source.href, fromHref: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAmbiguousSuggestionQuery(
|
export function getAmbiguousSuggestionQuery(
|
||||||
|
|||||||
@@ -29,14 +29,21 @@ export interface AssistantFileLinkActions {
|
|||||||
canResolveFile(source: AssistantFileLinkSource): boolean;
|
canResolveFile(source: AssistantFileLinkSource): boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssistantFileLinkLookupKey {
|
||||||
|
query: string;
|
||||||
|
path: string;
|
||||||
|
lineStart: number | null;
|
||||||
|
lineEnd: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
type AssistantFileLinkQueryKey = readonly [
|
type AssistantFileLinkQueryKey = readonly [
|
||||||
"assistantFileLink",
|
"assistantFileLink",
|
||||||
string | null,
|
string | null,
|
||||||
string | null,
|
string | null,
|
||||||
string,
|
AssistantFileLinkLookupKey | null,
|
||||||
];
|
];
|
||||||
|
|
||||||
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
|
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
|
||||||
|
|
||||||
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -59,7 +66,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
|
|||||||
? assistantFileLinkQueryKey({
|
? assistantFileLinkQueryKey({
|
||||||
serverId,
|
serverId,
|
||||||
workspaceRoot,
|
workspaceRoot,
|
||||||
token: resolution.token,
|
resolution,
|
||||||
})
|
})
|
||||||
: DISABLED_QUERY_KEY,
|
: DISABLED_QUERY_KEY,
|
||||||
[resolution, serverId, workspaceRoot],
|
[resolution, serverId, workspaceRoot],
|
||||||
@@ -179,7 +186,7 @@ function openAssistantFileLink(input: {
|
|||||||
const capturedQueryKey = assistantFileLinkQueryKey({
|
const capturedQueryKey = assistantFileLinkQueryKey({
|
||||||
serverId: capturedConfig.serverId,
|
serverId: capturedConfig.serverId,
|
||||||
workspaceRoot: capturedConfig.workspaceRoot,
|
workspaceRoot: capturedConfig.workspaceRoot,
|
||||||
token: capturedResolution.token,
|
resolution: capturedResolution,
|
||||||
});
|
});
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
@@ -245,9 +252,19 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
|
|||||||
function assistantFileLinkQueryKey(input: {
|
function assistantFileLinkQueryKey(input: {
|
||||||
serverId?: string;
|
serverId?: string;
|
||||||
workspaceRoot?: string;
|
workspaceRoot?: string;
|
||||||
token: string;
|
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
|
||||||
}): AssistantFileLinkQueryKey {
|
}): AssistantFileLinkQueryKey {
|
||||||
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
|
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,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dispatchResolvedLink(input: {
|
async function dispatchResolvedLink(input: {
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||||
getBottomSheetVisibleContentHeight,
|
|
||||||
getCompactSheetSafeAreaPadding,
|
|
||||||
} from "@/components/adaptive-modal-sheet-layout";
|
|
||||||
|
|
||||||
describe("getCompactSheetSafeAreaPadding", () => {
|
describe("getCompactSheetSafeAreaPadding", () => {
|
||||||
it("assigns safe clearance to the footer independently of decorative padding", () => {
|
it("assigns safe clearance to the footer independently of decorative padding", () => {
|
||||||
@@ -49,17 +46,3 @@ describe("getCompactSheetSafeAreaPadding", () => {
|
|||||||
).toEqual({});
|
).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,28 +10,6 @@ export interface CompactSheetSafeAreaPadding {
|
|||||||
footerPaddingBottom?: number;
|
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({
|
export function getCompactSheetSafeAreaPadding({
|
||||||
isCompact,
|
isCompact,
|
||||||
isKeyboardVisible,
|
isKeyboardVisible,
|
||||||
|
|||||||
@@ -12,22 +12,16 @@ import {
|
|||||||
useGlobalWebOverlayLayer,
|
useGlobalWebOverlayLayer,
|
||||||
useWebOverlayRegistration,
|
useWebOverlayRegistration,
|
||||||
} from "../lib/overlay-root";
|
} from "../lib/overlay-root";
|
||||||
import {
|
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||||
KEYBOARD_STATUS,
|
import Animated from "react-native-reanimated";
|
||||||
useBottomSheetInternal,
|
|
||||||
type BottomSheetBackgroundProps,
|
|
||||||
} from "@gorhom/bottom-sheet";
|
|
||||||
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
|
||||||
import { ArrowLeft, Search, X } from "lucide-react-native";
|
import { ArrowLeft, Search, X } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
IsolatedBottomSheetModal,
|
IsolatedBottomSheetModal,
|
||||||
|
SheetVisibleFrame,
|
||||||
type ContextBridge,
|
type ContextBridge,
|
||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
} from "@/components/ui/isolated-bottom-sheet-modal";
|
} from "@/components/ui/isolated-bottom-sheet-modal";
|
||||||
import {
|
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
|
||||||
getBottomSheetVisibleContentHeight,
|
|
||||||
getCompactSheetSafeAreaPadding,
|
|
||||||
} from "@/components/adaptive-modal-sheet-layout";
|
|
||||||
import { ScrollView } from "@/components/ui/scroll-view";
|
import { ScrollView } from "@/components/ui/scroll-view";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
|
||||||
@@ -207,10 +201,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
},
|
},
|
||||||
bottomSheetVisibleContent: {
|
|
||||||
minHeight: 0,
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
bottomSheetVisibleScroll: {
|
bottomSheetVisibleScroll: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
@@ -263,40 +253,6 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
|
|||||||
return <View style={[styles.sheetContent, style]}>{children}</View>;
|
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({
|
export function SheetHeaderView({
|
||||||
header,
|
header,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -471,8 +427,6 @@ export interface AdaptiveModalSheetProps {
|
|||||||
bodyStyle?: StyleProp<ViewStyle>;
|
bodyStyle?: StyleProp<ViewStyle>;
|
||||||
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
|
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
|
||||||
contentStyle?: StyleProp<ViewStyle>;
|
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. */
|
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
|
||||||
contextBridge?: ContextBridge | null;
|
contextBridge?: ContextBridge | null;
|
||||||
}
|
}
|
||||||
@@ -493,7 +447,6 @@ export function AdaptiveModalSheet({
|
|||||||
presentation,
|
presentation,
|
||||||
contentStyle,
|
contentStyle,
|
||||||
bodyStyle,
|
bodyStyle,
|
||||||
sizeContentToCurrentSnapPoint = true,
|
|
||||||
contextBridge = null,
|
contextBridge = null,
|
||||||
}: AdaptiveModalSheetProps) {
|
}: AdaptiveModalSheetProps) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
@@ -519,11 +472,15 @@ export function AdaptiveModalSheet({
|
|||||||
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
|
||||||
[compactSafeAreaPadding.footerPaddingBottom],
|
[compactSafeAreaPadding.footerPaddingBottom],
|
||||||
);
|
);
|
||||||
const footerView = footer ? (
|
const footerView = useMemo(
|
||||||
<View style={footerClearanceStyle}>
|
() =>
|
||||||
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
footer ? (
|
||||||
</View>
|
<View style={footerClearanceStyle}>
|
||||||
) : null;
|
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
|
||||||
|
</View>
|
||||||
|
) : null,
|
||||||
|
[footer, footerClearanceStyle, footerContainerStyle],
|
||||||
|
);
|
||||||
const handleIndicatorStyle = useMemo(
|
const handleIndicatorStyle = useMemo(
|
||||||
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
|
||||||
[theme.colors.palette.zinc],
|
[theme.colors.palette.zinc],
|
||||||
@@ -628,7 +585,6 @@ export function AdaptiveModalSheet({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
{footerView}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -650,11 +606,7 @@ export function AdaptiveModalSheet({
|
|||||||
accessible={false}
|
accessible={false}
|
||||||
presentation={presentation}
|
presentation={presentation}
|
||||||
>
|
>
|
||||||
{sizeContentToCurrentSnapPoint ? (
|
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
|
||||||
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
|
|
||||||
) : (
|
|
||||||
sheetContent
|
|
||||||
)}
|
|
||||||
</IsolatedBottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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,10 +6,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { ArrowLeft } from "lucide-react-native";
|
import { ArrowLeft } from "lucide-react-native";
|
||||||
import { ScreenHeader } from "./screen-header";
|
import { ScreenHeader } from "./screen-header";
|
||||||
import { ScreenTitle } from "./screen-title";
|
import { ScreenTitle } from "./screen-title";
|
||||||
|
import { CompactHeader } from "./compact-header";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
|
|
||||||
interface BackHeaderProps {
|
interface BackHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
titleAccessory?: ReactNode;
|
|
||||||
rightContent?: ReactNode;
|
rightContent?: ReactNode;
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
}
|
}
|
||||||
@@ -18,7 +19,15 @@ function goBack(): void {
|
|||||||
router.back();
|
router.back();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
|
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) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const handleBack = useCallback(() => {
|
const handleBack = useCallback(() => {
|
||||||
@@ -42,7 +51,6 @@ export function BackHeader({ title, titleAccessory, rightContent, onBack }: Back
|
|||||||
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{title && <ScreenTitle>{title}</ScreenTitle>}
|
{title && <ScreenTitle>{title}</ScreenTitle>}
|
||||||
{titleAccessory}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
right={rightContent}
|
right={rightContent}
|
||||||
@@ -56,10 +64,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
backButton: {
|
backButton: {
|
||||||
padding: {
|
padding: theme.spacing[2],
|
||||||
xs: theme.spacing[3],
|
|
||||||
md: theme.spacing[2],
|
|
||||||
},
|
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
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 { useMemo, type ReactElement, type ReactNode } from "react";
|
import { type ReactElement, type ReactNode } from "react";
|
||||||
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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 { isWeb } from "@/constants/platform";
|
||||||
import {
|
import {
|
||||||
iconButtonChromeFrameStyle,
|
iconButtonChromeFrameStyle,
|
||||||
iconButtonChromeStyle,
|
useIconButtonChromeStyle,
|
||||||
} from "@/components/ui/icon-button-chrome";
|
} from "@/components/ui/icon-button-chrome";
|
||||||
|
|
||||||
interface HeaderToggleButtonState {
|
interface HeaderToggleButtonState {
|
||||||
@@ -46,17 +46,11 @@ export function HeaderToggleButton({
|
|||||||
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
|
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const combinedStyle = useMemo(
|
const combinedStyle = useIconButtonChromeStyle({
|
||||||
() =>
|
size: "large",
|
||||||
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
|
disabled: Boolean(disabled),
|
||||||
iconButtonChromeStyle({
|
style,
|
||||||
size: "large",
|
});
|
||||||
state: { hovered: Boolean(hovered), pressed },
|
|
||||||
disabled: Boolean(disabled),
|
|
||||||
style,
|
|
||||||
}),
|
|
||||||
[disabled, style],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
import { useCallback, useMemo, type ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
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 { ScreenHeader } from "./screen-header";
|
||||||
import { ScreenTitle } from "./screen-title";
|
import { ScreenTitle } from "./screen-title";
|
||||||
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
|
import { CompactHeader } from "./compact-header";
|
||||||
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
|
import { SidebarMenuToggle } from "./sidebar-menu-toggle";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
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 {
|
interface MenuHeaderProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
@@ -18,130 +12,11 @@ interface MenuHeaderProps {
|
|||||||
borderless?: boolean;
|
borderless?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
|
||||||
|
const isCompact = useIsCompactFormFactor();
|
||||||
|
if (isCompact) {
|
||||||
|
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
@@ -158,31 +33,7 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
leadingToggle: {
|
|
||||||
marginLeft: {
|
|
||||||
xs: 0,
|
|
||||||
md: -theme.spacing[2],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
left: {
|
left: {
|
||||||
gap: theme.spacing[2],
|
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,
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -1,5 +1,13 @@
|
|||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
|
import {
|
||||||
|
CircleGauge,
|
||||||
|
FolderPlus,
|
||||||
|
GitBranch,
|
||||||
|
Server,
|
||||||
|
Settings,
|
||||||
|
X,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||||
import {
|
import {
|
||||||
@@ -26,7 +34,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
|
|||||||
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
|
||||||
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
|
||||||
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
|
||||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
import { DensityIcon } from "@/components/ui/density-icon";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||||
@@ -37,6 +45,7 @@ import {
|
|||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from "@/hooks/use-sidebar-workspaces-list";
|
} from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { density } from "@/styles/density";
|
||||||
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
|
||||||
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
|
||||||
import { RetainedPanelActivity } from "@/components/retained-panel";
|
import { RetainedPanelActivity } from "@/components/retained-panel";
|
||||||
@@ -280,30 +289,25 @@ function FooterIconButton({
|
|||||||
onPress,
|
onPress,
|
||||||
testID,
|
testID,
|
||||||
label,
|
label,
|
||||||
icon: Icon,
|
icon,
|
||||||
iconSizeAdjustment = 0,
|
iconSizeAdjustment = 0,
|
||||||
shortcutKeys,
|
shortcutKeys,
|
||||||
theme,
|
|
||||||
}: {
|
}: {
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
testID: string;
|
testID: string;
|
||||||
label: string;
|
label: string;
|
||||||
icon: typeof FolderPlus;
|
icon: LucideIcon;
|
||||||
/** Only for a glyph that reads larger than the others at the same size. */
|
/** Only for a glyph that reads larger than the others at the same size. */
|
||||||
iconSizeAdjustment?: number;
|
iconSizeAdjustment?: number;
|
||||||
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
|
||||||
theme: SidebarTheme;
|
|
||||||
buttonRef?: RefObject<View | null>;
|
buttonRef?: RefObject<View | null>;
|
||||||
}) {
|
}) {
|
||||||
const isCompact = useIsCompactFormFactor();
|
|
||||||
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={300}>
|
<Tooltip delayDuration={300}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Pressable
|
<Pressable
|
||||||
ref={buttonRef}
|
ref={buttonRef}
|
||||||
style={styles.footerIconButton(isCompact)}
|
style={styles.footerIconButton}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
nativeID={testID}
|
nativeID={testID}
|
||||||
collapsable={false}
|
collapsable={false}
|
||||||
@@ -313,9 +317,11 @@ function FooterIconButton({
|
|||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<Icon
|
<DensityIcon
|
||||||
size={iconSize + iconSizeAdjustment}
|
icon={icon}
|
||||||
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
size="md"
|
||||||
|
sizeAdjustment={iconSizeAdjustment}
|
||||||
|
color={hovered ? "foreground" : "foregroundMuted"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
@@ -328,12 +334,10 @@ function FooterIconButton({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarHostPicker({
|
function SidebarHostPicker({
|
||||||
theme,
|
|
||||||
label,
|
label,
|
||||||
onAddHost,
|
onAddHost,
|
||||||
onOpenHostSettings,
|
onOpenHostSettings,
|
||||||
}: {
|
}: {
|
||||||
theme: SidebarTheme;
|
|
||||||
label: string;
|
label: string;
|
||||||
onAddHost: () => void;
|
onAddHost: () => void;
|
||||||
onOpenHostSettings: (serverId: string) => void;
|
onOpenHostSettings: (serverId: string) => void;
|
||||||
@@ -377,7 +381,6 @@ function SidebarHostPicker({
|
|||||||
icon={Server}
|
icon={Server}
|
||||||
// Server's two boxes fill more of the square than the other glyphs.
|
// Server's two boxes fill more of the square than the other glyphs.
|
||||||
iconSizeAdjustment={-1}
|
iconSizeAdjustment={-1}
|
||||||
theme={theme}
|
|
||||||
/>
|
/>
|
||||||
</HostPicker>
|
</HostPicker>
|
||||||
);
|
);
|
||||||
@@ -399,7 +402,6 @@ function IconTooltipContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function SidebarFooter({
|
function SidebarFooter({
|
||||||
theme,
|
|
||||||
handleOpenProject,
|
handleOpenProject,
|
||||||
handleSettings,
|
handleSettings,
|
||||||
labels,
|
labels,
|
||||||
@@ -407,7 +409,6 @@ function SidebarFooter({
|
|||||||
handleOpenHostSettings,
|
handleOpenHostSettings,
|
||||||
onBeforeNavigate,
|
onBeforeNavigate,
|
||||||
}: {
|
}: {
|
||||||
theme: SidebarTheme;
|
|
||||||
handleOpenProject: () => void;
|
handleOpenProject: () => void;
|
||||||
handleSettings: () => void;
|
handleSettings: () => void;
|
||||||
labels: {
|
labels: {
|
||||||
@@ -436,11 +437,9 @@ function SidebarFooter({
|
|||||||
label={labels.addProject}
|
label={labels.addProject}
|
||||||
icon={FolderPlus}
|
icon={FolderPlus}
|
||||||
shortcutKeys={newAgentKeys}
|
shortcutKeys={newAgentKeys}
|
||||||
theme={theme}
|
|
||||||
/>
|
/>
|
||||||
<SidebarUsageIcon label={labels.usage} theme={theme} />
|
<SidebarUsageIcon label={labels.usage} />
|
||||||
<SidebarHostPicker
|
<SidebarHostPicker
|
||||||
theme={theme}
|
|
||||||
label={labels.hosts}
|
label={labels.hosts}
|
||||||
onAddHost={handleAddHost}
|
onAddHost={handleAddHost}
|
||||||
onOpenHostSettings={handleOpenHostSettings}
|
onOpenHostSettings={handleOpenHostSettings}
|
||||||
@@ -453,7 +452,6 @@ function SidebarFooter({
|
|||||||
label={labels.settings}
|
label={labels.settings}
|
||||||
icon={Settings}
|
icon={Settings}
|
||||||
shortcutKeys={settingsKeys}
|
shortcutKeys={settingsKeys}
|
||||||
theme={theme}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -461,7 +459,7 @@ function SidebarFooter({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
|
function SidebarUsageIcon({ label }: { label: string }) {
|
||||||
const openUsage = useOpenSidebarUsage();
|
const openUsage = useOpenSidebarUsage();
|
||||||
return (
|
return (
|
||||||
<FooterIconButton
|
<FooterIconButton
|
||||||
@@ -469,7 +467,6 @@ function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme
|
|||||||
testID="sidebar-usage-icon"
|
testID="sidebar-usage-icon"
|
||||||
label={label}
|
label={label}
|
||||||
icon={CircleGauge}
|
icon={CircleGauge}
|
||||||
theme={theme}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -612,7 +609,6 @@ function MobileSidebar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
theme={theme}
|
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
handleSettings={handleSettings}
|
handleSettings={handleSettings}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
@@ -789,7 +785,6 @@ function DesktopSidebar({
|
|||||||
<SidebarCalloutSlot />
|
<SidebarCalloutSlot />
|
||||||
|
|
||||||
<SidebarFooter
|
<SidebarFooter
|
||||||
theme={theme}
|
|
||||||
handleOpenProject={handleOpenProject}
|
handleOpenProject={handleOpenProject}
|
||||||
handleSettings={handleSettings}
|
handleSettings={handleSettings}
|
||||||
labels={labels}
|
labels={labels}
|
||||||
@@ -957,14 +952,14 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingVertical: theme.spacing[1.5],
|
paddingVertical: theme.spacing[1.5],
|
||||||
gap: 2,
|
gap: 2,
|
||||||
},
|
},
|
||||||
footerIconButton: (isCompact: boolean) => ({
|
footerIconButton: {
|
||||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
width: density.iconButton,
|
||||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
height: density.iconButton,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
}),
|
},
|
||||||
tooltipRow: {
|
tooltipRow: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -391,6 +391,43 @@ 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", () => {
|
it("falls back to inert markdown when details are unclosed", () => {
|
||||||
const source = "<details><summary>Open</summary>Still open";
|
const source = "<details><summary>Open</summary>Still open";
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ const FENCE_LINE_RE = /^ {0,3}([`~]{3,})[^\n\r]*(?:\r?\n|$)/gm;
|
|||||||
const BACKTICK_RUN_RE = /`+/g;
|
const BACKTICK_RUN_RE = /`+/g;
|
||||||
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
const SAFE_IMAGE_SRC_RE = /^(https?:\/\/|data:image\/(?:png|gif|jpe?g);base64,)/i;
|
||||||
const SAFE_LINK_HREF_RE = /^(https?:\/\/|#(?:$|[\w-]))/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 VOID_HTML_TAGS = new Set(["br", "img", "source"]);
|
||||||
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
const MARKDOWN_TAG_WRAPPERS: Readonly<Record<string, readonly [string, string]>> = {
|
||||||
b: ["**", "**"],
|
b: ["**", "**"],
|
||||||
@@ -373,14 +379,16 @@ function renderInlineTokens(tokens: HtmlToken[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const children = tokens.slice(index + 1, closeIndex);
|
const children = tokens.slice(index + 1, closeIndex);
|
||||||
output += renderHtmlTag(token, children);
|
const close = tokens[closeIndex];
|
||||||
|
const closeRaw = isClosingTag(close, token.name) ? close.raw : `</${token.name}>`;
|
||||||
|
output += renderHtmlTag(token, children, closeRaw);
|
||||||
index = closeIndex;
|
index = closeIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[], closeRaw: string): string {
|
||||||
if (token.name === "a") {
|
if (token.name === "a") {
|
||||||
return renderLinkToken(token, children);
|
return renderLinkToken(token, children);
|
||||||
}
|
}
|
||||||
@@ -400,7 +408,7 @@ function renderHtmlTag(token: HtmlTagToken, children: HtmlToken[]): string {
|
|||||||
if (token.name === "p" || token.name === "div") {
|
if (token.name === "p" || token.name === "div") {
|
||||||
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
|
return `\n\n${renderInlineTokens(children).trim()}\n\n`;
|
||||||
}
|
}
|
||||||
return `${token.raw}${renderInlineTokens(children)}</${token.name}>`;
|
return `${token.raw}${renderInlineTokens(children)}${closeRaw}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTableTokens(tokens: HtmlToken[]): string {
|
function renderTableTokens(tokens: HtmlToken[]): string {
|
||||||
@@ -606,7 +614,15 @@ function tokenizeHtmlishMarkdown(source: string): HtmlToken[] {
|
|||||||
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishTokenParser {
|
||||||
let stripNextLeadingLineBreak = false;
|
let stripNextLeadingLineBreak = false;
|
||||||
let skippedLength = 0;
|
let skippedLength = 0;
|
||||||
|
let writtenLength = 0;
|
||||||
|
let chunkStart = 0;
|
||||||
let parser!: Parser;
|
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(
|
parser = new Parser(
|
||||||
{
|
{
|
||||||
onopentag(name, attributes, isImplied) {
|
onopentag(name, attributes, isImplied) {
|
||||||
@@ -620,7 +636,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
|||||||
closing: false,
|
closing: false,
|
||||||
selfClosing,
|
selfClosing,
|
||||||
attributes,
|
attributes,
|
||||||
raw: renderStartTag(name, attributes),
|
raw: readRawTag() ?? renderStartTag(name, attributes),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onclosetag(name, isImplied) {
|
onclosetag(name, isImplied) {
|
||||||
@@ -633,7 +649,7 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
|||||||
closing: true,
|
closing: true,
|
||||||
selfClosing: false,
|
selfClosing: false,
|
||||||
attributes: {},
|
attributes: {},
|
||||||
raw: `</${name}>`,
|
raw: readRawTag() ?? `</${name}>`,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
ontext(value) {
|
ontext(value) {
|
||||||
@@ -660,6 +676,8 @@ function createHtmlishTokenParser(source: string, tokens: HtmlToken[]): HtmlishT
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
write(chunk) {
|
write(chunk) {
|
||||||
|
chunkStart = writtenLength;
|
||||||
|
writtenLength += chunk.length;
|
||||||
parser.write(chunk);
|
parser.write(chunk);
|
||||||
},
|
},
|
||||||
skip(length) {
|
skip(length) {
|
||||||
@@ -698,7 +716,58 @@ function escapeAttribute(value: string): string {
|
|||||||
|
|
||||||
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
function getProtectedMarkdownRanges(source: string): ProtectedMarkdownRange[] {
|
||||||
const fencedRanges = getFencedCodeRanges(source);
|
const fencedRanges = getFencedCodeRanges(source);
|
||||||
return mergeProtectedRanges([...fencedRanges, ...getInlineCodeRanges(source, fencedRanges)]);
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
function getFencedCodeRanges(source: string): ProtectedMarkdownRange[] {
|
||||||
|
|||||||
@@ -126,9 +126,6 @@ export function ProjectEditSheet({
|
|||||||
onClose={handleClose}
|
onClose={handleClose}
|
||||||
footer={footer}
|
footer={footer}
|
||||||
desktopMaxWidth={440}
|
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"
|
testID="project-edit-sheet"
|
||||||
>
|
>
|
||||||
<Field
|
<Field
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
type ActiveWorkspaceSelection,
|
type ActiveWorkspaceSelection,
|
||||||
} from "@/stores/navigation-active-workspace-store";
|
} from "@/stores/navigation-active-workspace-store";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
|
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||||
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
|
||||||
@@ -114,6 +115,7 @@ import {
|
|||||||
SidebarWorkspaceTrailingContent,
|
SidebarWorkspaceTrailingContent,
|
||||||
useSidebarWorkspaceTrailing,
|
useSidebarWorkspaceTrailing,
|
||||||
} from "@/components/sidebar/workspace-trailing";
|
} from "@/components/sidebar/workspace-trailing";
|
||||||
|
import { DensityIcon } from "@/components/ui/density-icon";
|
||||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
@@ -822,9 +824,10 @@ function NewWorkspaceGhostRow({
|
|||||||
{({ hovered, pressed }) => (
|
{({ hovered, pressed }) => (
|
||||||
<>
|
<>
|
||||||
<View style={styles.newWorkspaceGhostIconSlot}>
|
<View style={styles.newWorkspaceGhostIconSlot}>
|
||||||
<ThemedPlus
|
<DensityIcon
|
||||||
size={14}
|
icon={Plus}
|
||||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
size="sm"
|
||||||
|
color={hovered || pressed ? "foreground" : "foregroundMuted"}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text
|
<Text
|
||||||
@@ -2519,7 +2522,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
|
// 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.
|
// pressed fills stay the same box as every other row in the sidebar.
|
||||||
newWorkspaceGhostRow: {
|
newWorkspaceGhostRow: {
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
marginBottom: theme.spacing[0.5],
|
marginBottom: theme.spacing[0.5],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingLeft: theme.spacing[4],
|
paddingLeft: theme.spacing[4],
|
||||||
@@ -2539,27 +2542,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
|
// The width of a workspace row's status slot, so the label lands on the same rail as the
|
||||||
// titles above it.
|
// titles above it.
|
||||||
newWorkspaceGhostIconSlot: {
|
newWorkspaceGhostIconSlot: {
|
||||||
width: theme.iconSize.md,
|
width: densityIconBox("md"),
|
||||||
height: theme.iconSize.md,
|
height: densityIconBox("md"),
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
newWorkspaceGhostText: {
|
newWorkspaceGhostText: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
newWorkspaceGhostTextHovered: {
|
newWorkspaceGhostTextHovered: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
},
|
},
|
||||||
projectRow: {
|
projectRow: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
@@ -2600,7 +2603,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
projectTitle: {
|
projectTitle: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
fontWeight: "400",
|
fontWeight: "400",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
@@ -2681,7 +2684,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
right: theme.spacing[2],
|
right: theme.spacing[2],
|
||||||
},
|
},
|
||||||
workspaceRow: {
|
workspaceRow: {
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
marginBottom: theme.spacing[0.5],
|
marginBottom: theme.spacing[0.5],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingLeft: theme.spacing[2],
|
paddingLeft: theme.spacing[2],
|
||||||
|
|||||||
@@ -2,16 +2,11 @@ import { useMemo } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
import { ChevronDown, ChevronRight } from "lucide-react-native";
|
||||||
import { Pressable, Text } from "react-native";
|
import { Pressable, Text } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { DensityIcon } from "@/components/ui/density-icon";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { isNative } from "@/constants/platform";
|
import { isNative } from "@/constants/platform";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { density, densityFontSize } from "@/styles/density";
|
||||||
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function PinnedSectionHeader({
|
export function PinnedSectionHeader({
|
||||||
collapsed,
|
collapsed,
|
||||||
@@ -23,7 +18,6 @@ export function PinnedSectionHeader({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
|
||||||
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -37,7 +31,11 @@ export function PinnedSectionHeader({
|
|||||||
<>
|
<>
|
||||||
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
|
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
|
||||||
{hovered || isNative || isCompact ? (
|
{hovered || isNative || isCompact ? (
|
||||||
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
|
<DensityIcon
|
||||||
|
icon={collapsed ? ChevronRight : ChevronDown}
|
||||||
|
size="xs"
|
||||||
|
color="foregroundMuted"
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -47,7 +45,7 @@ export function PinnedSectionHeader({
|
|||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
header: {
|
header: {
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
alignSelf: "flex-start",
|
alignSelf: "flex-start",
|
||||||
@@ -58,7 +56,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: densityFontSize(theme, "sm"),
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
|
||||||
import { ProjectIconView } from "@/components/project-icon-view";
|
import { ProjectIconView } from "@/components/project-icon-view";
|
||||||
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
|
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { density, densityIconBox, densityIconProps } from "@/styles/density";
|
||||||
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import {
|
import {
|
||||||
getProjectStatusBadgeContent,
|
getProjectStatusBadgeContent,
|
||||||
@@ -35,9 +36,8 @@ const STATUS_BADGE_OFFSET = -4;
|
|||||||
//
|
//
|
||||||
// The filled alert occupies the full badge shell so needs-input remains more prominent than
|
// The filled alert occupies the full badge shell so needs-input remains more prominent than
|
||||||
// the passive status dots.
|
// the passive status dots.
|
||||||
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
|
const DensityProjectIconView = withUnistyles(ProjectIconView);
|
||||||
// workspaceBranchText) so the icon centers on the title rather than floating above it.
|
const projectIconProps = densityIconProps("md", () => ({}));
|
||||||
const LEADING_SLOT_HEIGHT = 20;
|
|
||||||
|
|
||||||
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
|
||||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||||
@@ -230,12 +230,12 @@ function ProjectIcon({
|
|||||||
projectViewKey: string;
|
projectViewKey: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ProjectIconView
|
<DensityProjectIconView
|
||||||
iconDataUri={iconDataUri}
|
iconDataUri={iconDataUri}
|
||||||
initial={placeholderInitial}
|
initial={placeholderInitial}
|
||||||
projectViewKey={projectViewKey}
|
projectViewKey={projectViewKey}
|
||||||
size={ICON_SIZE.md}
|
|
||||||
textStyle={styles.projectIconFallbackText}
|
textStyle={styles.projectIconFallbackText}
|
||||||
|
uniProps={projectIconProps}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
|
// 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
|
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
|
||||||
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
|
// only as tall as the icon puts it above the title and the kebab — which is why the
|
||||||
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
|
// workspace status indicator shares the same `rowLineHeight`.
|
||||||
projectLeadingVisualSlot: {
|
projectLeadingVisualSlot: {
|
||||||
width: theme.iconSize.md,
|
width: densityIconBox("md"),
|
||||||
height: LEADING_SLOT_HEIGHT,
|
height: density.rowLineHeight,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "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.
|
// Anchors the corner badge to the icon rather than to the taller slot.
|
||||||
projectIconBox: {
|
projectIconBox: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: theme.iconSize.md,
|
width: densityIconBox("md"),
|
||||||
height: theme.iconSize.md,
|
height: densityIconBox("md"),
|
||||||
},
|
},
|
||||||
projectIconFallbackText: {
|
projectIconFallbackText: {
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
|
|||||||
@@ -2,19 +2,11 @@ import { useCallback } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
import { ChevronDown, ChevronUp } from "lucide-react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
|
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { DensityIcon } from "@/components/ui/density-icon";
|
||||||
|
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||||
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
|
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
|
||||||
@@ -62,17 +54,11 @@ export function SidebarGroupToggleRow({
|
|||||||
{({ hovered, pressed }) => (
|
{({ hovered, pressed }) => (
|
||||||
<>
|
<>
|
||||||
<View style={styles.iconSlot}>
|
<View style={styles.iconSlot}>
|
||||||
{expanded ? (
|
<DensityIcon
|
||||||
<ThemedChevronUp
|
icon={expanded ? ChevronUp : ChevronDown}
|
||||||
size={14}
|
size="sm"
|
||||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
color={hovered || pressed ? "foreground" : "foregroundMuted"}
|
||||||
/>
|
/>
|
||||||
) : (
|
|
||||||
<ThemedChevronDown
|
|
||||||
size={14}
|
|
||||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
|
||||||
{label}
|
{label}
|
||||||
@@ -86,7 +72,7 @@ export function SidebarGroupToggleRow({
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
|
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
|
||||||
row: {
|
row: {
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
marginBottom: theme.spacing[0.5],
|
marginBottom: theme.spacing[0.5],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingLeft: theme.spacing[2],
|
paddingLeft: theme.spacing[2],
|
||||||
@@ -106,21 +92,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
|
// 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.
|
// titles above it rather than two points to their left.
|
||||||
iconSlot: {
|
iconSlot: {
|
||||||
width: theme.iconSize.md,
|
width: densityIconBox("md"),
|
||||||
height: theme.iconSize.md,
|
height: densityIconBox("md"),
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
textHovered: {
|
textHovered: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,22 +1,12 @@
|
|||||||
import {
|
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
|
||||||
useCallback,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
type ComponentType,
|
|
||||||
type ReactNode,
|
|
||||||
type Ref,
|
|
||||||
} from "react";
|
|
||||||
import { Pressable, Text, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
|
||||||
import { ICON_SIZE } from "@/styles/theme";
|
import { DensityIcon } from "@/components/ui/density-icon";
|
||||||
import type { Theme } from "@/styles/theme";
|
|
||||||
import { Shortcut } from "@/components/ui/shortcut";
|
import { Shortcut } from "@/components/ui/shortcut";
|
||||||
|
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
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";
|
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
|
||||||
|
|
||||||
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
|
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
|
||||||
@@ -49,7 +39,7 @@ interface SidebarHeaderRowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarHeaderRow({
|
export function SidebarHeaderRow({
|
||||||
icon: Icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
onPress,
|
onPress,
|
||||||
isActive = false,
|
isActive = false,
|
||||||
@@ -64,9 +54,8 @@ export function SidebarHeaderRow({
|
|||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
|
||||||
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
|
||||||
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
|
|
||||||
const isHighlighted = isHovered || isActive;
|
const isHighlighted = isHovered || isActive;
|
||||||
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
|
const iconSize = variant === "header" ? "md" : "sm";
|
||||||
|
|
||||||
let right = trailing ?? null;
|
let right = trailing ?? null;
|
||||||
if (right === null && shortcutKeys && isHovered) {
|
if (right === null && shortcutKeys && isHovered) {
|
||||||
@@ -91,10 +80,11 @@ export function SidebarHeaderRow({
|
|||||||
aria-selected={isActive}
|
aria-selected={isActive}
|
||||||
style={styles.button}
|
style={styles.button}
|
||||||
>
|
>
|
||||||
{ThemedIcon ? (
|
{icon ? (
|
||||||
<ThemedIcon
|
<DensityIcon
|
||||||
|
icon={icon}
|
||||||
size={iconSize}
|
size={iconSize}
|
||||||
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
|
color={isHighlighted ? "foreground" : "foregroundMuted"}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
|
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
|
||||||
@@ -140,7 +130,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
// Same row geometry as the settings sidebar items. Shorter than the header
|
// Same row geometry as the settings sidebar items. Shorter than the header
|
||||||
// strip so the hover highlight clears the strip's bottom separator.
|
// strip so the hover highlight clears the strip's bottom separator.
|
||||||
minHeight: 28,
|
minHeight: density.tightRowHeight,
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
rowHighlighted: {
|
rowHighlighted: {
|
||||||
@@ -152,17 +142,17 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
minHeight: 28,
|
minHeight: density.tightRowHeight,
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
// Match the project rows' inner padding so the icons align on one vertical
|
// Match the project rows' inner padding so the icons align on one vertical
|
||||||
// edge with the list below.
|
// edge with the list below.
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
},
|
},
|
||||||
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
|
iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
|
||||||
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
|
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
|
||||||
label: {
|
label: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ import {
|
|||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { buttonControlHeight } from "@/components/ui/control-geometry";
|
import { DensityIcon } from "@/components/ui/density-icon";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
import { useAppDiagnosticStore } from "@/diagnostics/store";
|
||||||
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
|
||||||
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||||
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
import { density } from "@/styles/density";
|
||||||
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
import { ICON_SIZE, type Theme } from "@/styles/theme";
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
import type { HostProfile } from "@/types/host-connection";
|
||||||
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
|
||||||
@@ -32,12 +32,10 @@ import { openExternalUrl } from "@/utils/open-external-url";
|
|||||||
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
|
||||||
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
|
||||||
const ThemedActivity = withUnistyles(Activity);
|
const ThemedActivity = withUnistyles(Activity);
|
||||||
const ThemedCircleHelp = withUnistyles(CircleHelp);
|
|
||||||
const ThemedGift = withUnistyles(Gift);
|
const ThemedGift = withUnistyles(Gift);
|
||||||
const ThemedKeyboard = withUnistyles(Keyboard);
|
const ThemedKeyboard = withUnistyles(Keyboard);
|
||||||
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
|
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
|
||||||
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
|
||||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
});
|
||||||
@@ -79,7 +77,6 @@ function HostVersionHint({ host }: { host: HostProfile }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SidebarHelpMenu() {
|
export function SidebarHelpMenu() {
|
||||||
const isCompact = useIsCompactFormFactor();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
const shortcutsAvailable = useKeyboardShortcutsAvailable();
|
||||||
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
|
||||||
@@ -106,15 +103,16 @@ export function SidebarHelpMenu() {
|
|||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<View>
|
<View>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
style={styles.trigger(isCompact)}
|
style={styles.trigger}
|
||||||
testID="sidebar-help"
|
testID="sidebar-help"
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel={t("sidebar.help.trigger")}
|
accessibilityLabel={t("sidebar.help.trigger")}
|
||||||
>
|
>
|
||||||
{({ hovered }) => (
|
{({ hovered }) => (
|
||||||
<ThemedCircleHelp
|
<DensityIcon
|
||||||
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
|
icon={CircleHelp}
|
||||||
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
|
size="md"
|
||||||
|
color={hovered ? "foreground" : "foregroundMuted"}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
@@ -184,14 +182,14 @@ export function SidebarHelpMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
trigger: (isCompact: boolean) => ({
|
trigger: {
|
||||||
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
width: density.iconButton,
|
||||||
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
|
height: density.iconButton,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[1],
|
paddingHorizontal: theme.spacing[1],
|
||||||
}),
|
},
|
||||||
tooltipText: {
|
tooltipText: {
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
color: theme.colors.popoverForeground,
|
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 { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { Theme } from "@/styles/theme";
|
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
|
||||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||||
import { withUnistyles } from "react-native-unistyles";
|
import { withUnistyles } from "react-native-unistyles";
|
||||||
import {
|
import {
|
||||||
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
|
|||||||
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
|
||||||
|
|
||||||
// Themed icon wrappers
|
// Themed icon wrappers
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
});
|
}));
|
||||||
// One mapping per bucket, resolved through the status-dot producer so a group header and
|
// 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.
|
// the rows under it cannot disagree about what "failed" looks like.
|
||||||
const needsInputColorMapping = (theme: Theme) => ({
|
const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||||
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||||
});
|
}));
|
||||||
const failedColorMapping = (theme: Theme) => ({
|
const failedGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||||
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
|
||||||
});
|
}));
|
||||||
const attentionColorMapping = (theme: Theme) => ({
|
const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||||
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
|
||||||
});
|
}));
|
||||||
const runningColorMapping = (theme: Theme) => ({
|
const runningGroupIconProps = densityIconProps("sm", (theme) => ({
|
||||||
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
|
||||||
});
|
}));
|
||||||
|
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||||
@@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({
|
|||||||
return <StatusGroupIcon bucket={leading.bucket} />;
|
return <StatusGroupIcon bucket={leading.bucket} />;
|
||||||
}
|
}
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
|
return <ThemedChevronRight uniProps={mutedGroupIconProps} />;
|
||||||
}
|
}
|
||||||
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
|
return <ThemedChevronDown uniProps={mutedGroupIconProps} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
|
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
|
||||||
switch (bucket) {
|
switch (bucket) {
|
||||||
case "needs_input":
|
case "needs_input":
|
||||||
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
|
return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
|
||||||
case "failed":
|
case "failed":
|
||||||
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
|
return <ThemedCircleX uniProps={failedGroupIconProps} />;
|
||||||
case "attention":
|
case "attention":
|
||||||
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
|
return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
|
||||||
case "running":
|
case "running":
|
||||||
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
|
return <ThemedCircleDot uniProps={runningGroupIconProps} />;
|
||||||
case "done":
|
case "done":
|
||||||
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
|
return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
statusWorkspaceListContainer: {},
|
statusWorkspaceListContainer: {},
|
||||||
statusGroupRow: {
|
statusGroupRow: {
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
@@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
statusGroupLeadingVisualSlot: {
|
statusGroupLeadingVisualSlot: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: theme.iconSize.md,
|
width: densityIconBox("md"),
|
||||||
height: theme.iconSize.md,
|
height: densityIconBox("md"),
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
statusGroupTitle: {
|
statusGroupTitle: {
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
fontWeight: "400",
|
fontWeight: "400",
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
workspaceRow: {
|
workspaceRow: {
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
marginBottom: theme.spacing[0.5],
|
marginBottom: theme.spacing[0.5],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingLeft: theme.spacing[2],
|
paddingLeft: theme.spacing[2],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type SidebarWorkspaceTrailing,
|
type SidebarWorkspaceTrailing,
|
||||||
} from "@/components/sidebar/workspace-trailing";
|
} from "@/components/sidebar/workspace-trailing";
|
||||||
import { useAppSettings } from "@/hooks/use-settings";
|
import { useAppSettings } from "@/hooks/use-settings";
|
||||||
|
import { density, densityFontSize, densityIconBox } from "@/styles/density";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
import { getStatusDotColor } from "@/utils/status-dot-color";
|
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||||
@@ -27,19 +28,16 @@ import {
|
|||||||
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
|
||||||
import { StatusRing } from "@/components/status-ring";
|
import { StatusRing } from "@/components/status-ring";
|
||||||
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
|
||||||
|
import { DensityIcon } from "@/components/ui/density-icon";
|
||||||
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
|
||||||
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
|
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
|
||||||
|
|
||||||
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
|
||||||
const needsInputColorMapping = (theme: Theme) => ({
|
const needsInputColorMapping = (theme: Theme) => ({
|
||||||
color: theme.colors.surface0,
|
color: theme.colors.surface0,
|
||||||
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
const ThemedCircleAlert = withUnistyles(CircleAlert);
|
||||||
const ThemedMonitor = withUnistyles(Monitor);
|
|
||||||
const ThemedFolder = withUnistyles(Folder);
|
|
||||||
const ThemedFolderGit2 = withUnistyles(FolderGit2);
|
|
||||||
|
|
||||||
export function SidebarWorkspaceRowFrame({
|
export function SidebarWorkspaceRowFrame({
|
||||||
workspace,
|
workspace,
|
||||||
@@ -241,15 +239,15 @@ function WorkspaceStatusIndicator({
|
|||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let KindIcon: typeof ThemedMonitor;
|
let KindIcon: typeof Monitor;
|
||||||
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
|
if (workspaceKind === "local_checkout") KindIcon = Monitor;
|
||||||
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
|
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
|
||||||
else KindIcon = ThemedFolder;
|
else KindIcon = Folder;
|
||||||
|
|
||||||
const dotColorStyle = getStatusDotColorStyle(bucket);
|
const dotColorStyle = getStatusDotColorStyle(bucket);
|
||||||
return (
|
return (
|
||||||
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
|
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
|
||||||
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
|
<DensityIcon icon={KindIcon} size="sm" color="foregroundMuted" />
|
||||||
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
|
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -482,8 +480,8 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
},
|
},
|
||||||
workspaceStatusDot: {
|
workspaceStatusDot: {
|
||||||
position: "relative",
|
position: "relative",
|
||||||
width: theme.iconSize.md,
|
width: densityIconBox("md"),
|
||||||
height: 20,
|
height: density.rowLineHeight,
|
||||||
borderRadius: theme.borderRadius.full,
|
borderRadius: theme.borderRadius.full,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
@@ -515,9 +513,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
// to the meta row, so it takes the full width the trailing slot leaves behind.
|
// to the meta row, so it takes the full width the trailing slot leaves behind.
|
||||||
workspaceBranchText: {
|
workspaceBranchText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: densityFontSize(theme, "base"),
|
||||||
fontWeight: "400",
|
fontWeight: "400",
|
||||||
lineHeight: 20,
|
lineHeight: density.rowLineHeight,
|
||||||
opacity: 0.76,
|
opacity: 0.76,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { View, Text, type GestureResponderEvent } from "react-native";
|
|||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { HostBadgeModel } from "@/hosts/appearance";
|
import type { HostBadgeModel } from "@/hosts/appearance";
|
||||||
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
|
||||||
|
import { density } from "@/styles/density";
|
||||||
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
|
||||||
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
|
||||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||||
@@ -485,7 +486,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
position: "relative",
|
position: "relative",
|
||||||
},
|
},
|
||||||
workspaceRow: {
|
workspaceRow: {
|
||||||
minHeight: 36,
|
minHeight: density.rowHeight,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingLeft: theme.spacing[2],
|
paddingLeft: theme.spacing[2],
|
||||||
|
|||||||
@@ -174,6 +174,7 @@ export function Button({
|
|||||||
...props
|
...props
|
||||||
}: PropsWithChildren<
|
}: PropsWithChildren<
|
||||||
Omit<PressableProps, "style"> & {
|
Omit<PressableProps, "style"> & {
|
||||||
|
ref?: React.Ref<View>;
|
||||||
variant?: ButtonVariant;
|
variant?: ButtonVariant;
|
||||||
size?: ButtonSize;
|
size?: ButtonSize;
|
||||||
leftIcon?: LeftIcon;
|
leftIcon?: LeftIcon;
|
||||||
|
|||||||
@@ -160,3 +160,16 @@ describe("combobox above-search ordering", () => {
|
|||||||
expect(getComboboxFallbackIndex(ordered.length, "below-search")).toBe(0);
|
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,18 +10,22 @@ export interface ComboboxOptionModel {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
/** Additional text to match without displaying it in the option row. */
|
||||||
|
searchText?: string;
|
||||||
kind?: ComboboxOptionKind;
|
kind?: ComboboxOptionKind;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DESCRIPTION_FALLBACK_TIER = 99;
|
const SECONDARY_TEXT_FALLBACK_TIER = 99;
|
||||||
|
|
||||||
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
function scoreOption(opt: ComboboxOptionModel, search: string): MatchScore | null {
|
||||||
const best = scoreTextFields(search, [opt.label, opt.id]);
|
const best = scoreTextFields(search, [opt.label, opt.id]);
|
||||||
if (best) return best;
|
if (best) return best;
|
||||||
if (!opt.description) return null;
|
const secondaryFields = opt.searchText
|
||||||
const descriptionScore = scoreTextFields(search, [opt.description]);
|
? [opt.label, opt.id, opt.description ?? "", opt.searchText]
|
||||||
if (!descriptionScore) return null;
|
: [opt.description ?? ""];
|
||||||
return { ...descriptionScore, tier: descriptionScore.tier + DESCRIPTION_FALLBACK_TIER };
|
const secondaryScore = scoreTextFields(search, secondaryFields);
|
||||||
|
if (!secondaryScore) return null;
|
||||||
|
return { ...secondaryScore, tier: secondaryScore.tier + SECONDARY_TEXT_FALLBACK_TIER };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BuildVisibleComboboxOptionsInput {
|
export interface BuildVisibleComboboxOptionsInput {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|||||||
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
import type { EditingTextInputHandle } from "@/components/ui/text-input";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
|
||||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||||
import { Check, File, Folder, Search } from "lucide-react-native";
|
import { Check, File, Folder, Search } from "lucide-react-native";
|
||||||
@@ -50,6 +50,7 @@ import type { ComboboxOptionModel } from "./combobox-options";
|
|||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import {
|
import {
|
||||||
IsolatedBottomSheetModal,
|
IsolatedBottomSheetModal,
|
||||||
|
SheetVisibleFrame,
|
||||||
useIsolatedBottomSheetVisibility,
|
useIsolatedBottomSheetVisibility,
|
||||||
} from "./isolated-bottom-sheet-modal";
|
} from "./isolated-bottom-sheet-modal";
|
||||||
import {
|
import {
|
||||||
@@ -948,9 +949,19 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
|||||||
() => [styles.comboboxTitle, { color: props.titleColor }],
|
() => [styles.comboboxTitle, { color: props.titleColor }],
|
||||||
[props.titleColor],
|
[props.titleColor],
|
||||||
);
|
);
|
||||||
|
const { footer, safeAreaBottom } = props;
|
||||||
const frameStyle = useMemo(
|
const frameStyle = useMemo(
|
||||||
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
|
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
|
||||||
[props.safeAreaBottom],
|
[footer, safeAreaBottom],
|
||||||
|
);
|
||||||
|
const footerStyle = useMemo(
|
||||||
|
() => [styles.footer, { paddingBottom: safeAreaBottom }],
|
||||||
|
[safeAreaBottom],
|
||||||
|
);
|
||||||
|
|
||||||
|
const footerView = useMemo(
|
||||||
|
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
|
||||||
|
[footer, footerStyle],
|
||||||
);
|
);
|
||||||
|
|
||||||
const body = props.hasChildren ? (
|
const body = props.hasChildren ? (
|
||||||
@@ -983,46 +994,47 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
|
|||||||
keyboardBlurBehavior="none"
|
keyboardBlurBehavior="none"
|
||||||
presentation={props.presentation}
|
presentation={props.presentation}
|
||||||
>
|
>
|
||||||
<View style={frameStyle}>
|
<SheetVisibleFrame footer={footerView}>
|
||||||
{props.header ? (
|
<View style={frameStyle}>
|
||||||
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
{props.header ? (
|
||||||
) : (
|
<SheetHeaderView header={props.header} onClose={props.onClose} />
|
||||||
<>
|
) : (
|
||||||
<View style={styles.bottomSheetHeader}>
|
<>
|
||||||
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
<View style={styles.bottomSheetHeader}>
|
||||||
{props.title}
|
<Text key={props.titleColor} style={comboboxTitleStyle}>
|
||||||
</Text>
|
{props.title}
|
||||||
</View>
|
</Text>
|
||||||
{props.stickyHeader}
|
</View>
|
||||||
{!props.hasChildren && props.searchable ? (
|
{props.stickyHeader}
|
||||||
<SearchInput
|
{!props.hasChildren && props.searchable ? (
|
||||||
placeholder={props.searchPlaceholder}
|
<SearchInput
|
||||||
onChangeText={props.setSearchQueryWithCallback}
|
placeholder={props.searchPlaceholder}
|
||||||
onSubmitEditing={props.handleSubmitSearch}
|
onChangeText={props.setSearchQueryWithCallback}
|
||||||
autoFocus={false}
|
onSubmitEditing={props.handleSubmitSearch}
|
||||||
useBottomSheetInput
|
autoFocus={false}
|
||||||
resetKey={props.searchResetKey}
|
useBottomSheetInput
|
||||||
/>
|
resetKey={props.searchResetKey}
|
||||||
) : null}
|
/>
|
||||||
</>
|
) : null}
|
||||||
)}
|
</>
|
||||||
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
)}
|
||||||
body
|
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
|
||||||
) : (
|
body
|
||||||
<BottomSheetScrollView
|
) : (
|
||||||
style={styles.mobileSheetBody}
|
<BottomSheetScrollView
|
||||||
contentContainerStyle={[
|
style={styles.mobileSheetBody}
|
||||||
styles.comboboxScrollContent,
|
contentContainerStyle={[
|
||||||
props.mobileChildrenContentContainerStyle,
|
styles.comboboxScrollContent,
|
||||||
]}
|
props.mobileChildrenContentContainerStyle,
|
||||||
keyboardShouldPersistTaps="handled"
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
keyboardShouldPersistTaps="handled"
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{body}
|
>
|
||||||
</BottomSheetScrollView>
|
{body}
|
||||||
)}
|
</BottomSheetScrollView>
|
||||||
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</SheetVisibleFrame>
|
||||||
</IsolatedBottomSheetModal>
|
</IsolatedBottomSheetModal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
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,47 +1,67 @@
|
|||||||
|
import { forwardRef, useMemo } from "react";
|
||||||
|
import { Text, type View } from "react-native";
|
||||||
import type { LucideIcon } from "lucide-react-native";
|
import type { LucideIcon } from "lucide-react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||||
import { ICON_SIZE, SPACING } from "@/styles/theme";
|
import { ICON_SIZE } from "@/styles/theme";
|
||||||
|
|
||||||
const FLOATING_ACTION_BUTTON_SIZE = 56;
|
import {
|
||||||
const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
|
FLOATING_ACTION_BUTTON_SIZE,
|
||||||
|
FLOATING_ACTION_BUTTON_INSET,
|
||||||
/** Space a scrolling surface keeps below its content so the button traps nothing. */
|
} from "./floating-action-layout";
|
||||||
export const FLOATING_ACTION_BUTTON_CLEARANCE =
|
export {
|
||||||
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
|
FLOATING_ACTION_BUTTON_CLEARANCE,
|
||||||
|
floatingActionsClearance,
|
||||||
|
} from "./floating-action-layout";
|
||||||
|
|
||||||
export interface FloatingActionButtonProps {
|
export interface FloatingActionButtonProps {
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
/** Names the action; the button carries no label. */
|
/** Names the action for assistive technology. */
|
||||||
accessibilityLabel: string;
|
accessibilityLabel: string;
|
||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
|
label?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
bottomInset?: number;
|
||||||
testID?: string;
|
testID?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The one floating action a scrolling surface can offer. It pins itself to the
|
* Pins an action to the bottom-right of its nearest positioned ancestor.
|
||||||
* bottom-right of its nearest positioned ancestor, so the ancestor decides what
|
* The surface reserves scroll clearance and uses bottomInset to separate stacked actions.
|
||||||
* the button may cover.
|
|
||||||
*/
|
*/
|
||||||
export function FloatingActionButton({
|
export const FloatingActionButton = forwardRef<View, FloatingActionButtonProps>(
|
||||||
icon: Icon,
|
function FloatingActionButton(
|
||||||
accessibilityLabel,
|
{ icon: Icon, accessibilityLabel, onPress, label, disabled = false, bottomInset = 0, testID },
|
||||||
onPress,
|
ref,
|
||||||
testID,
|
) {
|
||||||
}: FloatingActionButtonProps) {
|
const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
|
||||||
return (
|
const buttonStyle = useMemo(
|
||||||
<PressHighlight
|
() => [
|
||||||
accessibilityRole="button"
|
styles.button,
|
||||||
accessibilityLabel={accessibilityLabel}
|
label && styles.labeled,
|
||||||
onPress={onPress}
|
disabled && styles.disabled,
|
||||||
style={styles.button}
|
{ bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset },
|
||||||
highlightStyle={styles.pressed}
|
],
|
||||||
testID={testID}
|
[bottomInset, disabled, label],
|
||||||
>
|
);
|
||||||
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
|
return (
|
||||||
</PressHighlight>
|
<PressHighlight
|
||||||
);
|
ref={ref}
|
||||||
}
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={accessibilityLabel}
|
||||||
|
accessibilityState={accessibilityState}
|
||||||
|
disabled={disabled}
|
||||||
|
onPress={onPress}
|
||||||
|
style={buttonStyle}
|
||||||
|
highlightStyle={styles.pressed}
|
||||||
|
testID={testID}
|
||||||
|
>
|
||||||
|
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
|
||||||
|
{label ? <Text style={styles.label}>{label}</Text> : null}
|
||||||
|
</PressHighlight>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
button: {
|
button: {
|
||||||
@@ -56,6 +76,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
...theme.shadow.md,
|
...theme.shadow.md,
|
||||||
},
|
},
|
||||||
|
labeled: {
|
||||||
|
width: "auto",
|
||||||
|
minWidth: FLOATING_ACTION_BUTTON_SIZE,
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
},
|
||||||
|
label: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
|
||||||
|
disabled: { opacity: theme.opacity[50] },
|
||||||
pressed: {
|
pressed: {
|
||||||
backgroundColor: theme.colors.interactionHighlight,
|
backgroundColor: theme.colors.interactionHighlight,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
floatingActionsClearance,
|
||||||
|
FLOATING_ACTION_BUTTON_CLEARANCE,
|
||||||
|
} from "./floating-action-layout";
|
||||||
|
|
||||||
|
describe("floating action scroll clearance", () => {
|
||||||
|
it("keeps the last diff line above Jump to file before and after feedback is added", () => {
|
||||||
|
const jump = FLOATING_ACTION_BUTTON_CLEARANCE;
|
||||||
|
const feedback = FLOATING_ACTION_BUTTON_CLEARANCE + 44;
|
||||||
|
expect(floatingActionsClearance([jump, 0], 34)).toBe(106);
|
||||||
|
expect(floatingActionsClearance([jump, feedback], 34)).toBe(222);
|
||||||
|
expect(floatingActionsClearance([0, feedback], 34)).toBe(150);
|
||||||
|
expect(floatingActionsClearance([jump, feedback], 0)).toBe(188);
|
||||||
|
expect(floatingActionsClearance([0, 0], 34)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { SPACING } from "@/styles/theme";
|
||||||
|
|
||||||
|
export const FLOATING_ACTION_BUTTON_SIZE = 56;
|
||||||
|
export const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
|
||||||
|
export const FLOATING_ACTION_BUTTON_CLEARANCE =
|
||||||
|
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
|
||||||
|
|
||||||
|
/** Reserve the stacked actions and the safe-area inset once for their scrolling surface. */
|
||||||
|
export function floatingActionsClearance(
|
||||||
|
actionClearances: readonly number[],
|
||||||
|
bottomInset: number,
|
||||||
|
): number {
|
||||||
|
const clearance = actionClearances.reduce((total, action) => total + action, 0);
|
||||||
|
return clearance > 0 ? clearance + bottomInset : 0;
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createContext, useCallback, useContext } from "react";
|
||||||
import type { StyleProp, ViewStyle } from "react-native";
|
import type { StyleProp, ViewStyle } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
|
||||||
@@ -8,6 +9,8 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/
|
|||||||
export type IconButtonChromeSize = "large" | "small";
|
export type IconButtonChromeSize = "large" | "small";
|
||||||
|
|
||||||
const SMALL_ICON_BUTTON_SIZE = 20;
|
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;
|
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
|
||||||
|
|
||||||
export interface IconButtonChromeState {
|
export interface IconButtonChromeState {
|
||||||
@@ -27,20 +30,53 @@ interface IconButtonChromeOptions {
|
|||||||
state?: IconButtonChromeState;
|
state?: IconButtonChromeState;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** A circular hitbox and highlight, for buttons inside a pill-shaped container. */
|
||||||
|
round?: boolean;
|
||||||
style?: StyleProp<ViewStyle>;
|
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. */
|
/** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */
|
||||||
export function iconButtonChromeStyle({
|
export function iconButtonChromeStyle({
|
||||||
size,
|
size,
|
||||||
state,
|
state,
|
||||||
compact = false,
|
compact = false,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
round = false,
|
||||||
style,
|
style,
|
||||||
}: IconButtonChromeOptions): StyleProp<ViewStyle> {
|
}: IconButtonChromeOptions): StyleProp<ViewStyle> {
|
||||||
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
|
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
|
||||||
return [
|
return [
|
||||||
resolveIconButtonFrame(size, compact),
|
resolveIconButtonFrame(size, compact),
|
||||||
|
round ? styles.round : null,
|
||||||
style,
|
style,
|
||||||
highlighted ? styles.highlighted : null,
|
highlighted ? styles.highlighted : null,
|
||||||
disabled ? styles.disabled : null,
|
disabled ? styles.disabled : null,
|
||||||
@@ -67,11 +103,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
|
|||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
large: {
|
large: {
|
||||||
width: {
|
width: {
|
||||||
xs: 32,
|
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
|
||||||
md: HEADER_CONTROL_HEIGHT,
|
md: HEADER_CONTROL_HEIGHT,
|
||||||
},
|
},
|
||||||
height: {
|
height: {
|
||||||
xs: 32,
|
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
|
||||||
md: HEADER_CONTROL_HEIGHT,
|
md: HEADER_CONTROL_HEIGHT,
|
||||||
},
|
},
|
||||||
padding: 0,
|
padding: 0,
|
||||||
@@ -104,6 +140,9 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
outlineWidth: 0,
|
outlineWidth: 0,
|
||||||
outlineColor: "transparent",
|
outlineColor: "transparent",
|
||||||
},
|
},
|
||||||
|
round: {
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
highlighted: {
|
highlighted: {
|
||||||
backgroundColor: theme.colors.interactionHighlight,
|
backgroundColor: theme.colors.interactionHighlight,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -152,3 +152,5 @@ export function useIsolatedBottomSheetVisibility({
|
|||||||
handleSheetDismiss,
|
handleSheetDismiss,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { SheetVisibleFrame } from "./visible-frame";
|
||||||
|
|||||||
@@ -245,3 +245,12 @@ describe("bottom sheet visibility tracker", () => {
|
|||||||
expect(context.backPress.press()).toBe(false);
|
expect(context.backPress.press()).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not cancel the owner's editor when the compact sheet leaves the tree on resize", () => {
|
||||||
|
const context = setup();
|
||||||
|
openSheet(context);
|
||||||
|
context.tracker.attachController(null);
|
||||||
|
context.tracker.handleSheetDismiss();
|
||||||
|
expect(context.closeCount()).toBe(0);
|
||||||
|
expect(context.backPress.press()).toBe(false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ export function createBottomSheetVisibilityTracker(opts: {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleSheetDismiss() {
|
handleSheetDismiss() {
|
||||||
|
// Portal cleanup reports dismissal after the sheet leaves the tree. It is
|
||||||
|
// presentation teardown, so the owner keeps its desired state.
|
||||||
|
if (!controller) return;
|
||||||
if (visible) {
|
if (visible) {
|
||||||
setPhase("dismissing");
|
setPhase("dismissing");
|
||||||
notifyClose();
|
notifyClose();
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
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,6 +16,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
|
|||||||
import {
|
import {
|
||||||
iconButtonChromeGlyphSize,
|
iconButtonChromeGlyphSize,
|
||||||
iconButtonChromeStyle,
|
iconButtonChromeStyle,
|
||||||
|
useIconButtonChromeStyle,
|
||||||
smallIconButtonChromeFrameSize,
|
smallIconButtonChromeFrameSize,
|
||||||
} from "@/components/ui/icon-button-chrome";
|
} from "@/components/ui/icon-button-chrome";
|
||||||
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
|
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
|
||||||
@@ -60,6 +61,8 @@ interface ToolbarButtonCommonProps extends Omit<
|
|||||||
tooltip?: ReactNode;
|
tooltip?: ReactNode;
|
||||||
selected?: boolean;
|
selected?: boolean;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
|
/** A circular highlight, for buttons inside a pill-shaped container. */
|
||||||
|
round?: boolean;
|
||||||
shortcut?: ShortcutKey[][] | null;
|
shortcut?: ShortcutKey[][] | null;
|
||||||
tooltipSide?: "left" | "right" | "top" | "bottom";
|
tooltipSide?: "left" | "right" | "top" | "bottom";
|
||||||
style?: StyleProp<ViewStyle>;
|
style?: StyleProp<ViewStyle>;
|
||||||
@@ -81,6 +84,7 @@ export function ToolbarButton({
|
|||||||
tooltip: tooltipBody,
|
tooltip: tooltipBody,
|
||||||
selected = false,
|
selected = false,
|
||||||
compact = false,
|
compact = false,
|
||||||
|
round = false,
|
||||||
shortcut,
|
shortcut,
|
||||||
tooltipSide = "bottom",
|
tooltipSide = "bottom",
|
||||||
style,
|
style,
|
||||||
@@ -90,17 +94,14 @@ export function ToolbarButton({
|
|||||||
testID,
|
testID,
|
||||||
...props
|
...props
|
||||||
}: ToolbarButtonProps) {
|
}: ToolbarButtonProps) {
|
||||||
const buttonStyle = useMemo(
|
const buttonStyle = useIconButtonChromeStyle({
|
||||||
() => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) =>
|
size: "small",
|
||||||
iconButtonChromeStyle({
|
compact,
|
||||||
size: "small",
|
active: selected,
|
||||||
compact,
|
disabled: Boolean(disabled),
|
||||||
state: { ...state, active: selected },
|
round,
|
||||||
disabled: Boolean(disabled),
|
style,
|
||||||
style,
|
});
|
||||||
}),
|
|
||||||
[compact, disabled, selected, style],
|
|
||||||
);
|
|
||||||
const accessibilityState = useMemo(
|
const accessibilityState = useMemo(
|
||||||
() => ({ disabled: Boolean(disabled), selected }),
|
() => ({ disabled: Boolean(disabled), selected }),
|
||||||
[disabled, selected],
|
[disabled, selected],
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ afterEach(() => {
|
|||||||
|
|
||||||
function noop() {}
|
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", () => {
|
describe("EditingTextInputNative", () => {
|
||||||
it("uses the bottom-sheet input only inside a bottom sheet", () => {
|
it("uses the bottom-sheet input only inside a bottom sheet", () => {
|
||||||
act(() => {
|
act(() => {
|
||||||
@@ -76,6 +86,32 @@ describe("EditingTextInputNative", () => {
|
|||||||
expect(bottomSheetTextInputRender.mock.calls[0]?.[0]).toMatchObject({ testID: "inside" });
|
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", () => {
|
it("clears text via clear() when replaceText receives an empty string", () => {
|
||||||
const handleRef = createRef<EditingTextInputHandle>();
|
const handleRef = createRef<EditingTextInputHandle>();
|
||||||
|
|
||||||
@@ -105,12 +141,14 @@ describe("EditingTextInputNative", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
const grownInput = container?.querySelector("input");
|
const grownInput = container?.querySelector("input");
|
||||||
|
const handle = handleRef.current;
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
handleRef.current?.reset();
|
handleRef.current?.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(container?.querySelector("input")).not.toBe(grownInput);
|
expect(container?.querySelector("input")).not.toBe(grownInput);
|
||||||
|
expect(handleRef.current).toBe(handle);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores focus after replacing a reset native input", () => {
|
it("restores focus after replacing a reset native input", () => {
|
||||||
|
|||||||
@@ -69,52 +69,56 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
setReplacement((current) => ({ ...current, autoFocus }));
|
setReplacement((current) => ({ ...current, autoFocus }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(
|
||||||
focus: () => {
|
ref,
|
||||||
if (isAwaitingReplacementRef.current) {
|
() => ({
|
||||||
setReplacementFocus(true);
|
focus: () => {
|
||||||
return;
|
if (isAwaitingReplacementRef.current) {
|
||||||
}
|
setReplacementFocus(true);
|
||||||
inputRef.current?.focus();
|
return;
|
||||||
},
|
}
|
||||||
blur: () => {
|
inputRef.current?.focus();
|
||||||
if (isAwaitingReplacementRef.current) {
|
},
|
||||||
setReplacementFocus(false);
|
blur: () => {
|
||||||
return;
|
if (isAwaitingReplacementRef.current) {
|
||||||
}
|
setReplacementFocus(false);
|
||||||
inputRef.current?.blur();
|
return;
|
||||||
},
|
}
|
||||||
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
inputRef.current?.blur();
|
||||||
getText: () => textRef.current,
|
},
|
||||||
replaceText: (nextText, selection) => {
|
isFocused: () => inputRef.current?.isFocused?.() ?? false,
|
||||||
textRef.current = nextText;
|
getText: () => textRef.current,
|
||||||
if (inputRef.current?.replaceText) {
|
replaceText: (nextText, selection) => {
|
||||||
inputRef.current.replaceText(nextText, selection);
|
textRef.current = nextText;
|
||||||
return;
|
if (inputRef.current?.replaceText) {
|
||||||
}
|
inputRef.current.replaceText(nextText, selection);
|
||||||
if (nextText === "") {
|
return;
|
||||||
inputRef.current?.clear?.();
|
}
|
||||||
return;
|
if (nextText === "") {
|
||||||
}
|
inputRef.current?.clear?.();
|
||||||
inputRef.current?.setNativeProps?.({
|
return;
|
||||||
text: nextText,
|
}
|
||||||
...(selection ? { selection } : {}),
|
inputRef.current?.setNativeProps?.({
|
||||||
});
|
text: nextText,
|
||||||
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
...(selection ? { selection } : {}),
|
||||||
},
|
});
|
||||||
reset: () => {
|
if (selection) inputRef.current?.setSelection?.(selection.start, selection.end);
|
||||||
textRef.current = "";
|
},
|
||||||
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
reset: () => {
|
||||||
if (inputRef.current?.replaceText) {
|
textRef.current = "";
|
||||||
inputRef.current.replaceText("");
|
const autoFocus = inputRef.current?.isFocused?.() ?? false;
|
||||||
} else {
|
if (inputRef.current?.replaceText) {
|
||||||
inputRef.current?.clear?.();
|
inputRef.current.replaceText("");
|
||||||
}
|
} else {
|
||||||
isAwaitingReplacementRef.current = true;
|
inputRef.current?.clear?.();
|
||||||
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
|
}
|
||||||
},
|
isAwaitingReplacementRef.current = true;
|
||||||
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
|
setReplacement((current) => ({ revision: current.revision + 1, autoFocus }));
|
||||||
}));
|
},
|
||||||
|
getNativeRef: () => inputRef.current?.getNativeRef?.() ?? inputRef.current,
|
||||||
|
}),
|
||||||
|
[setReplacementFocus],
|
||||||
|
);
|
||||||
|
|
||||||
const handleChangeText = useCallback(
|
const handleChangeText = useCallback(
|
||||||
(nextText: string) => {
|
(nextText: string) => {
|
||||||
|
|||||||
@@ -57,31 +57,35 @@ export const EditingTextInput = forwardRef<EditingTextInputHandle, EditingTextIn
|
|||||||
onChangeTextRef.current?.(nextText);
|
onChangeTextRef.current?.(nextText);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
useImperativeHandle(
|
||||||
focus: () => inputRef.current?.focus(),
|
ref,
|
||||||
blur: () => inputRef.current?.blur(),
|
() => ({
|
||||||
isFocused: () => document.activeElement === inputRef.current,
|
focus: () => inputRef.current?.focus(),
|
||||||
getText: () => {
|
blur: () => inputRef.current?.blur(),
|
||||||
const input = inputRef.current as WebTextInputElement | null;
|
isFocused: () => document.activeElement === inputRef.current,
|
||||||
const nextText = input?.value ?? textRef.current;
|
getText: () => {
|
||||||
textRef.current = nextText;
|
const input = inputRef.current as WebTextInputElement | null;
|
||||||
return nextText;
|
const nextText = input?.value ?? textRef.current;
|
||||||
},
|
textRef.current = nextText;
|
||||||
replaceText: (nextText, selection) => {
|
return nextText;
|
||||||
textRef.current = nextText;
|
},
|
||||||
const input = inputRef.current as WebTextInputElement | null;
|
replaceText: (nextText, selection) => {
|
||||||
if (input && "value" in input) input.value = nextText;
|
textRef.current = nextText;
|
||||||
if (selection && typeof input?.setSelectionRange === "function") {
|
const input = inputRef.current as WebTextInputElement | null;
|
||||||
input.setSelectionRange(selection.start, selection.end);
|
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;
|
reset: () => {
|
||||||
if (input && "value" in input) input.value = "";
|
textRef.current = "";
|
||||||
},
|
const input = inputRef.current as WebTextInputElement | null;
|
||||||
getNativeRef: () => inputRef.current,
|
if (input && "value" in input) input.value = "";
|
||||||
}));
|
},
|
||||||
|
getNativeRef: () => inputRef.current,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TextInput
|
<TextInput
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { TextInputProps } from "react-native";
|
import type { TextInputProps } from "react-native";
|
||||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||||
|
|
||||||
|
/** Stable for the mounted editor lifetime; reset() owns any inner input replacement. */
|
||||||
export interface EditingTextInputHandle {
|
export interface EditingTextInputHandle {
|
||||||
focus(): void;
|
focus(): void;
|
||||||
blur(): void;
|
blur(): void;
|
||||||
|
|||||||
@@ -298,7 +298,6 @@ export function CompactModelSheet({
|
|||||||
onClose={close}
|
onClose={close}
|
||||||
snapPoints={SNAP_POINTS}
|
snapPoints={SNAP_POINTS}
|
||||||
scrollable={false}
|
scrollable={false}
|
||||||
sizeContentToCurrentSnapPoint={usesBottomSheet}
|
|
||||||
contentStyle={styles.sheetBody}
|
contentStyle={styles.sheetBody}
|
||||||
testID="agent-controls-model-sheet"
|
testID="agent-controls-model-sheet"
|
||||||
>
|
>
|
||||||
@@ -346,7 +345,6 @@ export function CompactModelSheet({
|
|||||||
onClose={closeModelBrowser}
|
onClose={closeModelBrowser}
|
||||||
snapPoints={SNAP_POINTS}
|
snapPoints={SNAP_POINTS}
|
||||||
scrollable={false}
|
scrollable={false}
|
||||||
sizeContentToCurrentSnapPoint
|
|
||||||
presentation="push"
|
presentation="push"
|
||||||
contentStyle={styles.sheetBody}
|
contentStyle={styles.sheetBody}
|
||||||
testID="agent-controls-model-browser-sheet"
|
testID="agent-controls-model-browser-sheet"
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ComposerDockBackground } from "./internal/background";
|
|||||||
export { ComposerDockBackground } from "./internal/background";
|
export { ComposerDockBackground } from "./internal/background";
|
||||||
import { ScrollView } from "@/components/ui/scroll-view";
|
import { ScrollView } from "@/components/ui/scroll-view";
|
||||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||||
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
import { resolveContentMaxWidth, useAppSettings } from "@/hooks/use-settings";
|
||||||
import { KeyboardTranslateView } from "@/keyboard/shift";
|
import { KeyboardTranslateView } from "@/keyboard/shift";
|
||||||
@@ -17,6 +18,7 @@ import Animated, {
|
|||||||
import { useKeyboardShift } from "@/keyboard/shift";
|
import { useKeyboardShift } from "@/keyboard/shift";
|
||||||
import {
|
import {
|
||||||
resolveCenteredClearance,
|
resolveCenteredClearance,
|
||||||
|
resolveInlineComposerCapacity,
|
||||||
updateComposerCapacity,
|
updateComposerCapacity,
|
||||||
type ComposerCapacity,
|
type ComposerCapacity,
|
||||||
} from "./internal/capacity";
|
} from "./internal/capacity";
|
||||||
@@ -71,12 +73,13 @@ function ComposerViewport({
|
|||||||
|
|
||||||
/** Bound the complete composer (including its controls), not just the text input. */
|
/** Bound the complete composer (including its controls), not just the text input. */
|
||||||
function ComposerViewportContent({ style, ...props }: ViewProps) {
|
function ComposerViewportContent({ style, ...props }: ViewProps) {
|
||||||
|
const compact = useIsCompactFormFactor();
|
||||||
const capacity = useContext(ViewportCapacity);
|
const capacity = useContext(ViewportCapacity);
|
||||||
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
|
if (!capacity) throw new Error("ComposerViewportContent requires ComposerViewport");
|
||||||
const constraint = useAnimatedStyle(() => ({
|
const constraint = useAnimatedStyle(() => ({
|
||||||
// Only viewport changes and keyboard start/end events trigger layout. Motion
|
// Only viewport changes and keyboard start/end events trigger layout. Motion
|
||||||
// stays in KeyboardTranslateView and never changes this constraint per frame.
|
// stays in KeyboardTranslateView and never changes this constraint per frame.
|
||||||
maxHeight: capacity.value,
|
maxHeight: resolveInlineComposerCapacity(capacity.value, compact),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
export { ComposerDockBackground } from "./internal/background";
|
export { ComposerDockBackground } from "./internal/background";
|
||||||
import { ScrollView } from "@/components/ui/scroll-view";
|
import { ScrollView } from "@/components/ui/scroll-view";
|
||||||
import type { ReactNode } from "react";
|
import { useState, useCallback, useMemo, type ReactNode } from "react";
|
||||||
import { View } from "react-native";
|
import { View } from "react-native";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { resolveInlineComposerCapacity } from "./internal/capacity";
|
||||||
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||||
|
|
||||||
interface ComposerDockProps {
|
interface ComposerDockProps {
|
||||||
@@ -14,23 +16,33 @@ export function ComposerDock({
|
|||||||
children: [content, composer, overlay],
|
children: [content, composer, overlay],
|
||||||
centered = false,
|
centered = false,
|
||||||
}: ComposerDockProps) {
|
}: 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) {
|
if (centered) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.centered}>
|
<View style={styles.centered} onLayout={measureViewport}>
|
||||||
<View style={styles.form}>
|
<View style={styles.form}>
|
||||||
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
|
<ScrollView style={styles.setup} keyboardShouldPersistTaps="handled">
|
||||||
{content}
|
{content}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
<View style={styles.centeredComposer}>{composer}</View>
|
<View style={[styles.centeredComposer, constraint]}>{composer}</View>
|
||||||
{overlay}
|
{overlay}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.surface}>
|
<View style={styles.surface} onLayout={measureViewport}>
|
||||||
<View style={styles.content}>{content}</View>
|
<View style={styles.content}>{content}</View>
|
||||||
<View style={styles.composer}>{composer}</View>
|
<View style={[styles.composer, constraint]}>{composer}</View>
|
||||||
{overlay}
|
{overlay}
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
resolveCenteredClearance,
|
resolveCenteredClearance,
|
||||||
|
resolveInlineComposerCapacity,
|
||||||
resolveComposerCapacity,
|
resolveComposerCapacity,
|
||||||
updateComposerCapacity,
|
updateComposerCapacity,
|
||||||
} from "./capacity";
|
} from "./capacity";
|
||||||
|
|
||||||
describe("composer viewport", () => {
|
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", () => {
|
it("preserves the editing capacity when the keyboard closes", () => {
|
||||||
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
const viewport = { height: 582, bottomInset: 24, safeAreaBottom: 0, centered: false };
|
||||||
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
const open = updateComposerCapacity(undefined, { ...viewport, keyboardShift: 308 });
|
||||||
|
|||||||
@@ -50,3 +50,13 @@ export function updateComposerCapacity(
|
|||||||
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
|
capacity: resolveComposerCapacity({ ...input, keyboardShift: keyboardReserve }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Compact editing leaves conversation space above the complete composer. */
|
||||||
|
export function resolveInlineComposerCapacity(
|
||||||
|
capacity: number | undefined,
|
||||||
|
compact: boolean,
|
||||||
|
): number | undefined {
|
||||||
|
"worklet";
|
||||||
|
if (capacity === undefined) return undefined;
|
||||||
|
return compact ? capacity * 0.6 : capacity;
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
const isHydrated = hydratedDraftKey === draftKey;
|
const isHydrated = hydratedDraftKey === draftKey;
|
||||||
const textReplacementRevisionRef = useRef(0);
|
const textReplacementRevisionRef = useRef(0);
|
||||||
const [textReplacement, setTextReplacement] = useState<TextReplacement>(() => ({
|
const [textReplacement, setTextReplacement] = useState<TextReplacement>(() => ({
|
||||||
|
kind: "initial",
|
||||||
key: `${draftKey}:0`,
|
key: `${draftKey}:0`,
|
||||||
text: textSource.getSnapshot(),
|
text: textSource.getSnapshot(),
|
||||||
}));
|
}));
|
||||||
@@ -123,6 +124,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
(nextText: string) => {
|
(nextText: string) => {
|
||||||
textReplacementRevisionRef.current += 1;
|
textReplacementRevisionRef.current += 1;
|
||||||
setTextReplacement({
|
setTextReplacement({
|
||||||
|
kind: "replace",
|
||||||
key: `${draftKey}:${textReplacementRevisionRef.current}`,
|
key: `${draftKey}:${textReplacementRevisionRef.current}`,
|
||||||
text: nextText,
|
text: nextText,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { ComposerEditingSession } from "./index";
|
||||||
|
|
||||||
|
function leaveLiveDraft(session: ComposerEditingSession) {
|
||||||
|
session.captureForRemount({
|
||||||
|
snapshot: { text: "Unpublished live draft", selection: { start: 4, end: 17 } },
|
||||||
|
focused: true,
|
||||||
|
replacementKey: "draft:applied",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("composer editing remount handoff", () => {
|
||||||
|
it("keeps live text and selection when remounting from an initial draft publication", () => {
|
||||||
|
const session = new ComposerEditingSession();
|
||||||
|
leaveLiveDraft(session);
|
||||||
|
const handoff = session.takeHandoff({ kind: "initial", key: "draft:initial", text: "stale" });
|
||||||
|
expect(handoff?.snapshot).toEqual({
|
||||||
|
text: "Unpublished live draft",
|
||||||
|
selection: { start: 4, end: 17 },
|
||||||
|
});
|
||||||
|
expect(handoff?.focused).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps local edits when the last applied replacement is still current", () => {
|
||||||
|
const session = new ComposerEditingSession();
|
||||||
|
leaveLiveDraft(session);
|
||||||
|
const handoff = session.takeHandoff({
|
||||||
|
kind: "replace",
|
||||||
|
key: "draft:applied",
|
||||||
|
text: "old replacement",
|
||||||
|
});
|
||||||
|
expect(handoff?.snapshot).toEqual({
|
||||||
|
text: "Unpublished live draft",
|
||||||
|
selection: { start: 4, end: 17 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["New explicit draft", ""])(
|
||||||
|
"honors an explicit replacement made while the editor is absent: %j",
|
||||||
|
(text) => {
|
||||||
|
const session = new ComposerEditingSession();
|
||||||
|
leaveLiveDraft(session);
|
||||||
|
const handoff = session.takeHandoff({ kind: "replace", key: "draft:new", text });
|
||||||
|
expect(handoff?.snapshot).toEqual({
|
||||||
|
text,
|
||||||
|
selection: { start: text.length, end: text.length },
|
||||||
|
});
|
||||||
|
expect(handoff?.focused).toBe(true);
|
||||||
|
expect(session.takeHandoff({ kind: "replace", key: "draft:new", text })).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
import type { TextReplacement } from "@/composer/types";
|
||||||
|
import type { ComposerInputMode } from "@/composer/input-mode";
|
||||||
|
|
||||||
|
export interface ComposerEditingHandoff {
|
||||||
|
snapshot: { text: string; selection: { start: number; end: number } };
|
||||||
|
focused: boolean;
|
||||||
|
replacementKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Editing metadata outlives a layout's input; draft text still belongs to its draft owner. */
|
||||||
|
export class ComposerEditingSession {
|
||||||
|
private fullscreen = false;
|
||||||
|
private handoff: ComposerEditingHandoff | null = null;
|
||||||
|
private listeners = new Set<() => void>();
|
||||||
|
getFullscreen = () => this.fullscreen;
|
||||||
|
subscribe = (listener: () => void) => {
|
||||||
|
this.listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
this.listeners.delete(listener);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
changePresentation(fullscreen: boolean, handoff: ComposerEditingHandoff) {
|
||||||
|
this.captureForRemount(handoff);
|
||||||
|
if (fullscreen === this.fullscreen) return;
|
||||||
|
this.fullscreen = fullscreen;
|
||||||
|
for (const listener of this.listeners) listener();
|
||||||
|
}
|
||||||
|
captureForRemount(handoff: ComposerEditingHandoff) {
|
||||||
|
this.handoff = handoff;
|
||||||
|
}
|
||||||
|
peekHandoff(replacement: TextReplacement): ComposerEditingHandoff | null {
|
||||||
|
const handoff = this.handoff;
|
||||||
|
if (!handoff || replacement.kind === "initial" || replacement.key === handoff.replacementKey)
|
||||||
|
return handoff;
|
||||||
|
// A replacement command issued since capture supersedes the old editor's live buffer.
|
||||||
|
return {
|
||||||
|
snapshot: {
|
||||||
|
text: replacement.text,
|
||||||
|
selection: { start: replacement.text.length, end: replacement.text.length },
|
||||||
|
},
|
||||||
|
focused: handoff.focused,
|
||||||
|
replacementKey: replacement.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
takeHandoff(replacement: TextReplacement) {
|
||||||
|
const handoff = this.peekHandoff(replacement);
|
||||||
|
this.handoff = null;
|
||||||
|
return handoff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComposerEditingSessions {
|
||||||
|
private workspaces = new Map<
|
||||||
|
string,
|
||||||
|
Map<string, { targetKey: string; sessions: Map<ComposerInputMode, ComposerEditingSession> }>
|
||||||
|
>();
|
||||||
|
get(workspaceKey: string, tabId: string, targetKey: string, purpose: ComposerInputMode) {
|
||||||
|
let workspace = this.workspaces.get(workspaceKey);
|
||||||
|
if (!workspace) {
|
||||||
|
workspace = new Map();
|
||||||
|
this.workspaces.set(workspaceKey, workspace);
|
||||||
|
}
|
||||||
|
let tab = workspace.get(tabId);
|
||||||
|
if (!tab || tab.targetKey !== targetKey) {
|
||||||
|
tab = { targetKey, sessions: new Map() };
|
||||||
|
workspace.set(tabId, tab);
|
||||||
|
}
|
||||||
|
let session = tab.sessions.get(purpose);
|
||||||
|
if (!session) {
|
||||||
|
session = new ComposerEditingSession();
|
||||||
|
tab.sessions.set(purpose, session);
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
retain(workspaceKey: string, tabIds: readonly string[]) {
|
||||||
|
const workspace = this.workspaces.get(workspaceKey);
|
||||||
|
if (!workspace) return;
|
||||||
|
const retained = new Set(tabIds);
|
||||||
|
for (const tabId of workspace.keys()) if (!retained.has(tabId)) workspace.delete(tabId);
|
||||||
|
if (workspace.size === 0) this.workspaces.delete(workspaceKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SessionsContext = createContext<ComposerEditingSessions | null>(null);
|
||||||
|
const WorkspaceContext = createContext<string | null>(null);
|
||||||
|
const ScopeContext = createContext<{ tabId: string; targetKey: string } | null>(null);
|
||||||
|
|
||||||
|
/** App-container ownership survives replacement of both the gesture shell and tab layout. */
|
||||||
|
export function ComposerEditingSessionsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [sessions] = useState(() => new ComposerEditingSessions());
|
||||||
|
return <SessionsContext.Provider value={sessions}>{children}</SessionsContext.Provider>;
|
||||||
|
}
|
||||||
|
export function WorkspaceComposerEditingSessionsProvider({
|
||||||
|
workspaceKey,
|
||||||
|
tabIds,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
workspaceKey: string;
|
||||||
|
tabIds: readonly string[];
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const sessions = useContext(SessionsContext);
|
||||||
|
useEffect(() => sessions?.retain(workspaceKey, tabIds), [sessions, workspaceKey, tabIds]);
|
||||||
|
return <WorkspaceContext.Provider value={workspaceKey}>{children}</WorkspaceContext.Provider>;
|
||||||
|
}
|
||||||
|
export function ComposerEditingScope({
|
||||||
|
tabId,
|
||||||
|
targetKey,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
tabId: string;
|
||||||
|
targetKey: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const scope = useMemo(() => ({ tabId, targetKey }), [tabId, targetKey]);
|
||||||
|
return <ScopeContext.Provider value={scope}>{children}</ScopeContext.Provider>;
|
||||||
|
}
|
||||||
|
export function useComposerEditingSession(
|
||||||
|
purpose: ComposerInputMode,
|
||||||
|
provided?: ComposerEditingSession,
|
||||||
|
): ComposerEditingSession {
|
||||||
|
const sessions = useContext(SessionsContext);
|
||||||
|
const scope = useContext(ScopeContext);
|
||||||
|
const workspaceKey = useContext(WorkspaceContext);
|
||||||
|
const [local] = useState(() => provided ?? new ComposerEditingSession());
|
||||||
|
if (provided) return provided;
|
||||||
|
return sessions && scope && workspaceKey
|
||||||
|
? sessions.get(workspaceKey, scope.tabId, scope.targetKey, purpose)
|
||||||
|
: local;
|
||||||
|
}
|
||||||
|
export function useComposerFullscreen(session: ComposerEditingSession): boolean {
|
||||||
|
return useSyncExternalStore(session.subscribe, session.getFullscreen, session.getFullscreen);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useComposerEditingSession, useComposerFullscreen } from "./editing-session";
|
||||||
import type { ComposerTextSource } from "./text-source";
|
import type { ComposerTextSource } from "./text-source";
|
||||||
import { createStore, type StoreApi } from "zustand/vanilla";
|
import { createStore, type StoreApi } from "zustand/vanilla";
|
||||||
import { useStore } from "zustand";
|
import { useStore } from "zustand";
|
||||||
@@ -1003,8 +1004,10 @@ function ComposerAutocompleteBinding({
|
|||||||
cursorIndex: Math.min(cursorIndex, userInput.length),
|
cursorIndex: Math.min(cursorIndex, userInput.length),
|
||||||
onAutocompleteApplied: () => inputRef.current?.focus(),
|
onAutocompleteApplied: () => inputRef.current?.focus(),
|
||||||
});
|
});
|
||||||
useImperativeHandle(ref, () => ({ onKeyPress: autocomplete.onKeyPress }), [
|
const handleKeyPress = autocomplete.onKeyPress;
|
||||||
autocomplete.onKeyPress,
|
useImperativeHandle(ref, () => ({ onKeyPress: (event) => show && handleKeyPress(event) }), [
|
||||||
|
show,
|
||||||
|
handleKeyPress,
|
||||||
]);
|
]);
|
||||||
const selectOption = autocomplete.onSelectOption;
|
const selectOption = autocomplete.onSelectOption;
|
||||||
const onSelect = useCallback(
|
const onSelect = useCallback(
|
||||||
@@ -1336,6 +1339,8 @@ function ComposerContentImpl({
|
|||||||
const nextPendingFileId = useRef(0);
|
const nextPendingFileId = useRef(0);
|
||||||
const isUploadingFile = pendingFiles.length > 0;
|
const isUploadingFile = pendingFiles.length > 0;
|
||||||
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
|
const [pendingNativeImagePastes, setPendingNativeImagePastes] = useState(0);
|
||||||
|
const editingSession = useComposerEditingSession(inputMode);
|
||||||
|
const isFullscreen = useComposerFullscreen(editingSession);
|
||||||
const [sendError, setSendError] = useState<string | null>(null);
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
const [isMessageInputFocused, setIsMessageInputFocused] = useState(false);
|
||||||
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
|
const [isGithubPickerOpen, setIsGithubPickerOpen] = useState(false);
|
||||||
@@ -2364,7 +2369,7 @@ function ComposerContentImpl({
|
|||||||
<View style={inputAreaContainerStyle}>
|
<View style={inputAreaContainerStyle}>
|
||||||
<View style={styles.inputAreaContent}>
|
<View style={styles.inputAreaContent}>
|
||||||
{queueList}
|
{queueList}
|
||||||
{sendErrorNode}
|
{!isFullscreen && sendErrorNode}
|
||||||
|
|
||||||
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
<View ref={messageInputContainerRef} style={styles.messageInputContainer}>
|
||||||
<ComposerAutocompleteBinding
|
<ComposerAutocompleteBinding
|
||||||
@@ -2372,7 +2377,7 @@ function ComposerContentImpl({
|
|||||||
cursor={cursor}
|
cursor={cursor}
|
||||||
inputRef={messageInputRef}
|
inputRef={messageInputRef}
|
||||||
anchorRef={messageInputContainerRef}
|
anchorRef={messageInputContainerRef}
|
||||||
show={mode.showAutocomplete}
|
show={mode.showAutocomplete && !isFullscreen}
|
||||||
ref={autocompleteRef}
|
ref={autocompleteRef}
|
||||||
configuration={autocompleteConfiguration}
|
configuration={autocompleteConfiguration}
|
||||||
/>
|
/>
|
||||||
@@ -2387,6 +2392,8 @@ function ComposerContentImpl({
|
|||||||
<RenderProfile id="MessageInput">
|
<RenderProfile id="MessageInput">
|
||||||
<StableMessageInput
|
<StableMessageInput
|
||||||
ref={messageInputRef}
|
ref={messageInputRef}
|
||||||
|
editingSession={editingSession}
|
||||||
|
submissionError={sendErrorNode}
|
||||||
value={textSource.getSnapshot()}
|
value={textSource.getSnapshot()}
|
||||||
onChangeText={setUserInput}
|
onChangeText={setUserInput}
|
||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import { Portal } from "@gorhom/portal";
|
||||||
|
import { BackHandler, View, useWindowDimensions } from "react-native";
|
||||||
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import Animated, { useAnimatedStyle } from "react-native-reanimated";
|
||||||
|
import { DEFAULT_FLOATING_PANEL_PORTAL_HOST } from "@/components/ui/floating-panel-portal";
|
||||||
|
import { useKeyboardShift } from "@/keyboard/shift";
|
||||||
|
|
||||||
|
/** Same-window host keeps the IME attached while escaping the dock's clipping. */
|
||||||
|
export function ComposerFullscreenHost({
|
||||||
|
children,
|
||||||
|
onExit,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onExit: () => void;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const handler = BackHandler.addEventListener("hardwareBackPress", () => {
|
||||||
|
onExit();
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return () => handler.remove();
|
||||||
|
}, [active, onExit]);
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { height } = useWindowDimensions();
|
||||||
|
const { layoutShift } = useKeyboardShift();
|
||||||
|
const frame = useAnimatedStyle(() => ({
|
||||||
|
height: Math.max(0, height - layoutShift.value),
|
||||||
|
paddingTop: insets.top,
|
||||||
|
paddingBottom: insets.bottom,
|
||||||
|
paddingLeft: insets.left,
|
||||||
|
paddingRight: insets.right,
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<Portal hostName={DEFAULT_FLOATING_PANEL_PORTAL_HOST}>
|
||||||
|
<View style={[styles.background, !active && styles.hidden]}>
|
||||||
|
<Animated.View style={[{ width: "100%" }, frame]}>{children}</Animated.View>
|
||||||
|
</View>
|
||||||
|
</Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
hidden: { display: "none" },
|
||||||
|
background: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useMemo, useLayoutEffect, useState, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { View } from "react-native";
|
||||||
|
import {
|
||||||
|
getOverlayRoot,
|
||||||
|
useOverlayLayer,
|
||||||
|
OverlayLayerProvider,
|
||||||
|
useWebOverlayRegistration,
|
||||||
|
} from "@/lib/overlay-root";
|
||||||
|
|
||||||
|
export function ComposerFullscreenHost({
|
||||||
|
children,
|
||||||
|
onExit,
|
||||||
|
active,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
onExit: () => void;
|
||||||
|
active: boolean;
|
||||||
|
}) {
|
||||||
|
const layer = useOverlayLayer("modal");
|
||||||
|
const registration = useWebOverlayRegistration({
|
||||||
|
active,
|
||||||
|
layer,
|
||||||
|
onKeyDown: (event) => {
|
||||||
|
if (event.key !== "Escape") return false;
|
||||||
|
event.preventDefault();
|
||||||
|
onExit();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [viewport, setViewport] = useState(readVisibleViewport);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const update = () => setViewport(readVisibleViewport());
|
||||||
|
const visualViewport = window.visualViewport;
|
||||||
|
update();
|
||||||
|
visualViewport?.addEventListener("resize", update);
|
||||||
|
visualViewport?.addEventListener("scroll", update);
|
||||||
|
window.addEventListener("resize", update);
|
||||||
|
return () => {
|
||||||
|
visualViewport?.removeEventListener("resize", update);
|
||||||
|
visualViewport?.removeEventListener("scroll", update);
|
||||||
|
window.removeEventListener("resize", update);
|
||||||
|
};
|
||||||
|
}, [active]);
|
||||||
|
const frameStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
display: active ? ("flex" as const) : ("none" as const),
|
||||||
|
position: "absolute" as const,
|
||||||
|
top: viewport.top,
|
||||||
|
left: viewport.left,
|
||||||
|
width: viewport.width,
|
||||||
|
zIndex: layer,
|
||||||
|
pointerEvents: "auto" as const,
|
||||||
|
height: viewport.height,
|
||||||
|
}),
|
||||||
|
[active, layer, viewport],
|
||||||
|
);
|
||||||
|
return createPortal(
|
||||||
|
<OverlayLayerProvider layer={layer}>
|
||||||
|
<View ref={registration} style={frameStyle}>
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</OverlayLayerProvider>,
|
||||||
|
getOverlayRoot(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVisibleViewport() {
|
||||||
|
const viewport = window.visualViewport;
|
||||||
|
return {
|
||||||
|
top: viewport?.offsetTop ?? 0,
|
||||||
|
left: viewport?.offsetLeft ?? 0,
|
||||||
|
width: viewport?.width ?? window.innerWidth,
|
||||||
|
height: viewport?.height ?? window.innerHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ interface ComposerHeightArgs {
|
|||||||
textareaRef: RefObject<unknown>;
|
textareaRef: RefObject<unknown>;
|
||||||
minHeight: number;
|
minHeight: number;
|
||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
|
onRenderedLinesChange?: (lines: number) => void;
|
||||||
|
presentationKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useComposerHeight({
|
export function useComposerHeight({
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ interface ComposerHeightArgs {
|
|||||||
textareaRef: RefObject<HTMLElement | null>;
|
textareaRef: RefObject<HTMLElement | null>;
|
||||||
minHeight: number;
|
minHeight: number;
|
||||||
maxHeight: number;
|
maxHeight: number;
|
||||||
|
onRenderedLinesChange?: (lines: number) => void;
|
||||||
|
presentationKey?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COPIED_STYLES = [
|
const COPIED_STYLES = [
|
||||||
@@ -36,11 +38,13 @@ export function useComposerHeight({
|
|||||||
textareaRef,
|
textareaRef,
|
||||||
minHeight,
|
minHeight,
|
||||||
maxHeight,
|
maxHeight,
|
||||||
|
onRenderedLinesChange,
|
||||||
|
presentationKey,
|
||||||
}: ComposerHeightArgs): ComposerHeightResult {
|
}: ComposerHeightArgs): ComposerHeightResult {
|
||||||
const [height, setHeight] = useState(minHeight);
|
const [height, setHeight] = useState(minHeight);
|
||||||
const heightRef = useRef(minHeight);
|
const heightRef = useRef(minHeight);
|
||||||
const paramsRef = useRef({ getText, minHeight, maxHeight });
|
const paramsRef = useRef({ getText, minHeight, maxHeight, onRenderedLinesChange });
|
||||||
paramsRef.current = { getText, minHeight, maxHeight };
|
paramsRef.current = { getText, minHeight, maxHeight, onRenderedLinesChange };
|
||||||
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
const mirrorRef = useRef<HTMLTextAreaElement | null>(null);
|
||||||
|
|
||||||
const setBoundedHeight = useCallback((nextHeight: number) => {
|
const setBoundedHeight = useCallback((nextHeight: number) => {
|
||||||
@@ -65,6 +69,13 @@ export function useComposerHeight({
|
|||||||
}
|
}
|
||||||
mirror.style.width = `${sourceWidth}px`;
|
mirror.style.width = `${sourceWidth}px`;
|
||||||
mirror.value = text.endsWith("\n") ? `${text} ` : text;
|
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(mirror.scrollHeight);
|
||||||
},
|
},
|
||||||
[setBoundedHeight, textareaRef],
|
[setBoundedHeight, textareaRef],
|
||||||
@@ -101,7 +112,7 @@ export function useComposerHeight({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
measure(getText());
|
measure(getText());
|
||||||
}, [maxHeight, minHeight, getText, measure]);
|
}, [maxHeight, minHeight, getText, measure, presentationKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const source = textareaRef.current;
|
const source = textareaRef.current;
|
||||||
@@ -115,7 +126,7 @@ export function useComposerHeight({
|
|||||||
});
|
});
|
||||||
observer.observe(source);
|
observer.observe(source);
|
||||||
return () => observer.disconnect();
|
return () => observer.disconnect();
|
||||||
}, [measure, textareaRef]);
|
}, [measure, textareaRef, presentationKey]);
|
||||||
|
|
||||||
const onTextChange = useCallback(
|
const onTextChange = useCallback(
|
||||||
(_previousText: string, nextText: string) => measure(nextText),
|
(_previousText: string, nextText: string) => measure(nextText),
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import {
|
||||||
|
useComposerEditingSession,
|
||||||
|
useComposerFullscreen,
|
||||||
|
type ComposerEditingSession,
|
||||||
|
} from "@/composer/editing-session";
|
||||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
@@ -56,6 +61,7 @@ import { useIsCompactFormFactor } from "@/constants/layout";
|
|||||||
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
import { useComposerKeyboardScope } from "@/composer/keyboard-scope";
|
||||||
import { RenderProfile } from "@/utils/render-profiler";
|
import { RenderProfile } from "@/utils/render-profiler";
|
||||||
import { useComposerHeight } from "./height";
|
import { useComposerHeight } from "./height";
|
||||||
|
import { ComposerInputPresentation } from "./presentation";
|
||||||
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
import { resolveComposerInputMode, type ComposerInputMode } from "@/composer/input-mode";
|
||||||
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
import type { NativePastedFile } from "@/composer/native-pasted-image";
|
||||||
import {
|
import {
|
||||||
@@ -77,6 +83,7 @@ import {
|
|||||||
applyDictationTranscript,
|
applyDictationTranscript,
|
||||||
computeCanStartDictation,
|
computeCanStartDictation,
|
||||||
resolveComposerSurfacePresentation,
|
resolveComposerSurfacePresentation,
|
||||||
|
resolveComposerSurfaceText,
|
||||||
runAlternateSendAction,
|
runAlternateSendAction,
|
||||||
runDefaultSendAction,
|
runDefaultSendAction,
|
||||||
runMessageInputKeyboardAction,
|
runMessageInputKeyboardAction,
|
||||||
@@ -106,6 +113,8 @@ export interface ComposerKeyPressEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageInputProps {
|
export interface MessageInputProps {
|
||||||
|
editingSession?: ComposerEditingSession;
|
||||||
|
submissionError?: React.ReactNode;
|
||||||
value: string;
|
value: string;
|
||||||
onChangeText: (text: string) => void;
|
onChangeText: (text: string) => void;
|
||||||
onSubmit: (payload: MessagePayload) => void;
|
onSubmit: (payload: MessagePayload) => void;
|
||||||
@@ -206,6 +215,7 @@ type WebTextInputKeyPressEvent = NativeSyntheticEvent<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
interface TextAreaHandle {
|
interface TextAreaHandle {
|
||||||
|
value?: string;
|
||||||
scrollHeight?: number;
|
scrollHeight?: number;
|
||||||
clientHeight?: number;
|
clientHeight?: number;
|
||||||
offsetHeight?: number;
|
offsetHeight?: number;
|
||||||
@@ -215,7 +225,7 @@ interface TextAreaHandle {
|
|||||||
style?: {
|
style?: {
|
||||||
height?: string;
|
height?: string;
|
||||||
overflowY?: string;
|
overflowY?: string;
|
||||||
} & Record<string, unknown>;
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function AttachButtonIcon({
|
function AttachButtonIcon({
|
||||||
@@ -433,7 +443,7 @@ function getTextInputNativeElement(current: ComposerTextInputHandle | null): HTM
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PasteImagesEffectArgs {
|
interface PasteImagesEffectArgs {
|
||||||
getWebTextArea: () => TextAreaHandle | null;
|
textarea: HTMLElement | null;
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
isDictating: boolean;
|
isDictating: boolean;
|
||||||
@@ -443,7 +453,7 @@ interface PasteImagesEffectArgs {
|
|||||||
|
|
||||||
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
||||||
const {
|
const {
|
||||||
getWebTextArea,
|
textarea,
|
||||||
isConnected,
|
isConnected,
|
||||||
disabled,
|
disabled,
|
||||||
isDictating,
|
isDictating,
|
||||||
@@ -454,19 +464,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWeb || !onAddImages) return;
|
if (!isWeb || !onAddImages) return;
|
||||||
|
|
||||||
const textarea = getWebTextArea() as
|
if (!textarea) return;
|
||||||
| (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;
|
let disposed = false;
|
||||||
const handlePaste = (event: ClipboardEvent) => {
|
const handlePaste = (event: ClipboardEvent) => {
|
||||||
@@ -493,14 +491,7 @@ function usePasteImagesEffect(args: PasteImagesEffectArgs): void {
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
textarea.removeEventListener?.("paste", handlePaste);
|
textarea.removeEventListener?.("paste", handlePaste);
|
||||||
};
|
};
|
||||||
}, [
|
}, [disabled, textarea, isConnected, isDictating, isRealtimeVoiceForCurrentAgent, onAddImages]);
|
||||||
disabled,
|
|
||||||
getWebTextArea,
|
|
||||||
isConnected,
|
|
||||||
isDictating,
|
|
||||||
isRealtimeVoiceForCurrentAgent,
|
|
||||||
onAddImages,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useAutoFocusOnWebEffect(
|
function useAutoFocusOnWebEffect(
|
||||||
@@ -628,6 +619,7 @@ function FocusHint({
|
|||||||
interface ComposerTextSurfaceProps {
|
interface ComposerTextSurfaceProps {
|
||||||
readOnly: boolean;
|
readOnly: boolean;
|
||||||
value: string;
|
value: string;
|
||||||
|
textReplacementKey: string;
|
||||||
textInputRef: React.Ref<ComposerTextInputHandle>;
|
textInputRef: React.Ref<ComposerTextInputHandle>;
|
||||||
textInputStyle: EditingTextInputProps["style"];
|
textInputStyle: EditingTextInputProps["style"];
|
||||||
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
|
readOnlyTextStyle: React.ComponentProps<typeof Text>["style"];
|
||||||
@@ -643,6 +635,9 @@ interface ComposerTextSurfaceProps {
|
|||||||
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
|
onSelectionChange: (event: NativeSyntheticEvent<TextInputSelectionChangeEventData>) => void;
|
||||||
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
|
onPasteImages: ((files: readonly NativePastedFile[]) => void) | undefined;
|
||||||
onPasteError: (message: string) => void;
|
onPasteError: (message: string) => void;
|
||||||
|
fullscreen: boolean;
|
||||||
|
compact: boolean;
|
||||||
|
onRenderedLinesChange: (lines: number) => void;
|
||||||
focusHintVisible: boolean;
|
focusHintVisible: boolean;
|
||||||
focusInputKeys: ShortcutChord | null | undefined;
|
focusInputKeys: ShortcutChord | null | undefined;
|
||||||
focusHintLabel: string;
|
focusHintLabel: string;
|
||||||
@@ -654,6 +649,23 @@ interface ComposerTextSurfaceProps {
|
|||||||
* state of this composer rather than a second one.
|
* state of this composer rather than a second one.
|
||||||
*/
|
*/
|
||||||
function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElement {
|
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) {
|
if (props.readOnly) {
|
||||||
return (
|
return (
|
||||||
<View style={styles.textInputScrollWrapper}>
|
<View style={styles.textInputScrollWrapper}>
|
||||||
@@ -664,12 +676,28 @@ function ComposerTextSurface(props: ComposerTextSurfaceProps): React.ReactElemen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<View style={styles.textInputScrollWrapper}>
|
<View
|
||||||
|
style={[
|
||||||
|
styles.textInputScrollWrapper,
|
||||||
|
props.fullscreen && styles.fullscreenTextSurface,
|
||||||
|
props.compact && !props.fullscreen && styles.compactTextSurface,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{!isWeb && props.compact && !props.fullscreen ? (
|
||||||
|
<Text
|
||||||
|
accessible={false}
|
||||||
|
pointerEvents="none"
|
||||||
|
style={[props.textInputStyle, styles.lineMeasurement]}
|
||||||
|
onTextLayout={measureLines}
|
||||||
|
>
|
||||||
|
{`${measurementText}\u200b`}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
<ComposerTextInput
|
<ComposerTextInput
|
||||||
ref={props.textInputRef}
|
ref={props.textInputRef}
|
||||||
dataSet={COMPOSER_INPUT_DATASET}
|
dataSet={COMPOSER_INPUT_DATASET}
|
||||||
initialValue={props.value}
|
initialValue={props.value}
|
||||||
onChangeText={props.onChangeText}
|
onChangeText={handleTextChange}
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
accessibilityLabel={props.accessibilityLabel}
|
accessibilityLabel={props.accessibilityLabel}
|
||||||
onFocus={props.onFocus}
|
onFocus={props.onFocus}
|
||||||
@@ -843,6 +871,7 @@ interface ToggleRealtimeVoiceContext {
|
|||||||
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
handleStopRealtimeVoice: () => Promise<unknown> | void;
|
||||||
toast: { error: (msg: string) => void };
|
toast: { error: (msg: string) => void };
|
||||||
interruptBeforeVoiceMessage: string;
|
interruptBeforeVoiceMessage: string;
|
||||||
|
exitFullscreen: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
||||||
@@ -858,6 +887,7 @@ function toggleRealtimeVoiceImpl(ctx: ToggleRealtimeVoiceContext): void {
|
|||||||
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
|
ctx.toast.error(ctx.interruptBeforeVoiceMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ctx.exitFullscreen();
|
||||||
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
void ctx.voice.startVoice(ctx.voiceServerId, ctx.voiceAgentId).catch((error) => {
|
||||||
console.error("[MessageInput] Failed to start realtime voice", error);
|
console.error("[MessageInput] Failed to start realtime voice", error);
|
||||||
const message = extractErrorMessage(error);
|
const message = extractErrorMessage(error);
|
||||||
@@ -872,6 +902,7 @@ interface StartDictationContext {
|
|||||||
canStartDictation: () => boolean;
|
canStartDictation: () => boolean;
|
||||||
toast: { error: (msg: string) => void };
|
toast: { error: (msg: string) => void };
|
||||||
startDictation: () => Promise<void>;
|
startDictation: () => Promise<void>;
|
||||||
|
exitFullscreen: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promise<void> {
|
||||||
@@ -882,6 +913,7 @@ async function startDictationIfAvailableImpl(ctx: StartDictationContext): Promis
|
|||||||
if (!ctx.canStartDictation()) {
|
if (!ctx.canStartDictation()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
ctx.exitFullscreen();
|
||||||
await ctx.startDictation();
|
await ctx.startDictation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -983,7 +1015,8 @@ function computeIsDictationStartEnabled(
|
|||||||
return (isReadyForDictation ?? isConnected) && !disabled;
|
return (isReadyForDictation ?? isConnected) && !disabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMaxInputHeight(windowHeight: number): number {
|
function resolveMaxInputHeight(windowHeight: number, compact: boolean): number {
|
||||||
|
if (compact) return DEFAULT_MAX_INPUT_HEIGHT;
|
||||||
if (!Number.isFinite(windowHeight) || windowHeight <= 0) return DEFAULT_MAX_INPUT_HEIGHT;
|
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));
|
return Math.max(DEFAULT_MAX_INPUT_HEIGHT, Math.floor(windowHeight * MAX_INPUT_VIEWPORT_RATIO));
|
||||||
}
|
}
|
||||||
@@ -1007,9 +1040,12 @@ function getComposerInputSnapshot(
|
|||||||
current: ComposerTextInputHandle | null,
|
current: ComposerTextInputHandle | null,
|
||||||
fallbackText: string,
|
fallbackText: string,
|
||||||
fallbackSelection: ComposerInputSnapshot["selection"],
|
fallbackSelection: ComposerInputSnapshot["selection"],
|
||||||
|
mountedTextArea?: TextAreaHandle | null,
|
||||||
): ComposerInputSnapshot {
|
): ComposerInputSnapshot {
|
||||||
const text = current?.getText() ?? fallbackText;
|
// The inner DOM ref can detach before the outer editing handle. Keep the mounted
|
||||||
const textArea = getWebTextAreaImpl(current);
|
// element available until this owner's ref callback captures the remount handoff.
|
||||||
|
const textArea = mountedTextArea ?? getWebTextAreaImpl(current);
|
||||||
|
const text = textArea?.value ?? current?.getText() ?? fallbackText;
|
||||||
const start = textArea?.selectionStart ?? fallbackSelection.start;
|
const start = textArea?.selectionStart ?? fallbackSelection.start;
|
||||||
const end = textArea?.selectionEnd ?? fallbackSelection.end;
|
const end = textArea?.selectionEnd ?? fallbackSelection.end;
|
||||||
return { text, selection: { start, end } };
|
return { text, selection: { start, end } };
|
||||||
@@ -1139,6 +1175,28 @@ function extractErrorMessage(error: unknown): string | null {
|
|||||||
return 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>(
|
export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||||
function MessageInput(props, ref) {
|
function MessageInput(props, ref) {
|
||||||
const {
|
const {
|
||||||
@@ -1186,11 +1244,15 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
textReplacement,
|
textReplacement,
|
||||||
submitLabel,
|
submitLabel,
|
||||||
} = resolveMessageInputProps(props);
|
} = resolveMessageInputProps(props);
|
||||||
|
const { isActiveComposer } = useComposerKeyboardScope();
|
||||||
const mode = resolveComposerInputMode(inputMode);
|
const mode = resolveComposerInputMode(inputMode);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isCompact = useIsCompactFormFactor();
|
const isCompact = useIsCompactFormFactor();
|
||||||
const { height: windowHeight } = useWindowDimensions();
|
const { height: windowHeight } = useWindowDimensions();
|
||||||
const maxInputHeight = resolveMaxInputHeight(windowHeight);
|
const editingSession = useComposerEditingSession(inputMode, props.editingSession);
|
||||||
|
const isFullscreen = useComposerFullscreen(editingSession);
|
||||||
|
const [renderedLines, setRenderedLines] = useState(1);
|
||||||
|
const maxInputHeight = resolveMaxInputHeight(windowHeight, isCompact);
|
||||||
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
const buttonIconSize = isWeb ? ICON_SIZE.md : ICON_SIZE.lg;
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const voice = useVoiceOptional();
|
const voice = useVoiceOptional();
|
||||||
@@ -1207,27 +1269,19 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const inputWrapperRef = useRef<View | null>(null);
|
const inputWrapperRef = useRef<View | null>(null);
|
||||||
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
|
const textInputRef = useRef<ComposerTextInputHandle | null>(null);
|
||||||
const isInputFocusedRef = useRef(false);
|
const isInputFocusedRef = useRef(false);
|
||||||
const valueRef = useRef(value);
|
const handoff = editingSession.peekHandoff(textReplacement);
|
||||||
const selectionRef = useRef({ start: value.length, end: value.length });
|
const focusIntentRef = useRef(Boolean(handoff?.focused));
|
||||||
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
|
const valueRef = useRef(handoff?.snapshot.text ?? value);
|
||||||
const webTextareaRef = useRef<HTMLElement | null>(null);
|
const selectionRef = useRef(
|
||||||
const getLiveText = useCallback(() => valueRef.current, []);
|
handoff?.snapshot.selection ?? { start: value.length, end: value.length },
|
||||||
const composerHeight = useComposerHeight({
|
|
||||||
getText: getLiveText,
|
|
||||||
textareaRef: webTextareaRef,
|
|
||||||
minHeight: MIN_INPUT_HEIGHT,
|
|
||||||
maxHeight: maxInputHeight,
|
|
||||||
});
|
|
||||||
const { style: composerHeightStyle, scrollEnabled: isComposerScrollEnabled } = composerHeight;
|
|
||||||
const measuredComposerHeight = composerHeight.mode === "measured" ? composerHeight : undefined;
|
|
||||||
const updateComposerHeightForText = measuredComposerHeight?.onTextChange;
|
|
||||||
const resetComposerHeight = measuredComposerHeight?.reset;
|
|
||||||
|
|
||||||
const handleComposerLayout = useCallback(
|
|
||||||
(event: LayoutChangeEvent) => onHeightChange?.(event.nativeEvent.layout.height),
|
|
||||||
[onHeightChange],
|
|
||||||
);
|
);
|
||||||
|
const activeComposerRef = useRef(isActiveComposer);
|
||||||
|
activeComposerRef.current = isActiveComposer;
|
||||||
|
const [webTextArea, setWebTextArea] = useState<HTMLElement | null>(null);
|
||||||
|
const appliedTextReplacementKeyRef = useRef(textReplacement.key);
|
||||||
|
const textReplacementRef = useRef(textReplacement);
|
||||||
|
textReplacementRef.current = textReplacement;
|
||||||
|
const webTextareaRef = useRef<HTMLElement | null>(null);
|
||||||
const updateLiveTextPresence = useCallback((text: string) => {
|
const updateLiveTextPresence = useCallback((text: string) => {
|
||||||
const nextHasLiveText = text.trim().length > 0;
|
const nextHasLiveText = text.trim().length > 0;
|
||||||
if (hasLiveTextRef.current === nextHasLiveText) return;
|
if (hasLiveTextRef.current === nextHasLiveText) return;
|
||||||
@@ -1235,6 +1289,82 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
setHasLiveText(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 replaceText = useCallback(
|
const replaceText = useCallback(
|
||||||
(nextText: string, selection?: { start: number; end: number }) => {
|
(nextText: string, selection?: { start: number; end: number }) => {
|
||||||
updateComposerHeightForText?.(valueRef.current, nextText);
|
updateComposerHeightForText?.(valueRef.current, nextText);
|
||||||
@@ -1412,8 +1542,9 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
canStartDictation,
|
canStartDictation,
|
||||||
toast,
|
toast,
|
||||||
startDictation,
|
startDictation,
|
||||||
|
exitFullscreen,
|
||||||
}),
|
}),
|
||||||
[canStartDictation, dictationUnavailableMessage, startDictation, toast],
|
[canStartDictation, dictationUnavailableMessage, startDictation, toast, exitFullscreen],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleVoicePress = useCallback(
|
const handleVoicePress = useCallback(
|
||||||
@@ -1485,10 +1616,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
handleStopRealtimeVoice,
|
handleStopRealtimeVoice,
|
||||||
toast,
|
toast,
|
||||||
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
|
interruptBeforeVoiceMessage: t("composer.voice.interruptBeforeVoice"),
|
||||||
|
exitFullscreen,
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
disabled,
|
disabled,
|
||||||
handleStopRealtimeVoice,
|
handleStopRealtimeVoice,
|
||||||
|
exitFullscreen,
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
isConnected,
|
isConnected,
|
||||||
t,
|
t,
|
||||||
@@ -1572,10 +1705,10 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
if (isWeb) {
|
if (isWeb) {
|
||||||
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
webTextareaRef.current = getWebTextArea() as HTMLElement | null;
|
||||||
}
|
}
|
||||||
}, [getWebTextArea]);
|
}, [getWebTextArea, isFullscreen]);
|
||||||
|
|
||||||
usePasteImagesEffect({
|
usePasteImagesEffect({
|
||||||
getWebTextArea,
|
textarea: webTextArea,
|
||||||
isConnected,
|
isConnected,
|
||||||
disabled,
|
disabled,
|
||||||
isDictating,
|
isDictating,
|
||||||
@@ -1596,16 +1729,30 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
const shouldHandleWebKeyPress = isWeb;
|
const shouldHandleWebKeyPress = isWeb;
|
||||||
const shouldSubmitOnEnter = isWeb && !isCompact;
|
const shouldSubmitOnEnter = isWeb && !isCompact;
|
||||||
|
|
||||||
function handleDesktopKeyPress(event: WebTextInputKeyPressEvent) {
|
const handleDesktopKeyPress = useCallback(
|
||||||
if (!shouldHandleWebKeyPress) return;
|
(event: WebTextInputKeyPressEvent) => {
|
||||||
handleDesktopKeyPressImpl(event, {
|
if (!shouldHandleWebKeyPress) return;
|
||||||
|
handleDesktopKeyPressImpl(event, {
|
||||||
|
onKeyPressCallback,
|
||||||
|
input: getComposerInputSnapshot(
|
||||||
|
textInputRef.current,
|
||||||
|
valueRef.current,
|
||||||
|
selectionRef.current,
|
||||||
|
),
|
||||||
|
submitOnEnter: shouldSubmitOnEnter,
|
||||||
|
isAgentRunning,
|
||||||
|
onQueue,
|
||||||
|
isSubmitDisabled,
|
||||||
|
isSubmitLoading,
|
||||||
|
disabled,
|
||||||
|
handleAlternateSendAction,
|
||||||
|
handleDefaultSendAction,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[
|
||||||
|
shouldHandleWebKeyPress,
|
||||||
onKeyPressCallback,
|
onKeyPressCallback,
|
||||||
input: getComposerInputSnapshot(
|
shouldSubmitOnEnter,
|
||||||
textInputRef.current,
|
|
||||||
valueRef.current,
|
|
||||||
selectionRef.current,
|
|
||||||
),
|
|
||||||
submitOnEnter: shouldSubmitOnEnter,
|
|
||||||
isAgentRunning,
|
isAgentRunning,
|
||||||
onQueue,
|
onQueue,
|
||||||
isSubmitDisabled,
|
isSubmitDisabled,
|
||||||
@@ -1613,8 +1760,8 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
disabled,
|
disabled,
|
||||||
handleAlternateSendAction,
|
handleAlternateSendAction,
|
||||||
handleDefaultSendAction,
|
handleDefaultSendAction,
|
||||||
});
|
],
|
||||||
}
|
);
|
||||||
|
|
||||||
const primaryActionKind = resolvePrimaryActionKind({
|
const primaryActionKind = resolvePrimaryActionKind({
|
||||||
hasSendableContent: hasSendableComposerContent({
|
hasSendableContent: hasSendableComposerContent({
|
||||||
@@ -1678,16 +1825,18 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleInputFocus = useCallback(() => {
|
const handleInputFocus = useCallback(() => {
|
||||||
|
focusIntentRef.current = true;
|
||||||
isInputFocusedRef.current = true;
|
isInputFocusedRef.current = true;
|
||||||
setIsInputFocused(true);
|
setIsInputFocused(true);
|
||||||
onFocusChange?.(true);
|
onFocusChange?.(true);
|
||||||
}, [onFocusChange]);
|
}, [onFocusChange]);
|
||||||
|
|
||||||
const handleInputBlur = useCallback(() => {
|
const handleInputBlur = useCallback(() => {
|
||||||
|
if (activeComposerRef.current && !isFullscreen) focusIntentRef.current = false;
|
||||||
isInputFocusedRef.current = false;
|
isInputFocusedRef.current = false;
|
||||||
setIsInputFocused(false);
|
setIsInputFocused(false);
|
||||||
onFocusChange?.(false);
|
onFocusChange?.(false);
|
||||||
}, [onFocusChange]);
|
}, [isFullscreen, onFocusChange]);
|
||||||
|
|
||||||
const handlePasteError = useCallback(
|
const handlePasteError = useCallback(
|
||||||
(message: string) => {
|
(message: string) => {
|
||||||
@@ -1735,8 +1884,12 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
// `fontFamily` here is silently dropped while every other property lands.
|
// `fontFamily` here is silently dropped while every other property lands.
|
||||||
// An inline style outranks both classes. See docs/unistyles.md.
|
// An inline style outranks both classes. See docs/unistyles.md.
|
||||||
const textInputStyle = useMemo(
|
const textInputStyle = useMemo(
|
||||||
() => [styles.textInput, mode.isMonospace && styles.textInputMonospace, composerHeightStyle],
|
() => [
|
||||||
[composerHeightStyle, mode.isMonospace],
|
styles.textInput,
|
||||||
|
mode.isMonospace && styles.textInputMonospace,
|
||||||
|
isFullscreen ? styles.fullscreenTextInput : composerHeightStyle,
|
||||||
|
],
|
||||||
|
[composerHeightStyle, mode.isMonospace, isFullscreen],
|
||||||
);
|
);
|
||||||
// Static content has no textarea to mirror, so it grows with its own text
|
// Static content has no textarea to mirror, so it grows with its own text
|
||||||
// instead of the measured input height.
|
// instead of the measured input height.
|
||||||
@@ -1780,107 +1933,192 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
|
[isDictating, isRealtimeVoiceForCurrentAgent, voice?.isMuted, buttonIconSize],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const sendButton = useMemo(
|
||||||
<View
|
() => (
|
||||||
ref={rootRef}
|
<PrimaryAction
|
||||||
style={styles.container}
|
kind={isFullscreen ? "send" : primaryActionKind}
|
||||||
testID="message-input-root"
|
activeActionContent={activeActionContent}
|
||||||
onLayout={handleComposerLayout}
|
shouldShow
|
||||||
>
|
canPressLoadingButton={canPressLoadingButton}
|
||||||
<MessageInputAutoFocus
|
onSubmitLoadingPress={onSubmitLoadingPress}
|
||||||
enabled={autoFocus}
|
onDefaultSendAction={handleDefaultSendAction}
|
||||||
autoFocusKey={autoFocusKey}
|
isSendButtonDisabled={resolvePresentationSendDisabled({
|
||||||
textInputRef={textInputRef}
|
fullscreen: isFullscreen,
|
||||||
|
disabled: isSendButtonDisabled,
|
||||||
|
hasText: hasLiveText,
|
||||||
|
attachments,
|
||||||
|
hasExternalContent,
|
||||||
|
allowEmpty: allowEmptySubmit,
|
||||||
|
})}
|
||||||
|
submitAccessibilityLabel={submitAccessibilityLabel}
|
||||||
|
sendButtonCombinedStyle={sendButtonCombinedStyle}
|
||||||
|
isSubmitLoading={isSubmitLoading}
|
||||||
|
submitIcon={submitIcon}
|
||||||
|
submitLabel={submitLabel}
|
||||||
|
submitButtonTestID={submitButtonTestID}
|
||||||
|
buttonIconSize={buttonIconSize}
|
||||||
|
sendKeys={DEFAULT_SEND_KEYS}
|
||||||
|
sendTooltipLabel={sendTooltipLabel}
|
||||||
/>
|
/>
|
||||||
{/* Regular input */}
|
),
|
||||||
<View
|
[
|
||||||
ref={inputWrapperRef}
|
isFullscreen,
|
||||||
style={inputWrapperCombinedStyle}
|
primaryActionKind,
|
||||||
pointerEvents={surfacePresentation.input.pointerEvents}
|
activeActionContent,
|
||||||
>
|
canPressLoadingButton,
|
||||||
{attachmentSlot}
|
onSubmitLoadingPress,
|
||||||
{/* Text input */}
|
handleDefaultSendAction,
|
||||||
<RenderProfile id="ComposerTextSurface">
|
isSendButtonDisabled,
|
||||||
<ComposerTextSurface
|
hasLiveText,
|
||||||
readOnly={readOnly}
|
attachments,
|
||||||
value={value}
|
hasExternalContent,
|
||||||
textInputRef={textInputRef}
|
allowEmptySubmit,
|
||||||
textInputStyle={textInputStyle}
|
submitAccessibilityLabel,
|
||||||
readOnlyTextStyle={readOnlyTextStyle}
|
sendButtonCombinedStyle,
|
||||||
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
isSubmitLoading,
|
||||||
accessibilityLabel={t(mode.accessibilityLabelKey)}
|
submitIcon,
|
||||||
onChangeText={handleInputChange}
|
submitLabel,
|
||||||
onFocus={handleInputFocus}
|
submitButtonTestID,
|
||||||
onBlur={handleInputBlur}
|
buttonIconSize,
|
||||||
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
sendTooltipLabel,
|
||||||
scrollEnabled={isComposerScrollEnabled}
|
],
|
||||||
autoFocus={false}
|
);
|
||||||
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
const textSurface = useMemo(
|
||||||
onSelectionChange={handleSelectionChange}
|
() => (
|
||||||
onPasteImages={onPasteImages}
|
<RenderProfile id="ComposerTextSurface">
|
||||||
onPasteError={handlePasteError}
|
<ComposerTextSurface
|
||||||
focusHintVisible={isWeb && !isInputFocused && !value}
|
readOnly={readOnly}
|
||||||
focusInputKeys={focusInputKeys}
|
value={
|
||||||
focusHintLabel={t("composer.input.focusHint", {
|
readOnly
|
||||||
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
? value
|
||||||
})}
|
: resolveComposerSurfaceText(
|
||||||
|
valueRef.current,
|
||||||
|
textReplacement,
|
||||||
|
appliedTextReplacementKeyRef.current,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
textReplacementKey={textReplacement.key}
|
||||||
|
textInputRef={assignTextInput}
|
||||||
|
textInputStyle={textInputStyle}
|
||||||
|
readOnlyTextStyle={readOnlyTextStyle}
|
||||||
|
placeholder={placeholder ?? t("composer.placeholders.fallback")}
|
||||||
|
accessibilityLabel={t(mode.accessibilityLabelKey)}
|
||||||
|
onChangeText={handleInputChange}
|
||||||
|
onFocus={handleInputFocus}
|
||||||
|
onBlur={handleInputBlur}
|
||||||
|
editable={!isDictating && !isRealtimeVoiceForCurrentAgent && !disabled}
|
||||||
|
scrollEnabled={isComposerScrollEnabled}
|
||||||
|
autoFocus={
|
||||||
|
Boolean(editingSession.peekHandoff(textReplacement)?.focused) && isActiveComposer
|
||||||
|
}
|
||||||
|
onKeyPress={shouldHandleWebKeyPress ? handleDesktopKeyPress : undefined}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
onPasteImages={onPasteImages}
|
||||||
|
onPasteError={handlePasteError}
|
||||||
|
fullscreen={isFullscreen}
|
||||||
|
compact={isCompact}
|
||||||
|
onRenderedLinesChange={setRenderedLines}
|
||||||
|
focusHintVisible={!isFullscreen && isWeb && !isInputFocused && !value}
|
||||||
|
focusInputKeys={focusInputKeys}
|
||||||
|
focusHintLabel={t("composer.input.focusHint", {
|
||||||
|
shortcut: focusInputKeys ? formatShortcut(focusInputKeys[0], getShortcutOs()) : "",
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</RenderProfile>
|
||||||
|
),
|
||||||
|
[
|
||||||
|
readOnly,
|
||||||
|
textReplacement,
|
||||||
|
editingSession,
|
||||||
|
isActiveComposer,
|
||||||
|
assignTextInput,
|
||||||
|
textInputStyle,
|
||||||
|
readOnlyTextStyle,
|
||||||
|
placeholder,
|
||||||
|
t,
|
||||||
|
mode.accessibilityLabelKey,
|
||||||
|
handleInputChange,
|
||||||
|
handleInputFocus,
|
||||||
|
handleInputBlur,
|
||||||
|
isDictating,
|
||||||
|
isRealtimeVoiceForCurrentAgent,
|
||||||
|
disabled,
|
||||||
|
isComposerScrollEnabled,
|
||||||
|
shouldHandleWebKeyPress,
|
||||||
|
handleDesktopKeyPress,
|
||||||
|
handleSelectionChange,
|
||||||
|
onPasteImages,
|
||||||
|
handlePasteError,
|
||||||
|
isFullscreen,
|
||||||
|
isCompact,
|
||||||
|
isInputFocused,
|
||||||
|
value,
|
||||||
|
focusInputKeys,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
const toolbar = useMemo(
|
||||||
|
() => (
|
||||||
|
<View style={styles.buttonRow}>
|
||||||
|
{/* Toolbar left: attachment button + agent controls */}
|
||||||
|
<View style={styles.leftButtonGroup}>
|
||||||
|
<AttachmentDropdown
|
||||||
|
visible={mode.showAttachments}
|
||||||
|
isConnected={isConnected}
|
||||||
|
disabled={disabled}
|
||||||
|
attachButtonStyle={attachButtonStyle}
|
||||||
|
renderAttachButtonIcon={renderAttachButtonIcon}
|
||||||
|
attachmentMenuItems={attachmentMenuItems}
|
||||||
|
addAttachmentLabel={t("composer.input.addAttachment")}
|
||||||
/>
|
/>
|
||||||
</RenderProfile>
|
{leftContent}
|
||||||
|
</View>
|
||||||
|
|
||||||
{/* Button row */}
|
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
||||||
<View style={styles.buttonRow}>
|
<View style={styles.rightButtonGroup}>
|
||||||
{/* Toolbar left: attachment button + agent controls */}
|
{beforeVoiceContent}
|
||||||
<View style={styles.leftButtonGroup}>
|
<VoiceButtonTooltip
|
||||||
<AttachmentDropdown
|
visible={mode.showVoice}
|
||||||
visible={mode.showAttachments}
|
onVoicePress={handleVoicePress}
|
||||||
isConnected={isConnected}
|
isDictationStartEnabled={isDictationStartEnabled}
|
||||||
disabled={disabled}
|
voiceButtonAccessibilityLabel={voiceButtonAccessibilityLabel}
|
||||||
attachButtonStyle={attachButtonStyle}
|
voiceButtonStyle={voiceButtonStyle}
|
||||||
renderAttachButtonIcon={renderAttachButtonIcon}
|
renderVoiceButtonIcon={renderVoiceButtonIcon}
|
||||||
attachmentMenuItems={attachmentMenuItems}
|
voiceTooltipText={voiceTooltipText}
|
||||||
addAttachmentLabel={t("composer.input.addAttachment")}
|
isRealtimeVoiceForCurrentAgent={isRealtimeVoiceForCurrentAgent}
|
||||||
/>
|
voiceMuteToggleKeys={voiceMuteToggleKeys}
|
||||||
{leftContent}
|
dictationToggleKeys={dictationToggleKeys}
|
||||||
</View>
|
/>
|
||||||
|
{rightContent}
|
||||||
{/* Right: voice button, contextual button (realtime/send/cancel) */}
|
{sendButton}
|
||||||
<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>
|
||||||
</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
|
<View
|
||||||
style={overlayContainerStyle}
|
style={overlayContainerStyle}
|
||||||
pointerEvents={surfacePresentation.overlay.pointerEvents}
|
pointerEvents={surfacePresentation.overlay.pointerEvents}
|
||||||
@@ -1903,6 +2141,62 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
|||||||
onRealtimeVoiceStop={handleRealtimeVoiceStop}
|
onRealtimeVoiceStop={handleRealtimeVoiceStop}
|
||||||
/>
|
/>
|
||||||
</View>
|
</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>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1947,6 +2241,19 @@ const styles = StyleSheet.create((theme: Theme) => ({
|
|||||||
inputWrapperReadOnly: {
|
inputWrapperReadOnly: {
|
||||||
borderStyle: "dotted",
|
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: {
|
textInputScrollWrapper: {
|
||||||
flexShrink: 1,
|
flexShrink: 1,
|
||||||
position: "relative",
|
position: "relative",
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useCallback, useMemo, type ReactNode, type Ref } from "react";
|
||||||
|
import { View, type ViewProps } from "react-native";
|
||||||
|
import { Maximize2, Minimize2 } from "lucide-react-native";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { isWeb } from "@/constants/platform";
|
||||||
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
|
import { ComposerFullscreenHost } from "./fullscreen-host";
|
||||||
|
|
||||||
|
function keepEditorFocused(event: import("react-native").PointerEvent) {
|
||||||
|
if (isWeb) event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Both presentations receive the same editing surface and send capability. */
|
||||||
|
export function ComposerInputPresentation({
|
||||||
|
fullscreen,
|
||||||
|
iconSize,
|
||||||
|
active,
|
||||||
|
surface,
|
||||||
|
toolbar,
|
||||||
|
send,
|
||||||
|
error,
|
||||||
|
onExit,
|
||||||
|
inputWrapperRef,
|
||||||
|
inputWrapperStyle,
|
||||||
|
inputPointerEvents,
|
||||||
|
attachmentSlot,
|
||||||
|
showFullscreenButton,
|
||||||
|
onEnter,
|
||||||
|
overlay,
|
||||||
|
}: {
|
||||||
|
fullscreen: boolean;
|
||||||
|
iconSize: number;
|
||||||
|
active: boolean;
|
||||||
|
surface: ReactNode;
|
||||||
|
toolbar: ReactNode;
|
||||||
|
send: ReactNode;
|
||||||
|
error: ReactNode;
|
||||||
|
onExit: () => void;
|
||||||
|
inputWrapperRef: Ref<View>;
|
||||||
|
inputWrapperStyle: ViewProps["style"];
|
||||||
|
inputPointerEvents: ViewProps["pointerEvents"];
|
||||||
|
attachmentSlot: ReactNode;
|
||||||
|
showFullscreenButton: boolean;
|
||||||
|
onEnter: () => void;
|
||||||
|
overlay: ReactNode;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
// ArrowUp ends at x19; the fullscreen glyphs end at x21 in Lucide's 24px viewBox.
|
||||||
|
// Compensate for that difference to align visible ink with Send, not just boxes.
|
||||||
|
const railAdjustment = iconSize / 12;
|
||||||
|
const maximizeIcon = useCallback(
|
||||||
|
(color: string) => <Maximize2 color={color} size={iconSize} />,
|
||||||
|
[iconSize],
|
||||||
|
);
|
||||||
|
const minimizeIcon = useCallback(
|
||||||
|
(color: string) => <Minimize2 color={color} size={iconSize} />,
|
||||||
|
[iconSize],
|
||||||
|
);
|
||||||
|
const enterStyle = useMemo(
|
||||||
|
() => [styles.fullscreenButton, { right: -6 + railAdjustment }],
|
||||||
|
[railAdjustment],
|
||||||
|
);
|
||||||
|
const exitStyle = useMemo(
|
||||||
|
() => [styles.iconButton, { marginRight: railAdjustment }],
|
||||||
|
[railAdjustment],
|
||||||
|
);
|
||||||
|
if (!fullscreen)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<View ref={inputWrapperRef} style={inputWrapperStyle} pointerEvents={inputPointerEvents}>
|
||||||
|
{attachmentSlot}
|
||||||
|
<View style={styles.inlineTextSurface}>
|
||||||
|
{surface}
|
||||||
|
{showFullscreenButton ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
accessibilityLabel={t("composer.input.fullscreen")}
|
||||||
|
leftIcon={maximizeIcon}
|
||||||
|
onPointerDown={keepEditorFocused}
|
||||||
|
onPress={onEnter}
|
||||||
|
style={enterStyle}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
{toolbar}
|
||||||
|
</View>
|
||||||
|
{overlay}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<ComposerFullscreenHost onExit={onExit} active={active}>
|
||||||
|
<View style={styles.fullscreen} testID="composer-fullscreen">
|
||||||
|
<View style={styles.exitRow}>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
accessibilityLabel={t("composer.input.exitFullscreen")}
|
||||||
|
onPointerDown={keepEditorFocused}
|
||||||
|
onPress={onExit}
|
||||||
|
style={exitStyle}
|
||||||
|
leftIcon={minimizeIcon}
|
||||||
|
hitSlop={8}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
{surface}
|
||||||
|
{error}
|
||||||
|
<View style={styles.sendRow}>{send}</View>
|
||||||
|
</View>
|
||||||
|
</ComposerFullscreenHost>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
inlineTextSurface: { flexShrink: 1, position: "relative" },
|
||||||
|
iconButton: {
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
fullscreenButton: {
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
padding: 0,
|
||||||
|
borderWidth: 0,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
},
|
||||||
|
fullscreen: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
padding: theme.spacing[4],
|
||||||
|
gap: theme.spacing[3],
|
||||||
|
},
|
||||||
|
exitRow: { alignItems: "flex-end", flexShrink: 0 },
|
||||||
|
sendRow: { flexShrink: 0, alignItems: "flex-end" },
|
||||||
|
}));
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user