Compare commits

..
Author SHA1 Message Date
Mohamed Boudra e791ab3df7 test(ios): run the native text probe on the main thread 2026-10-09 22:41:11 +02:00
Mohamed BoudraandOxxx f521abfb24 fix(ios): keep recycled text views at their container width
Apply the minimal candidate from #6364 and cover recycled geometry in the native probe. Document that the probe does not reproduce mounted Fabric clipping.

Co-authored-by: Oxxx <103127477+omcdowell@users.noreply.github.com>
2026-10-09 22:24:28 +02:00
Mohamed Boudra b89ebbede6 fix(ios): restore native text reflow for evaluation 2026-10-09 22:24:28 +02:00
147 changed files with 1959 additions and 7641 deletions
+1
View File
@@ -252,6 +252,7 @@
"packages/app/src/components/download-toast.tsx",
"packages/app/src/components/draggable-list.native.tsx",
"packages/app/src/components/headers/back-header.tsx",
"packages/app/src/components/headers/menu-header.tsx",
"packages/app/src/components/headers/screen-header.tsx",
"packages/app/src/components/host-status-dot.tsx",
"packages/app/src/components/hosts/host-picker.tsx",
-2
View File
@@ -2,7 +2,6 @@ const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
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 withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
@@ -146,7 +145,6 @@ export default {
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
withAndroidOptionalCamera,
[
"expo-splash-screen",
{
-61
View File
@@ -1,61 +0,0 @@
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 expect(row).toHaveAttribute("aria-selected", "true");
await expect(row).toHaveCSS("background-color", "rgb(212, 212, 216)");
await expect(row).toHaveCSS("background-color", "rgb(228, 228, 231)");
await page.screenshot({
path: testInfo.outputPath("light-selected-workspace.png"),
fullPage: true,
@@ -1,96 +0,0 @@
import type { Page, TestInfo } from "@playwright/test";
import { test, expect } from "../support/fixtures";
import { seedWorkspace } from "../support/helpers/seed-client";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { getServerId } from "../support/helpers/server-id";
const labels = {
long: {
title: "workspace-with-a-very-long-release-and-investigation-name",
project: "project-with-a-very-long-customer-integration-name",
},
short: { title: "main", project: "Paseo" },
};
async function openNamedWorkspace(page: Page, names: typeof labels.long) {
const workspace = await seedWorkspace({ repoPrefix: "header-width-", title: names.title });
try {
await workspace.client.renameProject(workspace.projectId, names.project);
const agent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: "Header layout check",
modeId: "load-test",
model: "e2e-fast-stream",
});
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: agent.id });
await expect(page.getByTestId("workspace-header-title")).toHaveText(names.title);
await expect(page.getByTestId("workspace-header-subtitle")).toHaveText(names.project);
await expect(
page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true }),
).toHaveCount(1);
return workspace;
} catch (error) {
await workspace.cleanup();
throw error;
}
}
async function expectHeaderContentsFit(page: Page, info: TestInfo, long: boolean) {
const title = page.getByTestId("workspace-header-title");
const project = page.getByTestId("workspace-header-subtitle");
const badge = page.getByTestId(`host-badge-${getServerId()}`).filter({ visible: true });
const trigger = page.getByRole("button", { name: /Switch tabs/ });
const measure = async () => ({
title: await title.boundingBox(),
project: await project.boundingBox(),
badge: await badge.boundingBox(),
icon: await badge.locator("svg").boundingBox(),
trigger: await trigger.boundingBox(),
titleTruncated: await title.evaluate((el) => el.scrollWidth > el.clientWidth),
projectTruncated: await project.evaluate((el) => el.scrollWidth > el.clientWidth),
});
await page.screenshot({ path: info.outputPath("header.png") });
await expect(async () => {
const bounds = await measure();
expect(bounds.title).not.toBeNull();
expect(bounds.project).not.toBeNull();
expect(bounds.badge).not.toBeNull();
expect(bounds.icon).not.toBeNull();
expect(bounds.trigger).not.toBeNull();
// Approved geometry: 12px outside margin, 46px leading island, 8px title gap.
expect(bounds.title!.x).toBe(66);
expect(bounds.project!.x).toBe(bounds.title!.x);
expect(bounds.trigger!.width).toBe(32);
expect(bounds.trigger!.height).toBe(32);
// The action frame starts 7px inside its island; preserve the 8px gap before it.
const contentRight = bounds.trigger!.x - 15;
for (const box of [bounds.title!, bounds.project!, bounds.badge!, bounds.icon!]) {
expect(box.x + box.width).toBeLessThanOrEqual(contentRight + 1);
}
expect(bounds.project!.width).toBeGreaterThan(20);
expect(bounds.icon!.width).toBe(12);
expect(bounds.badge!.x).toBeGreaterThan(bounds.project!.x + bounds.project!.width);
expect(bounds.titleTruncated).toBe(long);
expect(bounds.projectTruncated).toBe(long);
}).toPass({ timeout: 5_000 });
await info.attach("header-bounds", {
body: JSON.stringify(await measure(), null, 2),
contentType: "application/json",
});
}
for (const width of [320, 390]) {
for (const kind of ["long", "short"] as const) {
test(`compact header contains ${kind} labels at ${width}px`, async ({ page }, info) => {
await page.setViewportSize({ width, height: 844 });
const workspace = await openNamedWorkspace(page, labels[kind]);
try {
await expectHeaderContentsFit(page, info, kind === "long");
} finally {
await workspace.cleanup();
}
});
}
}
@@ -1,14 +1,7 @@
import type { Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
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";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -33,19 +26,14 @@ async function openComposerDiff(page: Page) {
await composerChangesPill(page).click();
}
async function seedChangedAgent(
repoPrefix: string,
featureValues?: Record<string, unknown>,
files: Array<{ path: string; content: string }> = [],
) {
async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
featureValues,
title: "Composer diff stat",
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
},
});
try {
@@ -168,691 +156,3 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
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,
);
}
@@ -1,55 +0,0 @@
import { expect, test, type Page } from "../support/fixtures";
import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
fillComposerDraft,
} 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();
}
});
}
@@ -96,64 +96,6 @@ async function seedAgentWithFileLink(input: LinkedFile) {
}
test.describe("CodeMirror workspace file editing", () => {
test("opens the non-ASCII file named by an assistant markdown link", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "encoded-markdown-link-",
title: "Non-ASCII file link",
initialPrompt: "Show the report link",
featureValues: { mockAssistantResponse: "[报告](docs/reports/开户赠金.md)" },
});
try {
await mkdir(path.join(session.cwd, "docs/reports"), { recursive: true });
await writeFile(path.join(session.cwd, "docs/reports/开户赠金.md"), "# Report opened\n");
await openAgentRoute(page, session);
await openAssistantMarkdownLink(page, "报告");
await expectFileTabOpen(page, "docs/reports/开户赠金.md");
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText("Report opened", { exact: true }),
).toBeVisible();
await page.screenshot({ path: test.info().outputPath("non-ascii-file-opened.png") });
} finally {
await session.cleanup();
}
});
for (const order of ["raw-first", "href-first"] as const) {
test(`opens literal and encoded percent filenames independently ${order}`, async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "percent-link-cache-",
title: "Distinct file links",
initialPrompt: "Show both filenames",
featureValues: {
mockAssistantResponse: "Raw `literal%20.md`. [Decoded](literal%20.md).",
},
});
try {
await writeFile(path.join(session.cwd, "literal%20.md"), "# Literal filename\n");
await writeFile(path.join(session.cwd, "literal .md"), "# Decoded filename\n");
await openAgentRoute(page, session);
const links = order === "raw-first" ? ["raw", "href"] : ["href", "raw"];
for (const link of links) {
await returnToAssistant(page, session.agentId);
if (link === "raw") {
await openAssistantInlinePath(page, "literal%20.md");
await expectFileTabOpen(page, "literal%20.md");
await expectPreviewText(page, "Literal filename");
} else {
await openAssistantMarkdownLink(page, "Decoded");
await expectFileTabOpen(page, "literal .md");
await expectPreviewText(page, "Decoded filename");
}
}
} finally {
await session.cleanup();
}
});
}
test("jumps to each relative reference to the same file", async ({ page }) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "relative-reference-qa-",
@@ -815,24 +757,3 @@ test.describe("CodeMirror workspace file editing", () => {
await expect(page.getByLabel("Vim mode NORMAL")).toBeVisible();
});
});
async function openAssistantMarkdownLink(page: Page, name: string): Promise<void> {
await page.getByRole("link", { name, exact: true }).first().click();
}
async function returnToAssistant(page: Page, agentId: string): Promise<void> {
await page.getByTestId(`workspace-tab-agent_${agentId}`).filter({ visible: true }).click();
}
async function openAssistantInlinePath(page: Page, token: string): Promise<void> {
await page.getByText(token, { exact: true }).click();
}
async function expectPreviewText(page: Page, text: string): Promise<void> {
await expect(
page
.getByTestId("workspace-file-pane")
.filter({ visible: true })
.getByText(text, { exact: true }),
).toBeVisible();
}
@@ -1,4 +1,3 @@
import type { Page, TestInfo } from "@playwright/test";
import { randomUUID } from "node:crypto";
import { test, expect } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -7,31 +6,6 @@ import type { SeedDaemonClient } from "../support/helpers/seed-client";
const MOBILE_VIEWPORT = { width: 390, height: 844 };
test.use({ viewport: MOBILE_VIEWPORT, deviceScaleFactor: 2 });
async function openTabSwitcher(page: Page) {
await page.getByRole("button", { name: /Switch tabs/ }).click();
const footer = page.getByRole("button", { name: "New terminal", exact: true });
await expect(footer).toBeVisible();
// Visibility alone passes while the sheet is still travelling up from below the viewport.
await expect
.poll(async () => {
const bounds = await footer.boundingBox();
return bounds ? bounds.y + bounds.height : Number.POSITIVE_INFINITY;
})
.toBeLessThanOrEqual(MOBILE_VIEWPORT.height);
}
async function captureScreen(page: Page, testInfo: TestInfo, name: string) {
await page.screenshot({ path: testInfo.outputPath(name + ".png") });
}
async function createTabFromSwitcher(page: Page, kind: "New agent" | "New terminal") {
await openTabSwitcher(page);
await page.getByRole("button", { name: kind, exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promise<string | null> {
const result = await client.fetchAgents({ scope: "active" });
return result.entries.find((entry) => entry.agent.id === agentId)?.agent.title ?? null;
@@ -46,7 +20,7 @@ async function fetchAgentTitle(client: SeedDaemonClient, agentId: string): Promi
test.describe("Workspace session rename (compact tab switcher)", () => {
test("renames an agent session from the switcher's per-session actions sheet", async ({
page,
}, testInfo) => {
}) => {
test.setTimeout(120_000);
await page.setViewportSize(MOBILE_VIEWPORT);
@@ -62,10 +36,8 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
timeout: 30_000,
});
await captureScreen(page, testInfo, "compact-chat-header");
// Open the sessions dropdown (a bottom sheet on compact).
await openTabSwitcher(page);
await page.getByRole("button", { name: /Switch tabs/ }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" }).first()).toBeVisible(
{
timeout: 10_000,
@@ -93,58 +65,12 @@ test.describe("Workspace session rename (compact tab switcher)", () => {
await renameModalSubmit(page, modalPrefix).click();
await expect(input).toHaveCount(0, { timeout: 15_000 });
await expect(page.getByText(renamed, { exact: true })).toBeVisible({
await expect(page.getByTestId("workspace-tab-switcher-trigger")).toContainText(renamed, {
timeout: 15_000,
});
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await expect.poll(() => fetchAgentTitle(session.client, session.agentId)).toBe(renamed);
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(1);
await expect(page.getByText("Rename agent", { exact: true })).toBeHidden();
await captureScreen(page, testInfo, "renamed-tab-in-switcher");
} finally {
await session.cleanup();
}
});
});
test("switcher footer creates tabs and returns to the existing chat", async ({
page,
}, testInfo) => {
const session = await seedMockAgentWorkspace({
repoPrefix: "compact-header-actions-",
title: "Existing chat",
});
try {
await openAgentRoute(page, session);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("1");
await createTabFromSwitcher(page, "New terminal");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("2");
await expect(page.getByTestId("terminal-surface").filter({ visible: true })).toHaveCount(1);
await captureScreen(page, testInfo, "compact-terminal-header");
await createTabFromSwitcher(page, "New agent");
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await expect(
page.getByRole("textbox", { name: "Message agent..." }).filter({ visible: true }),
).toHaveCount(1);
await captureScreen(page, testInfo, "compact-draft-header");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await captureScreen(page, testInfo, "switcher-three-tabs");
await page.getByText("Existing chat", { exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Switch tabs/ })).toHaveText("3");
await openTabSwitcher(page);
await expect(page.getByText("Existing chat", { exact: true })).toBeVisible();
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 10, y: 10 } });
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
await captureScreen(page, testInfo, "returned-chat-header");
} finally {
await session.cleanup();
}
});
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "printf '__AD_%s__\\n' OUTPUT_OK" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"terminal-surface\"" 10000
press "id=\"terminal-surface\""
type "clear; yes SAME | head -41; echo AD_SCROLL_MARKER; yes SAME | head -38" --delay-ms 0
@@ -2,9 +2,9 @@ context platform=ios timeout=60000 retries=1
env APP_ID=sh.paseo.debug
open "${APP_ID}"
wait "id=\"workspace-tab-switcher-trigger\"" 20000
press "id=\"workspace-tab-switcher-trigger\""
press "id=\"workspace-tab-switcher-new-terminal\""
wait "id=\"workspace-header-menu-trigger\"" 20000
press "id=\"workspace-header-menu-trigger\""
press "id=\"workspace-header-new-terminal\""
wait "id=\"terminal-keyboard-toggle\"" 10000
press "id=\"terminal-keyboard-toggle\""
type "clear; printf ' PASEO_TARGET\\n'" --delay-ms 0
@@ -660,9 +660,9 @@ adb shell am start -a android.intent.action.VIEW -d "paseo://h/${SERVER_ID}/agen
ad wait "text=\"${AGENT_TITLE}\"" 10000
run_host_scenario chat
ad keyboard dismiss || true
ad press 'id="workspace-tab-switcher-trigger"' --settle
ad wait 'id="workspace-tab-switcher-new-agent"' 10000
ad press 'id="workspace-tab-switcher-new-agent"' --settle
ad press 'id="workspace-header-menu-trigger"' --settle
ad wait 'id="workspace-header-new-agent"' 10000
ad press 'id="workspace-header-new-agent"' --settle
ad wait 'text="New Agent"' 10000
run_host_scenario workspace-draft
ad keyboard dismiss || true
@@ -0,0 +1,31 @@
# Native iOS text geometry regression
Run on the Namespace Mac with a fresh simulator development build. Use the PID
returned by `xcrun simctl launch <your-task-simulator-UDID> sh.paseo.debug`.
Attach only to your task's app, while no device replay is running:
```sh
PASEO_NATIVE_TEXT_REFLOW_PID=12345 \
PASEO_NATIVE_TEXT_REFLOW_LOG=/Volumes/devbox/workspace/native-text-reflow.log \
bash packages/app/e2e/mobile/native-text-reflow/ios.sh
```
The probe constructs the installed `RNUITextView`, fills its real UIKit text view,
and resizes its content container from 500 to 240 and back to 500 points. It
asserts the inner frame follows both widths and the same selection survives.
It then recycles the view the way Fabric's pool does and remounts it 30 points
wider and at the same size; the inner frame must still match the container.
A zeroed frame on recycle comes back 30 or 0 points wide. This assertion checks
the intermediate recycled geometry; it does not reproduce visible clipping in a
mounted Fabric paragraph or exercise the subsequent `drawRect` call.
It uses public UIView/UITextView operations, without mocking React Native.
LLDB detaches after the probe. An assertion failure or evaluation error fails the
shell command; the log distinguishes them.
This covers native container resizing and selection retention. It does not cover
Fabric event delivery, chat row height, reader anchoring, or the on-screen copy
menu. Those require the real chat journey: read an older multiline response,
open/close Explorer, resize/rotate, inspect complete paragraphs and bullets,
retain expanded/collapsed details, and check a stationary reader's position.
Accessibility exposes full text even when glyphs are clipped, so a text wait
alone does not verify that journey.
@@ -0,0 +1,76 @@
import lldb
def __lldb_init_module(debugger, _internal_dict):
process = debugger.GetSelectedTarget().GetProcess()
main_threads = [thread for thread in process if thread.GetQueueName() == "com.apple.main-thread"]
if len(main_threads) != 1 or not process.SetSelectedThread(main_threads[0]):
raise RuntimeError("Could not select the UIKit main thread")
debugger.HandleCommand("expression -l objc++ -- @import UIKit")
frame = process.GetSelectedThread().GetSelectedFrame()
options = lldb.SBExpressionOptions()
options.SetLanguage(lldb.eLanguageTypeObjC_plus_plus)
is_main_thread = frame.EvaluateExpression("(BOOL)[NSThread isMainThread]", options)
if is_main_thread.GetError().Fail() or is_main_thread.GetValueAsUnsigned() != 1:
raise RuntimeError("Refusing to run UIKit probe outside the main thread")
result = frame.EvaluateExpression(
r'''({
UIView *host = (UIView *)[[NSClassFromString(@"RNUITextView") alloc] init];
auto hostFrame = host.frame;
hostFrame.size.width = 500;
hostFrame.size.height = 200;
host.frame = hostFrame;
UIView *container = (UIView *)[host performSelector:@selector(contentView)];
container.frame = host.bounds;
UITextView *text = nil;
for (UIView *child in host.subviews) {
if ([child isKindOfClass:UITextView.class]) text = (UITextView *)child;
for (UIView *nested in child.subviews) {
if ([nested isKindOfClass:UITextView.class]) text = (UITextView *)nested;
}
}
text.frame = container.bounds;
text.font = [UIFont systemFontOfSize:16];
text.text = @"A realistic multiline paragraph must reflow when Explorer narrows the available chat width, then fill the restored width without replacing the text view or discarding a selection.";
auto range = text.selectedRange;
range.location = 12;
range.length = 9;
text.selectedRange = range;
auto frame = container.frame;
frame.size.width = 240;
container.frame = frame;
[container layoutIfNeeded];
CGFloat narrow = text.frame.size.width;
auto selection = text.selectedRange;
frame.size.width = 500;
container.frame = frame;
[container layoutIfNeeded];
CGFloat wide = text.frame.size.width;
auto keptSelection = text.selectedRange;
// Fabric recycles the view: the container keeps its size in the pool,
// and the next paragraph mounts it 30 points wider (a list-item paragraph
// reused by a full-width one) or at the same size.
[host performSelector:@selector(prepareForRecycle)];
frame.size.width = 530;
container.frame = frame;
[container layoutIfNeeded];
CGFloat reusedWider = text.frame.size.width;
[host performSelector:@selector(prepareForRecycle)];
container.frame = frame;
[container layoutIfNeeded];
CGFloat reusedSame = text.frame.size.width;
BOOL ok = text != nil && narrow == 240 && wide == 500 &&
reusedWider == 530 && reusedSame == 530 &&
selection.location == keptSelection.location &&
selection.length == keptSelection.length &&
selection.location == 12 && selection.length == 9;
[NSString stringWithFormat:@"NATIVE_REFLOW_%@ narrow=%.0f wide=%.0f recycled=%.0f,%.0f selection=%lu:%lu",
ok ? @"PASS" : @"FAIL", narrow, wide, reusedWider, reusedSame,
(unsigned long)keptSelection.location,
(unsigned long)keptSelection.length];
})''',
options,
)
if result.GetError().Fail():
raise RuntimeError(result.GetError().GetCString())
print(result.GetObjectDescription())
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
: "${PASEO_NATIVE_TEXT_REFLOW_PID:?Set the PID of your task-owned Paseo iOS simulator app}"
: "${PASEO_NATIVE_TEXT_REFLOW_LOG:?Set the test output log path}"
if [[ "$(ps -p "$PASEO_NATIVE_TEXT_REFLOW_PID" -o comm=)" != *PaseoDebug.app/PaseoDebug ]]; then
echo "Expected a running PaseoDebug simulator app" >&2
exit 1
fi
probe_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
xcrun lldb --batch -p "$PASEO_NATIVE_TEXT_REFLOW_PID" \
-o "command script import $probe_dir/ios.py" \
-o "process detach" -o quit > "$PASEO_NATIVE_TEXT_REFLOW_LOG" 2>&1
grep '^NATIVE_REFLOW_' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
grep -q '^NATIVE_REFLOW_PASS narrow=240 wide=500 recycled=530,530 selection=12:9$' "$PASEO_NATIVE_TEXT_REFLOW_LOG"
@@ -6,8 +6,6 @@ type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
text: unknown;
attachments: unknown;
agentId: string;
/** Absent when the client sends into an idle agent. */
activeTurnBehavior?: string;
@@ -31,8 +29,6 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
return {
type: "send_agent_message_request",
requestId: request.requestId,
text: request.text,
attachments: request.attachments,
agentId: request.agentId,
activeTurnBehavior:
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
@@ -78,7 +74,6 @@ export async function gateNextAgentMessage(page: Page) {
return {
waitForRequest,
requestCount: () => requests.length,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
@@ -30,6 +30,7 @@ async function capture(
name: string,
subject: Locator = headerButtons(page),
) {
await expect(headerButtons(page)).toHaveCount(1);
await expect(subject).toBeInViewport();
await waitForSettledPosition(subject);
const file = info.outputPath(`${name}.png`);
@@ -79,7 +80,6 @@ export async function withButtonAuthorExample(
refreshFromHeaderAndComposer: () =>
test.step("icon-only header action and labeled composer action update in place", async () => {
await exampleCommand(page, "action");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(headerButtons(page)).toHaveAccessibleName("Refresh workspace (1)");
await page.getByRole("button", { name: "Refresh context", exact: true }).click();
@@ -88,13 +88,7 @@ export async function withButtonAuthorExample(
).toContainText("Refreshed · 2");
await capture(page, info, "01-actions");
await page.setViewportSize(COMPACT);
await expect(headerButtons(page)).toHaveCount(0);
await capture(
page,
info,
"02-compact-actions",
page.getByRole("button", { name: "Refresh context", exact: true }),
);
await capture(page, info, "02-compact-actions");
}),
useMenusAndToggleButtons: () =>
test.step("named menus include separators, nested actions, disabled items, and custom content", async () => {
@@ -134,8 +128,7 @@ export async function withButtonAuthorExample(
await capture(page, info, "05-disabled");
await exampleCommand(page, "enable");
await page.setViewportSize(COMPACT);
await expect(headerButtons(page)).toHaveCount(0);
await page.getByRole("button", { name: "Composer tools", exact: true }).click();
await headerButtons(page).click();
await expect(
page.getByRole("menuitem", { name: "Refresh workspace", exact: true }),
).toBeInViewport();
@@ -150,7 +143,6 @@ export async function withButtonAuthorExample(
inspectLiveWorkspaceDetails: () =>
test.step("custom icons and content react to workspace changes and adapt to sheets", async () => {
await exampleCommand(page, "popover");
await expect(headerButtons(page)).toHaveCount(1);
await headerButtons(page).click();
await expect(
page.getByRole("button", { name: "Close workspace details", exact: true }),
@@ -202,6 +202,31 @@ async function expectHeaderTextTruncated(page: Page) {
await expect.poll(async () => (await hostIcon.boundingBox())?.width).toBe(12);
}
async function holdButton(page: Page, button: Locator) {
await button.hover();
await page.mouse.down();
}
async function openCompactOverflowWithHeaderChrome(page: Page) {
const hamburger = page.getByRole("button", { name: "Open menu", exact: true });
const action = page.getByRole("button", { name: "Deploy application", exact: true });
const overflow = page
.getByRole("toolbar", { name: "Workspace actions", exact: true })
.getByRole("button", { name: "More actions", exact: true });
await expect(action).toHaveCSS("border-top-width", "0px");
await expect(overflow).toHaveCSS("border-top-width", "0px");
await hamburger.hover();
const highlight = await hamburger.evaluate(
(element) => getComputedStyle(element).backgroundColor,
);
await action.hover();
await expect(action).toHaveCSS("background-color", highlight);
await holdButton(page, overflow);
await expect(overflow).toHaveCSS("background-color", highlight);
await expectSurfaceVisible(page);
await page.mouse.up();
}
export async function withButtonShowcase(
page: Page,
run: (buttons: {
@@ -383,7 +408,7 @@ export async function withButtonShowcase(
);
}),
openCompactSheets: () =>
test.step("compact header omits plugin actions while composer triggers open sheets", async () => {
test.step("compact triggers and overflow open sheets with working plugin context", async () => {
await page.setViewportSize(COMPACT);
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
await expect(
@@ -401,12 +426,39 @@ export async function withButtonShowcase(
"Button showcase with a long workspace title",
);
await expectHeaderTextTruncated(page);
await expectSurfaceVisible(page);
await openCompactOverflowWithHeaderChrome(page);
await expect(
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
page.getByRole("menuitem", { name: "Production locked", exact: true }),
).toBeDisabled();
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Production locked", exact: true }),
);
await choose(page, "Header checks");
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Deployment details");
await expectDetails(page);
await expectSurfaceVisible(
page,
page.getByRole("button", { name: "Close deployment details", exact: true }),
);
await press(page, "Close deployment details");
await showcase.setTitle("Compact checks");
await press(page, "Header checks");
await expect(
page
.getByRole("button", { name: "Header checks", exact: true })
.getByTestId("plugin-button-chevron"),
).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Open menu", exact: true }),
).toBeInViewport();
await expectSurfaceVisible(
page,
page.getByRole("menuitem", { name: "Run checks", exact: true }),
);
await choose(page, "Run checks");
await showcase.setTitle("Button showcase");
await press(page, "Composer status");
await expectDetails(page);
@@ -1,24 +0,0 @@
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;
@@ -42,7 +42,6 @@ import {
type HistoryStartPaginationInput,
type HistoryStartPaginationTransition,
} from "./history-start-pagination";
import { useStreamTopOverlayInset } from "./top-overlay";
import {
createHistoryStartSettleScheduler,
type HistoryStartSettleScheduler,
@@ -275,20 +274,12 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
Platform.OS === "android" && bottomAnchorController.mode === "sticky-bottom"
? undefined
: DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION;
const topOverlayInset = useStreamTopOverlayInset();
// The list is inverted, so its content's bottom padding is the visual top.
const listContentContainerStyle = useMemo(
() => [
baseListContentContainerStyle,
{
paddingBottom: streamKeyboardInset.contentContainerPaddingBottom + topOverlayInset,
},
],
[
baseListContentContainerStyle,
streamKeyboardInset.contentContainerPaddingBottom,
topOverlayInset,
{ paddingBottom: streamKeyboardInset.contentContainerPaddingBottom },
],
[baseListContentContainerStyle, streamKeyboardInset.contentContainerPaddingBottom],
);
const listInsetProps = useMemo(
() =>
@@ -1,14 +0,0 @@
import { createContext, useContext } from "react";
import { isNative } from "@/constants/platform";
/** The native inverted viewport reserves visual-top clearance; web stays in flow. */
export const supportsStreamTopOverlay = isNative;
const StreamTopOverlayContext = createContext(0);
/** Visual-top clearance supplied by the containing layout, in points. */
export const StreamTopOverlayProvider = StreamTopOverlayContext.Provider;
export function useStreamTopOverlayInset(): number {
return useContext(StreamTopOverlayContext);
}
+1 -1
View File
@@ -35,7 +35,7 @@ import { ChangelogHost } from "@/changelog";
import { AppDiagnosticHost } from "@/components/app-diagnostic-host";
import { AppearanceStyleBoundary } from "@/components/appearance-style-boundary";
import { LeftSidebar } from "@/components/left-sidebar";
import { WindowSidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import { WindowSidebarMenuToggle } from "@/components/headers/menu-header";
import { DesktopWindowControls } from "@/components/desktop/window-controls";
import { SidebarModelProvider } from "@/components/sidebar/sidebar-model";
import { WorkspacePinShortcutHandler } from "@/components/workspace-pin-shortcut-handler";
@@ -417,128 +417,6 @@ describe("parseAssistantFileLink", () => {
}
expect(() => parseFileProtocolUrl("file:///tmp/100% packet loss")).not.toThrow();
});
it("decodes percent-encoded relative markdown hrefs", () => {
// markdown-it percent-encodes non-ASCII characters in hrefs, so a link
// written as [x](docs/reports/开户赠金.md) arrives here percent-encoded.
expect(
parseAssistantFileLink("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md",
path: "/Users/test/project/docs/reports/开户赠金.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded spaces in relative markdown hrefs", () => {
expect(
parseAssistantFileLink("docs/my%20file.md#L5", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "docs/my%20file.md#L5",
path: "/Users/test/project/docs/my file.md",
lineStart: 5,
lineEnd: undefined,
});
});
it("decodes percent-encoded absolute hrefs with line suffixes", () => {
expect(
parseAssistantFileLink("/tmp/%E5%BC%80%E6%88%B7.md:3", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "/tmp/%E5%BC%80%E6%88%B7.md:3",
path: "/tmp/开户.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("decodes percent-encoded home-relative hrefs", () => {
expect(
parseAssistantFileLink("~/notes/%E5%BC%80%E6%88%B7.md", {
workspaceRoot: "/Users/test/project",
decodeHref: true,
}),
).toEqual({
raw: "~/notes/%E5%BC%80%E6%88%B7.md",
path: "~/notes/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
});
it("decodes percent-encoded Windows hrefs", () => {
expect(
parseAssistantFileLink("C:/repo/my%20dir/app.tsx#L8", {
workspaceRoot: "C:/repo",
decodeHref: true,
}),
).toEqual({
raw: "C:/repo/my%20dir/app.tsx#L8",
path: "C:/repo/my dir/app.tsx",
lineStart: 8,
lineEnd: undefined,
});
});
it("keeps literal percent signs in raw paths that are not hrefs", () => {
// Inline-code and linkified tokens are raw file paths: a real file named
// "my%20file.md" must not be decoded into "my file.md".
expect(
parseAssistantFileLink("docs/my%20file.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/my%20file.md:3", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/my%20file.md:3",
path: "/tmp/my%20file.md",
lineStart: 3,
lineEnd: undefined,
});
});
it("keeps non-ASCII characters in raw absolute paths", () => {
// `new URL` percent-encodes non-ASCII, so raw paths must not be routed
// through URL parsing — `/tmp/开户.md` must round-trip unchanged.
expect(
parseAssistantFileLink("/tmp/开户.md", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md",
path: "/tmp/开户.md",
lineStart: undefined,
lineEnd: undefined,
});
expect(
parseAssistantFileLink("/tmp/开户.md#L4", {
workspaceRoot: "/Users/test/project",
}),
).toEqual({
raw: "/tmp/开户.md#L4",
path: "/tmp/开户.md",
lineStart: 4,
lineEnd: undefined,
});
});
});
describe("normalizeInlinePathTarget", () => {
@@ -591,23 +469,3 @@ describe("normalizeInlinePathTarget", () => {
});
});
});
describe("encoded filename line markers", () => {
it.each(["docs/report.md%3A12", "docs/report.md%2812%2C4%29"])(
"keeps encoded line syntax in the filename %s",
(href) => {
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
}),
).toMatchObject({ path: `/workspace/${decodeURIComponent(href)}` });
expect(
parseAssistantFileLink(href, {
workspaceRoot: "/workspace",
decodeHref: true,
})?.lineStart,
).toBeUndefined();
},
);
});
+38 -98
View File
@@ -80,13 +80,6 @@ const ASSISTANT_FILE_EXTENSIONS = new Set([
export interface AssistantHrefParseOptions {
workspaceRoot?: string;
/**
* True only when `value` is a markdown href — the renderer percent-encodes
* non-ASCII and space characters, so the path must be decoded. Raw path
* text (inline code, linkified tokens) is not encoded; decoding it would
* send a real file named `my%20file.md` to `my file.md`.
*/
decodeHref?: boolean;
}
export type AssistantFileLinkClassification =
@@ -241,20 +234,13 @@ export function parseFileProtocolUrl(value: string): InlinePathTarget | null {
};
}
function parseAssistantInlinePathLink(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
function parseAssistantInlinePathLink(value: string): InlinePathTarget | null {
const inlinePathTarget = parseInlinePathToken(value);
if (!inlinePathTarget) {
return null;
}
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
const normalizedPath = normalizePathToken(inlinePathTarget.path);
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
@@ -291,7 +277,7 @@ export function classifyAssistantFileLink(
return null;
}
if (isAmbiguousWorkspaceCandidate(trimmed, target, options)) {
if (isAmbiguousWorkspaceCandidate(trimmed, target, options.workspaceRoot)) {
return {
kind: "ambiguousFileCandidate",
target,
@@ -322,19 +308,32 @@ export function parseAssistantFileLink(
return null;
}
const inlinePathTarget = parseAssistantInlinePathLink(trimmed, options);
const inlinePathTarget = parseAssistantInlinePathLink(trimmed);
if (inlinePathTarget) {
return inlinePathTarget;
}
const windowsTarget = parseWindowsFileLink(trimmed, value, options);
if (windowsTarget) {
return windowsTarget;
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (windowsPathMatch) {
const normalizedPath = normalizePathToken(windowsPathMatch[1] ?? "");
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw: value,
path: normalizedPath,
...lines,
};
}
const relativeTarget = parseWorkspaceRelativeFileLink(trimmed, {
workspaceRoot: options.workspaceRoot,
decodeHref: options.decodeHref,
});
if (relativeTarget) {
return relativeTarget;
@@ -344,33 +343,19 @@ export function parseAssistantFileLink(
return null;
}
// Hrefs go through URL parsing so `%`-escapes and `.`/`..` segments
// normalize before decode. Raw paths must not: `new URL` percent-encodes
// non-ASCII characters, which would corrupt `/tmp/开户.md` into the
// still-encoded name.
let pathPart: string;
let hashPart: string;
if (options.decodeHref) {
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
pathPart = safeDecodeURIComponent(parsedUrl.pathname);
hashPart = parsedUrl.hash;
} else {
const hashIndex = trimmed.indexOf("#");
pathPart = hashIndex >= 0 ? trimmed.slice(0, hashIndex) : trimmed;
hashPart = hashIndex >= 0 ? trimmed.slice(hashIndex) : "";
let parsedUrl: URL;
try {
parsedUrl = new URL(trimmed, "http://paseo.invalid");
} catch {
return null;
}
const normalizedPath = normalizePathToken(pathPart);
const normalizedPath = normalizePathToken(safeDecodeURIComponent(parsedUrl.pathname));
if (!normalizedPath || !isAbsolutePath(normalizedPath)) {
return null;
}
const lines = parseLineFragment(hashPart);
const lines = parseLineFragment(parsedUrl.hash);
if (!lines) {
return null;
}
@@ -401,42 +386,11 @@ export function isFileLookingAssistantToken(value: string): boolean {
return isPlausibleAssistantLocalPath(path);
}
function parseWindowsFileLink(
trimmed: string,
raw: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): InlinePathTarget | null {
const windowsPathMatch = trimmed.match(/^([A-Za-z]:[\\/][^?#]*)(#[^?]+)?$/);
if (!windowsPathMatch) {
return null;
}
let pathPart = windowsPathMatch[1] ?? "";
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
const normalizedPath = normalizePathToken(pathPart);
if (!normalizedPath) {
return null;
}
const lines = parseLineFragment(windowsPathMatch[2] ?? "");
if (!lines) {
return null;
}
return {
raw,
path: normalizedPath,
...lines,
};
}
function parseWorkspaceRelativeFileLink(
value: string,
options: AssistantHrefParseOptions,
): InlinePathTarget | null {
const parsed = parseLocalPathParts(value, options);
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return null;
}
@@ -468,11 +422,7 @@ function parseWorkspaceRelativeFileLink(
function parseLocalPathParts(
value: string,
options: Pick<AssistantHrefParseOptions, "decodeHref"> = {},
): {
path: string;
lines: Pick<InlinePathTarget, "lineStart" | "lineEnd">;
} | null {
): { path: string; lines: Pick<InlinePathTarget, "lineStart" | "lineEnd"> } | null {
const normalized = normalizePathToken(value);
if (!normalized || normalized.includes("?")) {
return null;
@@ -486,19 +436,14 @@ function parseLocalPathParts(
return null;
}
// Split literal line markers before decoding; encoded punctuation belongs
// to the filename. Raw inline-code/path text stays untouched.
const inlinePathTarget = parseInlinePathToken(beforeHash);
if (inlinePathTarget) {
let pathPart = inlinePathTarget.path;
if (options.decodeHref) {
pathPart = safeDecodeURIComponent(pathPart);
}
if (!isPlausibleAssistantLocalPath(pathPart)) {
if (!isPlausibleAssistantLocalPath(inlinePathTarget.path)) {
return null;
}
return {
path: pathPart,
path: inlinePathTarget.path,
lines: {
lineStart: inlinePathTarget.lineStart,
lineEnd: inlinePathTarget.lineEnd,
@@ -510,17 +455,12 @@ function parseLocalPathParts(
return null;
}
let decodedBeforeHash = beforeHash;
if (options.decodeHref) {
decodedBeforeHash = safeDecodeURIComponent(beforeHash);
}
const plausiblePath = parseInlinePathToken(decodedBeforeHash)?.path ?? decodedBeforeHash;
if (!isPlausibleAssistantLocalPath(plausiblePath)) {
if (!isPlausibleAssistantLocalPath(beforeHash)) {
return null;
}
return {
path: decodedBeforeHash,
path: beforeHash,
lines: fragmentLines,
};
}
@@ -605,14 +545,14 @@ function isExternalHref(value: string): boolean {
function isAmbiguousWorkspaceCandidate(
value: string,
target: InlinePathTarget,
options: AssistantHrefParseOptions = {},
workspaceRoot?: string,
): boolean {
const normalizedWorkspaceRoot = normalizePathInput(options.workspaceRoot);
const normalizedWorkspaceRoot = normalizePathInput(workspaceRoot);
if (!normalizedWorkspaceRoot || !isAllowedAbsolutePath(target.path, normalizedWorkspaceRoot)) {
return false;
}
const parsed = parseLocalPathParts(value, options);
const parsed = parseLocalPathParts(value);
if (!parsed || isAbsolutePath(parsed.path)) {
return false;
}
@@ -1,8 +1,4 @@
import { describe, expect, it } from "vitest";
import MarkdownIt from "markdown-it";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import {
classifyForResolution,
fetchDaemonResolution,
@@ -18,25 +14,6 @@ const CONTEXT: AssistantFileLinkContext = {
workspaceRoot: "/Users/test/project",
};
it("opens the existing non-ASCII file named by a rendered markdown link", async () => {
const workspaceRoot = await mkdtemp(path.join(tmpdir(), "assistant-file-link-"));
try {
await mkdir(path.join(workspaceRoot, "docs/reports"), { recursive: true });
await writeFile(path.join(workspaceRoot, "docs/reports/开户赠金.md"), "report content");
const tokens = new MarkdownIt().parseInline("[报告](docs/reports/开户赠金.md)", {});
const href = tokens[0].children?.find((token) => token.type === "link_open")?.attrGet("href");
expect(href).toBe("docs/reports/%E5%BC%80%E6%88%B7%E8%B5%A0%E9%87%91.md");
const resolution = classifyForResolution({ href: href! }, { workspaceRoot });
expect(resolution.kind).toBe("resolved");
if (resolution.kind !== "resolved" || resolution.value.kind !== "file") {
throw new Error("Expected a directly resolved file link");
}
expect(await readFile(resolution.value.target.path, "utf8")).toBe("report content");
} finally {
await rm(workspaceRoot, { recursive: true, force: true });
}
});
function resolvedSuggestions(
entries: DirectorySuggestionResult["entries"],
): DirectorySuggestionResult {
@@ -75,27 +52,6 @@ const unavailableSuggestions: GetDirectorySuggestions = async () => {
};
describe("classifyForResolution", () => {
it.each([
["C:/repo/my%20file.md", undefined],
["C:\\repo\\my%20file.md", undefined],
["C:/repo/my%20file.md:12", 12],
["C:\\repo\\my%20file.md:12", 12],
])("keeps the literal filename in Windows inline code %s", (href, lineStart) => {
for (const text of [href, undefined]) {
const result = classifyForResolution(
{ href, text, sourceType: "inline-code" },
{ workspaceRoot: "C:/repo" },
);
expect(result).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my%20file.md", lineStart } },
});
}
expect(classifyForResolution({ href }, { workspaceRoot: "C:/repo" })).toMatchObject({
kind: "resolved",
value: { kind: "file", target: { path: "C:/repo/my file.md", lineStart } },
});
});
it("returns the directFile target synchronously", () => {
const result = classifyForResolution({ href: "src/components/message.tsx#L33" }, CONTEXT);
@@ -209,46 +165,6 @@ describe("classifyForResolution", () => {
expect(result).toEqual({ kind: "resolved", value: { kind: "ignored" } });
});
it("decodes percent-encoded markdown hrefs", () => {
const result = classifyForResolution({ href: "docs/my%20file.md" }, CONTEXT);
expect(result).toEqual({
kind: "resolved",
value: {
kind: "file",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my file.md",
lineStart: undefined,
lineEnd: undefined,
},
},
});
});
it("keeps literal percent signs in inline-code paths", () => {
const result = classifyForResolution(
{
href: "docs/my%20file.md",
text: "docs/my%20file.md",
sourceType: "inline-code",
},
CONTEXT,
);
expect(result).toEqual({
kind: "needsLookup",
ambiguousQuery: "docs/my%20file.md",
token: "docs/my%20file.md",
target: {
raw: "docs/my%20file.md",
path: "/Users/test/project/docs/my%20file.md",
lineStart: undefined,
lineEnd: undefined,
},
});
});
});
describe("fetchDaemonResolution", () => {
@@ -108,15 +108,13 @@ export function classifyForResolution(
source: AssistantFileLinkSource,
context: AssistantFileLinkContext,
): AssistantFileLinkResolution {
const tokenInfo = getAssistantFileLinkTokenInfo(source);
const token = tokenInfo.token.trim();
const token = getAssistantFileLinkToken(source).trim();
if (!token) {
return { kind: "resolved", value: { kind: "ignored" } };
}
const classification = classifyAssistantFileLink(token, {
workspaceRoot: context.workspaceRoot,
decodeHref: tokenInfo.fromHref,
});
if (!classification) {
return { kind: "resolved", value: { kind: "ignored" } };
@@ -150,26 +148,14 @@ export function classifyForResolution(
}
export function getAssistantFileLinkToken(source: AssistantFileLinkSource): string {
return getAssistantFileLinkTokenInfo(source).token;
}
// Markdown link hrefs are percent-encoded by the renderer; inline-code and
// linkified text are raw file paths that must not be decoded.
function getAssistantFileLinkTokenInfo(source: AssistantFileLinkSource): {
token: string;
fromHref: boolean;
} {
if (source.sourceType === "inline-code") {
return { token: source.text?.trim() || source.href, fromHref: false };
}
if (isLinkifiedSource(source)) {
if (isLinkifiedSource(source) || source.sourceType === "inline-code") {
const text = source.text?.trim();
if (text && isFileLookingAssistantToken(text)) {
return { token: text, fromHref: false };
return text;
}
}
return { token: source.href, fromHref: true };
return source.href;
}
export function getAmbiguousSuggestionQuery(
@@ -29,21 +29,14 @@ export interface AssistantFileLinkActions {
canResolveFile(source: AssistantFileLinkSource): boolean;
}
interface AssistantFileLinkLookupKey {
query: string;
path: string;
lineStart: number | null;
lineEnd: number | null;
}
type AssistantFileLinkQueryKey = readonly [
"assistantFileLink",
string | null,
string | null,
AssistantFileLinkLookupKey | null,
string,
];
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, null] as const;
const DISABLED_QUERY_KEY = ["assistantFileLink", null, null, ""] as const;
export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult {
const { t } = useTranslation();
@@ -66,7 +59,7 @@ export function useFileLink(source: AssistantFileLinkSource): UseFileLinkResult
? assistantFileLinkQueryKey({
serverId,
workspaceRoot,
resolution,
token: resolution.token,
})
: DISABLED_QUERY_KEY,
[resolution, serverId, workspaceRoot],
@@ -186,7 +179,7 @@ function openAssistantFileLink(input: {
const capturedQueryKey = assistantFileLinkQueryKey({
serverId: capturedConfig.serverId,
workspaceRoot: capturedConfig.workspaceRoot,
resolution: capturedResolution,
token: capturedResolution.token,
});
const run = async () => {
@@ -252,19 +245,9 @@ function useStableSource(source: AssistantFileLinkSource): AssistantFileLinkSour
function assistantFileLinkQueryKey(input: {
serverId?: string;
workspaceRoot?: string;
resolution: Extract<AssistantFileLinkResolution, { kind: "needsLookup" }>;
token: string;
}): AssistantFileLinkQueryKey {
return [
"assistantFileLink",
input.serverId ?? null,
input.workspaceRoot ?? null,
{
query: input.resolution.ambiguousQuery,
path: input.resolution.target.path,
lineStart: input.resolution.target.lineStart ?? null,
lineEnd: input.resolution.target.lineEnd ?? null,
},
];
return ["assistantFileLink", input.serverId ?? null, input.workspaceRoot ?? null, input.token];
}
async function dispatchResolvedLink(input: {
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
describe("getCompactSheetSafeAreaPadding", () => {
it("assigns safe clearance to the footer independently of decorative padding", () => {
@@ -46,3 +49,17 @@ describe("getCompactSheetSafeAreaPadding", () => {
).toEqual({});
});
});
describe("getBottomSheetVisibleContentHeight", () => {
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
const layout = {
containerHeight: 874,
contentPosition: 88,
handleHeight: 24,
keyboardHeight: 344,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
});
});
@@ -10,6 +10,28 @@ export interface CompactSheetSafeAreaPadding {
footerPaddingBottom?: number;
}
interface BottomSheetVisibleContentHeightInput {
containerHeight: number;
contentPosition: number;
handleHeight: number;
keyboardHeight: number;
isKeyboardVisible: boolean;
}
export function getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
handleHeight,
keyboardHeight,
isKeyboardVisible,
}: BottomSheetVisibleContentHeightInput): number {
"worklet";
return Math.max(
0,
containerHeight - contentPosition - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
);
}
export function getCompactSheetSafeAreaPadding({
isCompact,
isKeyboardVisible,
@@ -12,16 +12,22 @@ import {
useGlobalWebOverlayLayer,
useWebOverlayRegistration,
} from "../lib/overlay-root";
import { type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated from "react-native-reanimated";
import {
KEYBOARD_STATUS,
useBottomSheetInternal,
type BottomSheetBackgroundProps,
} from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { ArrowLeft, Search, X } from "lucide-react-native";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
type ContextBridge,
useIsolatedBottomSheetVisibility,
} from "@/components/ui/isolated-bottom-sheet-modal";
import { getCompactSheetSafeAreaPadding } from "@/components/adaptive-modal-sheet-layout";
import {
getBottomSheetVisibleContentHeight,
getCompactSheetSafeAreaPadding,
} from "@/components/adaptive-modal-sheet-layout";
import { ScrollView } from "@/components/ui/scroll-view";
import { isWeb } from "@/constants/platform";
import { useKeyboardVisibility } from "@/hooks/use-keyboard-visibility";
@@ -201,6 +207,10 @@ const styles = StyleSheet.create((theme) => ({
flex: 1,
minHeight: 0,
},
bottomSheetVisibleContent: {
minHeight: 0,
overflow: "hidden",
},
bottomSheetVisibleScroll: {
flex: 1,
minHeight: 0,
@@ -253,6 +263,40 @@ function SheetContent({ style, children }: { style: StyleProp<ViewStyle>; childr
return <View style={[styles.sheetContent, style]}>{children}</View>;
}
function BottomSheetVisibleContent({ children }: { children: ReactNode }) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleContentStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
if (containerHeight < 0 || handleHeight < 0) {
return { height: 0 };
}
const initialDetentPosition = animatedDetentsState.get().detents?.[0];
const contentPosition =
initialDetentPosition == null
? animatedPosition.get()
: Math.min(animatedPosition.get(), initialDetentPosition);
const keyboardState = animatedKeyboardState.get();
return {
height: getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
handleHeight,
keyboardHeight: keyboardState.heightWithinContainer,
isKeyboardVisible: keyboardState.status === KEYBOARD_STATUS.SHOWN,
}),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.bottomSheetVisibleContent, visibleContentStyle]}>
{children}
</Animated.View>
);
}
export function SheetHeaderView({
header,
onClose,
@@ -427,6 +471,8 @@ export interface AdaptiveModalSheetProps {
bodyStyle?: StyleProp<ViewStyle>;
/** Layout intent for the sheet body, composed over the sheet's own content inset. */
contentStyle?: StyleProp<ViewStyle>;
/** Size compact sheet content to the live snap height instead of its largest snap point. */
sizeContentToCurrentSnapPoint?: boolean;
/** Re-establishes caller-owned contexts inside the native or compact sheet portal. */
contextBridge?: ContextBridge | null;
}
@@ -447,6 +493,7 @@ export function AdaptiveModalSheet({
presentation,
contentStyle,
bodyStyle,
sizeContentToCurrentSnapPoint = true,
contextBridge = null,
}: AdaptiveModalSheetProps) {
const { theme } = useUnistyles();
@@ -472,15 +519,11 @@ export function AdaptiveModalSheet({
() => ({ paddingBottom: compactSafeAreaPadding.footerPaddingBottom ?? 0 }),
[compactSafeAreaPadding.footerPaddingBottom],
);
const footerView = useMemo(
() =>
footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null,
[footer, footerClearanceStyle, footerContainerStyle],
);
const footerView = footer ? (
<View style={footerClearanceStyle}>
<View style={[styles.footer, footerContainerStyle]}>{footer}</View>
</View>
) : null;
const handleIndicatorStyle = useMemo(
() => ({ backgroundColor: theme.colors.palette.zinc[600] }),
[theme.colors.palette.zinc],
@@ -585,6 +628,7 @@ export function AdaptiveModalSheet({
</View>
)}
</View>
{footerView}
</>
);
@@ -606,7 +650,11 @@ export function AdaptiveModalSheet({
accessible={false}
presentation={presentation}
>
<SheetVisibleFrame footer={footerView}>{sheetContent}</SheetVisibleFrame>
{sizeContentToCurrentSnapPoint ? (
<BottomSheetVisibleContent>{sheetContent}</BottomSheetVisibleContent>
) : (
sheetContent
)}
</IsolatedBottomSheetModal>
);
}
@@ -1,99 +0,0 @@
import { useCallback, useId, useState, type ReactNode } from "react";
import { View, type LayoutChangeEvent } from "react-native";
import Svg, { Defs, LinearGradient as SvgLinearGradient, Rect, Stop } from "react-native-svg";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
/** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */
const FADE_OVERHANG = 24;
interface FloatingHeaderLayoutProps {
header: ReactNode;
/** Float only when the content supports visual-top clearance. */
floating: boolean;
children: (topOverlayInset: number) => ReactNode;
}
/**
* Stacks a header above content, or floats it over the content behind a fade. Children keep the
* same tree position in both modes, so toggling `floating` never remounts them.
*/
export function FloatingHeaderLayout({ header, floating, children }: FloatingHeaderLayoutProps) {
const [headerHeight, setHeaderHeight] = useState(0);
const handleHeaderLayout = useCallback((event: LayoutChangeEvent) => {
setHeaderHeight(Math.round(event.nativeEvent.layout.height));
}, []);
return (
<View style={styles.root}>
{floating ? null : header}
<View style={styles.content}>{children(floating ? headerHeight : 0)}</View>
{floating ? (
<View style={styles.overlay} pointerEvents="box-none" onLayout={handleHeaderLayout}>
<FloatingHeaderFade />
{header}
</View>
) : null}
</View>
);
}
function FloatingHeaderFadeSvg({ gradientId, color }: { gradientId: string; color: string }) {
return (
<Svg width="100%" height="100%" preserveAspectRatio="none">
<Defs>
<SvgLinearGradient id={gradientId} x1="0%" y1="0%" x2="0%" y2="100%">
{/* Vary opacity rather than interpolating toward `transparent`, which crosses black in
some engines and leaves a grey fringe. */}
<Stop offset="0%" stopColor={color} stopOpacity={1} />
<Stop offset="50%" stopColor={color} stopOpacity={0.97} />
<Stop offset="75%" stopColor={color} stopOpacity={0.8} />
<Stop offset="100%" stopColor={color} stopOpacity={0} />
</SvgLinearGradient>
</Defs>
<Rect x="0" y="0" width="100%" height="100%" fill={`url(#${gradientId})`} />
</Svg>
);
}
const ThemedFloatingHeaderFadeSvg = withUnistyles(FloatingHeaderFadeSvg);
const workspaceSurfaceColorMapping = (theme: Theme) => ({
color: theme.colors.surfaceWorkspace,
});
function FloatingHeaderFade() {
// React-generated ids contain characters that are invalid inside SVG fragment references.
const gradientId = `floating-header-fade-${useId().replace(/[^a-zA-Z0-9_-]/g, "")}`;
return (
<View style={styles.fade} pointerEvents="none">
<ThemedFloatingHeaderFadeSvg
gradientId={gradientId}
uniProps={workspaceSurfaceColorMapping}
/>
</View>
);
}
const styles = StyleSheet.create({
root: {
flex: 1,
minHeight: 0,
},
content: {
flex: 1,
minHeight: 0,
},
overlay: {
position: "absolute",
top: 0,
left: 0,
right: 0,
},
fade: {
position: "absolute",
top: 0,
left: 0,
right: 0,
bottom: -FADE_OVERHANG,
},
});
@@ -6,11 +6,10 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { ArrowLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { CompactHeader } from "./compact-header";
import { useIsCompactFormFactor } from "@/constants/layout";
interface BackHeaderProps {
title?: string;
titleAccessory?: ReactNode;
rightContent?: ReactNode;
onBack?: () => void;
}
@@ -19,15 +18,7 @@ function goBack(): void {
router.back();
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="back" title={title} onBack={onBack} actions={rightContent} />;
}
return <DesktopBackHeader title={title} rightContent={rightContent} onBack={onBack} />;
}
function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({ title, titleAccessory, rightContent, onBack }: BackHeaderProps) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const handleBack = useCallback(() => {
@@ -51,6 +42,7 @@ function DesktopBackHeader({ title, rightContent, onBack }: BackHeaderProps) {
<ArrowLeft size={theme.iconSize.lg} color={theme.colors.foregroundMuted} />
</Pressable>
{title && <ScreenTitle>{title}</ScreenTitle>}
{titleAccessory}
</>
}
right={rightContent}
@@ -64,7 +56,10 @@ const styles = StyleSheet.create((theme) => ({
gap: theme.spacing[2],
},
backButton: {
padding: theme.spacing[2],
padding: {
xs: theme.spacing[3],
md: theme.spacing[2],
},
borderRadius: theme.borderRadius.lg,
},
}));
@@ -1,168 +0,0 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Text, View } from "react-native";
import { router } from "expo-router";
import { ArrowLeft } from "lucide-react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { SidebarMenuToggle } from "@/components/headers/sidebar-menu-toggle";
import {
IconButtonChromeProvider,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
import { HEADER_INNER_HEIGHT_MOBILE, HEADER_TOP_PADDING_MOBILE } from "@/constants/layout";
import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedArrowLeft = withUnistyles(ArrowLeft);
const ISLAND_PADDING = 7;
const NO_SHORTCUT: ShortcutKey[] = [];
interface CompactHeaderProps {
/** The leading island: the sidebar menu, or a back button. */
navigation: "menu" | "back";
/** Back button action. Defaults to navigating back. */
onBack?: () => void;
title?: string;
titleTestID?: string;
/** A line under the title. */
subtitle?: ReactNode;
/** Show a placeholder instead of the title while it loads. */
loading?: boolean;
/** Icon actions inherit the island chrome through the shared button primitives. */
actions?: ReactNode;
}
/**
* The phone header: the navigation button and the screen's actions sit in islands, with the title
* left-aligned between them. It has no background of its own, so it reads the same in flow and floating
* over content (see `FloatingHeaderLayout`). Its height matches the old flat header, so anything
* offset by the header height still lines up.
*/
export function CompactHeader({
navigation,
onBack,
title,
titleTestID,
subtitle,
loading = false,
actions,
}: CompactHeaderProps) {
const insets = useSafeAreaInsets();
const rootStyle = useMemo(
() => [styles.root, { paddingTop: insets.top + HEADER_TOP_PADDING_MOBILE }],
[insets.top],
);
return (
<IconButtonChromeProvider value="header-island">
<View style={rootStyle}>
<View style={styles.row}>
<View style={styles.island}>
{navigation === "menu" ? <SidebarMenuToggle /> : <BackButton onBack={onBack} />}
</View>
<View style={styles.titleGroup}>
<CompactHeaderTitle
loading={loading}
title={title}
titleTestID={titleTestID}
subtitle={subtitle}
/>
</View>
{actions ? <View style={styles.island}>{actions}</View> : null}
</View>
</View>
</IconButtonChromeProvider>
);
}
function CompactHeaderTitle({
loading,
title,
titleTestID,
subtitle,
}: Pick<CompactHeaderProps, "title" | "titleTestID" | "subtitle"> & { loading: boolean }) {
if (loading) {
return <View style={styles.titleSkeleton} />;
}
return (
<>
{title ? (
<Text style={styles.title} numberOfLines={1} testID={titleTestID}>
{title}
</Text>
) : null}
{subtitle}
</>
);
}
function BackButton({ onBack }: { onBack?: () => void }) {
const { t } = useTranslation();
const handlePress = useCallback(() => {
if (onBack) {
onBack();
return;
}
router.back();
}, [onBack]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("common.actions.back")}
tooltipKeys={NO_SHORTCUT}
tooltipSide="right"
testID="header-back-button"
accessibilityRole="button"
accessibilityLabel={t("common.actions.back")}
>
<ThemedArrowLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={mutedIconColorMapping}
/>
</HeaderToggleButton>
);
}
const styles = StyleSheet.create((theme) => ({
root: {
paddingHorizontal: theme.spacing[3],
},
row: {
height: HEADER_INNER_HEIGHT_MOBILE,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
},
titleGroup: {
flex: 1,
minWidth: 0,
// Both title lines receive the remaining width; text alignment does not size the slot.
alignItems: "stretch",
},
island: {
flexDirection: "row",
alignItems: "center",
padding: ISLAND_PADDING,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface2,
...theme.shadow.sm,
},
title: {
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.medium,
color: theme.colors.foreground,
textAlign: "left",
},
titleSkeleton: {
width: 140,
maxWidth: "100%",
height: 22,
borderRadius: theme.borderRadius.full,
backgroundColor: theme.colors.surface3,
opacity: 0.25,
},
}));
@@ -1,4 +1,4 @@
import { type ReactElement, type ReactNode } from "react";
import { useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View, type PressableProps, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -7,7 +7,7 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import { isWeb } from "@/constants/platform";
import {
iconButtonChromeFrameStyle,
useIconButtonChromeStyle,
iconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
interface HeaderToggleButtonState {
@@ -46,11 +46,17 @@ export function HeaderToggleButton({
? ({ "aria-expanded": expandedState } as Record<string, boolean>)
: null;
const combinedStyle = useIconButtonChromeStyle({
size: "large",
disabled: Boolean(disabled),
style,
});
const combinedStyle = useMemo(
() =>
({ hovered, pressed }: { hovered?: boolean; pressed: boolean }) =>
iconButtonChromeStyle({
size: "large",
state: { hovered: Boolean(hovered), pressed },
disabled: Boolean(disabled),
style,
}),
[disabled, style],
);
return (
<Tooltip delayDuration={tooltipDelayDuration} enabledOnDesktop enabledOnMobile={false}>
@@ -1,10 +1,16 @@
import type { ReactNode } from "react";
import { StyleSheet } from "react-native-unistyles";
import { useCallback, useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { ScreenTitle } from "./screen-title";
import { CompactHeader } from "./compact-header";
import { SidebarMenuToggle } from "./sidebar-menu-toggle";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import { iconButtonChromeGlyphSize } from "@/components/ui/icon-button-chrome";
interface MenuHeaderProps {
title?: string;
@@ -12,11 +18,130 @@ interface MenuHeaderProps {
borderless?: boolean;
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
const isCompact = useIsCompactFormFactor();
if (isCompact) {
return <CompactHeader navigation="menu" title={title} actions={rightContent} />;
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ color }: { color: string }) {
const lineStyle = useMemo(() => [styles.mobileMenuLine, { backgroundColor: color }], [color]);
const shortLineStyle = useMemo(
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, { backgroundColor: color }],
[color],
);
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo(
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
[],
);
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
) : (
<PanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
color={
extraMutedIdleIcon ? theme.colors.foregroundExtraMuted : theme.colors.foregroundMuted
}
/>
)}
</HeaderToggleButton>
);
}
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const isMobile = useIsCompactFormFactor();
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
const placeholderStyle = useMemo(
() => [headerIconSlotStyle.slot, resolvedStyle],
[resolvedStyle],
);
if (!isMobile && !ownsTopLeft) {
return null;
}
if (!isMobile && hasTopLeftWindowControls) {
return (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps) {
return (
<ScreenHeader
left={
@@ -33,7 +158,31 @@ export function MenuHeader({ title, rightContent, borderless }: MenuHeaderProps)
}
const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
left: {
gap: theme.spacing[2],
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
@@ -1,167 +0,0 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { PanelLeft } from "lucide-react-native";
import { HeaderToggleButton, headerIconSlotStyle } from "./header-toggle-button";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { useIsCompactFormFactor } from "@/constants/layout";
import { getShortcutOs } from "@/utils/shortcut-platform";
import { useHasWindowChromeObstruction, useOwnsWindowChromeCorner } from "@/utils/desktop-window";
import {
extraMutedIconColorMapping,
iconButtonChromeGlyphSize,
mutedIconColorMapping,
} from "@/components/ui/icon-button-chrome";
const ThemedPanelLeft = withUnistyles(PanelLeft);
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 1.5;
function MobileMenuIcon({ extraMuted }: { extraMuted: boolean }) {
const color = extraMuted ? styles.mobileMenuLineExtraMuted : styles.mobileMenuLineMuted;
const lineStyle = useMemo(() => [styles.mobileMenuLine, color], [color]);
const shortLineStyle = useMemo(
() => [styles.mobileMenuLine, styles.mobileMenuLineShort, color],
[color],
);
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={lineStyle} />
<View style={lineStyle} />
<View style={shortLineStyle} />
</View>
);
}
function SidebarMenuToggleButton({
isMobile,
extraMutedIdleIcon = false,
resolvedStyle,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: Omit<SidebarMenuToggleProps, "style"> & {
isMobile: boolean;
extraMutedIdleIcon?: boolean;
resolvedStyle: StyleProp<ViewStyle>;
}) {
const { t } = useTranslation();
const isOpen = usePanelStore((state) => selectIsAgentListOpen(state, { isCompact: isMobile }));
const toggleAgentListForLayout = usePanelStore((state) => state.toggleAgentListForLayout);
const toggleShortcutKeys = useMemo(
() => (getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."]),
[],
);
const handlePress = useCallback(() => {
toggleAgentListForLayout({ isCompact: isMobile });
}, [toggleAgentListForLayout, isMobile]);
const accessibilityState = useMemo(() => ({ expanded: isOpen }), [isOpen]);
return (
<HeaderToggleButton
onPress={handlePress}
tooltipLabel={t("shell.menu.toggleSidebar")}
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={resolvedStyle}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? t("shell.menu.close") : t("shell.menu.open")}
accessibilityState={accessibilityState}
>
{isMobile ? (
<MobileMenuIcon extraMuted={extraMutedIdleIcon} />
) : (
<ThemedPanelLeft
size={iconButtonChromeGlyphSize("large")}
strokeWidth={1.5}
uniProps={extraMutedIdleIcon ? extraMutedIconColorMapping : mutedIconColorMapping}
/>
)}
</HeaderToggleButton>
);
}
export function SidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const isMobile = useIsCompactFormFactor();
const ownsTopLeft = useOwnsWindowChromeCorner("top-left");
const hasTopLeftWindowControls = useHasWindowChromeObstruction("top-left");
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
const placeholderStyle = useMemo(
() => [headerIconSlotStyle.slot, resolvedStyle],
[resolvedStyle],
);
if (!isMobile && !ownsTopLeft) {
return null;
}
if (!isMobile && hasTopLeftWindowControls) {
return (
<View pointerEvents="none" style={placeholderStyle}>
<View style={styles.desktopMenuIconSpace} />
</View>
);
}
return <SidebarMenuToggleButton {...props} isMobile={isMobile} resolvedStyle={resolvedStyle} />;
}
export function WindowSidebarMenuToggle({ style, ...props }: SidebarMenuToggleProps = {}) {
const resolvedStyle = useMemo(() => [styles.leadingToggle, style], [style]);
return (
<SidebarMenuToggleButton
{...props}
isMobile={false}
extraMutedIdleIcon
resolvedStyle={resolvedStyle}
/>
);
}
const styles = StyleSheet.create((theme) => ({
leadingToggle: {
marginLeft: {
xs: 0,
md: -theme.spacing[2],
},
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
desktopMenuIconSpace: {
width: theme.iconSize.md,
height: theme.iconSize.md,
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineMuted: {
backgroundColor: theme.colors.foregroundMuted,
},
mobileMenuLineExtraMuted: {
backgroundColor: theme.colors.foregroundExtraMuted,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
+30 -25
View File
@@ -1,13 +1,5 @@
import { router } from "expo-router";
import {
CircleGauge,
FolderPlus,
GitBranch,
Server,
Settings,
X,
type LucideIcon,
} from "lucide-react-native";
import { CircleGauge, FolderPlus, GitBranch, Server, Settings, X } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
import {
@@ -34,7 +26,7 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator";
import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows";
import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu";
import { SidebarResizeHandle } from "@/components/sidebar-resize-handle";
import { DensityIcon } from "@/components/ui/density-icon";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
@@ -45,7 +37,6 @@ import {
type SidebarProjectEntry,
type SidebarWorkspaceEntry,
} from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import { useSidebarModel } from "@/components/sidebar/sidebar-model";
import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins";
import { RetainedPanelActivity } from "@/components/retained-panel";
@@ -289,25 +280,30 @@ function FooterIconButton({
onPress,
testID,
label,
icon,
icon: Icon,
iconSizeAdjustment = 0,
shortcutKeys,
theme,
}: {
onPress: () => void;
testID: string;
label: string;
icon: LucideIcon;
icon: typeof FolderPlus;
/** Only for a glyph that reads larger than the others at the same size. */
iconSizeAdjustment?: number;
shortcutKeys?: ReturnType<typeof useShortcutKeys>;
theme: SidebarTheme;
buttonRef?: RefObject<View | null>;
}) {
const isCompact = useIsCompactFormFactor();
const iconSize = isCompact ? theme.iconSize.lg : theme.iconSize.md;
return (
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<Pressable
ref={buttonRef}
style={styles.footerIconButton}
style={styles.footerIconButton(isCompact)}
testID={testID}
nativeID={testID}
collapsable={false}
@@ -317,11 +313,9 @@ function FooterIconButton({
onPress={onPress}
>
{({ hovered }) => (
<DensityIcon
icon={icon}
size="md"
sizeAdjustment={iconSizeAdjustment}
color={hovered ? "foreground" : "foregroundMuted"}
<Icon
size={iconSize + iconSizeAdjustment}
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
/>
)}
</Pressable>
@@ -334,10 +328,12 @@ function FooterIconButton({
}
function SidebarHostPicker({
theme,
label,
onAddHost,
onOpenHostSettings,
}: {
theme: SidebarTheme;
label: string;
onAddHost: () => void;
onOpenHostSettings: (serverId: string) => void;
@@ -381,6 +377,7 @@ function SidebarHostPicker({
icon={Server}
// Server's two boxes fill more of the square than the other glyphs.
iconSizeAdjustment={-1}
theme={theme}
/>
</HostPicker>
);
@@ -402,6 +399,7 @@ function IconTooltipContent({
}
function SidebarFooter({
theme,
handleOpenProject,
handleSettings,
labels,
@@ -409,6 +407,7 @@ function SidebarFooter({
handleOpenHostSettings,
onBeforeNavigate,
}: {
theme: SidebarTheme;
handleOpenProject: () => void;
handleSettings: () => void;
labels: {
@@ -437,9 +436,11 @@ function SidebarFooter({
label={labels.addProject}
icon={FolderPlus}
shortcutKeys={newAgentKeys}
theme={theme}
/>
<SidebarUsageIcon label={labels.usage} />
<SidebarUsageIcon label={labels.usage} theme={theme} />
<SidebarHostPicker
theme={theme}
label={labels.hosts}
onAddHost={handleAddHost}
onOpenHostSettings={handleOpenHostSettings}
@@ -452,6 +453,7 @@ function SidebarFooter({
label={labels.settings}
icon={Settings}
shortcutKeys={settingsKeys}
theme={theme}
/>
</View>
</View>
@@ -459,7 +461,7 @@ function SidebarFooter({
);
}
function SidebarUsageIcon({ label }: { label: string }) {
function SidebarUsageIcon({ label, theme }: { label: string; theme: SidebarTheme }) {
const openUsage = useOpenSidebarUsage();
return (
<FooterIconButton
@@ -467,6 +469,7 @@ function SidebarUsageIcon({ label }: { label: string }) {
testID="sidebar-usage-icon"
label={label}
icon={CircleGauge}
theme={theme}
/>
);
}
@@ -609,6 +612,7 @@ function MobileSidebar({
)}
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -785,6 +789,7 @@ function DesktopSidebar({
<SidebarCalloutSlot />
<SidebarFooter
theme={theme}
handleOpenProject={handleOpenProject}
handleSettings={handleSettings}
labels={labels}
@@ -952,14 +957,14 @@ const styles = StyleSheet.create((theme) => ({
paddingVertical: theme.spacing[1.5],
gap: 2,
},
footerIconButton: {
width: density.iconButton,
height: density.iconButton,
footerIconButton: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
}),
tooltipRow: {
flexDirection: "row",
alignItems: "center",
@@ -126,6 +126,9 @@ export function ProjectEditSheet({
onClose={handleClose}
footer={footer}
desktopMaxWidth={440}
// Bound the compact scroller to the live snap height so the footer stays
// on screen instead of being pushed past the bottom of the sheet.
sizeContentToCurrentSnapPoint
testID="project-edit-sheet"
>
<Field
@@ -30,7 +30,6 @@ import {
type ActiveWorkspaceSelection,
} from "@/stores/navigation-active-workspace-store";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { getSidebarRowBackdrop } from "@/components/sidebar/sidebar-row-backdrop";
@@ -115,7 +114,6 @@ import {
SidebarWorkspaceTrailingContent,
useSidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { DensityIcon } from "@/components/ui/density-icon";
import { PressHighlight } from "@/components/ui/press-highlight";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Shortcut } from "@/components/ui/shortcut";
@@ -824,10 +822,9 @@ function NewWorkspaceGhostRow({
{({ hovered, pressed }) => (
<>
<View style={styles.newWorkspaceGhostIconSlot}>
<DensityIcon
icon={Plus}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
<ThemedPlus
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
</View>
<Text
@@ -2522,7 +2519,7 @@ const styles = StyleSheet.create((theme) => ({
// the step in reads as belonging to that project. Padding rather than margin, so the hover and
// pressed fills stay the same box as every other row in the sidebar.
newWorkspaceGhostRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[4],
@@ -2542,27 +2539,27 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it.
newWorkspaceGhostIconSlot: {
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
newWorkspaceGhostText: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
newWorkspaceGhostTextHovered: {
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
color: theme.colors.foreground,
},
projectRow: {
position: "relative",
minHeight: density.rowHeight,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -2603,7 +2600,7 @@ const styles = StyleSheet.create((theme) => ({
},
projectTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -2684,7 +2681,7 @@ const styles = StyleSheet.create((theme) => ({
right: theme.spacing[2],
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -2,11 +2,16 @@ import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight } from "lucide-react-native";
import { Pressable, Text } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { DensityIcon } from "@/components/ui/density-icon";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import { density, densityFontSize } from "@/styles/density";
import type { Theme } from "@/styles/theme";
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
export function PinnedSectionHeader({
collapsed,
@@ -18,6 +23,7 @@ export function PinnedSectionHeader({
const { t } = useTranslation();
const isCompact = useIsCompactFormFactor();
const accessibilityState = useMemo(() => ({ expanded: !collapsed }), [collapsed]);
const Chevron = collapsed ? ThemedChevronRight : ThemedChevronDown;
return (
<Pressable
@@ -31,11 +37,7 @@ export function PinnedSectionHeader({
<>
<Text style={styles.title}>{t("sidebar.pinned.title")}</Text>
{hovered || isNative || isCompact ? (
<DensityIcon
icon={collapsed ? ChevronRight : ChevronDown}
size="xs"
color="foregroundMuted"
/>
<Chevron size={12} uniProps={foregroundMutedColorMapping} />
) : null}
</>
)}
@@ -45,7 +47,7 @@ export function PinnedSectionHeader({
const styles = StyleSheet.create((theme) => ({
header: {
minHeight: density.rowHeight,
minHeight: 36,
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
@@ -56,7 +58,7 @@ const styles = StyleSheet.create((theme) => ({
},
title: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "sm"),
fontSize: theme.fontSize.sm,
fontWeight: theme.fontWeight.normal,
},
}));
@@ -4,8 +4,7 @@ import { useTranslation } from "react-i18next";
import { ChevronDown, ChevronRight, CircleAlert } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { getStatusBucketLabel } from "@/hooks/sidebar-status-view-model";
import { density, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import {
getProjectStatusBadgeContent,
@@ -36,8 +35,9 @@ const STATUS_BADGE_OFFSET = -4;
//
// The filled alert occupies the full badge shell so needs-input remains more prominent than
// the passive status dots.
const DensityProjectIconView = withUnistyles(ProjectIconView);
const projectIconProps = densityIconProps("md", () => ({}));
// Matches the workspace title's lineHeight (sidebar-workspace-row-content's
// workspaceBranchText) so the icon centers on the title rather than floating above it.
const LEADING_SLOT_HEIGHT = 20;
const ThemedActivityIndicator = withUnistyles(ActivityIndicator);
const ThemedCircleAlert = withUnistyles(CircleAlert);
@@ -230,12 +230,12 @@ function ProjectIcon({
projectViewKey: string;
}) {
return (
<DensityProjectIconView
<ProjectIconView
iconDataUri={iconDataUri}
initial={placeholderInitial}
projectViewKey={projectViewKey}
size={ICON_SIZE.md}
textStyle={styles.projectIconFallbackText}
uniProps={projectIconProps}
/>
);
}
@@ -271,12 +271,12 @@ const styles = StyleSheet.create((theme) => {
return {
// The slot is as tall as the title's line box, not as tall as the icon, and centers the
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a slot
// only as tall as the icon puts it above the title and the kebab — which is why the
// workspace status indicator shares the same `rowLineHeight`.
// icon inside it. Rows lay their leading visual out with alignItems:flex-start, so a
// 16pt slot next to a 20pt line box puts the icon 2pt above the title and the kebab —
// which is why the workspace status indicator is also 20 tall. Keep the two in step.
projectLeadingVisualSlot: {
width: densityIconBox("md"),
height: density.rowLineHeight,
width: theme.iconSize.md,
height: LEADING_SLOT_HEIGHT,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -284,8 +284,8 @@ const styles = StyleSheet.create((theme) => {
// Anchors the corner badge to the icon rather than to the taller slot.
projectIconBox: {
position: "relative",
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
},
projectIconFallbackText: {
fontSize: 9,
@@ -2,11 +2,19 @@ import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
import { ChevronDown, ChevronUp } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { isWeb } from "@/constants/platform";
import { sidebarWorkspaceRowStyles } from "@/components/sidebar/sidebar-workspace-row-content";
import { DensityIcon } from "@/components/ui/density-icon";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
const foregroundColorMapping = (theme: Theme) => ({
color: theme.colors.foreground,
});
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronUp = withUnistyles(ChevronUp);
/**
* The row that ends a truncated group. It is a workspace row that happens to say "Show more", so
@@ -54,11 +62,17 @@ export function SidebarGroupToggleRow({
{({ hovered, pressed }) => (
<>
<View style={styles.iconSlot}>
<DensityIcon
icon={expanded ? ChevronUp : ChevronDown}
size="sm"
color={hovered || pressed ? "foreground" : "foregroundMuted"}
/>
{expanded ? (
<ThemedChevronUp
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<ThemedChevronDown
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</View>
<Text style={hovered || pressed ? styles.textHovered : styles.text} numberOfLines={1}>
{label}
@@ -72,7 +86,7 @@ export function SidebarGroupToggleRow({
const styles = StyleSheet.create((theme) => ({
// Kept in step with `workspaceRow` in sidebar-workspace-list.tsx and sidebar-status-list.tsx.
row: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -92,21 +106,21 @@ const styles = StyleSheet.create((theme) => ({
// The width of a workspace row's status slot, so the label lands on the same rail as the
// titles above it rather than two points to their left.
iconSlot: {
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
alignItems: "center",
justifyContent: "center",
flexShrink: 0,
},
text: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
textHovered: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
minWidth: 0,
flexShrink: 1,
},
@@ -1,12 +1,22 @@
import { useCallback, useState, type ComponentType, type ReactNode, type Ref } from "react";
import {
useCallback,
useMemo,
useState,
type ComponentType,
type ReactNode,
type Ref,
} from "react";
import { Pressable, Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HEADER_INNER_HEIGHT, HEADER_INNER_HEIGHT_MOBILE } from "@/constants/layout";
import { DensityIcon } from "@/components/ui/density-icon";
import { ICON_SIZE } from "@/styles/theme";
import type { Theme } from "@/styles/theme";
import { Shortcut } from "@/components/ui/shortcut";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { ShortcutKey } from "@/utils/format-shortcut";
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
type SidebarHeaderRowVariant = "header" | "compact" | "inline";
export type SidebarRowIcon = ComponentType<{ size: number; color: string }>;
@@ -39,7 +49,7 @@ interface SidebarHeaderRowProps {
}
export function SidebarHeaderRow({
icon,
icon: Icon,
label,
onPress,
isActive = false,
@@ -54,8 +64,9 @@ export function SidebarHeaderRow({
const [isHovered, setIsHovered] = useState(false);
const handlePointerEnter = useCallback(() => setIsHovered(true), []);
const handlePointerLeave = useCallback(() => setIsHovered(false), []);
const ThemedIcon = useMemo(() => (Icon ? withUnistyles(Icon) : null), [Icon]);
const isHighlighted = isHovered || isActive;
const iconSize = variant === "header" ? "md" : "sm";
const iconSize = variant === "header" ? ICON_SIZE.md : ICON_SIZE.sm;
let right = trailing ?? null;
if (right === null && shortcutKeys && isHovered) {
@@ -80,11 +91,10 @@ export function SidebarHeaderRow({
aria-selected={isActive}
style={styles.button}
>
{icon ? (
<DensityIcon
icon={icon}
{ThemedIcon ? (
<ThemedIcon
size={iconSize}
color={isHighlighted ? "foreground" : "foregroundMuted"}
uniProps={isHighlighted ? foregroundColorMapping : foregroundMutedColorMapping}
/>
) : (
<View style={variant === "header" ? styles.iconSpacer : styles.iconSpacerCompact} />
@@ -130,7 +140,7 @@ const styles = StyleSheet.create((theme) => ({
alignItems: "center",
// Same row geometry as the settings sidebar items. Shorter than the header
// strip so the hover highlight clears the strip's bottom separator.
minHeight: density.tightRowHeight,
minHeight: 28,
borderRadius: theme.borderRadius.lg,
},
rowHighlighted: {
@@ -142,17 +152,17 @@ const styles = StyleSheet.create((theme) => ({
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
minHeight: density.tightRowHeight,
minHeight: 28,
paddingVertical: theme.spacing[1],
// Match the project rows' inner padding so the icons align on one vertical
// edge with the list below.
paddingHorizontal: theme.spacing[2],
},
iconSpacer: { width: densityIconBox("md"), height: densityIconBox("md") },
iconSpacerCompact: { width: densityIconBox("sm"), height: densityIconBox("sm") },
iconSpacer: { width: ICON_SIZE.md, height: ICON_SIZE.md },
iconSpacerCompact: { width: ICON_SIZE.sm, height: ICON_SIZE.sm },
label: {
flexShrink: 1,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: theme.fontWeight.normal,
color: theme.colors.foregroundMuted,
},
@@ -14,14 +14,14 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { DensityIcon } from "@/components/ui/density-icon";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useAppDiagnosticStore } from "@/diagnostics/store";
import { useKeyboardShortcutsAvailable } from "@/keyboard/availability";
import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store";
import { useSessionStore } from "@/stores/session-store";
import { density } from "@/styles/density";
import { ICON_SIZE, type Theme } from "@/styles/theme";
import type { HostProfile } from "@/types/host-connection";
import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates";
@@ -32,10 +32,12 @@ import { openExternalUrl } from "@/utils/open-external-url";
const DISCORD_URL = "https://discord.gg/jz8T2uahpH";
const GITHUB_ISSUE_URL = "https://github.com/getpaseo/paseo/issues/new";
const ThemedActivity = withUnistyles(Activity);
const ThemedCircleHelp = withUnistyles(CircleHelp);
const ThemedGift = withUnistyles(Gift);
const ThemedKeyboard = withUnistyles(Keyboard);
const ThemedDiscordIcon = withUnistyles(DiscordIcon);
const ThemedGitHubIcon = withUnistyles(GitHubIcon);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
});
@@ -77,6 +79,7 @@ function HostVersionHint({ host }: { host: HostProfile }) {
}
export function SidebarHelpMenu() {
const isCompact = useIsCompactFormFactor();
const { t } = useTranslation();
const shortcutsAvailable = useKeyboardShortcutsAvailable();
const openAppDiagnostic = useAppDiagnosticStore((state) => state.open);
@@ -103,16 +106,15 @@ export function SidebarHelpMenu() {
<TooltipTrigger asChild>
<View>
<DropdownMenuTrigger
style={styles.trigger}
style={styles.trigger(isCompact)}
testID="sidebar-help"
accessibilityRole="button"
accessibilityLabel={t("sidebar.help.trigger")}
>
{({ hovered }) => (
<DensityIcon
icon={CircleHelp}
size="md"
color={hovered ? "foreground" : "foregroundMuted"}
<ThemedCircleHelp
size={isCompact ? ICON_SIZE.lg : ICON_SIZE.md}
uniProps={hovered ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</DropdownMenuTrigger>
@@ -182,14 +184,14 @@ export function SidebarHelpMenu() {
}
const styles = StyleSheet.create((theme) => ({
trigger: {
width: density.iconButton,
height: density.iconButton,
trigger: (isCompact: boolean) => ({
width: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
height: isCompact ? buttonControlHeight.md : buttonControlHeight.xs,
alignItems: "center",
justifyContent: "center",
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[1],
},
}),
tooltipText: {
fontSize: theme.fontSize.base,
color: theme.colors.popoverForeground,
@@ -28,7 +28,7 @@ import type { HostBadgeModel } from "@/hosts/appearance";
import { isWeb as platformIsWeb, isNative as platformIsNative } from "@/constants/platform";
import { useIsCompactFormFactor } from "@/constants/layout";
import { StyleSheet } from "react-native-unistyles";
import { density, densityFontSize, densityIconBox, densityIconProps } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import { withUnistyles } from "react-native-unistyles";
import {
@@ -82,23 +82,23 @@ import type { DraggableListDragHandleProps } from "@/components/draggable-list.t
import { useLongPressDragInteraction } from "@/components/sidebar/use-long-press-drag-interaction";
// Themed icon wrappers
const mutedGroupIconProps = densityIconProps("sm", (theme) => ({
const foregroundMutedColorMapping = (theme: Theme) => ({
color: theme.colors.foregroundMuted,
}));
});
// One mapping per bucket, resolved through the status-dot producer so a group header and
// the rows under it cannot disagree about what "failed" looks like.
const needsInputGroupIconProps = densityIconProps("sm", (theme) => ({
const needsInputColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
}));
const failedGroupIconProps = densityIconProps("sm", (theme) => ({
});
const failedColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "failed" }) ?? undefined,
}));
const attentionGroupIconProps = densityIconProps("sm", (theme) => ({
});
const attentionColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "attention" }) ?? undefined,
}));
const runningGroupIconProps = densityIconProps("sm", (theme) => ({
});
const runningColorMapping = (theme: Theme) => ({
color: getStatusDotColor({ theme, bucket: "running" }) ?? undefined,
}));
});
const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight);
@@ -470,23 +470,23 @@ function StatusGroupLeadingVisual({
return <StatusGroupIcon bucket={leading.bucket} />;
}
if (collapsed) {
return <ThemedChevronRight uniProps={mutedGroupIconProps} />;
return <ThemedChevronRight size={14} uniProps={foregroundMutedColorMapping} />;
}
return <ThemedChevronDown uniProps={mutedGroupIconProps} />;
return <ThemedChevronDown size={14} uniProps={foregroundMutedColorMapping} />;
}
function StatusGroupIcon({ bucket }: { bucket: StatusBucket }) {
switch (bucket) {
case "needs_input":
return <ThemedCircleAlert uniProps={needsInputGroupIconProps} />;
return <ThemedCircleAlert size={14} uniProps={needsInputColorMapping} />;
case "failed":
return <ThemedCircleX uniProps={failedGroupIconProps} />;
return <ThemedCircleX size={14} uniProps={failedColorMapping} />;
case "attention":
return <ThemedCircleCheck uniProps={attentionGroupIconProps} />;
return <ThemedCircleCheck size={14} uniProps={attentionColorMapping} />;
case "running":
return <ThemedCircleDot uniProps={runningGroupIconProps} />;
return <ThemedCircleDot size={14} uniProps={runningColorMapping} />;
case "done":
return <ThemedCircleCheck uniProps={mutedGroupIconProps} />;
return <ThemedCircleCheck size={14} uniProps={foregroundMutedColorMapping} />;
}
}
@@ -1058,7 +1058,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusWorkspaceListContainer: {},
statusGroupRow: {
minHeight: density.rowHeight,
minHeight: 36,
paddingVertical: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg,
@@ -1084,8 +1084,8 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupLeadingVisualSlot: {
position: "relative",
width: densityIconBox("md"),
height: densityIconBox("md"),
width: theme.iconSize.md,
height: theme.iconSize.md,
flexShrink: 0,
alignItems: "center",
justifyContent: "center",
@@ -1099,7 +1099,7 @@ const styles = StyleSheet.create((theme) => ({
},
statusGroupTitle: {
color: theme.colors.foregroundMuted,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
minWidth: 0,
flexShrink: 1,
@@ -1108,7 +1108,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[0.5],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -16,7 +16,6 @@ import {
type SidebarWorkspaceTrailing,
} from "@/components/sidebar/workspace-trailing";
import { useAppSettings } from "@/hooks/use-settings";
import { density, densityFontSize, densityIconBox } from "@/styles/density";
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
@@ -28,16 +27,19 @@ import {
import { shouldRenderSyncedStatusLoader } from "@/utils/status-loader";
import { StatusRing } from "@/components/status-ring";
import { resolveSidebarWorkspacePrimaryLabel } from "@/components/sidebar/sidebar-workspace-title";
import { DensityIcon } from "@/components/ui/density-icon";
import { TrailingActionScrim } from "@/components/ui/trailing-action-scrim";
import { useWorkspaceLabelDefinitions } from "@/workspace-labels";
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const needsInputColorMapping = (theme: Theme) => ({
color: theme.colors.surface0,
fill: getStatusDotColor({ theme, bucket: "needs_input" }) ?? undefined,
});
const ThemedCircleAlert = withUnistyles(CircleAlert);
const ThemedMonitor = withUnistyles(Monitor);
const ThemedFolder = withUnistyles(Folder);
const ThemedFolderGit2 = withUnistyles(FolderGit2);
export function SidebarWorkspaceRowFrame({
workspace,
@@ -239,15 +241,15 @@ function WorkspaceStatusIndicator({
) : null;
}
let KindIcon: typeof Monitor;
if (workspaceKind === "local_checkout") KindIcon = Monitor;
else if (workspaceKind === "worktree") KindIcon = FolderGit2;
else KindIcon = Folder;
let KindIcon: typeof ThemedMonitor;
if (workspaceKind === "local_checkout") KindIcon = ThemedMonitor;
else if (workspaceKind === "worktree") KindIcon = ThemedFolderGit2;
else KindIcon = ThemedFolder;
const dotColorStyle = getStatusDotColorStyle(bucket);
return (
<View style={styles.workspaceStatusDot} testID={`workspace-status-indicator-${bucket}`}>
<DensityIcon icon={KindIcon} size="sm" color="foregroundMuted" />
<KindIcon size={14} uniProps={foregroundMutedColorMapping} />
{dotColorStyle ? <StatusDotOverlay dotColorStyle={dotColorStyle} /> : null}
</View>
);
@@ -480,8 +482,8 @@ const styles = StyleSheet.create((theme) => ({
},
workspaceStatusDot: {
position: "relative",
width: densityIconBox("md"),
height: density.rowLineHeight,
width: theme.iconSize.md,
height: 20,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
alignItems: "center",
@@ -513,9 +515,9 @@ const styles = StyleSheet.create((theme) => ({
// to the meta row, so it takes the full width the trailing slot leaves behind.
workspaceBranchText: {
color: theme.colors.foreground,
fontSize: densityFontSize(theme, "base"),
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: density.rowLineHeight,
lineHeight: 20,
opacity: 0.76,
flex: 1,
minWidth: 0,
@@ -4,7 +4,6 @@ import { View, Text, type GestureResponderEvent } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import type { HostBadgeModel } from "@/hosts/appearance";
import type { SidebarWorkspaceEntry } from "@/hooks/use-sidebar-workspaces-list";
import { density } from "@/styles/density";
import type { SidebarSurfaceBackdrop } from "@/styles/surface-backdrop";
import type { DraggableListDragHandleProps } from "@/components/draggable-list.types";
import type { ShortcutKey } from "@/utils/format-shortcut";
@@ -486,7 +485,7 @@ const styles = StyleSheet.create((theme) => ({
position: "relative",
},
workspaceRow: {
minHeight: density.rowHeight,
minHeight: 36,
marginBottom: theme.spacing[1],
paddingVertical: theme.spacing[2],
paddingLeft: theme.spacing[2],
@@ -174,7 +174,6 @@ export function Button({
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
ref?: React.Ref<View>;
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: LeftIcon;
+43 -55
View File
@@ -28,7 +28,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context";
import type { EditingTextInputHandle } from "@/components/ui/text-input";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { BottomSheetScrollView, type BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import { BottomSheetScrollView, BottomSheetBackgroundProps } from "@gorhom/bottom-sheet";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import { Check, File, Folder, Search } from "lucide-react-native";
@@ -50,7 +50,6 @@ import type { ComboboxOptionModel } from "./combobox-options";
import { isWeb } from "@/constants/platform";
import {
IsolatedBottomSheetModal,
SheetVisibleFrame,
useIsolatedBottomSheetVisibility,
} from "./isolated-bottom-sheet-modal";
import {
@@ -949,19 +948,9 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
() => [styles.comboboxTitle, { color: props.titleColor }],
[props.titleColor],
);
const { footer, safeAreaBottom } = props;
const frameStyle = useMemo(
() => [styles.mobileSheetFrame, { paddingBottom: footer ? 0 : safeAreaBottom }],
[footer, safeAreaBottom],
);
const footerStyle = useMemo(
() => [styles.footer, { paddingBottom: safeAreaBottom }],
[safeAreaBottom],
);
const footerView = useMemo(
() => (footer ? <View style={footerStyle}>{footer}</View> : undefined),
[footer, footerStyle],
() => [styles.mobileSheetFrame, { paddingBottom: props.safeAreaBottom }],
[props.safeAreaBottom],
);
const body = props.hasChildren ? (
@@ -994,47 +983,46 @@ function MobileComboboxBody(props: MobileBodyProps): ReactElement {
keyboardBlurBehavior="none"
presentation={props.presentation}
>
<SheetVisibleFrame footer={footerView}>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
</View>
</SheetVisibleFrame>
<View style={frameStyle}>
{props.header ? (
<SheetHeaderView header={props.header} onClose={props.onClose} />
) : (
<>
<View style={styles.bottomSheetHeader}>
<Text key={props.titleColor} style={comboboxTitleStyle}>
{props.title}
</Text>
</View>
{props.stickyHeader}
{!props.hasChildren && props.searchable ? (
<SearchInput
placeholder={props.searchPlaceholder}
onChangeText={props.setSearchQueryWithCallback}
onSubmitEditing={props.handleSubmitSearch}
autoFocus={false}
useBottomSheetInput
resetKey={props.searchResetKey}
/>
) : null}
</>
)}
{props.hasChildren && !props.mobileChildrenScrollEnabled ? (
body
) : (
<BottomSheetScrollView
style={styles.mobileSheetBody}
contentContainerStyle={[
styles.comboboxScrollContent,
props.mobileChildrenContentContainerStyle,
]}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
{body}
</BottomSheetScrollView>
)}
{props.footer ? <View style={styles.footer}>{props.footer}</View> : null}
</View>
</IsolatedBottomSheetModal>
);
}
@@ -1,51 +0,0 @@
import { useMemo, type ComponentType } from "react";
import { withUnistyles } from "react-native-unistyles";
import { densityIconSize, type DensityIconSize } from "@/styles/density";
import type { Theme } from "@/styles/theme";
type IconComponent = ComponentType<{ size: number; color: string; strokeWidth?: number }>;
type IconColor = {
[K in keyof Theme["colors"]]: Theme["colors"][K] extends string ? K : never;
}[keyof Theme["colors"]];
function IconGlyph({
icon: Icon,
size,
color,
strokeWidth,
}: {
icon: IconComponent;
size: number;
color: string;
strokeWidth?: number;
}) {
return <Icon size={size} color={color} strokeWidth={strokeWidth} />;
}
const ThemedIconGlyph = withUnistyles(IconGlyph);
/** An icon sized by density: larger on compact layouts. See `@/styles/density`. */
export function DensityIcon({
icon,
size,
sizeAdjustment = 0,
color,
strokeWidth,
}: {
icon: IconComponent;
size: DensityIconSize;
/** Only for a glyph that reads larger than others at the same size. */
sizeAdjustment?: number;
color: IconColor;
strokeWidth?: number;
}) {
const uniProps = useMemo(
() => (theme: Theme, rt: { breakpoint?: string }) => ({
size: densityIconSize(rt, size) + sizeAdjustment,
color: theme.colors[color],
}),
[size, sizeAdjustment, color],
);
return <ThemedIconGlyph icon={icon} strokeWidth={strokeWidth} uniProps={uniProps} />;
}
@@ -1,67 +1,47 @@
import { forwardRef, useMemo } from "react";
import { Text, type View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { ICON_SIZE } from "@/styles/theme";
import { ICON_SIZE, SPACING } from "@/styles/theme";
import {
FLOATING_ACTION_BUTTON_SIZE,
FLOATING_ACTION_BUTTON_INSET,
} from "./floating-action-layout";
export {
FLOATING_ACTION_BUTTON_CLEARANCE,
floatingActionsClearance,
} from "./floating-action-layout";
const FLOATING_ACTION_BUTTON_SIZE = 56;
const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
/** Space a scrolling surface keeps below its content so the button traps nothing. */
export const FLOATING_ACTION_BUTTON_CLEARANCE =
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
export interface FloatingActionButtonProps {
icon: LucideIcon;
/** Names the action for assistive technology. */
/** Names the action; the button carries no label. */
accessibilityLabel: string;
onPress: () => void;
label?: string;
disabled?: boolean;
bottomInset?: number;
testID?: string;
}
/**
* Pins an action to the bottom-right of its nearest positioned ancestor.
* The surface reserves scroll clearance and uses bottomInset to separate stacked actions.
* The one floating action a scrolling surface can offer. It pins itself to the
* bottom-right of its nearest positioned ancestor, so the ancestor decides what
* the button may cover.
*/
export const FloatingActionButton = forwardRef<View, FloatingActionButtonProps>(
function FloatingActionButton(
{ icon: Icon, accessibilityLabel, onPress, label, disabled = false, bottomInset = 0, testID },
ref,
) {
const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
const buttonStyle = useMemo(
() => [
styles.button,
label && styles.labeled,
disabled && styles.disabled,
{ bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset },
],
[bottomInset, disabled, label],
);
return (
<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>
);
},
);
export function FloatingActionButton({
icon: Icon,
accessibilityLabel,
onPress,
testID,
}: FloatingActionButtonProps) {
return (
<PressHighlight
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={onPress}
style={styles.button}
highlightStyle={styles.pressed}
testID={testID}
>
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
button: {
@@ -76,15 +56,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
...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: {
backgroundColor: theme.colors.interactionHighlight,
},
@@ -1,17 +0,0 @@
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);
});
});
@@ -1,15 +0,0 @@
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,4 +1,3 @@
import { createContext, useCallback, useContext } from "react";
import type { StyleProp, ViewStyle } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { HEADER_CONTROL_HEIGHT } from "@/components/ui/control-geometry";
@@ -9,8 +8,6 @@ export { extraMutedIconColorMapping, mutedIconColorMapping } from "@/components/
export type IconButtonChromeSize = "large" | "small";
const SMALL_ICON_BUTTON_SIZE = 20;
/** Width and height of a large icon button on compact layouts. */
export const COMPACT_LARGE_ICON_BUTTON_SIZE = 32;
const COMPACT_SMALL_ICON_BUTTON_SIZE = 32;
export interface IconButtonChromeState {
@@ -30,53 +27,20 @@ interface IconButtonChromeOptions {
state?: IconButtonChromeState;
compact?: boolean;
disabled?: boolean;
/** A circular hitbox and highlight, for buttons inside a pill-shaped container. */
round?: boolean;
style?: StyleProp<ViewStyle>;
}
const IconButtonChromeContext = createContext<"default" | "header-island">("default");
/** A container owns the chrome of its icon actions, including custom menu and badge triggers. */
export const IconButtonChromeProvider = IconButtonChromeContext.Provider;
/** The same state callback works with Pressable, TooltipTrigger, and DropdownMenuTrigger. */
export function useIconButtonChromeStyle({
size,
compact,
disabled,
round,
style,
active,
}: Omit<IconButtonChromeOptions, "state"> & { active?: boolean }) {
const scope = useContext(IconButtonChromeContext);
return useCallback(
(state: IconButtonChromeState) =>
iconButtonChromeStyle({
size: scope === "header-island" ? "large" : size,
compact,
disabled,
round: scope === "header-island" || round,
style,
state: { ...state, active: active || state.active },
}),
[scope, size, compact, disabled, round, style, active],
);
}
/** Shared hitbox and interaction chrome for icon-only header and toolbar controls. */
export function iconButtonChromeStyle({
size,
state,
compact = false,
disabled = false,
round = false,
style,
}: IconButtonChromeOptions): StyleProp<ViewStyle> {
const highlighted = state?.active || state?.hovered || state?.pressed || state?.open;
return [
resolveIconButtonFrame(size, compact),
round ? styles.round : null,
style,
highlighted ? styles.highlighted : null,
disabled ? styles.disabled : null,
@@ -103,11 +67,11 @@ export function smallIconButtonChromeFrameSize(compact = false): number {
const styles = StyleSheet.create((theme) => ({
large: {
width: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
height: {
xs: COMPACT_LARGE_ICON_BUTTON_SIZE,
xs: 32,
md: HEADER_CONTROL_HEIGHT,
},
padding: 0,
@@ -140,9 +104,6 @@ const styles = StyleSheet.create((theme) => ({
outlineWidth: 0,
outlineColor: "transparent",
},
round: {
borderRadius: theme.borderRadius.full,
},
highlighted: {
backgroundColor: theme.colors.interactionHighlight,
},
@@ -152,5 +152,3 @@ export function useIsolatedBottomSheetVisibility({
handleSheetDismiss,
};
}
export { SheetVisibleFrame } from "./visible-frame";
@@ -245,12 +245,3 @@ describe("bottom sheet visibility tracker", () => {
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,9 +121,6 @@ export function createBottomSheetVisibilityTracker(opts: {
}
},
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) {
setPhase("dismissing");
notifyClose();
@@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout";
describe("getBottomSheetVisibleContentHeight", () => {
it("stops subtracting the retained keyboard height after the keyboard hides", () => {
const layout = {
containerHeight: 874,
contentPosition: 88,
handleHeight: 24,
keyboardHeight: 344,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: true })).toBe(418);
expect(getBottomSheetVisibleContentHeight({ ...layout, isKeyboardVisible: false })).toBe(762);
});
});
// Below the lowest snap the frame must travel with the sheet, not shrink to the screen edge.
describe("sheet footer motion", () => {
it("keeps the frame height fixed while opening and panning below the lowest snap", () => {
const layout = {
containerHeight: 1000,
handleHeight: 24,
keyboardHeight: 0,
isKeyboardVisible: false,
lowestDetentPosition: 400,
};
const heights = [1000, 800, 600, 400].map((contentPosition) =>
getBottomSheetVisibleContentHeight({ ...layout, contentPosition }),
);
expect(heights).toEqual([576, 576, 576, 576]);
});
});
it("resizes above the lowest snap and never returns negative or unmeasured heights", () => {
const layout = {
containerHeight: 1000,
handleHeight: 24,
keyboardHeight: 0,
isKeyboardVisible: false,
lowestDetentPosition: 400,
};
expect(getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100 })).toBe(876);
expect(
getBottomSheetVisibleContentHeight({
...layout,
contentPosition: 100,
keyboardHeight: 950,
isKeyboardVisible: true,
}),
).toBe(0);
expect(
getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, handleHeight: -999 }),
).toBe(0);
expect(
getBottomSheetVisibleContentHeight({ ...layout, contentPosition: 100, containerHeight: -999 }),
).toBe(0);
});
@@ -1,25 +0,0 @@
interface BottomSheetVisibleContentHeightInput {
containerHeight: number;
contentPosition: number;
lowestDetentPosition?: number;
handleHeight: number;
keyboardHeight: number;
isKeyboardVisible: boolean;
}
export function getBottomSheetVisibleContentHeight({
containerHeight,
contentPosition,
lowestDetentPosition,
handleHeight,
keyboardHeight,
isKeyboardVisible,
}: BottomSheetVisibleContentHeightInput): number {
"worklet";
if (containerHeight < 0 || handleHeight < 0) return 0;
const position = Math.min(contentPosition, lowestDetentPosition ?? contentPosition);
return Math.max(
0,
containerHeight - position - handleHeight - (isKeyboardVisible ? keyboardHeight : 0),
);
}
@@ -1,42 +0,0 @@
import type { ReactNode } from "react";
import { View, StyleSheet } from "react-native";
import { KEYBOARD_STATUS, useBottomSheetInternal } from "@gorhom/bottom-sheet";
import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { getBottomSheetVisibleContentHeight } from "./visible-frame-layout";
/** Fixed-detent sheet content. The footer shares the sheet's translation below its lowest snap. */
export function SheetVisibleFrame({
children,
footer,
}: {
children: ReactNode;
footer?: ReactNode;
}) {
const { animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition } =
useBottomSheetInternal();
const visibleStyle = useAnimatedStyle(() => {
const { containerHeight, handleHeight } = animatedLayoutState.get();
const keyboard = animatedKeyboardState.get();
return {
height: getBottomSheetVisibleContentHeight({
containerHeight,
handleHeight,
contentPosition: animatedPosition.get(),
lowestDetentPosition: animatedDetentsState.get().detents?.[0],
keyboardHeight: keyboard.heightWithinContainer,
isKeyboardVisible: keyboard.status === KEYBOARD_STATUS.SHOWN,
}),
};
}, [animatedDetentsState, animatedKeyboardState, animatedLayoutState, animatedPosition]);
return (
<Animated.View style={[styles.frame, visibleStyle]}>
<View style={styles.body}>{children}</View>
{footer}
</Animated.View>
);
}
const styles = StyleSheet.create({
frame: { minHeight: 0, overflow: "hidden" },
body: { flex: 1, minHeight: 0 },
});
@@ -16,7 +16,6 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
import {
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
useIconButtonChromeStyle,
smallIconButtonChromeFrameSize,
} from "@/components/ui/icon-button-chrome";
import { WORKSPACE_PANE_TRAILING_GLYPH_RAIL } from "@/components/tree-primitives";
@@ -61,8 +60,6 @@ interface ToolbarButtonCommonProps extends Omit<
tooltip?: ReactNode;
selected?: boolean;
compact?: boolean;
/** A circular highlight, for buttons inside a pill-shaped container. */
round?: boolean;
shortcut?: ShortcutKey[][] | null;
tooltipSide?: "left" | "right" | "top" | "bottom";
style?: StyleProp<ViewStyle>;
@@ -84,7 +81,6 @@ export function ToolbarButton({
tooltip: tooltipBody,
selected = false,
compact = false,
round = false,
shortcut,
tooltipSide = "bottom",
style,
@@ -94,14 +90,17 @@ export function ToolbarButton({
testID,
...props
}: ToolbarButtonProps) {
const buttonStyle = useIconButtonChromeStyle({
size: "small",
compact,
active: selected,
disabled: Boolean(disabled),
round,
style,
});
const buttonStyle = useMemo(
() => (state: { hovered?: boolean; pressed: boolean; open?: boolean }) =>
iconButtonChromeStyle({
size: "small",
compact,
state: { ...state, active: selected },
disabled: Boolean(disabled),
style,
}),
[compact, disabled, selected, style],
);
const accessibilityState = useMemo(
() => ({ disabled: Boolean(disabled), selected }),
[disabled, selected],
@@ -298,6 +298,7 @@ export function CompactModelSheet({
onClose={close}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint={usesBottomSheet}
contentStyle={styles.sheetBody}
testID="agent-controls-model-sheet"
>
@@ -345,6 +346,7 @@ export function CompactModelSheet({
onClose={closeModelBrowser}
snapPoints={SNAP_POINTS}
scrollable={false}
sizeContentToCurrentSnapPoint
presentation="push"
contentStyle={styles.sheetBody}
testID="agent-controls-model-browser-sheet"
+1 -6
View File
@@ -33,18 +33,13 @@ export {
getIsElectronMac as getIsElectronRuntimeMac,
} from "./platform";
/** Compact layouts are the breakpoints below `md`. */
export function isCompactBreakpoint(breakpoint: string | undefined): boolean {
return breakpoint === "xs" || breakpoint === "sm";
}
/**
* Reactive hook — re-renders the component when the breakpoint changes.
* Always use this instead of reading UnistylesRuntime.breakpoint directly.
*/
export function useIsCompactFormFactor(): boolean {
const { rt } = useUnistyles();
return isCompactBreakpoint(rt.breakpoint);
return rt.breakpoint === "xs" || rt.breakpoint === "sm";
}
// SplitContainer relies on dnd-kit and DOM-backed accessibility helpers.
@@ -315,7 +315,6 @@ function reviewActionsForFirstAddition(): NonNullable<
commentsByTarget: new Map(),
editor: { target, commentId: null, body: "" },
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
@@ -389,7 +389,6 @@ describe("diff document model", () => {
commentId: null,
},
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
@@ -791,7 +790,6 @@ function reviewActionsWithEditor(
commentsByTarget: new Map(),
editor: target ? { target, body: "", commentId: null } : null,
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
+20 -47
View File
@@ -1,5 +1,3 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -33,7 +31,6 @@ import {
import { type ParsedDiffFile } from "@/git/use-diff-query";
import type { ChangesState } from "@/panels/changes/state";
import { defaultChangesState } from "@/panels/changes/state";
import { ReviewCommentSheet, ReviewFeedbackAction } from "@/review";
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
import { ChangedFilesTree } from "@/git/changed-files-tree";
import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file";
@@ -1551,9 +1548,7 @@ export function ChangesSurface({
isDiffLoading,
reviewActions,
reviewAttachment,
reviewFeedback,
} = useWorkingDiff({
presentation,
serverId,
workspaceId: workspaceId ?? undefined,
cwd,
@@ -1734,7 +1729,6 @@ export function ChangesSurface({
],
);
const { bottom: bottomInset } = useSafeAreaInsets();
const hasChanges = files.length > 0;
const jumpToFileInset = jumpToFileClearance({
isCompact: isMobile,
@@ -1784,41 +1778,27 @@ export function ChangesSurface({
});
const diffContent: ReactElement = (
<View style={styles.diffContainer}>
<DiffBodyContent
isStatusLoading={isStatusLoading}
statusErrorMessage={statusErrorMessage}
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
diffTooLarge={diffTooLarge}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
emptyAction={emptyAction}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
>
<DiffDocument
files={files}
contentInsetBottom={floatingActionsClearance(
[jumpToFileInset, reviewFeedback.clearance],
bottomInset,
)}
collapseState={collapseState}
displayPreferences={sharedDisplayPreferences}
mode={workingMode}
/>
</DiffBodyContent>
<ReviewFeedbackAction
bottomOffset={jumpToFileInset}
enabled={enabled}
hasDiff={presentation !== "tree"}
model={reviewFeedback.model}
state={reviewFeedback.state}
serverId={serverId}
workspaceId={workspaceId}
<DiffBodyContent
isStatusLoading={isStatusLoading}
statusErrorMessage={statusErrorMessage}
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
diffTooLarge={diffTooLarge}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
emptyAction={emptyAction}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
>
<DiffDocument
files={files}
contentInsetBottom={jumpToFileInset}
collapseState={collapseState}
displayPreferences={sharedDisplayPreferences}
mode={workingMode}
/>
</View>
</DiffBodyContent>
);
const bodyContent = (
<ChangesBody
@@ -1954,13 +1934,6 @@ export function ChangesSurface({
) : null}
</View>
<ReviewCommentSheet
enabled={enabled}
editor={reviewActions.sheetEditor}
reviewActions={reviewActions}
onChangeBody={reviewActions.onChangeEditorBody}
/>
<ChangesCommits
presentation={presentation}
serverId={serverId}
@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { FolderTree } from "lucide-react-native";
import {
@@ -54,7 +53,6 @@ export interface JumpToFileProps {
/** Compact overview of a loaded diff: a floating action that opens the changed-files tree. */
export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) {
const { t } = useTranslation();
const { bottom } = useSafeAreaInsets();
const [isOpen, setIsOpen] = useState(false);
// The sheet is a transient overview, so it always opens fully expanded and
// never writes to the panel's persisted folder state.
@@ -85,7 +83,6 @@ export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) {
<>
<FloatingActionButton
icon={FolderTree}
bottomInset={bottom}
accessibilityLabel={title}
onPress={open}
testID="changes-jump-to-file"
+1 -12
View File
@@ -7,7 +7,6 @@ import {
buildReviewDraftKey,
useInlineReviewController,
useReviewAttachmentSnapshot,
useReviewFeedback,
} from "@/review";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
@@ -20,7 +19,6 @@ interface UseWorkingDiffOptions {
ignoreWhitespace: boolean;
enabled: boolean;
queryScope?: string;
presentation?: string;
}
export function useWorkingDiff({
@@ -30,7 +28,6 @@ export function useWorkingDiff({
ignoreWhitespace,
enabled,
queryScope,
presentation,
}: UseWorkingDiffOptions) {
const {
status,
@@ -84,7 +81,7 @@ export function useWorkingDiff({
}),
[baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId],
);
const reviewActions = useInlineReviewController({ reviewDraftKey, presentation });
const reviewActions = useInlineReviewController({ reviewDraftKey });
const reviewAttachment = useReviewAttachmentSnapshot({
key: reviewDraftKey,
diffFiles: files,
@@ -93,13 +90,6 @@ export function useWorkingDiff({
baseRef,
});
const reviewFeedback = useReviewFeedback({
serverId,
workspaceId,
reviewDraftKey,
attachment: reviewAttachment,
});
return {
status,
isStatusLoading,
@@ -117,7 +107,6 @@ export function useWorkingDiff({
isDiffLoading,
reviewActions,
reviewAttachment,
reviewFeedback,
};
}
-12
View File
@@ -30,7 +30,6 @@ export const ar: TranslationResources = {
back: "خلف",
loading: "تحميل...",
actions: {
save: "حفظ",
back: "خلف",
cancel: "يلغي",
close: "يغلق",
@@ -2006,17 +2005,6 @@ export const ar: TranslationResources = {
accessibility: "تم استخدام نافذة السياق{{percentage}}%",
},
review: {
feedback: {
send: "إرسال الملاحظات ({{count}})",
sending: "جارٍ إرسال الملاحظات ({{count}})",
chooseAgent: "اختر وكيلاً",
sent: "تم إرسال الملاحظات إلى {{recipient}}",
"no-agents": "افتح علامة تبويب وكيل في مساحة العمل هذه لإرسال الملاحظات.",
disconnected: "اتصل بالمضيف لإرسال الملاحظات.",
"no-context": "لم تعد التعليقات المحفوظة تتطابق مع هذا الفرق.",
failed: "تعذر إرسال الملاحظات. حاول مرة أخرى.",
prompt: "يرجى معالجة مراجعة الكود هذه.",
},
comment: {
add: "إضافة تعليق المراجعة",
edit: "تحرير تعليق المراجعة",
-12
View File
@@ -26,7 +26,6 @@ export const en = {
back: "Back",
loading: "Loading...",
actions: {
save: "Save",
back: "Back",
cancel: "Cancel",
close: "Close",
@@ -2031,17 +2030,6 @@ export const en = {
accessibility: "Context window {{percentage}}% used",
},
review: {
feedback: {
send: "Send feedback ({{count}})",
sending: "Sending feedback ({{count}})",
chooseAgent: "Choose an agent",
sent: "Feedback sent to {{recipient}}",
"no-agents": "Open an agent tab in this workspace to send feedback.",
disconnected: "Connect to the host to send feedback.",
"no-context": "Saved comments no longer match this diff.",
failed: "Failed to send feedback. Try again.",
prompt: "Please address this code review.",
},
comment: {
add: "Add review comment",
edit: "Edit review comment",
-12
View File
@@ -31,7 +31,6 @@ export const es: TranslationResources = {
back: "Atrás",
loading: "Cargando...",
actions: {
save: "Guardar",
back: "Atrás",
cancel: "Cancelar",
close: "Cerrar",
@@ -2054,17 +2053,6 @@ export const es: TranslationResources = {
accessibility: "Ventana de contexto{{percentage}}% utilizada",
},
review: {
feedback: {
send: "Enviar comentarios ({{count}})",
sending: "Enviando comentarios ({{count}})",
chooseAgent: "Elegir un agente",
sent: "Comentarios enviados a {{recipient}}",
"no-agents": "Abre una pestaña de agente en este espacio de trabajo para enviar comentarios.",
disconnected: "Conéctate al host para enviar comentarios.",
"no-context": "Los comentarios guardados ya no coinciden con este diff.",
failed: "No se pudieron enviar los comentarios. Inténtalo de nuevo.",
prompt: "Por favor, atiende esta revisión de código.",
},
comment: {
add: "Agregar comentario de revisión",
edit: "Editar comentario de revisión",
-13
View File
@@ -31,7 +31,6 @@ export const fr: TranslationResources = {
back: "Retour",
loading: "Chargement…",
actions: {
save: "Enregistrer",
back: "Retour",
cancel: "Annuler",
close: "Fermer",
@@ -2051,18 +2050,6 @@ export const fr: TranslationResources = {
accessibility: "Fenêtre de contexte : {{percentage}} % utilisés",
},
review: {
feedback: {
send: "Envoyer les commentaires ({{count}})",
sending: "Envoi des commentaires ({{count}})",
chooseAgent: "Choisir un agent",
sent: "Commentaires envoyés à {{recipient}}",
"no-agents":
"Ouvrez un onglet d’agent dans cet espace de travail pour envoyer les commentaires.",
disconnected: "Connectez-vous à l’hôte pour envoyer les commentaires.",
"no-context": "Les commentaires enregistrés ne correspondent plus à ce diff.",
failed: "Échec de l’envoi des commentaires. Réessayez.",
prompt: "Veuillez traiter cette revue de code.",
},
comment: {
add: "Ajouter un commentaire de revue",
edit: "Modifier le commentaire de revue",
-13
View File
@@ -31,7 +31,6 @@ export const ja: TranslationResources = {
back: "戻る",
loading: "読み込み中...",
actions: {
save: "保存",
back: "戻る",
cancel: "キャンセル",
close: "閉じる",
@@ -2026,18 +2025,6 @@ export const ja: TranslationResources = {
accessibility: "コンテキストウィンドウ{{percentage}}%使用",
},
review: {
feedback: {
send: "フィードバックを送信 ({{count}})",
sending: "フィードバックを送信中 ({{count}})",
chooseAgent: "エージェントを選択",
sent: "{{recipient}} にフィードバックを送信しました",
"no-agents":
"フィードバックを送信するには、このワークスペースでエージェントタブを開いてください。",
disconnected: "フィードバックを送信するにはホストに接続してください。",
"no-context": "保存したコメントはこの差分と一致しなくなりました。",
failed: "フィードバックを送信できませんでした。もう一度お試しください。",
prompt: "このコードレビューに対応してください。",
},
comment: {
add: "レビューコメントを追加",
edit: "レビューコメントを編集",
-12
View File
@@ -30,7 +30,6 @@ export const ko: TranslationResources = {
back: "뒤로",
loading: "불러오는 중...",
actions: {
save: "저장",
back: "뒤로",
cancel: "취소",
close: "닫기",
@@ -2016,17 +2015,6 @@ export const ko: TranslationResources = {
accessibility: "컨텍스트 윈도우 {{percentage}}% 사용됨",
},
review: {
feedback: {
send: "피드백 보내기 ({{count}})",
sending: "피드백 전송 중 ({{count}})",
chooseAgent: "에이전트 선택",
sent: "{{recipient}}에게 피드백을 보냈습니다",
"no-agents": "피드백을 보내려면 이 작업 공간에서 에이전트 탭을 여세요.",
disconnected: "피드백을 보내려면 호스트에 연결하세요.",
"no-context": "저장된 댓글이 더 이상 이 변경 사항과 일치하지 않습니다.",
failed: "피드백 전송에 실패했습니다. 다시 시도하세요.",
prompt: "이 코드 리뷰를 반영해 주세요.",
},
comment: {
add: "리뷰 댓글 추가",
edit: "리뷰 댓글 편집",
-12
View File
@@ -31,7 +31,6 @@ export const ptBR: TranslationResources = {
back: "Voltar",
loading: "Carregando...",
actions: {
save: "Salvar",
back: "Voltar",
cancel: "Cancelar",
close: "Fechar",
@@ -2039,17 +2038,6 @@ export const ptBR: TranslationResources = {
accessibility: "Janela de contexto {{percentage}}% usada",
},
review: {
feedback: {
send: "Enviar comentários ({{count}})",
sending: "Enviando comentários ({{count}})",
chooseAgent: "Escolher um agente",
sent: "Comentários enviados para {{recipient}}",
"no-agents": "Abra uma aba de agente neste espaço de trabalho para enviar comentários.",
disconnected: "Conecte-se ao host para enviar comentários.",
"no-context": "Os comentários salvos não correspondem mais a este diff.",
failed: "Falha ao enviar comentários. Tente novamente.",
prompt: "Por favor, atenda a esta revisão de código.",
},
comment: {
add: "Adicionar comentário de revisão",
edit: "Editar comentário de revisão",
-12
View File
@@ -31,7 +31,6 @@ export const ru: TranslationResources = {
back: "Назад",
loading: "Загрузка...",
actions: {
save: "Сохранить",
back: "Назад",
cancel: "Отмена",
close: "Закрыть",
@@ -2039,17 +2038,6 @@ export const ru: TranslationResources = {
accessibility: "Использовано {{percentage}}% контекстного окна",
},
review: {
feedback: {
send: "Отправить отзыв ({{count}})",
sending: "Отправка отзыва ({{count}})",
chooseAgent: "Выберите агента",
sent: "Отзыв отправлен: {{recipient}}",
"no-agents": "Откройте вкладку агента в этой рабочей области, чтобы отправить отзыв.",
disconnected: "Подключитесь к хосту, чтобы отправить отзыв.",
"no-context": "Сохранённые комментарии больше не соответствуют этому diff.",
failed: "Не удалось отправить отзыв. Повторите попытку.",
prompt: "Пожалуйста, учтите эту проверку кода.",
},
comment: {
add: "Добавить комментарий к ревью",
edit: "Изменить комментарий к ревью",
-12
View File
@@ -30,7 +30,6 @@ export const zhCN: TranslationResources = {
back: "返回",
loading: "加载中...",
actions: {
save: "保存",
back: "返回",
cancel: "取消",
close: "关闭",
@@ -1984,17 +1983,6 @@ export const zhCN: TranslationResources = {
accessibility: "上下文窗口已使用 {{percentage}}%",
},
review: {
feedback: {
send: "发送反馈 ({{count}})",
sending: "正在发送反馈 ({{count}})",
chooseAgent: "选择智能体",
sent: "已向 {{recipient}} 发送反馈",
"no-agents": "请在此工作区打开智能体标签页以发送反馈。",
disconnected: "请连接到主机以发送反馈。",
"no-context": "保存的评论不再匹配此差异。",
failed: "发送反馈失败。请重试。",
prompt: "请处理此代码审查。",
},
comment: {
add: "添加 review 评论",
edit: "编辑 review 评论",
+26 -36
View File
@@ -7,11 +7,9 @@ import { Text, View } from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { HeaderIconBadge } from "@/components/headers/header-icon-badge";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { CompactHeader } from "@/components/headers/compact-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { ScreenTitle } from "@/components/headers/screen-title";
import { HostFilter } from "@/components/hosts/host-filter";
import type { HostPickerHost } from "@/components/hosts/host-picker";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useHostRuntimeClient, useHosts } from "@/runtime/host-runtime";
import type { Theme } from "@/styles/theme";
@@ -89,14 +87,19 @@ function PluginHostFilter({
pluginId,
identity,
params,
hosts,
serverIds,
}: {
serverId: string;
pluginId: string;
identity: PluginSurfaceContributionIdentity;
params: PluginScreenParams;
hosts: HostPickerHost[];
serverIds: string[];
}) {
const allHosts = useHosts();
const hosts = useMemo(
() => allHosts.filter((host) => serverIds.includes(host.serverId)),
[allHosts, serverIds],
);
const selectHost = useCallback(
(nextServerId: string) => {
// A legacy row remembers its own host; a screen's host carries to all the plugin's items.
@@ -110,6 +113,9 @@ function PluginHostFilter({
},
[identity, params, pluginId],
);
const show = serverIds.length > 1 && hosts.length > 1;
if (!show) return null;
return (
<HostFilter
hosts={hosts}
@@ -149,12 +155,11 @@ export function PluginSurfaceScreen() {
[identity, plugin],
);
const hostLabel = hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
// The hosts this contribution is installed on; the filter shows when there is a choice.
const filterHosts = useMemo(() => {
if (!identity) return [];
const serverIds = getPluginSurfaceContributionServerIds(installations, pluginId, identity);
return hosts.filter((candidate) => serverIds.includes(candidate.serverId));
}, [hosts, identity, installations, pluginId]);
const contributionServerIds = useMemo(
() =>
identity ? getPluginSurfaceContributionServerIds(installations, pluginId, identity) : [],
[identity, installations, pluginId],
);
const title = useMemo(
() =>
surface
@@ -182,23 +187,18 @@ export function PluginSurfaceScreen() {
),
[Icon, title],
);
const hostFilter = useMemo(
() =>
identity && filterHosts.length > 1 ? (
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
hosts={filterHosts}
/>
) : null,
[filterHosts, identity, params, pluginId, serverId],
);
const headerRight = useMemo(
() => (
<>
{hostFilter}
{identity ? (
<PluginHostFilter
serverId={serverId}
pluginId={pluginId}
identity={identity}
params={params}
serverIds={contributionServerIds}
/>
) : null}
<HeaderToggleButton
accessibilityLabel="Close plugin"
onPress={close}
@@ -211,22 +211,12 @@ export function PluginSurfaceScreen() {
</HeaderToggleButton>
</>
),
[close, hostFilter],
[close, contributionServerIds, identity, params, pluginId, serverId],
);
return (
<View style={styles.screen}>
{compact ? (
<CompactHeader
navigation="back"
onBack={close}
title={title}
titleTestID="plugin-surface-title"
actions={hostFilter}
/>
) : (
<ScreenHeader left={headerLeft} right={headerRight} />
)}
<ScreenHeader left={headerLeft} right={headerRight} />
<View style={styles.body}>
{plugin && surface && client ? (
<SurfaceErrorBoundary
-106
View File
@@ -1,106 +0,0 @@
import { useCallback, useMemo } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { FormTextInput } from "@/components/ui/form-field";
import { Button } from "@/components/ui/button";
import type { InlineReviewActions, InlineReviewEditorState } from "./geometry";
export function ReviewCommentSheet({
editor,
reviewActions,
onChangeBody,
enabled,
}: {
enabled: boolean | undefined;
editor: InlineReviewEditorState | null;
reviewActions: InlineReviewActions;
onChangeBody: (body: string) => void;
}) {
if (enabled === false || !editor) return null;
return (
<OpenReviewCommentSheet
editor={editor}
reviewActions={reviewActions}
onChangeBody={onChangeBody}
/>
);
}
function OpenReviewCommentSheet({
editor,
reviewActions,
onChangeBody,
}: {
editor: InlineReviewEditorState;
reviewActions: InlineReviewActions;
onChangeBody: (body: string) => void;
}) {
const { t } = useTranslation();
const context = `${editor.target.filePath} · ${editor.target.side === "old" ? "−" : "+"}${editor.target.lineNumber}`;
const header = useMemo(
() => ({
title: t("review.comment.label"),
subtitle: <Text style={styles.context}>{context}</Text>,
}),
[context, t],
);
const { onSaveEditor, onCancelEditor } = reviewActions;
const handleSave = useCallback(() => onSaveEditor(editor.body), [onSaveEditor, editor.body]);
const canSave = editor.body.trim().length > 0;
const footer = useMemo(
() => (
<View style={styles.actions}>
<Button
size="md"
variant="secondary"
accessibilityLabel={t("review.comment.cancelAccessibility")}
onPress={onCancelEditor}
>
{t("review.comment.cancel")}
</Button>
<Button
size="md"
variant="default"
accessibilityLabel={t("review.comment.saveAccessibility")}
disabled={!canSave}
onPress={handleSave}
>
{t("common.actions.save")}
</Button>
</View>
),
[canSave, handleSave, onCancelEditor, t],
);
return (
<AdaptiveModalSheet
visible
header={header}
onClose={onCancelEditor}
snapPoints={COMMENT_SNAP_POINTS}
scrollable={false}
testID="review-comment-sheet"
footer={footer}
>
<FormTextInput
key={editor.target.key + (editor.commentId ?? "new")}
size="md"
multiline
accessibilityLabel={t("review.comment.label")}
placeholder={t("review.comment.placeholder")}
initialValue={editor.body}
onChangeText={onChangeBody}
style={styles.input}
/>
</AdaptiveModalSheet>
);
}
const COMMENT_SNAP_POINTS = ["50%", "90%"];
const styles = StyleSheet.create((theme) => ({
context: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
input: { flex: 1, minHeight: 0, textAlignVertical: "top" },
actions: { flex: 1, flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2] },
}));
@@ -1,145 +0,0 @@
import { describe, expect, it } from "vitest";
import { createReviewEditor, createReviewEditorScope, type ReviewCommentWriter } from "./index";
import type { ReviewDraftComment } from "../state";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
const target: ReviewableDiffTarget = {
key: "src/example.ts:new:2",
filePath: "src/example.ts",
side: "new",
lineNumber: 2,
hunkHeader: "@@ -1,2 +1,2 @@",
hunkIndex: 0,
lineIndex: 2,
oldLineNumber: null,
newLineNumber: 2,
lineType: "add",
content: "const value = next;",
};
function memoryWriter() {
const drafts = new Map<string, ReviewDraftComment[]>();
let sequence = 0;
const writer: ReviewCommentWriter = {
saveComment: ({ key, id, comment }) => {
const current = drafts.get(key) ?? [];
if (id && current.some((saved) => saved.id === id)) {
drafts.set(
key,
current.map((saved) => (saved.id === id ? { ...saved, ...comment } : saved)),
);
} else {
const saved = {
...comment,
id: String(++sequence),
createdAt: "2026-10-09T00:00:00Z",
updatedAt: "2026-10-09T00:00:00Z",
};
drafts.set(key, [...current, saved]);
}
},
deleteComment: ({ key, id }) => {
drafts.set(
key,
(drafts.get(key) ?? []).filter((comment) => comment.id !== id),
);
},
};
return { writer, comments: (key: string) => drafts.get(key) ?? [] };
}
describe("review editor", () => {
it("saves trimmed comments, edits the same record, and cancels without altering saved comments", () => {
const storage = memoryWriter();
const editor = createReviewEditor("first", storage.writer);
editor.start(target);
editor.setBody(" First comment ");
editor.save(editor.getState()!.body);
expect(editor.getState()).toBeNull();
const saved = storage.comments("first")[0]!;
expect(saved).toMatchObject({
filePath: target.filePath,
side: "new",
lineNumber: 2,
body: "First comment",
});
editor.edit(target, saved);
editor.setBody("Discarded edit");
editor.cancel();
expect(storage.comments("first")).toEqual([saved]);
editor.edit(target, saved);
editor.save(" Updated comment ");
expect(storage.comments("first")).toEqual([{ ...saved, body: "Updated comment" }]);
editor.start(target);
editor.setBody("Discarded new comment");
editor.cancel();
expect(storage.comments("first")).toHaveLength(1);
});
it("keeps an open edit saveable after its submitted record is acknowledged", () => {
const storage = memoryWriter();
const editor = createReviewEditor("first", storage.writer);
editor.start(target);
editor.save("Submitted body");
const saved = storage.comments("first")[0]!;
editor.edit(target, saved);
editor.setBody("Typed before acknowledgement");
storage.writer.deleteComment({ key: "first", id: saved.id });
expect(editor.getState()!.body).toBe("Typed before acknowledgement");
editor.save(editor.getState()!.body);
expect(storage.comments("first")).toMatchObject([
{
body: "Typed before acknowledgement",
filePath: target.filePath,
lineNumber: target.lineNumber,
},
]);
});
it("rejects empty saves and repeated saves after the editor closes", () => {
const storage = memoryWriter();
const editor = createReviewEditor("first", storage.writer);
editor.start(target);
editor.save(" \n ");
expect(editor.getState()).toEqual({ target, commentId: null, body: "" });
expect(storage.comments("first")).toEqual([]);
editor.save("Saved once");
editor.save("Duplicate");
expect(storage.comments("first").map((comment) => comment.body)).toEqual(["Saved once"]);
});
it("isolates draft ownership and closes an editor when its saved comment is deleted", () => {
const storage = memoryWriter();
const first = createReviewEditor("first", storage.writer);
const second = createReviewEditor("second", storage.writer);
first.start(target);
first.save("First workspace");
const saved = storage.comments("first")[0]!;
first.edit(target, saved);
expect(second.getState()).toBeNull();
second.start(target);
second.save("Second workspace");
first.delete(saved.id);
expect(first.getState()).toBeNull();
expect(storage.comments("first")).toEqual([]);
expect(storage.comments("second").map((comment) => comment.body)).toEqual(["Second workspace"]);
});
});
it("retains an open editor through remounts without exposing it on another surface or draft", async () => {
const storage = memoryWriter();
const scope = createReviewEditorScope(storage.writer);
const editor = scope.forDraft("first", "diff");
const unsubscribe = editor.subscribe(() => {});
editor.start(target);
editor.setBody("Unsaved wide body");
unsubscribe();
await Promise.resolve();
const compact = scope.forDraft("first", "diff");
expect(compact.getState()!.body).toBe("Unsaved wide body");
expect(scope.forDraft("first", "combined").getState()).toBeNull();
expect(scope.forDraft("second", "diff").getState()).toBeNull();
compact.save(compact.getState()!.body);
expect(storage.comments("first")[0]!.body).toBe("Unsaved wide body");
expect(scope.forDraft("first", "diff").getState()).toBeNull();
});
-84
View File
@@ -1,84 +0,0 @@
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import type { InlineReviewEditorState } from "../geometry";
import type { ReviewDraftComment } from "../state";
export interface ReviewCommentWriter {
saveComment: (input: {
key: string;
id: string | null;
comment: Pick<ReviewDraftComment, "filePath" | "side" | "lineNumber" | "body">;
}) => void;
deleteComment: (input: { key: string; id: string }) => void;
}
/** One editor belongs to one review draft; replacing the draft creates a fresh editor. */
export function createReviewEditor(key: string, writer: ReviewCommentWriter) {
return buildReviewEditor(key, writer, () => {});
}
function buildReviewEditor(key: string, writer: ReviewCommentWriter, onUnused: () => void) {
let editor: InlineReviewEditorState | null = null;
const listeners = new Set<() => void>();
function publish(next: InlineReviewEditorState | null) {
editor = next;
listeners.forEach((listener) => listener());
if (!editor && listeners.size === 0) onUnused();
}
return {
getState: () => editor,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
queueMicrotask(() => {
if (!editor && listeners.size === 0) onUnused();
});
};
},
start: (target: ReviewableDiffTarget) => publish({ target, commentId: null, body: "" }),
edit: (target: ReviewableDiffTarget, comment: ReviewDraftComment) =>
publish({ target, commentId: comment.id, body: comment.body }),
setBody: (body: string) => {
if (editor) publish({ ...editor, body });
},
cancel: () => publish(null),
save: (body: string) => {
const trimmedBody = body.trim();
if (!editor || !trimmedBody) return;
writer.saveComment({
key,
id: editor.commentId,
comment: {
filePath: editor.target.filePath,
side: editor.target.side,
lineNumber: editor.target.lineNumber,
body: trimmedBody,
},
});
publish(null);
},
delete: (id: string) => {
writer.deleteComment({ key, id });
if (editor?.commentId === id) publish(null);
},
};
}
/** An open draft survives presentation remounts; each presentation owns its editor for that draft. */
export function createReviewEditorScope(writer: ReviewCommentWriter) {
const editors = new Map<string, ReturnType<typeof createReviewEditor>>();
return {
forDraft(key: string, presentation: string) {
const owner = JSON.stringify([key, presentation]);
let model = editors.get(owner);
if (!model) {
const created = buildReviewEditor(key, writer, () => {
if (editors.get(owner) === created) editors.delete(owner);
});
model = created;
editors.set(owner, model);
}
return model;
},
};
}
@@ -1,272 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createReviewFeedback,
createReviewFeedbackScope,
type ReviewFeedbackPorts,
type ReviewFeedbackSnapshot,
type FeedbackAttachment,
type FeedbackRecipient,
} from "./index";
import type { ReviewDraftComment } from "../state";
function makeComment(id: string, body = id): ReviewDraftComment {
return {
id,
body,
filePath: "sample.ts",
side: "new",
lineNumber: 2,
createdAt: "2026-10-09T00:00:00Z",
updatedAt: "2026-10-09T00:00:00Z",
};
}
function makeRecipient(agentId: string): FeedbackRecipient {
return {
agentId,
tab: {
key: `agent_${agentId}`,
tabId: `agent_${agentId}`,
kind: "agent",
target: { kind: "agent", agentId },
},
};
}
function makeAttachment(comments: readonly ReviewDraftComment[]): FeedbackAttachment | null {
if (!comments.length) return null;
const line = {
type: "add" as const,
content: "const value = 1",
oldLineNumber: null,
newLineNumber: 2,
};
return {
kind: "review",
reviewDraftKey: "draft",
commentCount: comments.length,
attachment: {
type: "review",
mimeType: "application/paseo-review",
cwd: "/workspace",
mode: "uncommitted",
baseRef: null,
comments: comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
context: { hunkHeader: "@@ -1 +1 @@", targetLine: line, lines: [line] },
})),
},
};
}
function setup(agentIds: string[], unavailableIds: string[] = [], create = createReviewFeedback) {
let comments = [makeComment("first")];
let recipients = agentIds.map(makeRecipient);
let connected = true;
let rejectSend: ((error: Error) => void) | null = null;
let resolveSend: (() => void) | null = null;
const sends: Array<{ recipient: FeedbackRecipient; attachment: FeedbackAttachment }> = [];
const read = (): ReviewFeedbackSnapshot => ({
comments,
recipients,
connected,
attachment: makeAttachment(comments.filter((comment) => !unavailableIds.includes(comment.id))),
});
const ports: ReviewFeedbackPorts = {
read,
send: (recipient, attachment) => {
sends.push({ recipient, attachment });
return new Promise<void>((resolve, reject) => {
resolveSend = resolve;
rejectSend = reject;
});
},
clearSent: (sent) => {
comments = comments.filter(
(current) =>
!sent.some(
(prior) =>
prior.id === current.id &&
prior.updatedAt === current.updatedAt &&
prior.body === current.body,
),
);
},
failedMessage: () => "Failed to send",
};
const model = create(ports);
return {
model,
ports,
sends,
comments: () => comments,
setComments: (next: ReviewDraftComment[]) => {
comments = next;
model.refresh();
},
setRecipients: (ids: string[]) => {
recipients = ids.map(makeRecipient);
model.refresh();
},
disconnect: () => {
connected = false;
model.refresh();
},
succeed: () => resolveSend!(),
fail: () => rejectSend!(new Error("Host rejected feedback")),
};
}
describe("review feedback", () => {
it("counts only attached comments, freezes the sending count, and retains orphans", async () => {
const feedback = setup(["one"], ["orphan"]);
feedback.setComments([makeComment("first"), makeComment("orphan")]);
expect(feedback.model.getState()).toMatchObject({ commentCount: 2, sendableCommentCount: 1 });
const pending = feedback.model.press();
feedback.setComments([makeComment("first"), makeComment("orphan"), makeComment("added")]);
expect(feedback.model.getState()).toMatchObject({
phase: { kind: "sending", commentCount: 1 },
sendableCommentCount: 2,
});
expect(feedback.sends[0]!.attachment.commentCount).toBe(1);
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([makeComment("orphan"), makeComment("added")]);
});
it("retains an entirely orphaned draft without claiming any comments are sendable", async () => {
const feedback = setup(["one"], ["first"]);
expect(feedback.model.getState()).toMatchObject({
commentCount: 1,
sendableCommentCount: 0,
disabledReason: "no-context",
canSend: false,
});
await feedback.model.press();
expect(feedback.sends).toEqual([]);
expect(feedback.comments()).toEqual([makeComment("first")]);
});
it("retains comments and explains why no workspace agent can receive them", async () => {
const feedback = setup([]);
await feedback.model.press();
expect(feedback.model.getState()).toMatchObject({
canSend: false,
disabledReason: "no-agents",
commentCount: 1,
});
expect(feedback.sends).toEqual([]);
expect(feedback.comments()).toEqual([makeComment("first")]);
});
it("sends once to the only recipient and clears only sent versions", async () => {
const feedback = setup(["one"]);
const pending = feedback.model.press();
await feedback.model.press();
await feedback.model.send("one");
expect(feedback.sends).toEqual([
{ recipient: makeRecipient("one"), attachment: makeAttachment([makeComment("first")]) },
]);
feedback.setComments([makeComment("first", "Edited during send"), makeComment("added")]);
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([
makeComment("first", "Edited during send"),
makeComment("added"),
]);
expect(feedback.model.getState().phase).toEqual({
kind: "sent",
recipient: makeRecipient("one"),
});
});
it("clears unchanged sent comments while preserving additions", async () => {
const feedback = setup(["one"]);
const pending = feedback.model.press();
feedback.setComments([makeComment("first"), makeComment("added")]);
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([makeComment("added")]);
});
it("asks for a recipient when several tabs exist and validates the selection again", async () => {
const feedback = setup(["one", "two"]);
await feedback.model.press();
expect(feedback.model.getState().phase).toEqual({ kind: "choosing" });
expect(feedback.sends).toEqual([]);
feedback.setRecipients(["one"]);
await feedback.model.send("two");
expect(feedback.sends).toEqual([]);
const pending = feedback.model.send("one");
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([]);
});
it("retains comments on failure and retries using the latest saved version", async () => {
const feedback = setup(["one"]);
const failed = feedback.model.press();
feedback.fail();
await failed;
expect(feedback.model.getState().phase).toEqual({
kind: "failed",
message: "Host rejected feedback",
});
expect(feedback.comments()).toEqual([makeComment("first")]);
feedback.setComments([makeComment("first", "Corrected before retry")]);
const retried = feedback.model.press();
feedback.succeed();
await retried;
expect(feedback.sends).toHaveLength(2);
expect(feedback.sends[1]!.attachment.attachment.comments[0]!.body).toBe(
"Corrected before retry",
);
expect(feedback.comments()).toEqual([]);
});
it("keeps workspace drafts independent and prevents disconnected sends", async () => {
const first = setup(["one"]);
const second = setup(["other"]);
first.disconnect();
await first.model.press();
expect(first.model.getState().disabledReason).toBe("disconnected");
const pending = second.model.press();
second.succeed();
await pending;
expect(first.comments()).toEqual([makeComment("first")]);
expect(first.sends).toEqual([]);
expect(second.comments()).toEqual([]);
});
});
describe("shared review submission ownership", () => {
it("blocks another surface and reconnects to the pending draft while other drafts remain usable", async () => {
const scope = createReviewFeedbackScope();
const first = setup(["one"], [], (ports) => scope.connect("workspace-one", ports));
const second = scope.connect("workspace-one", first.ports);
const other = setup(["other"], [], (ports) => scope.connect("workspace-other", ports));
const unsubscribe = first.model.subscribe(() => {});
const pending = first.model.press();
unsubscribe();
await Promise.resolve();
const reconnected = scope.connect("workspace-one", first.ports);
expect(second.getState()).toMatchObject({ canSend: false, phase: { kind: "sending" } });
await second.press();
await reconnected.send("one");
expect(first.sends).toHaveLength(1);
const otherPending = other.model.press();
expect(other.sends).toHaveLength(1);
first.fail();
await pending;
expect(second.getState().phase).toEqual({ kind: "failed", message: "Host rejected feedback" });
const retry = second.press();
first.succeed();
await retry;
expect(first.sends).toHaveLength(2);
other.succeed();
await otherPending;
});
it("opens only the active surface's recipient chooser", async () => {
const scope = createReviewFeedbackScope();
const first = setup(["one", "two"], [], (ports) => scope.connect("same", ports));
const second = scope.connect("same", first.ports);
await first.model.press();
expect(first.model.getState().phase).toEqual({ kind: "choosing" });
expect(second.getState().phase).toEqual({ kind: "idle" });
first.model.closeMenu();
expect(first.model.getState().phase).toEqual({ kind: "idle" });
});
});
-177
View File
@@ -1,177 +0,0 @@
import type { ComposerAttachment } from "@/attachments/types";
import type { ReviewDraftComment } from "../state";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface FeedbackRecipient {
agentId: string;
tab: WorkspaceTabDescriptor;
}
export type FeedbackAttachment = Extract<ComposerAttachment, { kind: "review" }>;
export interface ReviewFeedbackSnapshot {
comments: readonly ReviewDraftComment[];
attachment: FeedbackAttachment | null;
recipients: readonly FeedbackRecipient[];
connected: boolean;
}
export interface ReviewFeedbackPorts {
read: () => ReviewFeedbackSnapshot;
send: (recipient: FeedbackRecipient, attachment: FeedbackAttachment) => Promise<void>;
clearSent: (comments: readonly ReviewDraftComment[]) => void;
failedMessage: () => string;
}
export type FeedbackPhase =
| { kind: "idle" }
| { kind: "choosing" }
| { kind: "sending"; recipient: FeedbackRecipient; commentCount: number }
| { kind: "failed"; message: string }
| { kind: "sent"; recipient: FeedbackRecipient };
export interface ReviewFeedbackState {
phase: FeedbackPhase;
commentCount: number;
sendableCommentCount: number;
recipients: readonly FeedbackRecipient[];
disabledReason: "no-agents" | "disconnected" | "no-context" | null;
canSend: boolean;
}
export function createReviewFeedback(ports: ReviewFeedbackPorts) {
return createFeedbackView(ports, createFeedbackWorkflow());
}
/** Surfaces share submission ownership while keeping their chooser and live inputs local. */
export function createReviewFeedbackScope() {
const workflows = new Map<string, ReturnType<typeof createFeedbackWorkflow>>();
return {
connect(key: string, ports: ReviewFeedbackPorts) {
let workflow = workflows.get(key);
if (!workflow) {
const created = createFeedbackWorkflow(() => {
if (workflows.get(key) === created) workflows.delete(key);
});
workflow = created;
workflows.set(key, workflow);
}
return createFeedbackView(ports, workflow);
},
};
}
function createFeedbackWorkflow(onUnused: () => void = () => {}) {
let phase: FeedbackPhase = { kind: "idle" };
const observers = new Set<() => void>();
function releaseIfUnused() {
queueMicrotask(() => {
if (observers.size === 0 && phase.kind !== "sending") onUnused();
});
}
return {
getPhase: () => phase,
changePhase(next: FeedbackPhase) {
phase = next;
observers.forEach((observer) => observer());
releaseIfUnused();
},
subscribe(observer: () => void) {
observers.add(observer);
return () => {
observers.delete(observer);
releaseIfUnused();
};
},
};
}
function createFeedbackView(
ports: ReviewFeedbackPorts,
workflow: ReturnType<typeof createFeedbackWorkflow>,
) {
let choosing = false;
const choosingPhase: FeedbackPhase = { kind: "choosing" };
const getPhase = () => (choosing ? choosingPhase : workflow.getPhase());
let state = deriveState(ports.read(), getPhase());
const listeners = new Set<() => void>();
function publish() {
state = deriveState(ports.read(), getPhase());
listeners.forEach((listener) => listener());
}
async function send(agentId: string) {
const snapshot = ports.read();
const current = deriveState(snapshot, workflow.getPhase());
const recipient = snapshot.recipients.find((candidate) => candidate.agentId === agentId);
if (!current.canSend || !recipient || !snapshot.attachment) return;
const attachment = snapshot.attachment;
const sentComments = snapshot.comments.filter((comment) =>
attachment.attachment.comments.some(
(sent) =>
sent.filePath === comment.filePath &&
sent.side === comment.side &&
sent.lineNumber === comment.lineNumber &&
sent.body === comment.body,
),
);
choosing = false;
workflow.changePhase({ kind: "sending", recipient, commentCount: attachment.commentCount });
publish();
try {
await ports.send(recipient, attachment);
ports.clearSent(sentComments);
workflow.changePhase({ kind: "sent", recipient });
} catch (error) {
workflow.changePhase({
kind: "failed",
message: error instanceof Error ? error.message : ports.failedMessage(),
});
}
publish();
}
return {
getState: () => {
if (state.phase !== getPhase()) state = deriveState(ports.read(), getPhase());
return state;
},
subscribe: (listener: () => void) => {
listeners.add(listener);
const unsubscribe = workflow.subscribe(() => {
choosing = false;
publish();
});
return () => {
listeners.delete(listener);
unsubscribe();
};
},
refresh: publish,
press: async () => {
const snapshot = ports.read();
if (!deriveState(snapshot, workflow.getPhase()).canSend) return;
if (snapshot.recipients.length === 1) {
await send(snapshot.recipients[0]!.agentId);
} else {
choosing = true;
publish();
}
},
closeMenu: () => {
if (choosing) {
choosing = false;
publish();
}
},
send,
};
}
function deriveState(snapshot: ReviewFeedbackSnapshot, phase: FeedbackPhase): ReviewFeedbackState {
let disabledReason: ReviewFeedbackState["disabledReason"] = null;
if (snapshot.recipients.length === 0) disabledReason = "no-agents";
else if (!snapshot.connected) disabledReason = "disconnected";
else if (!snapshot.attachment) disabledReason = "no-context";
return {
phase,
commentCount: snapshot.comments.length,
sendableCommentCount: snapshot.attachment?.commentCount ?? 0,
recipients: snapshot.recipients,
disabledReason,
canSend: snapshot.comments.length > 0 && disabledReason === null && phase.kind !== "sending",
};
}
@@ -1,284 +0,0 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View } from "react-native";
import { Send } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { useMenuContext } from "@/components/ui/menu";
import { Button } from "@/components/ui/button";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
import { FloatingActionButton } from "@/components/ui/floating-action-button";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import type { FeedbackRecipient, ReviewFeedbackState, createReviewFeedback } from "./index";
type FeedbackModel = ReturnType<typeof createReviewFeedback>;
interface ReviewFeedbackActionProps {
bottomOffset: number;
model: FeedbackModel;
state: ReviewFeedbackState;
serverId: string;
workspaceId: string;
}
export function ReviewFeedbackAction({
enabled,
hasDiff,
...props
}: Omit<ReviewFeedbackActionProps, "workspaceId"> & {
workspaceId?: string | null;
enabled: boolean | undefined;
hasDiff: boolean;
}) {
if (enabled === false || !hasDiff) return null;
return <OpenReviewFeedbackAction {...props} workspaceId={props.workspaceId ?? ""} />;
}
function OpenReviewFeedbackAction({
model,
state,
serverId,
workspaceId,
bottomOffset,
}: ReviewFeedbackActionProps) {
const { t } = useTranslation();
const { bottom } = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) model.closeMenu();
},
[model],
);
const noteStyle = useMemo(
() => [
styles.note,
!isCompact && styles.centered,
{
bottom:
bottom +
bottomOffset +
(isCompact ? 88 : buttonControlHeight.sm + SPACING[4] + SPACING[2]),
},
],
[bottom, bottomOffset, isCompact],
);
const sending = state.phase.kind === "sending";
const label = t(sending ? "review.feedback.sending" : "review.feedback.send", {
count: state.phase.kind === "sending" ? state.phase.commentCount : state.sendableCommentCount,
});
const unavailableNotice =
state.disabledReason && state.commentCount > 0 ? (
<Text style={styles.text}>{t(`review.feedback.${state.disabledReason}`)}</Text>
) : null;
let notice: ReactNode = unavailableNotice;
if (state.phase.kind === "failed")
notice = <Text style={styles.error}>{state.phase.message}</Text>;
else if (state.phase.kind === "sent")
notice = (
<>
<SentNotice
recipient={state.phase.recipient}
serverId={serverId}
workspaceId={workspaceId}
/>
{unavailableNotice}
</>
);
return (
<>
{state.commentCount > 0 ? (
<DropdownMenu
open={state.phase.kind === "choosing"}
onOpenChange={onOpenChange}
compactMode="sheet"
>
<FeedbackTrigger
model={model}
label={label}
disabled={!state.canSend}
bottomInset={bottom + bottomOffset}
/>
<DropdownMenuContent
side="top"
align="center"
sheetTitle={t("review.feedback.chooseAgent")}
>
{state.recipients.map((recipient) => (
<RecipientOption
key={recipient.tab.key}
recipient={recipient}
model={model}
serverId={serverId}
workspaceId={workspaceId}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{notice ? (
<View style={noteStyle} accessibilityRole="alert">
{notice}
</View>
) : null}
</>
);
}
function FeedbackTrigger({
model,
label,
disabled,
bottomInset,
}: {
model: FeedbackModel;
label: string;
disabled: boolean;
bottomInset: number;
}) {
const { triggerRef } = useMenuContext("FeedbackTrigger");
const isCompact = useIsCompactFormFactor();
const press = useCallback(() => {
void model.press();
}, [model]);
if (!isCompact) {
return (
<View
pointerEvents="box-none"
style={[styles.wideAction, { bottom: bottomInset + SPACING[4] }]}
>
<Button
ref={triggerRef}
variant="default"
size="sm"
leftIcon={Send}
accessibilityLabel={label}
disabled={disabled}
onPress={press}
>
{label}
</Button>
</View>
);
}
return (
<FloatingActionButton
ref={triggerRef}
icon={Send}
label={label}
accessibilityLabel={label}
disabled={disabled}
bottomInset={bottomInset}
onPress={press}
/>
);
}
function RecipientOption({
recipient,
model,
serverId,
workspaceId,
}: {
recipient: FeedbackRecipient;
model: FeedbackModel;
serverId: string;
workspaceId: string;
}) {
const choose = useCallback(() => {
void model.send(recipient.agentId);
}, [model, recipient.agentId]);
const render = useCallback(
(presentation: WorkspaceTabPresentation) => (
<RecipientRow presentation={presentation} onSelect={choose} />
),
[choose],
);
return (
<WorkspaceTabPresentationResolver
tab={recipient.tab}
serverId={serverId}
workspaceId={workspaceId}
>
{render}
</WorkspaceTabPresentationResolver>
);
}
function RecipientRow({
presentation,
onSelect,
}: {
presentation: WorkspaceTabPresentation;
onSelect: () => void;
}) {
const leading = useMemo(
() => <WorkspaceTabIcon presentation={presentation} backdrop="surface0" />,
[presentation],
);
return (
<DropdownMenuItem leading={leading} onSelect={onSelect}>
{presentation.label}
</DropdownMenuItem>
);
}
function SentNotice({
recipient,
serverId,
workspaceId,
}: {
recipient: FeedbackRecipient;
serverId: string;
workspaceId: string;
}) {
const { t } = useTranslation();
const render = useCallback(
(presentation: WorkspaceTabPresentation) => (
<Text style={styles.text}>
{t("review.feedback.sent", { recipient: presentation.label })}
</Text>
),
[t],
);
return (
<WorkspaceTabPresentationResolver
tab={recipient.tab}
serverId={serverId}
workspaceId={workspaceId}
>
{render}
</WorkspaceTabPresentationResolver>
);
}
const styles = StyleSheet.create((theme) => ({
wideAction: { position: "absolute", left: 0, right: 0, alignItems: "center" },
centered: { alignItems: "center" },
note: {
position: "absolute",
right: theme.spacing[4],
left: theme.spacing[4],
alignItems: "flex-end",
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
}));
@@ -1,120 +0,0 @@
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import {
useReviewDraftComments,
getReviewDraftComments,
clearSentReviewDraftComments,
} from "../store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useAppSettings } from "@/hooks/use-settings";
import { dispatchComposerAgentMessage } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { SPACING } from "@/styles/theme";
import { i18n } from "@/i18n/i18next";
import {
createReviewFeedbackScope,
type FeedbackAttachment,
type FeedbackRecipient,
} from "./index";
const feedbackScope = createReviewFeedbackScope();
export function useReviewFeedback(input: {
reviewDraftKey: string;
serverId: string;
workspaceId?: string;
attachment: FeedbackAttachment | null;
}) {
const { serverId, reviewDraftKey, workspaceId } = input;
const isCompact = useIsCompactFormFactor();
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const { settings } = useAppSettings();
const comments = useReviewDraftComments(reviewDraftKey);
const layout = useWorkspaceLayoutStore((store) =>
workspaceKey ? store.layoutByWorkspace[workspaceKey] : undefined,
);
// The adapter keeps live host and diff inputs available to commands without reconstructing the workflow.
const live = useRef({
client,
connected,
attachment: input.attachment,
sendBehavior: settings.sendBehavior,
});
live.current = {
client,
connected,
attachment: input.attachment,
sendBehavior: settings.sendBehavior,
};
const model = useMemo(
() =>
feedbackScope.connect(reviewDraftKey, {
read: () => ({
comments: getReviewDraftComments(reviewDraftKey) ?? [],
attachment: live.current.attachment,
connected: live.current.connected,
recipients: readRecipients(workspaceKey),
}),
send: async (recipient, attachment) => {
const { client: targetClient, sendBehavior } = live.current;
if (!targetClient) throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
const activeTurnBehavior = sendBehavior === "steer" ? "steer" : "interrupt";
await dispatchComposerAgentMessage({
client: targetClient,
agentId: recipient.agentId,
text: i18n.t("review.feedback.prompt"),
attachments: [attachment],
encodeImages: async () => [],
submission: createMessageSubmissionWriter(serverId),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
recipient.agentId,
).turnId ?? undefined)
: undefined,
});
},
clearSent: (sentComments) =>
clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }),
failedMessage: () => i18n.t("review.feedback.failed"),
}),
[reviewDraftKey, serverId, workspaceKey],
);
useEffect(() => {
model.refresh();
}, [model, comments, layout, connected, input.attachment]);
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
return {
model,
state,
clearance:
state.commentCount > 0
? (isCompact ? FLOATING_ACTION_BUTTON_CLEARANCE : buttonControlHeight.sm + SPACING[4]) + 44
: 0,
};
}
function readRecipients(workspaceKey: string | null): FeedbackRecipient[] {
const tabs = workspaceKey
? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)
: [];
const descriptors = deriveWorkspaceTabModel({ tabs }).tabs;
const recipients: FeedbackRecipient[] = [];
for (const { descriptor } of descriptors) {
if (descriptor.target.kind === "agent")
recipients.push({ agentId: descriptor.target.agentId, tab: descriptor });
}
return recipients;
}
-1
View File
@@ -19,7 +19,6 @@ export interface InlineReviewActions {
onStartComment: (target: ReviewableDiffTarget) => void;
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
onCancelEditor: () => void;
onChangeEditorBody: (body: string) => void;
onSaveEditor: (body: string) => void;
onDeleteComment: (id: string) => void;
}
-5
View File
@@ -31,8 +31,3 @@ export {
SMALL_ACTION_HIT_SLOP,
useInlineReviewController,
} from "./surface";
export { ReviewCommentSheet } from "./comment-sheet";
export { useReviewFeedback } from "./feedback/use-feedback";
export { ReviewFeedbackAction } from "./feedback/surface";
+1 -56
View File
@@ -2,16 +2,7 @@ import { describe, expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import {
buildReviewAttachmentSnapshot,
buildReviewDraftKey,
addReviewDraftComment,
saveReviewDraftComment,
clearSentReviewDraftComments,
getReviewDraftComments,
resetReviewDraftStore,
useReviewDraftStore,
} from "./store";
import { buildReviewAttachmentSnapshot, buildReviewDraftKey } from "./store";
import {
addCommentToState,
clearReviewInState,
@@ -313,49 +304,3 @@ describe("buildReviewAttachmentSnapshot", () => {
});
});
});
describe("successful feedback cleanup", () => {
it("clears sent versions only, preserving edits, additions and other workspace drafts", () => {
resetReviewDraftStore();
const first = addReviewDraftComment({ key: "first", comment: makeComment() });
const edited = addReviewDraftComment({
key: "first",
comment: makeComment({ id: "edited", body: "Before send" }),
});
const other = addReviewDraftComment({ key: "other", comment: makeComment({ id: "other" }) });
useReviewDraftStore.getState().updateComment({
key: "first",
id: edited.id,
updates: { body: "After send" },
updatedAt: edited.updatedAt,
});
const added = addReviewDraftComment({ key: "first", comment: makeComment({ id: "added" }) });
clearSentReviewDraftComments({ key: "first", comments: [first, edited] });
expect(getReviewDraftComments("first")).toEqual([{ ...edited, body: "After send" }, added]);
expect(getReviewDraftComments("other")).toEqual([other]);
resetReviewDraftStore();
});
});
it("saves an acknowledged open edit as a new version and keeps subsequent edits on that record", () => {
resetReviewDraftStore();
const submitted = addReviewDraftComment({ key: "first", comment: makeComment() });
clearSentReviewDraftComments({ key: "first", comments: [submitted] });
saveReviewDraftComment({
key: "first",
id: submitted.id,
comment: { ...submitted, body: "Open edit after acknowledgement" },
});
const restored = getReviewDraftComments("first")![0]!;
expect(restored.body).toBe("Open edit after acknowledgement");
expect(restored.id).not.toBe(submitted.id);
clearSentReviewDraftComments({ key: "first", comments: [submitted] });
expect(getReviewDraftComments("first")).toEqual([restored]);
saveReviewDraftComment({
key: "first",
id: restored.id,
comment: { ...restored, body: "Saved again" },
});
expect(getReviewDraftComments("first")).toMatchObject([{ id: restored.id, body: "Saved again" }]);
resetReviewDraftStore();
});
-40
View File
@@ -320,43 +320,3 @@ export function useReviewAttachmentSnapshot(input: {
[comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles],
);
}
/** Acknowledgement removes only the versions included in that submission. */
export function clearSentReviewDraftComments(input: {
key: string;
comments: readonly ReviewDraftComment[];
}): void {
const sentById = new Map(input.comments.map((comment) => [comment.id, comment]));
useReviewDraftStore.setState((state) => {
const current = state.drafts[input.key];
if (!current) return state;
const remaining = current.filter((comment) => {
const sent = sentById.get(comment.id);
return !sent || sent.updatedAt !== comment.updatedAt || sent.body !== comment.body;
});
if (remaining.length === current.length) return state;
return { drafts: { ...state.drafts, [input.key]: remaining } };
});
}
/** Acknowledgement can remove the original while the user still has an edit open. */
export function saveReviewDraftComment(input: {
key: string;
id: string | null;
comment: Pick<ReviewDraftComment, "filePath" | "side" | "lineNumber" | "body">;
}): void {
const store = useReviewDraftStore.getState();
if (input.id && store.drafts[input.key]?.some((comment) => comment.id === input.id)) {
store.updateComment({ key: input.key, id: input.id, updates: { body: input.comment.body } });
} else {
store.addComment({
key: input.key,
comment: {
filePath: input.comment.filePath,
side: input.comment.side,
lineNumber: input.comment.lineNumber,
body: input.comment.body,
},
});
}
}
+56 -40
View File
@@ -9,24 +9,45 @@ import { useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
getInlineReviewThreadState,
getSplitInlineReviewThreadState,
type InlineReviewActions,
} from "./geometry";
import {
getInlineReviewThreadViewportStyle,
getSplitInlineReviewThreadState,
groupInlineReviewCommentsByTarget,
InlineReviewEditor,
InlineReviewGutterCell,
InlineReviewThread,
SMALL_ACTION_HIT_SLOP,
useInlineReviewController,
} from "./surface";
type InlineReviewActions,
} from "./index";
void testI18n;
const { pressablePropsByLabel } = vi.hoisted(() => {
const { theme, pressablePropsByLabel } = vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
return { pressablePropsByLabel: new Map<string, Record<string, unknown>>() };
return {
theme: {
spacing: { 1: 4, 2: 8, 3: 12 },
borderWidth: { 1: 1 },
borderRadius: { base: 4, md: 6, lg: 8, xl: 12, full: 999 },
opacity: { 50: 0.5 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { normal: "400", medium: "500" },
lineHeight: { diff: 18 },
colors: {
accent: "#0a84ff",
accentForeground: "#fff",
border: "#555",
destructive: "#ff453a",
foreground: "#fff",
foregroundMuted: "#aaa",
surface1: "#111",
surface2: "#222",
surface3: "#333",
palette: { white: "#fff" },
},
},
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
};
});
vi.mock("react-native", async (importOriginal) => {
@@ -67,16 +88,13 @@ vi.mock("react-native", async (importOriginal) => {
};
});
vi.mock("react-native-unistyles", async () => {
const { darkTheme: theme } = await import("@/styles/theme");
return {
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
}));
vi.mock("@/constants/platform", () => ({
getIsElectron: () => false,
@@ -85,6 +103,19 @@ vi.mock("@/constants/platform", () => ({
isWeb: true,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
Check: createIcon("Check"),
CircleDot: createIcon("CircleDot"),
Pencil: createIcon("Pencil"),
Plus: createIcon("Plus"),
Trash2: createIcon("Trash2"),
X: createIcon("X"),
};
});
function target(overrides: Partial<ReviewableDiffTarget> = {}): ReviewableDiffTarget {
return {
filePath: "src/example.ts",
@@ -116,7 +147,6 @@ function buildReviewActions(overrides: Partial<InlineReviewActions> = {}): Inlin
onStartComment: vi.fn(),
onEditComment: vi.fn(),
onCancelEditor: vi.fn(),
onChangeEditorBody: () => {},
onSaveEditor: vi.fn(),
onDeleteComment: vi.fn(),
...overrides,
@@ -282,9 +312,8 @@ describe("git diff inline review helpers", () => {
it("keeps the line number visible and only floats the plus for line hover", () => {
const reviewTarget = target();
const { queryByTestId, queryByText, rerender } = render(
const { container, queryByText, rerender } = render(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen={false}
@@ -295,11 +324,10 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={COMMENT_LIST}
isEditorOpen={false}
@@ -310,11 +338,10 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen
@@ -325,11 +352,10 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={COMMENT_LIST}
isEditorOpen={false}
@@ -341,7 +367,7 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeTruthy();
expect(container.querySelector("[data-icon='Plus']")).toBeTruthy();
});
});
@@ -358,7 +384,7 @@ describe("InlineReviewEditor", () => {
const onCancel = vi.fn();
const onSave = vi.fn();
const { getByTestId } = render(
<ReviewEditorFixture
<InlineReviewEditor
initialBody=" initial "
onCancel={onCancel}
onSave={onSave}
@@ -378,7 +404,7 @@ describe("InlineReviewEditor", () => {
const onCancel = vi.fn();
const onSave = vi.fn();
const { getByTestId } = render(
<ReviewEditorFixture
<InlineReviewEditor
initialBody="ready"
onCancel={onCancel}
onSave={onSave}
@@ -402,7 +428,7 @@ describe("InlineReviewEditor", () => {
removeEventListener: vi.fn(),
});
const { getByTestId, queryByText } = render(
<ReviewEditorFixture
<InlineReviewEditor
initialBody="ready"
onCancel={vi.fn()}
onSave={vi.fn()}
@@ -446,13 +472,3 @@ describe("InlineReviewThread", () => {
expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1");
});
});
function ReviewEditorFixture({
initialBody,
...props
}: Omit<React.ComponentProps<typeof InlineReviewEditor>, "body" | "onChangeBody"> & {
initialBody: string;
}) {
const [body, onChangeBody] = React.useState(initialBody);
return <InlineReviewEditor {...props} body={body} onChangeBody={onChangeBody} />;
}
+84 -50
View File
@@ -1,11 +1,4 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2 } from "lucide-react-native";
@@ -24,17 +17,10 @@ import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { createReviewEditorScope } from "./editor";
import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { Theme } from "@/styles/theme";
import {
useReviewDraftComments,
useReviewDraftStore,
saveReviewDraftComment,
type ReviewDraftComment,
} from "./store";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
INLINE_REVIEW_EDITOR_HEIGHT,
@@ -112,42 +98,92 @@ export function groupInlineReviewCommentsByTarget(
return grouped;
}
const reviewEditors = createReviewEditorScope({
saveComment: saveReviewDraftComment,
deleteComment: (input) => useReviewDraftStore.getState().deleteComment(input),
});
export function useInlineReviewController(input: {
reviewDraftKey: string;
presentation?: string;
}): InlineReviewActions & {
sheetEditor: InlineReviewEditorState | null;
onChangeEditorBody: (body: string) => void;
} {
const isCompact = useIsCompactFormFactor();
export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions {
const reviewComments = useReviewDraftComments(input.reviewDraftKey);
const commentsByTarget = useMemo(
() => groupInlineReviewCommentsByTarget(reviewComments),
[reviewComments],
);
const model = useMemo(
() => reviewEditors.forDraft(input.reviewDraftKey, input.presentation ?? "diff"),
[input.reviewDraftKey, input.presentation],
const [editor, setEditor] = useState<InlineReviewEditorState | null>(null);
const addComment = useReviewDraftStore((state) => state.addComment);
const updateComment = useReviewDraftStore((state) => state.updateComment);
const deleteComment = useReviewDraftStore((state) => state.deleteComment);
useEffect(() => {
setEditor(null);
}, [input.reviewDraftKey]);
const handleStartComment = useCallback((target: ReviewableDiffTarget) => {
setEditor({ target, commentId: null, body: "" });
}, []);
const handleEditComment = useCallback(
(target: ReviewableDiffTarget, comment: ReviewDraftComment) => {
setEditor({ target, commentId: comment.id, body: comment.body });
},
[],
);
const editor = useSyncExternalStore(model.subscribe, model.getState, model.getState);
return useMemo(
const handleCancelEditor = useCallback(() => {
setEditor(null);
}, []);
const handleSaveEditor = useCallback(
(body: string) => {
const trimmedBody = body.trim();
if (!editor || trimmedBody.length === 0) {
return;
}
if (editor.commentId) {
updateComment({
key: input.reviewDraftKey,
id: editor.commentId,
updates: { body: trimmedBody },
});
} else {
addComment({
key: input.reviewDraftKey,
comment: {
filePath: editor.target.filePath,
side: editor.target.side,
lineNumber: editor.target.lineNumber,
body: trimmedBody,
},
});
}
setEditor(null);
},
[addComment, editor, input.reviewDraftKey, updateComment],
);
const handleDeleteComment = useCallback(
(id: string) => {
deleteComment({ key: input.reviewDraftKey, id });
setEditor((current) => (current?.commentId === id ? null : current));
},
[deleteComment, input.reviewDraftKey],
);
return useMemo<InlineReviewActions>(
() => ({
commentsByTarget,
editor: isCompact ? null : editor,
sheetEditor: isCompact ? editor : null,
onChangeEditorBody: model.setBody,
onStartComment: model.start,
onEditComment: model.edit,
onCancelEditor: model.cancel,
onSaveEditor: model.save,
onDeleteComment: model.delete,
editor,
onStartComment: handleStartComment,
onEditComment: handleEditComment,
onCancelEditor: handleCancelEditor,
onSaveEditor: handleSaveEditor,
onDeleteComment: handleDeleteComment,
}),
[commentsByTarget, editor, isCompact, model],
[
commentsByTarget,
editor,
handleCancelEditor,
handleDeleteComment,
handleEditComment,
handleSaveEditor,
handleStartComment,
],
);
}
@@ -285,8 +321,7 @@ export function InlineReviewThread({
const editorElement = editor ? (
<InlineReviewEditor
key={editingCommentId ?? "new"}
body={editor.body}
onChangeBody={reviewActions.onChangeEditorBody}
initialBody={editor.body}
onCancel={reviewActions.onCancelEditor}
onSave={reviewActions.onSaveEditor}
testID="inline-review-editor"
@@ -393,20 +428,19 @@ export function getInlineReviewThreadViewportStyle({
}
export function InlineReviewEditor({
body,
onChangeBody,
initialBody,
onCancel,
onSave,
testID,
}: {
body: string;
onChangeBody: (body: string) => void;
initialBody: string;
onCancel: () => void;
onSave: (body: string) => void;
testID?: string;
}) {
const { t } = useTranslation();
const inputRef = useRef<EditingTextInputHandle | null>(null);
const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false);
const trimmedBody = body.trim();
const canSave = trimmedBody.length > 0;
@@ -472,7 +506,7 @@ export function InlineReviewEditor({
placeholderTextColor={styles.placeholderColor.color}
multiline
initialValue={body}
onChangeText={onChangeBody}
onChangeText={setBody}
onFocus={handleFocus}
onBlur={handleBlur}
style={inputStyle}
@@ -31,7 +31,8 @@ import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { Shortcut } from "@/components/ui/shortcut";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TitlebarDragRegion } from "@/components/desktop/titlebar-drag-region";
import { MenuHeader } from "@/components/headers/menu-header";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { ScreenHeader } from "@/components/headers/screen-header";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useToast } from "@/contexts/toast-context";
import { useAgentInputDraft } from "@/composer/draft/input-draft";
@@ -2367,6 +2368,7 @@ export function NewWorkspaceScreen({
},
});
const screenHeaderLeft = useMemo(() => <SidebarMenuToggle />, []);
const importSession = useImportSession({ serverId: selectedServerId });
const composer = isTerminalLaunch ? (
@@ -2429,7 +2431,7 @@ export function NewWorkspaceScreen({
);
return (
<FileDropZone style={styles.container}>
<MenuHeader borderless />
<ScreenHeader left={screenHeaderLeft} borderless />
<View style={styles.content}>
<TitlebarDragRegion />
<NewWorkspaceLayout
@@ -1,72 +0,0 @@
import { useMemo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { CompactHeader } from "@/components/headers/compact-header";
import {
EXPLORER_TOGGLE_KEYS,
WorkspaceExplorerToggle,
} from "@/screens/workspace/workspace-explorer-toggle";
import { WorkspaceHeaderProjectRow } from "@/screens/workspace/workspace-header-project-row";
export { MobileWorkspaceTabSwitcher } from "./tab-switcher";
interface CompactWorkspaceHeaderProps {
serverId: string;
isLoading: boolean;
title: string;
subtitle: string;
isSubtitleDistinct: boolean;
onToggleExplorer: () => void;
explorerToggleLabel: string;
explorerToggleAccessibilityState: { expanded: boolean };
/** Tab switcher and workspace menu, before the explorer toggle. */
children: ReactNode;
}
/** The compact header with the workspace's project row and controls. */
export function CompactWorkspaceHeader({
isLoading,
title,
subtitle,
isSubtitleDistinct,
onToggleExplorer,
explorerToggleLabel,
explorerToggleAccessibilityState,
children,
serverId,
}: CompactWorkspaceHeaderProps) {
const { t } = useTranslation();
const projectRow = useMemo(
() => (
<WorkspaceHeaderProjectRow
subtitle={subtitle}
isSubtitleDistinct={isSubtitleDistinct}
serverId={serverId}
/>
),
[subtitle, isSubtitleDistinct, serverId],
);
const actions = (
<>
{children}
<WorkspaceExplorerToggle
onPress={onToggleExplorer}
label={explorerToggleLabel}
tooltipLabel={t("workspace.tabs.explorerSidebar.toggle")}
tooltipKeys={EXPLORER_TOGGLE_KEYS}
accessibilityState={explorerToggleAccessibilityState}
mobile
/>
</>
);
return (
<CompactHeader
navigation="menu"
title={title}
titleTestID="workspace-header-title"
subtitle={projectRow}
loading={isLoading}
actions={actions}
/>
);
}
@@ -1,501 +0,0 @@
import { memo, useCallback, useMemo, useRef, useState } from "react";
import { Keyboard, Pressable, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { SquarePen, SquareTerminal } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { Button } from "@/components/ui/button";
import { Combobox, type ComboboxOption } from "@/components/ui/combobox";
import { useIconButtonChromeStyle } from "@/components/ui/icon-button-chrome";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabOptionRow,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import { MobileTabTrailingAccessory } from "@/screens/workspace/workspace-tab-trailing-accessory";
import {
buildWorkspaceTabMenuEntries,
type WorkspaceTabMenuLabels,
} from "@/screens/workspace/workspace-tab-menu";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
// The trigger draws at 32pt beside its island neighbours, so only vertical slop is free.
const TAB_SWITCHER_TRIGGER_HIT_SLOP = { top: 8, bottom: 8 } as const;
function getFallbackTabOptionLabel(
tab: WorkspaceTabDescriptor,
labels: {
newTab: string;
newAgent: string;
setup: string;
terminal: string;
browser: string;
agent: string;
changes: string;
files: string;
pullRequest: string;
},
): string {
if (tab.target.kind === "new_tab") {
return labels.newTab;
}
if (tab.target.kind === "draft") {
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return labels.setup;
}
if (tab.target.kind === "terminal") {
return labels.terminal;
}
if (tab.target.kind === "browser") {
return labels.browser;
}
if (tab.target.kind === "file") {
return tab.target.path.split("/").findLast(Boolean) ?? tab.target.path;
}
if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") {
return labels.changes;
}
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
return labels.agent;
}
function getFallbackTabOptionDescription(
tab: WorkspaceTabDescriptor,
labels: {
newTab: string;
newAgent: string;
workspaceSetup: string;
agent: string;
terminal: string;
browser: string;
changes: string;
files: string;
pullRequest: string;
},
): string {
if (tab.target.kind === "new_tab") {
return labels.newTab;
}
if (tab.target.kind === "draft") {
return labels.newAgent;
}
if (tab.target.kind === "setup") {
return labels.workspaceSetup;
}
if (tab.target.kind === "agent") {
return labels.agent;
}
if (tab.target.kind === "terminal") {
return labels.terminal;
}
if (tab.target.kind === "browser") {
return labels.browser;
}
if (tab.target.kind === "provider_subagent") {
return labels.agent;
}
if (tab.target.kind === "commit_diff") {
return tab.target.sha.slice(0, 7);
}
if (tab.target.kind === "working_diff" || tab.target.kind === "changes_tree") {
return labels.changes;
}
if (tab.target.kind === "files") {
return labels.files;
}
if (tab.target.kind === "pull_request") {
return labels.pullRequest;
}
if (tab.target.kind === "plugin") {
return tab.target.panelId;
}
return tab.target.path;
}
interface MobileWorkspaceTabSwitcherProps {
tabs: WorkspaceTabDescriptor[];
activeTabKey: string;
normalizedServerId: string;
normalizedWorkspaceId: string;
onSelectSwitcherTab: (key: string) => void;
createTerminalDisabled: boolean;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}
function MobileWorkspaceTabOption({
tab,
tabIndex,
tabCount,
normalizedServerId,
normalizedWorkspaceId,
selected,
active,
onPress,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: {
tab: WorkspaceTabDescriptor;
tabIndex: number;
tabCount: number;
normalizedServerId: string;
normalizedWorkspaceId: string;
selected: boolean;
active: boolean;
onPress: () => void;
onCopyResumeCommand: (agentId: string) => Promise<void> | void;
onCopyAgentId: (agentId: string) => Promise<void> | void;
onCopyTerminalId: (terminalId: string) => Promise<void> | void;
onCopyFilePath: (path: string) => Promise<void> | void;
onReloadAgent: (agentId: string) => Promise<void> | void;
onRenameTab: (tab: WorkspaceTabDescriptor) => void;
onCloseTab: (tabId: string) => Promise<void> | void;
onCloseTabsAbove: (tabId: string) => Promise<void> | void;
onCloseTabsBelow: (tabId: string) => Promise<void> | void;
onCloseOtherTabs: (tabId: string) => Promise<void> | void;
}) {
const { t } = useTranslation();
const tabMenuLabels = useMemo<WorkspaceTabMenuLabels>(
() => ({
copyResumeCommand: t("workspace.tabs.menu.copyResumeCommand"),
copyAgentId: t("workspace.tabs.menu.copyAgentId"),
copyTerminalId: t("workspace.tabs.menu.copyTerminalId"),
copyFilePath: t("workspace.tabs.menu.copyFilePath"),
rename: t("workspace.tabs.menu.rename"),
closeAbove: t("workspace.tabs.menu.closeAbove"),
closeBelow: t("workspace.tabs.menu.closeBelow"),
closeLeft: t("workspace.tabs.menu.closeLeft"),
closeRight: t("workspace.tabs.menu.closeRight"),
closeOthers: t("workspace.tabs.menu.closeOthers"),
reloadAgent: t("workspace.tabs.menu.reloadAgent"),
reloadAgentTooltip: t("workspace.tabs.menu.reloadAgentTooltip"),
close: t("workspace.tabs.menu.close"),
}),
[t],
);
const menuTestIDBase = `workspace-tab-menu-${tab.tabId}`;
const menuEntries = buildWorkspaceTabMenuEntries({
surface: "mobile",
tab,
index: tabIndex,
tabCount,
menuTestIDBase,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsBefore: onCloseTabsAbove,
onCloseTabsAfter: onCloseTabsBelow,
onCloseOtherTabs,
labels: tabMenuLabels,
});
const fallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}),
[t],
);
const fallbackLabel = getFallbackTabOptionLabel(tab, fallbackLabels);
const trailingAccessory = useMemo(
() => (
<MobileTabTrailingAccessory
menuTestIDBase={menuTestIDBase}
presentationLabel={fallbackLabel}
menuEntries={menuEntries}
/>
),
[menuTestIDBase, fallbackLabel, menuEntries],
);
const renderPresentation = useCallback(
(presentation: WorkspaceTabPresentation) => (
<WorkspaceTabOptionRow
presentation={presentation}
selected={selected}
active={active}
onPress={onPress}
trailingAccessory={trailingAccessory}
/>
),
[selected, active, onPress, trailingAccessory],
);
return (
<WorkspaceTabPresentationResolver
tab={tab}
serverId={normalizedServerId}
workspaceId={normalizedWorkspaceId}
>
{renderPresentation}
</WorkspaceTabPresentationResolver>
);
}
export const MobileWorkspaceTabSwitcher = memo(function MobileWorkspaceTabSwitcher({
tabs,
activeTabKey,
normalizedServerId,
normalizedWorkspaceId,
onSelectSwitcherTab,
createTerminalDisabled,
onCreateDraftTab,
onCreateTerminal,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
}: MobileWorkspaceTabSwitcherProps) {
const { t } = useTranslation();
const tabFallbackLabels = useMemo(
() => ({
newTab: t("workspace.tabs.actions.newTab"),
newAgent: t("workspace.tabs.fallback.newAgent"),
setup: t("workspace.tabs.fallback.setup"),
workspaceSetup: t("workspace.tabs.fallback.workspaceSetup"),
terminal: t("workspace.tabs.fallback.terminal"),
browser: t("workspace.tabs.fallback.browser"),
agent: t("workspace.tabs.fallback.agent"),
changes: t("panels.diff.changesLabel"),
files: t("panels.files.label"),
pullRequest: t("panels.pullRequest.label"),
}),
[t],
);
const tabSwitcherOptions = useMemo(
() =>
tabs.map((tab) => ({
id: tab.key,
label: getFallbackTabOptionLabel(tab, tabFallbackLabels),
description: getFallbackTabOptionDescription(tab, tabFallbackLabels),
})),
[tabFallbackLabels, tabs],
);
const tabByKey = useMemo(() => new Map(tabs.map((tab) => [tab.key, tab])), [tabs]);
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
const [isOpen, setIsOpen] = useState(false);
const anchorRef = useRef<View>(null);
const tabIndexByKey = useMemo(() => {
const map = new Map<string, number>();
tabs.forEach((tab, index) => {
map.set(tab.key, index);
});
return map;
}, [tabs]);
const handleOpenSwitcher = useCallback(() => {
Keyboard.dismiss();
setIsOpen(true);
}, []);
const handleNewAgent = useCallback(() => {
setIsOpen(false);
onCreateDraftTab();
}, [onCreateDraftTab]);
const handleNewTerminal = useCallback(() => {
setIsOpen(false);
onCreateTerminal();
}, [onCreateTerminal]);
const footer = useMemo(
() => (
<View style={styles.tabSwitcherFooter}>
<Button
testID="workspace-tab-switcher-new-agent"
style={styles.tabSwitcherFooterButton}
variant="secondary"
leftIcon={SquarePen}
onPress={handleNewAgent}
>
{t("workspace.header.actions.newAgent")}
</Button>
<Button
testID="workspace-tab-switcher-new-terminal"
style={styles.tabSwitcherFooterButton}
variant="secondary"
leftIcon={SquareTerminal}
disabled={createTerminalDisabled}
onPress={handleNewTerminal}
>
{t("workspace.header.actions.newTerminal")}
</Button>
</View>
),
[createTerminalDisabled, handleNewAgent, handleNewTerminal, t],
);
const renderTabOption = useCallback(
({
option,
selected,
active,
onPress,
}: {
option: ComboboxOption;
selected: boolean;
active: boolean;
onPress: () => void;
}) => {
const tab = tabByKey.get(option.id);
if (!tab) {
return <View />;
}
const tabIndex = tabIndexByKey.get(tab.key) ?? -1;
if (tabIndex < 0) {
return <View />;
}
return (
<MobileWorkspaceTabOption
tab={tab}
tabIndex={tabIndex}
tabCount={tabs.length}
normalizedServerId={normalizedServerId}
normalizedWorkspaceId={normalizedWorkspaceId}
selected={selected}
active={active}
onPress={onPress}
onCopyResumeCommand={onCopyResumeCommand}
onCopyAgentId={onCopyAgentId}
onCopyTerminalId={onCopyTerminalId}
onCopyFilePath={onCopyFilePath}
onReloadAgent={onReloadAgent}
onRenameTab={onRenameTab}
onCloseTab={onCloseTab}
onCloseTabsAbove={onCloseTabsAbove}
onCloseTabsBelow={onCloseTabsBelow}
onCloseOtherTabs={onCloseOtherTabs}
/>
);
},
[
tabByKey,
tabIndexByKey,
tabs.length,
normalizedServerId,
normalizedWorkspaceId,
onCopyResumeCommand,
onCopyAgentId,
onCopyTerminalId,
onCopyFilePath,
onReloadAgent,
onRenameTab,
onCloseTab,
onCloseTabsAbove,
onCloseTabsBelow,
onCloseOtherTabs,
],
);
return (
<>
<Pressable
ref={anchorRef}
testID="workspace-tab-switcher-trigger"
accessibilityRole="button"
accessibilityLabel={t("workspace.tabs.switcher.trigger", { count: tabs.length })}
hitSlop={TAB_SWITCHER_TRIGGER_HIT_SLOP}
style={triggerStyle}
onPress={handleOpenSwitcher}
>
<View style={styles.tabCountBadge}>
<Text style={styles.tabCountText}>{tabs.length}</Text>
</View>
</Pressable>
<Combobox
options={tabSwitcherOptions}
value={activeTabKey}
onSelect={onSelectSwitcherTab}
searchable={false}
title={t("workspace.tabs.switcher.title")}
searchPlaceholder={t("workspace.tabs.switcher.searchPlaceholder")}
open={isOpen}
onOpenChange={setIsOpen}
anchorRef={anchorRef}
renderOption={renderTabOption}
footer={footer}
/>
</>
);
});
const styles = StyleSheet.create((theme) => ({
tabSwitcherFooter: {
flexDirection: "row",
gap: theme.spacing[3],
paddingHorizontal: theme.spacing[6],
paddingVertical: theme.spacing[3],
},
tabSwitcherFooterButton: {
flex: 1,
},
// Sized and stroked like the 16px header glyphs beside it (1.5 stroke on a 24 viewbox = 1px).
tabCountBadge: {
minWidth: 16,
height: 16,
paddingHorizontal: 2,
alignItems: "center",
justifyContent: "center",
borderRadius: theme.borderRadius.sm,
borderWidth: theme.borderWidth[1],
borderColor: theme.colors.foregroundMuted,
},
// Android pads text above the ascender by default, which pushes the digit below center.
tabCountText: {
fontSize: 10,
lineHeight: 12,
includeFontPadding: false,
textAlign: "center",
textAlignVertical: "center",
fontWeight: theme.fontWeight.medium,
color: theme.colors.foregroundMuted,
fontVariant: ["tabular-nums"],
},
}));
@@ -11,8 +11,6 @@ import type { ShortcutKey } from "@/utils/format-shortcut";
const ThemedPanelRight = withUnistyles(PanelRight);
export const EXPLORER_TOGGLE_KEYS: ShortcutKey[] = ["mod", "E"];
interface WorkspaceExplorerToggleProps {
onPress: () => void;
label: string;
@@ -2,7 +2,14 @@ import React, { useCallback, useMemo } from "react";
import { View } from "react-native";
import { useRouter, type Href } from "expo-router";
import { useTranslation } from "react-i18next";
import { Copy, Ellipsis, Globe, Import as ImportIcon, Settings } from "lucide-react-native";
import {
Copy,
Ellipsis,
Globe,
Import as ImportIcon,
Settings,
SquarePen,
} from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import type { TerminalProfile } from "@getpaseo/protocol/messages";
import {
@@ -17,7 +24,6 @@ import {
extraMutedIconColorMapping,
iconButtonChromeGlyphSize,
iconButtonChromeStyle,
useIconButtonChromeStyle,
} from "@/components/ui/icon-button-chrome";
import { TerminalProfileIcon } from "@/components/terminal-profile-icon";
import { useDaemonConfig } from "@/hooks/use-daemon-config";
@@ -30,13 +36,16 @@ import type { Theme } from "@/styles/theme";
const ThemedEllipsis = withUnistyles(Ellipsis);
const ThemedCopy = withUnistyles(Copy);
const ThemedSquarePen = withUnistyles(SquarePen);
const ThemedGlobe = withUnistyles(Globe);
const ThemedImport = withUnistyles(ImportIcon);
const ThemedSettings = withUnistyles(Settings);
const mutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
const MENU_NEW_AGENT_ICON = <ThemedSquarePen size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_BROWSER_ICON = <ThemedGlobe size={16} uniProps={mutedColorMapping} />;
const MENU_NEW_TERMINAL_ICON = <TerminalProfileIcon iconKey={undefined} size={16} />;
const MENU_IMPORT_ICON = <ThemedImport size={16} uniProps={mutedColorMapping} />;
const MENU_COPY_ICON = <ThemedCopy size={16} uniProps={mutedColorMapping} />;
const MENU_SETTINGS_ICON = <ThemedSettings size={16} uniProps={mutedColorMapping} />;
@@ -194,25 +203,28 @@ export interface WorkspaceHeaderMenuMobileProps extends WorkspaceHeaderWorkspace
normalizedServerId: string;
showCreateBrowserTab: boolean;
createTerminalDisabled: boolean;
onCreateDraftTab: () => void;
onCreateTerminal: () => void;
onCreateTerminalWithProfile: (profile: TerminalProfile) => void;
onCreateBrowser: () => void;
}
/**
* Compact layouts have no tab strip. New agent and New terminal sit in the tab switcher's footer;
* the rarer launches stay here alongside the workspace actions.
* Compact layouts have no tab strip to launch from, so new tabs live here alongside the workspace
* actions.
*/
export function WorkspaceHeaderMenuMobile({
normalizedServerId,
showCreateBrowserTab,
createTerminalDisabled,
onCreateDraftTab,
onCreateTerminal,
onCreateTerminalWithProfile,
onCreateBrowser,
...workspaceActions
}: WorkspaceHeaderMenuMobileProps) {
const { t } = useTranslation();
const router = useRouter();
const triggerStyle = useIconButtonChromeStyle({ size: "large" });
const { config } = useDaemonConfig(normalizedServerId);
const profiles = useMemo(
() => resolveTerminalProfiles(config?.terminalProfiles),
@@ -227,7 +239,7 @@ export function WorkspaceHeaderMenuMobile({
<DropdownMenu compactMode="sheet">
<DropdownMenuTrigger
testID="workspace-header-menu-trigger"
style={triggerStyle}
style={workspaceHeaderMenuButtonStyle}
hitSlop={COMPACT_HEADER_BUTTON_HIT_SLOP}
accessibilityRole="button"
accessibilityLabel={t("workspace.header.actions.workspaceActions")}
@@ -240,6 +252,13 @@ export function WorkspaceHeaderMenuMobile({
testID="workspace-header-menu"
sheetTitle={t("workspace.header.actions.workspaceActions")}
>
<DropdownMenuItem
testID="workspace-header-new-agent"
leading={MENU_NEW_AGENT_ICON}
onSelect={onCreateDraftTab}
>
{t("workspace.header.actions.newAgent")}
</DropdownMenuItem>
{showCreateBrowserTab ? (
<DropdownMenuItem
testID="workspace-header-new-browser"
@@ -252,6 +271,14 @@ export function WorkspaceHeaderMenuMobile({
<WorkspaceHeaderWorkspaceActionItems {...workspaceActions} />
<DropdownMenuSeparator />
<DropdownMenuLabel>{t("workspace.tabs.actions.terminalProfilesMenu")}</DropdownMenuLabel>
<DropdownMenuItem
testID="workspace-header-new-terminal"
leading={MENU_NEW_TERMINAL_ICON}
disabled={createTerminalDisabled}
onSelect={onCreateTerminal}
>
{t("workspace.header.actions.newTerminal")}
</DropdownMenuItem>
{profiles.map((profile) => (
<HeaderMenuProfileItem
key={profile.id}
@@ -1,68 +0,0 @@
import { Text, View } from "react-native";
import { StyleSheet } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { HostBadge } from "@/hosts/host-badge";
import { useHostBadges } from "@/hosts/use-host-badges";
/**
* Which project the workspace belongs to, and which machine it runs on.
*
* Compact gets both, on their own line under the workspace name: this header is the only thing on
* screen that says where the workspace lives, because the sidebar that normally carries the host
* badge is closed. It still follows the host's own badge setting, so a purely local setup stays
* quiet. A project name that only repeats the workspace name is dropped on wide, where the two sit
* side by side, and kept on compact, where the line exists for the host anyway.
*/
export function WorkspaceHeaderProjectRow({
subtitle,
isSubtitleDistinct,
serverId,
}: {
subtitle: string;
isSubtitleDistinct: boolean;
serverId: string;
}) {
const isCompact = useIsCompactFormFactor();
const hostBadge = useHostBadges({ enabled: isCompact }).get(serverId) ?? null;
const showProject = isSubtitleDistinct || isCompact;
if (!showProject && !hostBadge) {
return null;
}
return (
<View style={styles.row}>
{showProject ? (
<Text testID="workspace-header-subtitle" style={styles.project} numberOfLines={1}>
{subtitle}
</Text>
) : null}
{showProject && hostBadge ? <Text style={styles.separator}>·</Text> : null}
{hostBadge ? <HostBadge badge={hostBadge} /> : null}
</View>
);
}
const styles = StyleSheet.create((theme) => ({
// The compact header stretches this row to its available title width. Within that width,
// the project truncates and HostBadge yields its label while retaining its fixed icon.
row: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1.5],
minWidth: 0,
flexShrink: 1,
},
project: {
color: theme.colors.foregroundMuted,
fontSize: {
xs: theme.fontSize.sm,
md: theme.fontSize.base,
},
flexShrink: 1,
minWidth: 0,
},
separator: {
color: theme.colors.foregroundExtraMuted,
fontSize: theme.fontSize.sm,
flexShrink: 0,
},
}));
File diff suppressed because it is too large Load Diff
-57
View File
@@ -1,57 +0,0 @@
import { isCompactBreakpoint } from "@/constants/layout";
import { ICON_SIZE, type Theme } from "@/styles/theme";
/**
* Density sizes for surfaces that opt into touch sizing: rows, icons, and text grow on compact
* layouts and keep their desktop sizes from the `md` breakpoint up. Plain theme tokens stay
* desktop-sized everywhere, so a surface migrates by swapping its sizes for these.
*
* Style values are Unistyles breakpoint values, resolved inside `StyleSheet.create`. Icon sizes
* are props, so icons take them through `DensityIcon` or a `densityIconProps` mapping.
*/
export type DensityIconSize = keyof typeof ICON_SIZE;
type TextSize = "sm" | "base";
const COMPACT_ICON_SIZE: Record<DensityIconSize, number> = { xs: 14, sm: 18, md: 20, lg: 24 };
const COMPACT_TEXT_SIZE: Record<TextSize, keyof Theme["fontSize"]> = { sm: "base", base: "lg" };
// Compact layouts end where `md` begins; see `isCompactBreakpoint`.
function byLayout<T>(compact: T, desktop: T) {
return { xs: compact, md: desktop };
}
export const density = {
/** A list row such as a sidebar entry. */
rowHeight: byLayout(44, 36),
/** A row in a tight group, such as the sidebar's navigation rows. */
tightRowHeight: byLayout(40, 28),
/** The line height of a row's `base` text; slots that align to its first line share it. */
rowLineHeight: byLayout(22, 20),
/** A square icon-only button that sits in a row or toolbar. */
iconButton: byLayout(44, 28),
};
/** The width or height of a slot that holds an icon of `size`. */
export function densityIconBox(size: DensityIconSize) {
return byLayout(COMPACT_ICON_SIZE[size], ICON_SIZE[size]);
}
export function densityFontSize(theme: Theme, size: TextSize) {
return byLayout(theme.fontSize[COMPACT_TEXT_SIZE[size]], theme.fontSize[size]);
}
export function densityIconSize(rt: { breakpoint?: string }, size: DensityIconSize): number {
return isCompactBreakpoint(rt.breakpoint) ? COMPACT_ICON_SIZE[size] : ICON_SIZE[size];
}
/** Extends a `withUnistyles` icon mapping so the icon also takes its density size. */
export function densityIconProps<P extends object>(
size: DensityIconSize,
mapping: (theme: Theme) => P,
) {
return (theme: Theme, rt: { breakpoint?: string }) => ({
...mapping(theme),
size: densityIconSize(rt, size),
});
}

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