mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-17 06:42:40 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9f9982b488 | ||
|
|
b63a325be3 | ||
|
|
6ec6633425 | ||
|
|
b26b9234c0 |
@@ -2,6 +2,7 @@ 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");
|
||||
@@ -145,6 +146,7 @@ export default {
|
||||
withAndroidScroll,
|
||||
[withAndroidAsyncStorageSize, 64],
|
||||
...buildProfile.cameraPlugins,
|
||||
withAndroidOptionalCamera,
|
||||
[
|
||||
"expo-splash-screen",
|
||||
{
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createRequire } from "node:module";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Load the copies the Expo CLI uses for prebuild, not older hoisted ones.
|
||||
const requireFromExpo = createRequire(require.resolve("expo/package.json"));
|
||||
const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json"));
|
||||
const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config");
|
||||
const { compileModsAsync } = requireFromExpoCli(
|
||||
"@expo/config-plugins/build/plugins/mod-compiler.js",
|
||||
);
|
||||
|
||||
interface ManifestEntry {
|
||||
$: Record<string, string>;
|
||||
}
|
||||
|
||||
const originalFdroidFlag = process.env.PASEO_FDROID_BUILD;
|
||||
|
||||
async function resolveAndroidManifest() {
|
||||
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] });
|
||||
await compileModsAsync(exp, {
|
||||
projectRoot: __dirname,
|
||||
introspect: true,
|
||||
platforms: ["android"],
|
||||
assertMissingModProviders: false,
|
||||
});
|
||||
return exp._internal.modResults.android.manifest.manifest;
|
||||
}
|
||||
|
||||
describe("Android app config", () => {
|
||||
afterEach(() => {
|
||||
if (originalFdroidFlag === undefined) {
|
||||
delete process.env.PASEO_FDROID_BUILD;
|
||||
} else {
|
||||
process.env.PASEO_FDROID_BUILD = originalFdroidFlag;
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Google Play", "0"],
|
||||
["F-Droid", "1"],
|
||||
])(
|
||||
"%s build installs on devices without a camera",
|
||||
async (_build, fdroidFlag) => {
|
||||
process.env.PASEO_FDROID_BUILD = fdroidFlag;
|
||||
const manifest = await resolveAndroidManifest();
|
||||
const permissions = (manifest["uses-permission"] ?? []).map(
|
||||
(entry: ManifestEntry) => entry.$["android:name"],
|
||||
);
|
||||
const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$);
|
||||
|
||||
expect(permissions).toContain("android.permission.CAMERA");
|
||||
expect(features).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ "android:name": "android.hardware.camera", "android:required": "false" },
|
||||
{ "android:name": "android.hardware.camera.autofocus", "android:required": "false" },
|
||||
]),
|
||||
);
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -1,7 +1,14 @@
|
||||
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";
|
||||
@@ -26,14 +33,19 @@ async function openComposerDiff(page: Page) {
|
||||
await composerChangesPill(page).click();
|
||||
}
|
||||
|
||||
async function seedChangedAgent(repoPrefix: string) {
|
||||
async function seedChangedAgent(
|
||||
repoPrefix: string,
|
||||
featureValues?: Record<string, unknown>,
|
||||
files: Array<{ path: string; content: string }> = [],
|
||||
) {
|
||||
const workspace = await seedMockAgentWorkspace({
|
||||
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: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
|
||||
},
|
||||
});
|
||||
try {
|
||||
@@ -156,3 +168,691 @@ 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,31 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,76 +0,0 @@
|
||||
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())
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/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,6 +6,8 @@ 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;
|
||||
@@ -29,6 +31,8 @@ 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,
|
||||
@@ -74,6 +78,7 @@ export async function gateNextAgentMessage(page: Page) {
|
||||
|
||||
return {
|
||||
waitForRequest,
|
||||
requestCount: () => requests.length,
|
||||
accept(index = 0) {
|
||||
const heldMessage = heldMessages[index];
|
||||
if (!serverSocket || !heldMessage) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
const { withAndroidManifest } = require("expo/config-plugins");
|
||||
|
||||
// Requesting CAMERA implies these features are required, which hides the app
|
||||
// on Google Play for devices without a camera. Pairing works without QR scanning.
|
||||
const OPTIONAL_CAMERA_FEATURES = ["android.hardware.camera", "android.hardware.camera.autofocus"];
|
||||
|
||||
function withAndroidOptionalCamera(config) {
|
||||
return withAndroidManifest(config, (modConfig) => {
|
||||
const manifest = modConfig.modResults.manifest;
|
||||
const features = manifest["uses-feature"] ?? [];
|
||||
for (const name of OPTIONAL_CAMERA_FEATURES) {
|
||||
const existing = features.find((feature) => feature.$["android:name"] === name);
|
||||
if (existing) {
|
||||
existing.$["android:required"] = "false";
|
||||
} else {
|
||||
features.push({ $: { "android:name": name, "android:required": "false" } });
|
||||
}
|
||||
}
|
||||
manifest["uses-feature"] = features;
|
||||
return modConfig;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = withAndroidOptionalCamera;
|
||||
@@ -174,6 +174,7 @@ export function Button({
|
||||
...props
|
||||
}: PropsWithChildren<
|
||||
Omit<PressableProps, "style"> & {
|
||||
ref?: React.Ref<View>;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
leftIcon?: LeftIcon;
|
||||
|
||||
@@ -1,47 +1,67 @@
|
||||
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, SPACING } from "@/styles/theme";
|
||||
import { ICON_SIZE } from "@/styles/theme";
|
||||
|
||||
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;
|
||||
import {
|
||||
FLOATING_ACTION_BUTTON_SIZE,
|
||||
FLOATING_ACTION_BUTTON_INSET,
|
||||
} from "./floating-action-layout";
|
||||
export {
|
||||
FLOATING_ACTION_BUTTON_CLEARANCE,
|
||||
floatingActionsClearance,
|
||||
} from "./floating-action-layout";
|
||||
|
||||
export interface FloatingActionButtonProps {
|
||||
icon: LucideIcon;
|
||||
/** Names the action; the button carries no label. */
|
||||
/** Names the action for assistive technology. */
|
||||
accessibilityLabel: string;
|
||||
onPress: () => void;
|
||||
label?: string;
|
||||
disabled?: boolean;
|
||||
bottomInset?: number;
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Pins an action to the bottom-right of its nearest positioned ancestor.
|
||||
* The surface reserves scroll clearance and uses bottomInset to separate stacked actions.
|
||||
*/
|
||||
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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
button: {
|
||||
@@ -56,6 +76,15 @@ 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,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
floatingActionsClearance,
|
||||
FLOATING_ACTION_BUTTON_CLEARANCE,
|
||||
} from "./floating-action-layout";
|
||||
|
||||
describe("floating action scroll clearance", () => {
|
||||
it("keeps the last diff line above Jump to file before and after feedback is added", () => {
|
||||
const jump = FLOATING_ACTION_BUTTON_CLEARANCE;
|
||||
const feedback = FLOATING_ACTION_BUTTON_CLEARANCE + 44;
|
||||
expect(floatingActionsClearance([jump, 0], 34)).toBe(106);
|
||||
expect(floatingActionsClearance([jump, feedback], 34)).toBe(222);
|
||||
expect(floatingActionsClearance([0, feedback], 34)).toBe(150);
|
||||
expect(floatingActionsClearance([jump, feedback], 0)).toBe(188);
|
||||
expect(floatingActionsClearance([0, 0], 34)).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SPACING } from "@/styles/theme";
|
||||
|
||||
export const FLOATING_ACTION_BUTTON_SIZE = 56;
|
||||
export const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
|
||||
export const FLOATING_ACTION_BUTTON_CLEARANCE =
|
||||
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
|
||||
|
||||
/** Reserve the stacked actions and the safe-area inset once for their scrolling surface. */
|
||||
export function floatingActionsClearance(
|
||||
actionClearances: readonly number[],
|
||||
bottomInset: number,
|
||||
): number {
|
||||
const clearance = actionClearances.reduce((total, action) => total + action, 0);
|
||||
return clearance > 0 ? clearance + bottomInset : 0;
|
||||
}
|
||||
@@ -245,3 +245,12 @@ 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,6 +121,9 @@ 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();
|
||||
|
||||
@@ -315,6 +315,7 @@ function reviewActionsForFirstAddition(): NonNullable<
|
||||
commentsByTarget: new Map(),
|
||||
editor: { target, commentId: null, body: "" },
|
||||
onStartComment() {},
|
||||
onChangeEditorBody() {},
|
||||
onCancelEditor() {},
|
||||
onSaveEditor() {},
|
||||
onEditComment() {},
|
||||
|
||||
@@ -389,6 +389,7 @@ describe("diff document model", () => {
|
||||
commentId: null,
|
||||
},
|
||||
onStartComment() {},
|
||||
onChangeEditorBody() {},
|
||||
onCancelEditor() {},
|
||||
onSaveEditor() {},
|
||||
onEditComment() {},
|
||||
@@ -790,6 +791,7 @@ function reviewActionsWithEditor(
|
||||
commentsByTarget: new Map(),
|
||||
editor: target ? { target, body: "", commentId: null } : null,
|
||||
onStartComment() {},
|
||||
onChangeEditorBody() {},
|
||||
onCancelEditor() {},
|
||||
onSaveEditor() {},
|
||||
onEditComment() {},
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -31,6 +33,7 @@ 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";
|
||||
@@ -1548,7 +1551,9 @@ export function ChangesSurface({
|
||||
isDiffLoading,
|
||||
reviewActions,
|
||||
reviewAttachment,
|
||||
reviewFeedback,
|
||||
} = useWorkingDiff({
|
||||
presentation,
|
||||
serverId,
|
||||
workspaceId: workspaceId ?? undefined,
|
||||
cwd,
|
||||
@@ -1729,6 +1734,7 @@ export function ChangesSurface({
|
||||
],
|
||||
);
|
||||
|
||||
const { bottom: bottomInset } = useSafeAreaInsets();
|
||||
const hasChanges = files.length > 0;
|
||||
const jumpToFileInset = jumpToFileClearance({
|
||||
isCompact: isMobile,
|
||||
@@ -1778,27 +1784,41 @@ export function ChangesSurface({
|
||||
});
|
||||
|
||||
const diffContent: ReactElement = (
|
||||
<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 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>
|
||||
</View>
|
||||
);
|
||||
const bodyContent = (
|
||||
<ChangesBody
|
||||
@@ -1934,6 +1954,13 @@ export function ChangesSurface({
|
||||
) : null}
|
||||
</View>
|
||||
|
||||
<ReviewCommentSheet
|
||||
enabled={enabled}
|
||||
editor={reviewActions.sheetEditor}
|
||||
reviewActions={reviewActions}
|
||||
onChangeBody={reviewActions.onChangeEditorBody}
|
||||
/>
|
||||
|
||||
<ChangesCommits
|
||||
presentation={presentation}
|
||||
serverId={serverId}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -53,6 +54,7 @@ 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.
|
||||
@@ -83,6 +85,7 @@ export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) {
|
||||
<>
|
||||
<FloatingActionButton
|
||||
icon={FolderTree}
|
||||
bottomInset={bottom}
|
||||
accessibilityLabel={title}
|
||||
onPress={open}
|
||||
testID="changes-jump-to-file"
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
buildReviewDraftKey,
|
||||
useInlineReviewController,
|
||||
useReviewAttachmentSnapshot,
|
||||
useReviewFeedback,
|
||||
} from "@/review";
|
||||
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
|
||||
import { useCheckoutStatusQuery } from "@/git/use-status-query";
|
||||
@@ -19,6 +20,7 @@ interface UseWorkingDiffOptions {
|
||||
ignoreWhitespace: boolean;
|
||||
enabled: boolean;
|
||||
queryScope?: string;
|
||||
presentation?: string;
|
||||
}
|
||||
|
||||
export function useWorkingDiff({
|
||||
@@ -28,6 +30,7 @@ export function useWorkingDiff({
|
||||
ignoreWhitespace,
|
||||
enabled,
|
||||
queryScope,
|
||||
presentation,
|
||||
}: UseWorkingDiffOptions) {
|
||||
const {
|
||||
status,
|
||||
@@ -81,7 +84,7 @@ export function useWorkingDiff({
|
||||
}),
|
||||
[baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId],
|
||||
);
|
||||
const reviewActions = useInlineReviewController({ reviewDraftKey });
|
||||
const reviewActions = useInlineReviewController({ reviewDraftKey, presentation });
|
||||
const reviewAttachment = useReviewAttachmentSnapshot({
|
||||
key: reviewDraftKey,
|
||||
diffFiles: files,
|
||||
@@ -90,6 +93,13 @@ export function useWorkingDiff({
|
||||
baseRef,
|
||||
});
|
||||
|
||||
const reviewFeedback = useReviewFeedback({
|
||||
serverId,
|
||||
workspaceId,
|
||||
reviewDraftKey,
|
||||
attachment: reviewAttachment,
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
isStatusLoading,
|
||||
@@ -107,6 +117,7 @@ export function useWorkingDiff({
|
||||
isDiffLoading,
|
||||
reviewActions,
|
||||
reviewAttachment,
|
||||
reviewFeedback,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ar: TranslationResources = {
|
||||
back: "خلف",
|
||||
loading: "تحميل...",
|
||||
actions: {
|
||||
save: "حفظ",
|
||||
back: "خلف",
|
||||
cancel: "يلغي",
|
||||
close: "يغلق",
|
||||
@@ -2005,6 +2006,17 @@ 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: "تحرير تعليق المراجعة",
|
||||
|
||||
@@ -26,6 +26,7 @@ export const en = {
|
||||
back: "Back",
|
||||
loading: "Loading...",
|
||||
actions: {
|
||||
save: "Save",
|
||||
back: "Back",
|
||||
cancel: "Cancel",
|
||||
close: "Close",
|
||||
@@ -2030,6 +2031,17 @@ 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",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const es: TranslationResources = {
|
||||
back: "Atrás",
|
||||
loading: "Cargando...",
|
||||
actions: {
|
||||
save: "Guardar",
|
||||
back: "Atrás",
|
||||
cancel: "Cancelar",
|
||||
close: "Cerrar",
|
||||
@@ -2053,6 +2054,17 @@ 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",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const fr: TranslationResources = {
|
||||
back: "Retour",
|
||||
loading: "Chargement…",
|
||||
actions: {
|
||||
save: "Enregistrer",
|
||||
back: "Retour",
|
||||
cancel: "Annuler",
|
||||
close: "Fermer",
|
||||
@@ -2050,6 +2051,18 @@ 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",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ja: TranslationResources = {
|
||||
back: "戻る",
|
||||
loading: "読み込み中...",
|
||||
actions: {
|
||||
save: "保存",
|
||||
back: "戻る",
|
||||
cancel: "キャンセル",
|
||||
close: "閉じる",
|
||||
@@ -2025,6 +2026,18 @@ 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: "レビューコメントを編集",
|
||||
|
||||
@@ -30,6 +30,7 @@ export const ko: TranslationResources = {
|
||||
back: "뒤로",
|
||||
loading: "불러오는 중...",
|
||||
actions: {
|
||||
save: "저장",
|
||||
back: "뒤로",
|
||||
cancel: "취소",
|
||||
close: "닫기",
|
||||
@@ -2015,6 +2016,17 @@ 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: "리뷰 댓글 편집",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ptBR: TranslationResources = {
|
||||
back: "Voltar",
|
||||
loading: "Carregando...",
|
||||
actions: {
|
||||
save: "Salvar",
|
||||
back: "Voltar",
|
||||
cancel: "Cancelar",
|
||||
close: "Fechar",
|
||||
@@ -2038,6 +2039,17 @@ 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",
|
||||
|
||||
@@ -31,6 +31,7 @@ export const ru: TranslationResources = {
|
||||
back: "Назад",
|
||||
loading: "Загрузка...",
|
||||
actions: {
|
||||
save: "Сохранить",
|
||||
back: "Назад",
|
||||
cancel: "Отмена",
|
||||
close: "Закрыть",
|
||||
@@ -2038,6 +2039,17 @@ 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: "Изменить комментарий к ревью",
|
||||
|
||||
@@ -30,6 +30,7 @@ export const zhCN: TranslationResources = {
|
||||
back: "返回",
|
||||
loading: "加载中...",
|
||||
actions: {
|
||||
save: "保存",
|
||||
back: "返回",
|
||||
cancel: "取消",
|
||||
close: "关闭",
|
||||
@@ -1983,6 +1984,17 @@ 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 评论",
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
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] },
|
||||
}));
|
||||
@@ -0,0 +1,145 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
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",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
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,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,120 @@
|
||||
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;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ 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;
|
||||
}
|
||||
|
||||
@@ -31,3 +31,8 @@ export {
|
||||
SMALL_ACTION_HIT_SLOP,
|
||||
useInlineReviewController,
|
||||
} from "./surface";
|
||||
|
||||
export { ReviewCommentSheet } from "./comment-sheet";
|
||||
|
||||
export { useReviewFeedback } from "./feedback/use-feedback";
|
||||
export { ReviewFeedbackAction } from "./feedback/surface";
|
||||
|
||||
@@ -2,7 +2,16 @@ 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 } from "./store";
|
||||
import {
|
||||
buildReviewAttachmentSnapshot,
|
||||
buildReviewDraftKey,
|
||||
addReviewDraftComment,
|
||||
saveReviewDraftComment,
|
||||
clearSentReviewDraftComments,
|
||||
getReviewDraftComments,
|
||||
resetReviewDraftStore,
|
||||
useReviewDraftStore,
|
||||
} from "./store";
|
||||
import {
|
||||
addCommentToState,
|
||||
clearReviewInState,
|
||||
@@ -304,3 +313,49 @@ 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();
|
||||
});
|
||||
|
||||
@@ -320,3 +320,43 @@ 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,45 +9,24 @@ import { useReviewDraftStore, type ReviewDraftComment } from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import {
|
||||
getInlineReviewThreadState,
|
||||
getInlineReviewThreadViewportStyle,
|
||||
getSplitInlineReviewThreadState,
|
||||
type InlineReviewActions,
|
||||
} from "./geometry";
|
||||
import {
|
||||
getInlineReviewThreadViewportStyle,
|
||||
groupInlineReviewCommentsByTarget,
|
||||
InlineReviewEditor,
|
||||
InlineReviewGutterCell,
|
||||
InlineReviewThread,
|
||||
SMALL_ACTION_HIT_SLOP,
|
||||
useInlineReviewController,
|
||||
type InlineReviewActions,
|
||||
} from "./index";
|
||||
} from "./surface";
|
||||
|
||||
void testI18n;
|
||||
|
||||
const { theme, pressablePropsByLabel } = vi.hoisted(() => {
|
||||
const { pressablePropsByLabel } = vi.hoisted(() => {
|
||||
Object.assign(globalThis, { __DEV__: false });
|
||||
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>>(),
|
||||
};
|
||||
return { pressablePropsByLabel: new Map<string, Record<string, unknown>>() };
|
||||
});
|
||||
|
||||
vi.mock("react-native", async (importOriginal) => {
|
||||
@@ -88,13 +67,16 @@ vi.mock("react-native", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
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("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("@/constants/platform", () => ({
|
||||
getIsElectron: () => false,
|
||||
@@ -103,19 +85,6 @@ 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",
|
||||
@@ -147,6 +116,7 @@ function buildReviewActions(overrides: Partial<InlineReviewActions> = {}): Inlin
|
||||
onStartComment: vi.fn(),
|
||||
onEditComment: vi.fn(),
|
||||
onCancelEditor: vi.fn(),
|
||||
onChangeEditorBody: () => {},
|
||||
onSaveEditor: vi.fn(),
|
||||
onDeleteComment: vi.fn(),
|
||||
...overrides,
|
||||
@@ -312,8 +282,9 @@ describe("git diff inline review helpers", () => {
|
||||
|
||||
it("keeps the line number visible and only floats the plus for line hover", () => {
|
||||
const reviewTarget = target();
|
||||
const { container, queryByText, rerender } = render(
|
||||
const { queryByTestId, queryByText, rerender } = render(
|
||||
<InlineReviewGutterCell
|
||||
actionTestID="add-comment-glyph"
|
||||
reviewTarget={reviewTarget}
|
||||
comments={EMPTY_COMMENTS}
|
||||
isEditorOpen={false}
|
||||
@@ -324,10 +295,11 @@ describe("git diff inline review helpers", () => {
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||
expect(queryByTestId("add-comment-glyph")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<InlineReviewGutterCell
|
||||
actionTestID="add-comment-glyph"
|
||||
reviewTarget={reviewTarget}
|
||||
comments={COMMENT_LIST}
|
||||
isEditorOpen={false}
|
||||
@@ -338,10 +310,11 @@ describe("git diff inline review helpers", () => {
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||
expect(queryByTestId("add-comment-glyph")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<InlineReviewGutterCell
|
||||
actionTestID="add-comment-glyph"
|
||||
reviewTarget={reviewTarget}
|
||||
comments={EMPTY_COMMENTS}
|
||||
isEditorOpen
|
||||
@@ -352,10 +325,11 @@ describe("git diff inline review helpers", () => {
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
|
||||
expect(queryByTestId("add-comment-glyph")).toBeNull();
|
||||
|
||||
rerender(
|
||||
<InlineReviewGutterCell
|
||||
actionTestID="add-comment-glyph"
|
||||
reviewTarget={reviewTarget}
|
||||
comments={COMMENT_LIST}
|
||||
isEditorOpen={false}
|
||||
@@ -367,7 +341,7 @@ describe("git diff inline review helpers", () => {
|
||||
);
|
||||
|
||||
expect(queryByText("2")).toBeTruthy();
|
||||
expect(container.querySelector("[data-icon='Plus']")).toBeTruthy();
|
||||
expect(queryByTestId("add-comment-glyph")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -384,7 +358,7 @@ describe("InlineReviewEditor", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<InlineReviewEditor
|
||||
<ReviewEditorFixture
|
||||
initialBody=" initial "
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
@@ -404,7 +378,7 @@ describe("InlineReviewEditor", () => {
|
||||
const onCancel = vi.fn();
|
||||
const onSave = vi.fn();
|
||||
const { getByTestId } = render(
|
||||
<InlineReviewEditor
|
||||
<ReviewEditorFixture
|
||||
initialBody="ready"
|
||||
onCancel={onCancel}
|
||||
onSave={onSave}
|
||||
@@ -428,7 +402,7 @@ describe("InlineReviewEditor", () => {
|
||||
removeEventListener: vi.fn(),
|
||||
});
|
||||
const { getByTestId, queryByText } = render(
|
||||
<InlineReviewEditor
|
||||
<ReviewEditorFixture
|
||||
initialBody="ready"
|
||||
onCancel={vi.fn()}
|
||||
onSave={vi.fn()}
|
||||
@@ -472,3 +446,13 @@ 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} />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react-native";
|
||||
@@ -17,10 +24,17 @@ 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, type ReviewDraftComment } from "./store";
|
||||
import {
|
||||
useReviewDraftComments,
|
||||
useReviewDraftStore,
|
||||
saveReviewDraftComment,
|
||||
type ReviewDraftComment,
|
||||
} from "./store";
|
||||
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
|
||||
import {
|
||||
INLINE_REVIEW_EDITOR_HEIGHT,
|
||||
@@ -98,92 +112,42 @@ export function groupInlineReviewCommentsByTarget(
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions {
|
||||
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();
|
||||
const reviewComments = useReviewDraftComments(input.reviewDraftKey);
|
||||
const commentsByTarget = useMemo(
|
||||
() => groupInlineReviewCommentsByTarget(reviewComments),
|
||||
[reviewComments],
|
||||
);
|
||||
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 model = useMemo(
|
||||
() => reviewEditors.forDraft(input.reviewDraftKey, input.presentation ?? "diff"),
|
||||
[input.reviewDraftKey, input.presentation],
|
||||
);
|
||||
|
||||
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>(
|
||||
const editor = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||
return useMemo(
|
||||
() => ({
|
||||
commentsByTarget,
|
||||
editor,
|
||||
onStartComment: handleStartComment,
|
||||
onEditComment: handleEditComment,
|
||||
onCancelEditor: handleCancelEditor,
|
||||
onSaveEditor: handleSaveEditor,
|
||||
onDeleteComment: handleDeleteComment,
|
||||
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,
|
||||
}),
|
||||
[
|
||||
commentsByTarget,
|
||||
editor,
|
||||
handleCancelEditor,
|
||||
handleDeleteComment,
|
||||
handleEditComment,
|
||||
handleSaveEditor,
|
||||
handleStartComment,
|
||||
],
|
||||
[commentsByTarget, editor, isCompact, model],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -321,7 +285,8 @@ export function InlineReviewThread({
|
||||
const editorElement = editor ? (
|
||||
<InlineReviewEditor
|
||||
key={editingCommentId ?? "new"}
|
||||
initialBody={editor.body}
|
||||
body={editor.body}
|
||||
onChangeBody={reviewActions.onChangeEditorBody}
|
||||
onCancel={reviewActions.onCancelEditor}
|
||||
onSave={reviewActions.onSaveEditor}
|
||||
testID="inline-review-editor"
|
||||
@@ -428,19 +393,20 @@ export function getInlineReviewThreadViewportStyle({
|
||||
}
|
||||
|
||||
export function InlineReviewEditor({
|
||||
initialBody,
|
||||
body,
|
||||
onChangeBody,
|
||||
onCancel,
|
||||
onSave,
|
||||
testID,
|
||||
}: {
|
||||
initialBody: string;
|
||||
body: string;
|
||||
onChangeBody: (body: string) => void;
|
||||
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;
|
||||
@@ -506,7 +472,7 @@ export function InlineReviewEditor({
|
||||
placeholderTextColor={styles.placeholderColor.color}
|
||||
multiline
|
||||
initialValue={body}
|
||||
onChangeText={setBody}
|
||||
onChangeText={onChangeBody}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
style={inputStyle}
|
||||
|
||||
@@ -22,7 +22,11 @@ export default defineConfig({
|
||||
test: {
|
||||
name: "unit",
|
||||
environment: "node",
|
||||
include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"],
|
||||
include: [
|
||||
"src/**/*.{test,spec}.{ts,tsx}",
|
||||
"native-release-version.test.ts",
|
||||
"app.config.test.ts",
|
||||
],
|
||||
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
|
||||
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Command, Option } from "commander";
|
||||
import { createAgentCommand } from "./commands/agent/index.js";
|
||||
import { createDaemonCommand } from "./commands/daemon/index.js";
|
||||
import { createPermitCommand } from "./commands/permit/index.js";
|
||||
import { createUsageCommand } from "./commands/usage/index.js";
|
||||
import { createProviderCommand } from "./commands/provider/index.js";
|
||||
import { createPluginCommand } from "./commands/plugin/index.js";
|
||||
import { createProjectCommand } from "./commands/project/index.js";
|
||||
@@ -148,6 +149,7 @@ export function createCli(): Command {
|
||||
|
||||
// Provider commands
|
||||
program.addCommand(createProviderCommand());
|
||||
program.addCommand(createUsageCommand());
|
||||
program.addCommand(createPluginCommand());
|
||||
|
||||
// Speech model commands
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { render } from "../../output/index.js";
|
||||
import { createCli } from "../../cli.js";
|
||||
import { runUsageCommand } from "./index.js";
|
||||
|
||||
const options = { daemonTarget: { kind: "endpoint" as const, host: "example.test:1234" } };
|
||||
const reports: UsageReportEntry[] = [
|
||||
{
|
||||
id: "plugin.source:work.account",
|
||||
sourceId: "plugin.source",
|
||||
sourceLabel: "Plugin Source",
|
||||
account: { label: "Work" },
|
||||
fetchedAt: "2026-10-09T12:00:00Z",
|
||||
icon: "<svg />",
|
||||
report: {
|
||||
status: "available",
|
||||
planLabel: "Pro",
|
||||
windows: [
|
||||
{
|
||||
id: "5h",
|
||||
label: "Five hours",
|
||||
usedPct: 12.5,
|
||||
remainingPct: 87.5,
|
||||
resetsAt: "2026-10-09T17:00:00Z",
|
||||
runsOutAt: "2026-10-09T16:00:00Z",
|
||||
shortfallPct: 4.25,
|
||||
},
|
||||
{ id: "week", label: "Weekly", usedPct: null, remainingPct: 0 },
|
||||
{ id: "extra", label: "Extra", usedPct: 0 },
|
||||
],
|
||||
balances: [
|
||||
{
|
||||
id: "credit",
|
||||
label: "Credit",
|
||||
used: 0,
|
||||
remaining: 2.5,
|
||||
limit: 10,
|
||||
unit: "usd",
|
||||
resetsAt: "2026-11-01T00:00:00Z",
|
||||
},
|
||||
{ id: "requests", label: "Requests", remaining: null, unit: "requests" },
|
||||
],
|
||||
details: [{ id: "tier", label: "Tier", value: "Enterprise" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "plugin.source:personal",
|
||||
sourceId: "plugin.source",
|
||||
sourceLabel: "Plugin Source",
|
||||
account: {},
|
||||
fetchedAt: "2026-10-09T12:00:00Z",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "rejected", status: 401, refreshedBy: "codex login" },
|
||||
},
|
||||
loginErrors: [
|
||||
{
|
||||
harness: "Claude Code",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "expired", expiresAt: "2026-10-08T00:00:00Z", refreshedBy: "claude" },
|
||||
},
|
||||
},
|
||||
{
|
||||
harness: "Codex",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "rejected", status: 401, refreshedBy: "codex login" },
|
||||
},
|
||||
},
|
||||
{ harness: "Other", report: { status: "error", error: "Login service failed" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "other:broken",
|
||||
sourceId: "other",
|
||||
sourceLabel: "Other",
|
||||
account: {},
|
||||
fetchedAt: "2026-10-09T12:00:00Z",
|
||||
report: { status: "error", error: "Source failed" },
|
||||
},
|
||||
{
|
||||
id: "other:noquota",
|
||||
sourceId: "other",
|
||||
sourceLabel: "Other",
|
||||
account: {},
|
||||
fetchedAt: "2026-10-09T12:00:00Z",
|
||||
report: {
|
||||
status: "unavailable",
|
||||
problem: { kind: "no_quota", detail: "No quota for this plan" },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
function host(entries = reports, supported = true) {
|
||||
const requests: Array<Parameters<NonNullable<Parameters<typeof runUsageCommand>[2]>>[0]> = [];
|
||||
const queries: Array<
|
||||
{ agentId?: string; forceRefresh?: boolean; reportIds?: string[] } | undefined
|
||||
> = [];
|
||||
let closed = 0;
|
||||
return {
|
||||
requests,
|
||||
queries,
|
||||
get closed() {
|
||||
return closed;
|
||||
},
|
||||
connect: async (input: (typeof requests)[number]) => {
|
||||
requests.push(input);
|
||||
return {
|
||||
getLastServerInfoMessage: () => ({ features: { usageSources: supported } }),
|
||||
listUsageReports: async (query?: (typeof queries)[number]) => {
|
||||
queries.push(query);
|
||||
const selected = query?.reportIds
|
||||
? entries.filter((entry) => query.reportIds!.includes(entry.id))
|
||||
: entries;
|
||||
return { requestId: "request", reports: selected };
|
||||
},
|
||||
close: async () => {
|
||||
closed++;
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("preserves complete JSON entries, numeric values and quiet full IDs", async () => {
|
||||
const daemon = host();
|
||||
const result = await runUsageCommand(options, { kind: "list" }, daemon.connect);
|
||||
expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports);
|
||||
expect(render(result, { quiet: true })).toBe(reports.map((entry) => entry.id).join("\n"));
|
||||
expect(daemon.requests).toEqual([{ target: options.daemonTarget }]);
|
||||
expect(daemon.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("lists all windows, balances, missing values, failures and remedies", async () => {
|
||||
const result = await runUsageCommand(options, { kind: "list" }, host().connect);
|
||||
const text = render(result, { noColor: true });
|
||||
for (const expected of [
|
||||
"REPORT ID",
|
||||
"SOURCE",
|
||||
"ACCOUNT",
|
||||
"STATUS",
|
||||
"USAGE",
|
||||
"FETCHED",
|
||||
"Five hours: 12.5% used, 87.5% remaining",
|
||||
"Weekly: - used, 0% remaining",
|
||||
"Extra: 0% used, - remaining",
|
||||
"Credit: 0 used, 2.5 remaining, 10 limit (usd)",
|
||||
"Requests: - used, - remaining, - limit (requests)",
|
||||
"Login expired at 2026-10-08T00:00:00Z. Run claude",
|
||||
"Login rejected (HTTP 401). Run codex login",
|
||||
"Other: Login service failed",
|
||||
"Source failed",
|
||||
"No quota for this plan",
|
||||
])
|
||||
expect(text).toContain(expected);
|
||||
expect(render(result, { noHeaders: true })).not.toContain("REPORT ID");
|
||||
});
|
||||
|
||||
it("inspects exact opaque IDs and includes resets, forecasts, details and login failures", async () => {
|
||||
const daemon = host();
|
||||
const result = await runUsageCommand(
|
||||
options,
|
||||
{ kind: "inspect", id: reports[0]!.id },
|
||||
daemon.connect,
|
||||
);
|
||||
expect(JSON.parse(render(result, { format: "json" }))).toEqual(reports[0]);
|
||||
const text = render(result);
|
||||
for (const expected of [
|
||||
"Source ID: plugin.source",
|
||||
"Plan: Pro",
|
||||
"Resets: 2026-10-09T17:00:00Z",
|
||||
"Runs out: 2026-10-09T16:00:00Z",
|
||||
"Shortfall: 4.25%",
|
||||
"Resets: -",
|
||||
"Resets: 2026-11-01T00:00:00Z",
|
||||
"Tier: Enterprise",
|
||||
])
|
||||
expect(text).toContain(expected);
|
||||
const failure = await runUsageCommand(
|
||||
options,
|
||||
{ kind: "inspect", id: reports[1]!.id },
|
||||
daemon.connect,
|
||||
);
|
||||
expect(render(failure)).toContain("Login failures:\n Claude Code: Login expired at");
|
||||
expect(render(failure)).toContain(" Other: Login service failed");
|
||||
});
|
||||
|
||||
it("discovers before inspecting, then refreshes only the selected report", async () => {
|
||||
const daemon = host();
|
||||
await runUsageCommand(
|
||||
{ ...options, refresh: true },
|
||||
{ kind: "inspect", id: reports[0]!.id },
|
||||
daemon.connect,
|
||||
);
|
||||
expect(daemon.queries).toEqual([undefined, { reportIds: [reports[0]!.id], forceRefresh: true }]);
|
||||
});
|
||||
|
||||
it("rejects agent selection combined with inspect before connecting", async () => {
|
||||
const daemon = host();
|
||||
await expect(
|
||||
runUsageCommand(
|
||||
{ ...options, agent: "agent-A" },
|
||||
{ kind: "inspect", id: reports[0]!.id },
|
||||
daemon.connect,
|
||||
),
|
||||
).rejects.toMatchObject({ code: "INVALID_OPTIONS" });
|
||||
expect(daemon.requests).toEqual([]);
|
||||
});
|
||||
|
||||
it("passes agent account selection and refresh without converting it to token consumption", async () => {
|
||||
const daemon = host();
|
||||
await runUsageCommand(
|
||||
{ ...options, agent: "agent-123", refresh: true },
|
||||
{ kind: "list" },
|
||||
daemon.connect,
|
||||
);
|
||||
expect(daemon.queries).toEqual([{ agentId: "agent-123", forceRefresh: true }]);
|
||||
});
|
||||
|
||||
it("returns an empty array in JSON and explains empty discovery in human output", async () => {
|
||||
const result = await runUsageCommand(options, { kind: "list" }, host([]).connect);
|
||||
expect(render(result, { format: "json" })).toBe("[]");
|
||||
expect(render(result)).toBe("No usage reports returned");
|
||||
expect(render(result, { quiet: true })).toBe("");
|
||||
});
|
||||
|
||||
it("rejects unknown IDs and prefixes and closes the client", async () => {
|
||||
const daemon = host();
|
||||
await expect(
|
||||
runUsageCommand(options, { kind: "inspect", id: "plugin.source:work" }, daemon.connect),
|
||||
).rejects.toEqual({
|
||||
code: "REPORT_NOT_FOUND",
|
||||
message: "Unknown usage report: plugin.source:work",
|
||||
});
|
||||
expect(daemon.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("gates unsupported hosts before any request and closes the connection", async () => {
|
||||
const daemon = host(reports, false);
|
||||
await expect(runUsageCommand(options, { kind: "list" }, daemon.connect)).rejects.toMatchObject({
|
||||
code: "UNSUPPORTED_HOST",
|
||||
});
|
||||
expect(daemon.queries).toEqual([]);
|
||||
expect(daemon.closed).toBe(1);
|
||||
});
|
||||
|
||||
it("propagates connection and request failures", async () => {
|
||||
await expect(
|
||||
runUsageCommand(options, { kind: "list" }, async () => {
|
||||
throw new Error("Connection failed");
|
||||
}),
|
||||
).rejects.toThrow("Connection failed");
|
||||
let closed = false;
|
||||
const connect = async () => ({
|
||||
getLastServerInfoMessage: () => ({ features: { usageSources: true } }),
|
||||
listUsageReports: async () => {
|
||||
throw new Error("Request failed");
|
||||
},
|
||||
close: async () => {
|
||||
closed = true;
|
||||
},
|
||||
});
|
||||
await expect(runUsageCommand(options, { kind: "list" }, connect)).rejects.toThrow(
|
||||
"Request failed",
|
||||
);
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
describe("usage CLI registration", () => {
|
||||
it("registers default, ls and inspect with the accepted selectors", () => {
|
||||
const usage = createCli().commands.find((command) => command.name() === "usage")!;
|
||||
expect(usage.commands.map((command) => command.name())).toEqual(["ls", "inspect"]);
|
||||
expect(usage.helpInformation()).toContain("--agent <id>");
|
||||
expect(usage.commands[0]!.helpInformation()).toContain("--refresh");
|
||||
expect(usage.commands[1]!.helpInformation()).toContain("<report-id>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { Command } from "commander";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { UsageReportEntry } from "@getpaseo/protocol/messages";
|
||||
import { connectToDaemon, type ConnectOptions } from "../../utils/client.js";
|
||||
import { addJsonAndDaemonHostOptions } from "../../utils/command-options.js";
|
||||
import { withOutput, type CommandOptions, type CommandResult } from "../../output/index.js";
|
||||
import { usageSchema } from "./presentation.js";
|
||||
|
||||
interface UsageOptions extends CommandOptions {
|
||||
agent?: string;
|
||||
refresh?: boolean;
|
||||
}
|
||||
|
||||
type UsageSelection = { kind: "list" } | { kind: "inspect"; id: string };
|
||||
interface UsageClient {
|
||||
getLastServerInfoMessage(): { features?: { usageSources?: boolean } } | null;
|
||||
listUsageReports: DaemonClient["listUsageReports"];
|
||||
close: DaemonClient["close"];
|
||||
}
|
||||
type ConnectUsage = (options: ConnectOptions) => Promise<UsageClient>;
|
||||
|
||||
export async function runUsageCommand(
|
||||
options: UsageOptions,
|
||||
selection: UsageSelection,
|
||||
connect: ConnectUsage = connectToDaemon,
|
||||
): Promise<CommandResult<UsageReportEntry>> {
|
||||
const conflictingSelectors = selection.kind === "inspect" && options.agent !== undefined;
|
||||
if (conflictingSelectors) {
|
||||
throw {
|
||||
code: "INVALID_OPTIONS",
|
||||
message:
|
||||
"--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.",
|
||||
};
|
||||
}
|
||||
const client = await connect({ target: options.daemonTarget });
|
||||
try {
|
||||
if (client.getLastServerInfoMessage()?.features?.usageSources !== true) {
|
||||
throw { code: "UNSUPPORTED_HOST", message: "Update the host to use usage reports." };
|
||||
}
|
||||
if (selection.kind === "list") {
|
||||
const { reports } = await client.listUsageReports({
|
||||
agentId: options.agent,
|
||||
forceRefresh: options.refresh,
|
||||
});
|
||||
return { type: "list", data: reports, schema: usageSchema };
|
||||
}
|
||||
// Report-ID queries only select known accounts. Discover before selecting, including on cold hosts.
|
||||
const { reports } = await client.listUsageReports();
|
||||
let entry = reports.find((report) => report.id === selection.id);
|
||||
if (entry && options.refresh) {
|
||||
const refreshed = await client.listUsageReports({
|
||||
reportIds: [selection.id],
|
||||
forceRefresh: true,
|
||||
});
|
||||
entry = refreshed.reports.find((report) => report.id === selection.id);
|
||||
}
|
||||
if (!entry) {
|
||||
throw { code: "REPORT_NOT_FOUND", message: `Unknown usage report: ${selection.id}` };
|
||||
}
|
||||
return { type: "single", data: entry, schema: usageSchema };
|
||||
} finally {
|
||||
await client.close().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
function addListOptions(command: Command): Command {
|
||||
return addJsonAndDaemonHostOptions(command)
|
||||
.option(
|
||||
"--agent <id>",
|
||||
"Show account quota associated with this agent (not agent token consumption)",
|
||||
)
|
||||
.option("--refresh", "Refresh usage instead of using the host cache");
|
||||
}
|
||||
|
||||
export function createUsageCommand(): Command {
|
||||
const usage = addListOptions(new Command("usage").description("Show account usage reports"));
|
||||
const list = (options: CommandOptions, _command: Command) =>
|
||||
runUsageCommand(options, { kind: "list" });
|
||||
usage.action(withOutput(list));
|
||||
addListOptions(usage.command("ls").description("List account quotas and balances")).action(
|
||||
withOutput(list),
|
||||
);
|
||||
addJsonAndDaemonHostOptions(
|
||||
usage
|
||||
.command("inspect")
|
||||
.description("Show a complete account usage report")
|
||||
.argument("<report-id>", "Exact opaque Report ID")
|
||||
.option("--refresh", "Refresh the selected report instead of using the host cache"),
|
||||
).action(
|
||||
withOutput((id: string, options: CommandOptions, _command: Command) =>
|
||||
runUsageCommand(options, { kind: "inspect", id }),
|
||||
),
|
||||
);
|
||||
return usage;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
UsageReportEntry,
|
||||
UsageReport,
|
||||
UsageProblem,
|
||||
ProviderUsageWindow,
|
||||
ProviderUsageBalance,
|
||||
} from "@getpaseo/protocol/messages";
|
||||
import { renderTable, type OutputSchema } from "../../output/index.js";
|
||||
|
||||
function value(input: string | number | null | undefined): string {
|
||||
return input == null ? "-" : String(input);
|
||||
}
|
||||
|
||||
function percent(input: number | null | undefined): string {
|
||||
return input == null ? "-" : `${input}%`;
|
||||
}
|
||||
|
||||
function problemText(problem: UsageProblem): string {
|
||||
if (problem.kind === "no_quota") return problem.detail;
|
||||
const remedy = problem.refreshedBy
|
||||
? `Run ${problem.refreshedBy} to refresh login.`
|
||||
: "Sign in again.";
|
||||
if (problem.kind === "expired") return `Login expired at ${problem.expiresAt}. ${remedy}`;
|
||||
return `Login rejected (HTTP ${problem.status}). ${remedy}`;
|
||||
}
|
||||
|
||||
function failureText(report: UsageReport): string {
|
||||
if (report.status === "error") return report.error;
|
||||
if (report.status === "unavailable") return problemText(report.problem);
|
||||
return "-";
|
||||
}
|
||||
|
||||
function windowText(window: ProviderUsageWindow): string {
|
||||
return `${window.label}: ${percent(window.usedPct)} used, ${percent(window.remainingPct)} remaining`;
|
||||
}
|
||||
|
||||
function balanceText(balance: ProviderUsageBalance): string {
|
||||
return `${balance.label}: ${value(balance.used)} used, ${value(balance.remaining)} remaining, ${value(balance.limit)} limit (${balance.unit})`;
|
||||
}
|
||||
|
||||
function summary(entry: UsageReportEntry): string[] {
|
||||
const { report } = entry;
|
||||
if (report.status !== "available") {
|
||||
const lines = [failureText(report)];
|
||||
for (const login of entry.loginErrors ?? [])
|
||||
lines.push(`${login.harness}: ${failureText(login.report)}`);
|
||||
return lines;
|
||||
}
|
||||
const lines = report.windows.map(windowText);
|
||||
lines.push(...(report.balances ?? []).map(balanceText));
|
||||
return lines.length ? lines : ["-"];
|
||||
}
|
||||
|
||||
interface SummaryRow {
|
||||
id: string;
|
||||
source: string;
|
||||
account: string;
|
||||
status: string;
|
||||
usage: string;
|
||||
fetched: string;
|
||||
}
|
||||
|
||||
const summarySchema: OutputSchema<SummaryRow> = {
|
||||
idField: "id",
|
||||
columns: [
|
||||
{ header: "REPORT ID", field: "id" },
|
||||
{ header: "SOURCE", field: "source" },
|
||||
{ header: "ACCOUNT", field: "account" },
|
||||
{ header: "STATUS", field: "status" },
|
||||
{ header: "USAGE", field: "usage" },
|
||||
{ header: "FETCHED", field: "fetched" },
|
||||
],
|
||||
};
|
||||
|
||||
function inspect(entry: UsageReportEntry): string {
|
||||
const lines = [
|
||||
`Report ID: ${entry.id}`,
|
||||
`Source: ${entry.sourceLabel}`,
|
||||
`Source ID: ${entry.sourceId}`,
|
||||
`Account: ${value(entry.account.label)}`,
|
||||
`Status: ${entry.report.status}`,
|
||||
`Fetched: ${value(entry.fetchedAt)}`,
|
||||
];
|
||||
const { report } = entry;
|
||||
if (report.status === "available") {
|
||||
lines.push(`Plan: ${value(report.planLabel)}`, "Windows:");
|
||||
if (!report.windows.length) lines.push(" -");
|
||||
for (const window of report.windows) {
|
||||
lines.push(` ${windowText(window)} [${window.id}]`, ` Resets: ${value(window.resetsAt)}`);
|
||||
if (window.runsOutAt != null) lines.push(` Runs out: ${window.runsOutAt}`);
|
||||
if (window.shortfallPct != null) lines.push(` Shortfall: ${percent(window.shortfallPct)}`);
|
||||
}
|
||||
lines.push("Balances:");
|
||||
if (!report.balances?.length) lines.push(" -");
|
||||
for (const balance of report.balances ?? []) {
|
||||
lines.push(
|
||||
` ${balanceText(balance)} [${balance.id}]`,
|
||||
` Resets: ${value(balance.resetsAt)}`,
|
||||
);
|
||||
}
|
||||
lines.push("Details:");
|
||||
if (!report.details?.length) lines.push(" -");
|
||||
for (const detail of report.details ?? []) lines.push(` ${detail.label}: ${detail.value}`);
|
||||
} else {
|
||||
lines.push(`Reason: ${failureText(report)}`);
|
||||
}
|
||||
if (entry.loginErrors?.length) {
|
||||
lines.push("Login failures:");
|
||||
for (const login of entry.loginErrors)
|
||||
lines.push(` ${login.harness}: ${failureText(login.report)}`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export const usageSchema: OutputSchema<UsageReportEntry> = {
|
||||
idField: "id",
|
||||
columns: [],
|
||||
renderHuman(result, options) {
|
||||
if (result.type === "single") return inspect(result.data);
|
||||
if (!result.data.length) return "No usage reports returned";
|
||||
const rows: SummaryRow[] = [];
|
||||
for (const entry of result.data) {
|
||||
for (const [index, usage] of summary(entry).entries()) {
|
||||
const first = index === 0;
|
||||
rows.push({
|
||||
id: first ? entry.id : "",
|
||||
source: first ? entry.sourceLabel : "",
|
||||
account: first ? value(entry.account.label) : "",
|
||||
status: first ? entry.report.status : "",
|
||||
usage,
|
||||
fetched: first ? value(entry.fetchedAt) : "",
|
||||
});
|
||||
}
|
||||
}
|
||||
return renderTable<SummaryRow>({ type: "list", data: rows, schema: summarySchema }, options);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createE2ETestContext } from "./helpers/test-daemon.ts";
|
||||
import { connectToDaemon } from "../src/utils/client.ts";
|
||||
import { resolveCliVersion } from "../src/version.ts";
|
||||
|
||||
const context = await createE2ETestContext();
|
||||
try {
|
||||
const directory = join(context.workDir, "usage-plugin");
|
||||
await mkdir(directory);
|
||||
await writeFile(
|
||||
join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "cli-usage",
|
||||
requirements: { paseo: `>=${resolveCliVersion()}` },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(directory, "index.server.ts"),
|
||||
`
|
||||
import { z } from 'zod';
|
||||
export default function contribute(server) {
|
||||
const counts = new Map();
|
||||
server.registerUsageSource({
|
||||
id: 'cli-usage', label: 'CLI usage fixture', input: z.object({ key: z.string() }),
|
||||
discover: async () => [
|
||||
{ key: 'work.account', label: 'Work', input: { key: 'work' } },
|
||||
{ key: 'personal', input: { key: 'personal' } },
|
||||
{ key: 'broken', harness: 'Harness A', input: { key: 'rejected' } },
|
||||
{ key: 'broken', harness: 'Harness B', input: { key: 'expired' } },
|
||||
{ key: 'error', input: { key: 'error' } },
|
||||
],
|
||||
fetch: async ({key}) => {
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
if (key === 'rejected') return {status: 'unavailable', problem: {kind: 'rejected', status: 401, refreshedBy: 'codex login'}};
|
||||
if (key === 'expired') return {status: 'unavailable', problem: {kind: 'expired', expiresAt: '2026-10-08T00:00:00Z', refreshedBy: 'claude'}};
|
||||
if (key === 'error') throw new Error('Quota service failed');
|
||||
return { status: 'available', planLabel: 'Pro', windows: [
|
||||
{ id: 'session', label: 'Session', usedPct: counts.get(key), remainingPct: 99, resetsAt: '2026-10-09T17:00:00Z', runsOutAt: '2026-10-09T16:00:00Z', shortfallPct: 4.25 },
|
||||
{ id: 'weekly', label: 'Weekly', usedPct: null, remainingPct: 0 },
|
||||
], balances: [
|
||||
{ id: 'credits', label: 'Credits', remaining: 2.5, unit: 'credits' },
|
||||
{ id: 'tokens', label: 'Tokens', used: 0, limit: 100, unit: 'tokens' },
|
||||
], details: [{ id: 'tier', label: 'Tier', value: 'Enterprise' }] };
|
||||
},
|
||||
});
|
||||
return () => {};
|
||||
}`,
|
||||
);
|
||||
const client = await connectToDaemon({ target: { kind: "instance", home: context.paseoHome } });
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(directory, "cli-usage");
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
async function ok(args: string[]) {
|
||||
const result = await context.paseo(args);
|
||||
assert.equal(result.exitCode, 0, result.stderr);
|
||||
assert.equal(result.stderr, "");
|
||||
return result.stdout;
|
||||
}
|
||||
// The first usage request is inspect: no earlier usage discovery has populated known IDs.
|
||||
const cold = JSON.parse(await ok(["usage", "inspect", "cli-usage:work.account", "--json"]));
|
||||
assert.equal(cold.id, "cli-usage:work.account");
|
||||
assert.equal(cold.report.windows[0].usedPct, 1);
|
||||
for (const args of [
|
||||
["usage", "--agent", "agent-A", "inspect", cold.id, "--json"],
|
||||
["usage", "inspect", cold.id, "--agent", "agent-A", "--json"],
|
||||
]) {
|
||||
const conflicting = await context.paseo(args);
|
||||
assert.equal(conflicting.exitCode, 1, `Expected selector rejection: ${args.join(" ")}`);
|
||||
assert.equal(conflicting.stdout, "");
|
||||
assert.deepEqual(JSON.parse(conflicting.stderr).error, {
|
||||
code: "INVALID_OPTIONS",
|
||||
message:
|
||||
"--agent cannot be combined with usage inspect. Use usage ls --agent <id> for agent account usage, or usage inspect <report-id> for an exact report.",
|
||||
});
|
||||
}
|
||||
|
||||
const list = JSON.parse(await ok(["usage", "ls", "--json"]));
|
||||
const defaultList = JSON.parse(await ok(["usage", "--json"]));
|
||||
assert.deepEqual(defaultList, list);
|
||||
assert.deepEqual(list.map((entry: { id: string }) => entry.id).sort(), [
|
||||
"cli-usage:broken",
|
||||
"cli-usage:error",
|
||||
"cli-usage:personal",
|
||||
"cli-usage:work.account",
|
||||
]);
|
||||
const work = list.find((entry: { id: string }) => entry.id === cold.id);
|
||||
assert.deepEqual(work, cold);
|
||||
const table = await ok(["usage", "ls", "--no-color"]);
|
||||
for (const text of [
|
||||
"REPORT ID",
|
||||
"Credits: - used, 2.5 remaining, - limit (credits)",
|
||||
"Tokens: 0 used, - remaining, 100 limit (tokens)",
|
||||
"Weekly: - used, 0% remaining",
|
||||
"Harness A: Login rejected (HTTP 401). Run codex login",
|
||||
"Harness B: Login expired at",
|
||||
"Quota service failed",
|
||||
])
|
||||
assert.ok(table.includes(text), text);
|
||||
const quiet = await ok(["usage", "ls", "--quiet"]);
|
||||
assert.deepEqual(
|
||||
quiet.trim().split("\n").sort(),
|
||||
list.map((entry: { id: string }) => entry.id).sort(),
|
||||
);
|
||||
assert.ok(!(await ok(["usage", "ls", "--no-headers"])).includes("REPORT ID"));
|
||||
const detail = await ok(["usage", "inspect", cold.id]);
|
||||
for (const text of [
|
||||
"Plan: Pro",
|
||||
"Resets: 2026-10-09T17:00:00Z",
|
||||
"Runs out: 2026-10-09T16:00:00Z",
|
||||
"Shortfall: 4.25%",
|
||||
"Tier: Enterprise",
|
||||
])
|
||||
assert.ok(detail.includes(text), text);
|
||||
const refreshed = JSON.parse(await ok(["usage", "inspect", cold.id, "--refresh", "--json"]));
|
||||
assert.equal(refreshed.report.windows[0].usedPct, 2);
|
||||
const personal = JSON.parse(await ok(["usage", "inspect", "cli-usage:personal", "--json"]));
|
||||
assert.equal(personal.report.windows[0].usedPct, 1);
|
||||
const allRefreshed = JSON.parse(await ok(["usage", "ls", "--refresh", "--json"]));
|
||||
assert.equal(
|
||||
allRefreshed.find((entry: { id: string }) => entry.id === cold.id).report.windows[0].usedPct,
|
||||
3,
|
||||
);
|
||||
for (const id of ["cli-usage:work", "missing"]) {
|
||||
const unknown = await context.paseo(["usage", "inspect", id, "--json"]);
|
||||
assert.equal(unknown.exitCode, 1);
|
||||
assert.equal(unknown.stdout, "");
|
||||
assert.equal(JSON.parse(unknown.stderr).error.code, "REPORT_NOT_FOUND");
|
||||
}
|
||||
const agentFailure = await context.paseo(["usage", "ls", "--agent", "missing-agent", "--json"]);
|
||||
assert.equal(agentFailure.exitCode, 1);
|
||||
assert.equal(agentFailure.stdout, "");
|
||||
assert.match(JSON.parse(agentFailure.stderr).error.message, /Unknown agent/);
|
||||
const remove = await context.paseo(["plugin", "remove", "cli-usage", "--json"]);
|
||||
assert.equal(remove.exitCode, 0, remove.stderr);
|
||||
assert.equal((await ok(["usage", "ls", "--json"])).trim(), "[]");
|
||||
assert.equal((await ok(["usage"])).trim(), "No usage reports returned");
|
||||
const connection = await context.paseo(["usage", "ls", "--host", "127.0.0.1:1", "--json"]);
|
||||
assert.equal(connection.exitCode, 1);
|
||||
assert.equal(connection.stdout, "");
|
||||
assert.equal(JSON.parse(connection.stderr).error.code, "DAEMON_UNREACHABLE");
|
||||
console.log(
|
||||
"Usage CLI: cold inspect, default/ls, complete JSON, mixed statuses, all windows/balances, inspect details, refresh, IDs, errors and empty discovery passed.",
|
||||
);
|
||||
} finally {
|
||||
await context.stop();
|
||||
}
|
||||
@@ -298,6 +298,35 @@ describe("createGitLabService", () => {
|
||||
expect(calls[1]).toEqual(["mr", "view", "14", "-F", "json"]);
|
||||
});
|
||||
|
||||
it.each(["list", "view"])(
|
||||
"resolves the current merge request when %s returns legacy null references",
|
||||
async (legacyCommand) => {
|
||||
const legacyMr = {
|
||||
...OPEN_MR,
|
||||
reference: "!14",
|
||||
references: null,
|
||||
web_url: "https://gitlab.example.com/example-group/example-project/merge_requests/14",
|
||||
};
|
||||
const { service } = makeService((args) => {
|
||||
const mr = args[1] === legacyCommand ? legacyMr : OPEN_MR;
|
||||
if (args[0] === "mr" && args[1] === "list") return ok(JSON.stringify([mr]));
|
||||
if (args[0] === "mr" && args[1] === "view") return ok(JSON.stringify(mr));
|
||||
if (args[0] === "api") return ok("{}");
|
||||
throw new Error(`unexpected call: ${args.join(" ")}`);
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.getCurrentPullRequestStatus({ cwd: "/repo", headRef: "release/v0.4.0" }),
|
||||
).resolves.toMatchObject({
|
||||
number: 14,
|
||||
title: OPEN_MR.title,
|
||||
state: "open",
|
||||
headRefName: "release/v0.4.0",
|
||||
url: legacyCommand === "view" ? legacyMr.web_url : OPEN_MR.web_url,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a conflicting merge request as CONFLICTING", async () => {
|
||||
const conflicting = {
|
||||
...OPEN_MR,
|
||||
|
||||
@@ -168,7 +168,7 @@ const GitLabMergeRequestSchema = z
|
||||
description: z.string().nullable().optional(),
|
||||
labels: z.array(z.string()).optional(),
|
||||
updated_at: z.string().optional(),
|
||||
references: z.object({ full: z.string().optional() }).passthrough().optional(),
|
||||
references: z.object({ full: z.string().optional() }).passthrough().nullable().optional(),
|
||||
head_pipeline: GitLabPipelineSchema.nullable().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
diff --git a/node_modules/react-native-uitextview/ios/RNUITextView.mm b/node_modules/react-native-uitextview/ios/RNUITextView.mm
|
||||
index d0c31ed..d1605ea 100644
|
||||
--- a/node_modules/react-native-uitextview/ios/RNUITextView.mm
|
||||
+++ b/node_modules/react-native-uitextview/ios/RNUITextView.mm
|
||||
@@ -22,6 +22,7 @@ using namespace facebook::react;
|
||||
RNUITextViewShadowNode::ConcreteState::Shared _state;
|
||||
UITapGestureRecognizer * _outsideTapRecognizer;
|
||||
BOOL _suppressSelectionChange;
|
||||
+ CGRect _lastTextLayoutFrame;
|
||||
}
|
||||
|
||||
+ (ComponentDescriptorProvider)componentDescriptorProvider
|
||||
@@ -47,7 +48,8 @@ using namespace facebook::react;
|
||||
_textView.delegate = self;
|
||||
// Must match RCTTextLayoutManager, which measures with usesFontLeading = NO.
|
||||
_textView.layoutManager.usesFontLeading = NO;
|
||||
- [self addSubview:_textView];
|
||||
+ _textView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
|
||||
+ [_view addSubview:_textView];
|
||||
|
||||
const auto longPressGestureRecognizer = [[UILongPressGestureRecognizer alloc] initWithTarget:self
|
||||
action:@selector(handleLongPressIfNecessary:)];
|
||||
@@ -91,19 +93,21 @@ using namespace facebook::react;
|
||||
[super prepareForRecycle];
|
||||
_state.reset();
|
||||
|
||||
- // Reset the frame to zero so that when it properly lays out on the next use
|
||||
- _textView.frame = CGRectZero;
|
||||
+ // Keep the text view on the content container's bounds. The container keeps
|
||||
+ // its size while the view waits in the recycle pool, and autoresizing only
|
||||
+ // applies the size delta on reuse, so a zeroed text view would come back as
|
||||
+ // wide as that delta (zero, or a two-word column).
|
||||
+ _textView.frame = _view.bounds;
|
||||
_textView.attributedText = nil;
|
||||
+ _lastTextLayoutFrame = CGRectZero;
|
||||
}
|
||||
|
||||
- (void)layoutSubviews
|
||||
{
|
||||
[super layoutSubviews];
|
||||
- // _textView's frame is assigned inside drawRect, which only fires when
|
||||
- // state changes. Trigger a redraw whenever the host frame moves out from
|
||||
- // under it (rotation, parent relayout) so the text view resizes and
|
||||
- // onTextLayout re-fires with the new line wrapping.
|
||||
- if (!CGRectEqualToRect(_textView.frame, _view.frame)) {
|
||||
+ // Autoresizing updates UIKit immediately; report the new line layout when
|
||||
+ // the content container's bounds change.
|
||||
+ if (!CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds)) {
|
||||
[self setNeedsDisplay];
|
||||
}
|
||||
}
|
||||
@@ -124,10 +128,16 @@ using namespace facebook::react;
|
||||
// entirely when nothing actually changed so a JS-side state update made in
|
||||
// response to onSelectionChange doesn't deselect what the user is selecting.
|
||||
const BOOL textChanged = ![_textView.attributedText isEqualToAttributedString:convertedAttrString];
|
||||
- const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.frame);
|
||||
- if (!textChanged && !frameChanged) {
|
||||
+ const BOOL frameChanged = !CGRectEqualToRect(_textView.frame, _view.bounds);
|
||||
+ const BOOL layoutChanged = !CGRectEqualToRect(_lastTextLayoutFrame, _view.bounds);
|
||||
+ if (!textChanged && !frameChanged && !layoutChanged) {
|
||||
return;
|
||||
}
|
||||
+ // Size the text view before handing it the text, so UIKit lays the text out
|
||||
+ // at the container width instead of a stale width.
|
||||
+ if (frameChanged) {
|
||||
+ _textView.frame = _view.bounds;
|
||||
+ }
|
||||
if (textChanged) {
|
||||
// Reassigning attributedText clears any active selection. Save it and
|
||||
// restore after, while suppressing the synthetic textViewDidChangeSelection
|
||||
@@ -141,9 +151,6 @@ using namespace facebook::react;
|
||||
}
|
||||
_suppressSelectionChange = NO;
|
||||
}
|
||||
- if (frameChanged) {
|
||||
- _textView.frame = _view.frame;
|
||||
- }
|
||||
|
||||
__block std::vector<std::string> lines;
|
||||
const int maxLines = props.numberOfLines;
|
||||
@@ -162,6 +169,7 @@ using namespace facebook::react;
|
||||
}
|
||||
}];
|
||||
|
||||
+ _lastTextLayoutFrame = _view.bounds;
|
||||
if (_eventEmitter != nullptr) {
|
||||
std::dynamic_pointer_cast<const facebook::react::RNUITextViewEventEmitter>(_eventEmitter)
|
||||
->onTextLayout(facebook::react::RNUITextViewEventEmitter::OnTextLayout{static_cast<int>(self.tag), lines});
|
||||
@@ -21,8 +21,49 @@ paseo attach <id> # Stream agent output
|
||||
paseo send <id> "also fix linting" # Send follow-up task
|
||||
paseo logs <id> # View agent timeline
|
||||
paseo stop <id> # Stop an agent
|
||||
paseo usage # Show account quotas and balances
|
||||
```
|
||||
|
||||
## Account usage
|
||||
|
||||
Show account quotas and balances from the host's usage sources:
|
||||
|
||||
```bash
|
||||
paseo usage
|
||||
paseo usage ls --refresh
|
||||
paseo usage ls --agent <agent-id>
|
||||
paseo usage inspect <report-id>
|
||||
paseo usage inspect <report-id> --refresh
|
||||
```
|
||||
|
||||
`paseo usage` is equivalent to `paseo usage ls`. The summary groups windows and balances by account, with report IDs, source and account labels, status, and fetch timestamps. Missing values appear as `-`; unavailable and failed reports include their reasons.
|
||||
|
||||
Copy the full report ID from the list into `inspect` to see reset times, balances and units, plan details, supplied forecasts, and login failures with recovery instructions. Report IDs require exact matches.
|
||||
|
||||
`--agent` selects the account quota associated with an agent. It does not measure that agent's token consumption or cost. Use the full agent ID, and do not combine `--agent` with `inspect`.
|
||||
|
||||
Queries use the host's five-minute cache when available. Add `--refresh` to bypass it. Use `--host` or `--home` to [select a daemon](#select-one-daemon). Hosts without usage-source support return an update message.
|
||||
|
||||
### Usage in scripts
|
||||
|
||||
```bash
|
||||
paseo usage ls --json
|
||||
paseo usage inspect <report-id> --json
|
||||
paseo usage ls --quiet
|
||||
```
|
||||
|
||||
List JSON is always an array, including `[]` when no reports are returned. Inspect JSON is one report entry. Each entry includes `id`, `sourceId`, `sourceLabel`, `account`, `fetchedAt`, and a nested `report`. Read `report.status` before accessing its data:
|
||||
|
||||
| Status | Fields |
|
||||
| ------------- | ---------------------------------------------------------------------- |
|
||||
| `available` | `windows`, with optional `planLabel`, `balances`, and `details` |
|
||||
| `unavailable` | `problem`, describing expired or rejected credentials or missing quota |
|
||||
| `error` | `error`, the failure message |
|
||||
|
||||
Entries may also include `loginErrors`. Structured output preserves numeric values and optional fields. Standard `--format yaml`, `--no-headers`, and `--no-color` options are supported; `--quiet` prints full report IDs.
|
||||
|
||||
A completed query exits with status `0`, including when individual reports are unavailable or failed. Connection or request failures, unsupported hosts, unknown report IDs, and conflicting selectors exit with status `1` and write the error to stderr. Human output says **No usage reports returned** for an empty result; this does not establish whether account discovery succeeded for every source.
|
||||
|
||||
## Provider diagnostics
|
||||
|
||||
Ask the daemon to inspect the provider environment it actually uses:
|
||||
|
||||
@@ -12,10 +12,6 @@ const patchedPackages = [
|
||||
nodeModulesPath: "node_modules/react-native-markdown-display",
|
||||
patchPrefix: "react-native-markdown-display+",
|
||||
},
|
||||
{
|
||||
nodeModulesPath: "node_modules/react-native-uitextview",
|
||||
patchPrefix: "react-native-uitextview+",
|
||||
},
|
||||
// Remove after react-native-unistyles ships
|
||||
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user