Compare commits

..
58 changed files with 289 additions and 3628 deletions
-2
View File
@@ -61,8 +61,6 @@ Core direct providers: `claude` (in `providers/claude/agent.ts`), `codex` (`code
Claude first-party model metadata lives in `packages/server/src/server/agent/providers/claude/model-manifest.ts`. When adding or updating a Claude model, update that manifest only; the model picker thinking options and Claude-specific feature gates are derived from the manifest. Do not add model-specific Claude capability lists in feature code.
Keep the curated Claude catalog: `supportedModels()` has historically omitted older versions and carries less metadata. Discovery is only an opt-in visibility gate, not a replacement catalog or an entitlement guarantee. Claude Code v2.1.197 added `resolvedModel` to disambiguate alias rows; a bare family alias does not establish access to a particular version. Explicit user-configured models must remain selectable when discovery fails or omits them.
Paseo tools are not implemented as MCP tools internally. They live in a shared tool catalog under `packages/server/src/server/agent/tools/`; MCP is only the fallback adapter. The daemon resolves `agents.providers.<provider>.paseoTools` by the exact provider ID. The catalog policy belongs to the caller: it filters the tools exposed to the current agent. When that agent calls `create_agent`, the child receives the policy for the child provider ID; the caller's policy is not inherited.
A provider that can register runtime tools directly should set `supportsNativePaseoTools: true` and consume the already-filtered `launchContext.paseoTools` in `createSession`/`resumeSession`. When native tools are present, `AgentManager` strips the internal Paseo MCP server from the provider launch config so the provider does not receive the same tools twice. Providers that only know MCP should keep `supportsMcpServers: true` and let the daemon inject `/mcp/agents`; the MCP server builds the same policy-filtered catalog for that caller. Filtering is enforced at catalog registration in both paths. Browser tools remain subject to the daemon browser-tools setting and browser-host availability.
-2
View File
@@ -2,7 +2,6 @@ const fs = require("node:fs");
const path = require("node:path");
const pkg = require("./package.json");
const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storage-size");
const withAndroidOptionalCamera = require("./plugins/with-android-optional-camera");
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
@@ -146,7 +145,6 @@ export default {
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
withAndroidOptionalCamera,
[
"expo-splash-screen",
{
-61
View File
@@ -1,61 +0,0 @@
import { createRequire } from "node:module";
import { afterEach, describe, expect, it } from "vitest";
// Load the copies the Expo CLI uses for prebuild, not older hoisted ones.
const requireFromExpo = createRequire(require.resolve("expo/package.json"));
const requireFromExpoCli = createRequire(requireFromExpo.resolve("@expo/cli/package.json"));
const { getPrebuildConfigAsync } = requireFromExpoCli("@expo/prebuild-config");
const { compileModsAsync } = requireFromExpoCli(
"@expo/config-plugins/build/plugins/mod-compiler.js",
);
interface ManifestEntry {
$: Record<string, string>;
}
const originalFdroidFlag = process.env.PASEO_FDROID_BUILD;
async function resolveAndroidManifest() {
const { exp } = await getPrebuildConfigAsync(__dirname, { platforms: ["android"] });
await compileModsAsync(exp, {
projectRoot: __dirname,
introspect: true,
platforms: ["android"],
assertMissingModProviders: false,
});
return exp._internal.modResults.android.manifest.manifest;
}
describe("Android app config", () => {
afterEach(() => {
if (originalFdroidFlag === undefined) {
delete process.env.PASEO_FDROID_BUILD;
} else {
process.env.PASEO_FDROID_BUILD = originalFdroidFlag;
}
});
it.each([
["Google Play", "0"],
["F-Droid", "1"],
])(
"%s build installs on devices without a camera",
async (_build, fdroidFlag) => {
process.env.PASEO_FDROID_BUILD = fdroidFlag;
const manifest = await resolveAndroidManifest();
const permissions = (manifest["uses-permission"] ?? []).map(
(entry: ManifestEntry) => entry.$["android:name"],
);
const features = (manifest["uses-feature"] ?? []).map((entry: ManifestEntry) => entry.$);
expect(permissions).toContain("android.permission.CAMERA");
expect(features).toEqual(
expect.arrayContaining([
{ "android:name": "android.hardware.camera", "android:required": "false" },
{ "android:name": "android.hardware.camera.autofocus", "android:required": "false" },
]),
);
},
30_000,
);
});
@@ -1,14 +1,7 @@
import type { Locator } from "@playwright/test";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
import { gateNextAgentMessage } from "../support/helpers/agent-message-gate";
import {
fillComposerDraft,
expectComposerDraft,
attachFileFromMenu,
} from "../support/helpers/composer";
import { ensureExplorerSidebar, openFilesPanel } from "../support/helpers/workspace-tabs";
const APP_SETTINGS_KEY = "@paseo:app-settings";
@@ -33,19 +26,14 @@ async function openComposerDiff(page: Page) {
await composerChangesPill(page).click();
}
async function seedChangedAgent(
repoPrefix: string,
featureValues?: Record<string, unknown>,
files: Array<{ path: string; content: string }> = [],
) {
async function seedChangedAgent(repoPrefix: string) {
const workspace = await seedMockAgentWorkspace({
repoPrefix,
featureValues,
title: "Composer diff stat",
repo: {
withRemote: true,
// Exclude the local remote before the daemon starts observing this checkout.
files: [{ path: ".gitignore", content: "/remote.git/\n" }, ...files],
files: [{ path: ".gitignore", content: "/remote.git/\n" }],
},
});
try {
@@ -168,691 +156,3 @@ test("composer diff stat reveals Changes, then opens the diff in the focused pan
await workspace.cleanup();
}
});
async function tapFirstAddedDiffLine(
page: Page,
surface: Locator = page.getByTestId("working-diff-panel"),
) {
const body = surface.getByTestId("diff-file-0-body");
await expect(body).toBeVisible({ timeout: 30000 });
const bounds = await body.boundingBox();
if (!bounds) throw new Error("Expanded diff body has no bounds");
const fontSize = await surface
.getByTestId("git-diff-canvas")
.evaluate((element) => Number.parseFloat(getComputedStyle(element).fontSize));
await page.touchscreen.tap(bounds.x + 120, bounds.y + Math.round(fontSize * 1.5) * 2.5);
}
async function openReviewDiff(page: Page, workspace: Awaited<ReturnType<typeof seedChangedAgent>>) {
await page.setViewportSize({ width: 1400, height: 900 });
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await expect(composerChangesPill(page)).toBeVisible({ timeout: 30000 });
if (await page.getByTestId("workspace-tab-working_diff").count()) {
await selectDiffTab(page);
} else {
await revealComposerChangesInExplorer(page);
await openComposerDiff(page);
}
await expect(page.getByTestId("diff-file-0-body")).toBeVisible();
}
async function saveReviewComment(page: Page, body: string) {
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
await page.getByRole("button", { name: "Save review comment", exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
async function cancelReviewComment(page: Page) {
await page.getByRole("button", { name: "Cancel review comment", exact: true }).click();
await expect(page.getByRole("button", { name: "Bottom sheet backdrop" })).toHaveCount(0);
}
test.describe("review comments", () => {
test.use({ hasTouch: true });
test("compact review comments open a sheet and preserve saved comments through save, edit, cancel and reload", async ({
page,
}) => {
const workspace = await seedChangedAgent("compact-review-comment-");
try {
await test.step("Open a compact sheet with reachable input and actions", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await tapFirstAddedDiffLine(page);
await expectCompactCommentSheet(page);
});
await test.step("Save, discard an edit, then save an updated comment", async () => {
await saveReviewComment(page, " Please simplify this. ");
await expectSavedReview(page, "Please simplify this.");
await editSavedReview(page, "Please simplify this.");
await fillReviewComment(page, "Discard this edit");
await cancelReviewComment(page);
await expectSavedReview(page, "Please simplify this.");
await editSavedReview(page, "Please simplify this.");
await saveReviewComment(page, "Updated comment");
await expectSavedReview(page, "Updated comment");
});
await test.step("Discard a new comment and retain the saved one after reload", async () => {
await tapFirstAddedDiffLine(page);
await fillReviewComment(page, "Discard new comment");
await cancelReviewComment(page);
await expectReviewAbsent(page, "Discard new comment");
await reloadReview(page);
await expectSavedReview(page, "Updated comment");
});
} finally {
await workspace.cleanup();
}
});
test("wide review comments retain inline editing", async ({ page }) => {
const workspace = await seedChangedAgent("wide-review-comment-");
try {
await test.step("Save and cancel an inline edit on a wide screen", async () => {
await openReviewDiff(page, workspace);
await tapFirstAddedDiffLine(page);
await expectInlineCommentEditor(page);
await saveReviewComment(page, "Wide comment");
await expectSavedReview(page, "Wide comment");
await editSavedReview(page, "Wide comment");
await expectInlineCommentEditor(page);
await cancelReviewComment(page);
await expectSavedReview(page, "Wide comment");
});
await test.step("The existing composer still sends the review", async () => {
await sendReviewThroughComposer(page, workspace.agentId);
await selectDiffTab(page);
await expectReviewAbsent(page, "Wide comment");
});
} finally {
await workspace.cleanup();
}
});
});
async function sendReviewThroughComposer(page: Page, agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).click();
await expect(page.getByTestId("composer-review-attachment-pill")).toBeVisible();
await page.getByRole("textbox", { name: "Message agent..." }).fill("Address this review");
await page.getByRole("button", { name: "Send message", exact: true }).click();
await expect(page.getByText("Address this review", { exact: true }).first()).toBeVisible();
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
}
async function addSavedReview(
page: Page,
body: string,
surface: Locator = page.getByTestId("working-diff-panel"),
) {
await tapFirstAddedDiffLine(page, surface);
await saveReviewComment(page, body);
await expectSavedReview(page, body, surface);
}
async function expectSavedReview(
page: Page,
body: string,
surface: Locator = page.getByTestId("working-diff-panel"),
) {
await expect(surface.getByText(body, { exact: true })).toBeVisible();
}
async function editSavedReview(page: Page, expectedBody: string) {
await page
.getByTestId("working-diff-panel")
.getByRole("button", { name: "Edit review comment", exact: true })
.click();
await expect(page.getByRole("textbox", { name: "Review comment" })).toHaveValue(expectedBody);
}
async function sendFeedback(page: Page, count: number) {
await page.getByRole("button", { name: `Send feedback (${count})`, exact: true }).click();
}
async function expectFeedbackSent(page: Page, recipient: string) {
await expect(page.getByText(`Feedback sent to ${recipient}`, { exact: true })).toBeVisible();
await expect(page.getByTestId("working-diff-panel")).toBeVisible();
}
test.describe("send review feedback", () => {
test.use({ hasTouch: true });
test("compact feedback sends directly to the only workspace agent and preserves its composer draft", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-single-");
try {
await test.step("Save a review without changing the chat draft", async () => {
await openReviewDiff(page, workspace);
await selectAgentTab(page, workspace.agentId);
await fillComposerDraft(page, "Keep this thought");
await attachFileFromMenu(page, {
name: "unrelated.txt",
mimeType: "text/plain",
buffer: Buffer.from("Unrelated draft attachment"),
});
await selectDiffTab(page);
await showCompactLayout(page);
await addSavedReview(page, "Please simplify this.");
});
await test.step("Send directly and stay on the diff", async () => {
await sendFeedback(page, 1);
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Please simplify this.");
await captureFeedback(page, "single");
});
await test.step("Return to the untouched composer draft", async () => {
await showWideLayout(page);
await selectAgentTab(page, workspace.agentId);
await expectComposerDraft(page, "Keep this thought");
await expect(page.getByText("unrelated.txt", { exact: true })).toBeVisible();
await expect(page.getByTestId("composer-review-attachment-pill")).toHaveCount(0);
});
} finally {
await workspace.cleanup();
}
});
test("feedback pending ownership follows the draft across Diff and compact Changes", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-shared-pending-");
const gate = await gateNextAgentMessage(page);
try {
await test.step("Send feedback from the Diff tab and hold its real request", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "One draft across surfaces");
await sendFeedback(page, 1);
await gate.waitForRequest();
});
await test.step("The compact Changes surface observes the same pending operation", async () => {
await revealCompactChanges(page);
await expect(
compactReviewSurface(page).getByRole("button", {
name: "Sending feedback (1)",
exact: true,
}),
).toBeDisabled();
expect(gate.requestCount()).toBe(1);
gate.accept();
await expect(
compactReviewSurface(page).getByText("Feedback sent to Composer diff stat", {
exact: true,
}),
).toBeVisible();
});
} finally {
await workspace.cleanup();
}
});
test("an unsaved review survives presentation changes and acknowledgement before Save", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-editor-ownership-");
const gate = await gateNextAgentMessage(page);
try {
await test.step("Carry unsaved inline text into the compact sheet and back", async () => {
await openReviewDiff(page, workspace);
await tapFirstAddedDiffLine(page);
await fillReviewComment(page, "Body across presentations");
await showCompactLayout(page);
await expectReviewEditorBody(page, "Body across presentations");
await showWideLayout(page);
await expectReviewEditorBody(page, "Body across presentations");
await saveReviewComment(page, "Body across presentations");
});
await test.step("Keep an open edit saveable after acknowledgement removes its original", async () => {
await sendFeedback(page, 1);
await gate.waitForRequest();
await editSavedReview(page, "Body across presentations");
await fillReviewComment(page, "Keep this unsaved edit");
gate.accept();
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewEditorBody(page, "Keep this unsaved edit");
await showCompactLayout(page);
await expectReviewEditorBody(page, "Keep this unsaved edit");
await saveReviewComment(page, "Keep this unsaved edit");
await expectSavedReview(page, "Keep this unsaved edit");
});
} finally {
await workspace.cleanup();
}
});
test("saved feedback preserves navigation across multiple diff files", async ({ page }) => {
const workspace = await seedMultiFileReview();
try {
await test.step("Save compact feedback and retain both floating actions", async () => {
await openCompactReviewExplorer(page, workspace);
await addSavedReview(page, "Keep file navigation available", compactReviewSurface(page));
await expectReviewNavigationReachable(page);
});
await test.step("Jump to another file without covering the feedback action", async () => {
await jumpToReviewFile(page, "z-last.ts");
await scrollReviewToLastLine(page);
await expectReviewNavigationReachable(page);
await captureFeedback(page, "navigation-compact");
});
await test.step("Wide file navigation and feedback remain reachable", async () => {
await openReviewDiff(page, workspace);
await navigateWideReviewFile(page, "README.md");
await expectFeedbackReachable(page);
await expectSavedReview(page, "Keep file navigation available");
await captureFeedback(page, "navigation-wide");
});
} finally {
await workspace.cleanup();
}
});
test("feedback sends only matching comments and preserves orphaned comments", async ({
page,
}) => {
const workspace = await seedMultiFileReview();
try {
await test.step("A saved comment whose diff disappears has no sendable context", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "Retain this orphan");
await setReadmeDiff(workspace, false);
await expectUnmatchedFeedback(page);
});
await test.step("Send the matching subset with an accurate count", async () => {
await addSavedReview(page, "Send this matching comment");
await sendFeedback(page, 1);
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Send this matching comment");
await expectUnmatchedFeedback(page);
});
await test.step("Restoring the missing diff reveals the unsent comment", async () => {
await setReadmeDiff(workspace, true);
await expectSavedReview(page, "Retain this orphan");
await expectFeedbackReachable(page);
});
} finally {
await workspace.cleanup();
}
});
test("zero open agent tabs retain feedback with a disabled reason", async ({ page }) => {
const workspace = await seedChangedAgent("review-feedback-zero-");
try {
await test.step("Close the agent tab while keeping the review", async () => {
await openReviewDiff(page, workspace);
await addSavedReview(page, "Keep until an agent is opened");
await closeAgentTab(page, workspace.agentId);
});
await test.step("The disabled action explains why in wide and compact layouts", async () => {
await expectFeedbackUnavailable(page);
await captureFeedback(page, "zero-wide");
await showCompactLayout(page);
await expectFeedbackUnavailable(page);
await expectSavedReview(page, "Keep until an agent is opened");
await captureFeedback(page, "zero-compact");
});
} finally {
await workspace.cleanup();
}
});
test("only open tabs in this workspace appear in compact and wide recipient menus", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-multiple-");
const other = await seedChangedAgent("review-feedback-other-");
try {
const second = await createReviewRecipient(workspace, "Second reviewer");
const historical = await createReviewRecipient(workspace, "Closed historical agent");
await test.step("Open two local tabs and a tab in another workspace", async () => {
await openReviewDiff(page, other);
await addSavedReview(page, "Other workspace review");
await openReviewDiff(page, workspace);
await closeAgentTab(page, historical.id);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: second.id });
await selectDiffTab(page);
await addSavedReview(page, "Review for second agent");
});
await test.step("The wide menu includes only local open agents", async () => {
await captureWideFeedbackAppearance(page);
});
await test.step("Escape returns keyboard focus to feedback so the chooser can reopen", async () => {
await openFeedbackWithKeyboard(page);
await dismissRecipientMenu(page);
await expectFeedbackFocused(page);
await reopenFeedbackWithKeyboard(page);
await dismissRecipientMenu(page);
await expectFeedbackFocused(page);
});
await test.step("Choose the second agent in the compact menu and stay on the diff", async () => {
await showCompactLayout(page);
await captureFeedback(page, "cta-compact");
await sendFeedback(page, 1);
await expectRecipientMenu(page);
await captureFeedback(page, "menu-compact");
await chooseFeedbackRecipient(page, "Second reviewer");
await expectFeedbackSent(page, "Second reviewer");
await expectReviewAbsent(page, "Review for second agent");
});
await test.step("The other workspace's saved review remains", async () => {
await openReviewDiff(page, other);
await expectSavedReview(page, "Other workspace review");
});
} finally {
await workspace.cleanup();
await other.cleanup();
}
});
test("failed feedback remains editable and retries through the real provider", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-retry-", { mockPromptRejections: 1 });
try {
await test.step("A provider rejection retains the saved review", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "Initial feedback");
await sendFeedback(page, 1);
await expectFeedbackFailure(page);
await expectSavedReview(page, "Initial feedback");
await captureFeedback(page, "failure");
});
await test.step("Edit the retained feedback and retry successfully", async () => {
await editSavedReview(page, "Initial feedback");
await saveReviewComment(page, "Corrected feedback");
await sendFeedback(page, 1);
await expectFeedbackSent(page, "Composer diff stat");
await expectReviewAbsent(page, "Corrected feedback");
});
} finally {
await workspace.cleanup();
}
});
test("pending feedback blocks repeat submission and preserves edits and additions", async ({
page,
}) => {
const workspace = await seedChangedAgent("review-feedback-pending-");
const gate = await gateNextAgentMessage(page);
try {
await test.step("Hold the real request while feedback is pending", async () => {
await openReviewDiff(page, workspace);
await showCompactLayout(page);
await addSavedReview(page, "Sent version");
await sendFeedback(page, 1);
const request = await gate.waitForRequest();
expect(request.agentId).toBe(workspace.agentId);
expect(request.text).toBe("Please address this code review.");
expect(request.attachments).toMatchObject([
{
type: "review",
cwd: workspace.cwd,
comments: [
{
filePath: "README.md",
lineNumber: 2,
body: "Sent version",
context: { targetLine: { content: "export const one = 1;", newLineNumber: 2 } },
},
],
},
]);
await expectFeedbackPending(page);
});
await test.step("Save an edit and another comment before acknowledgement", async () => {
await editSavedReview(page, "Sent version");
await saveReviewComment(page, "Edited during send");
await addSavedReview(page, "Added during send");
await expectFeedbackPending(page);
expect(gate.requestCount()).toBe(1);
await captureFeedback(page, "pending");
});
await test.step("A real acknowledgement preserves both new versions", async () => {
gate.accept();
await expectFeedbackSent(page, "Composer diff stat");
await expectSavedReview(page, "Edited during send");
await expectSavedReview(page, "Added during send");
await expectReviewAbsent(page, "Sent version");
});
} finally {
await workspace.cleanup();
}
});
});
async function showCompactLayout(page: Page) {
await page.setViewportSize({ width: 390, height: 844 });
}
async function showWideLayout(page: Page) {
await page.setViewportSize({ width: 1400, height: 900 });
}
async function selectAgentTab(page: Page, agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ position: { x: 12, y: 13 } });
}
async function selectDiffTab(page: Page) {
await page.getByTestId("workspace-tab-working_diff").click({ position: { x: 12, y: 13 } });
}
async function fillReviewComment(page: Page, body: string) {
await page.getByRole("textbox", { name: "Review comment" }).fill(body);
}
async function expectReviewAbsent(page: Page, body: string) {
await expect(page.getByTestId("working-diff-panel").getByText(body, { exact: true })).toHaveCount(
0,
);
}
async function reloadReview(page: Page) {
await page.reload();
await expect(page.getByTestId("working-diff-panel")).toBeVisible({ timeout: 30000 });
}
async function expectInlineCommentEditor(page: Page) {
await expect(page.getByTestId("inline-review-editor")).toBeVisible();
await expect(page.getByTestId("review-comment-sheet")).toHaveCount(0);
}
async function expectCompactCommentSheet(page: Page) {
await expect(page.getByTestId("review-comment-sheet")).toBeVisible();
await expect(page.getByText("README.md · +2", { exact: true })).toBeVisible();
await expect(page.getByRole("textbox", { name: "Review comment" })).toBeInViewport({ ratio: 1 });
for (const action of ["Save", "Cancel"])
await expect(
page.getByRole("button", { name: `${action} review comment`, exact: true }),
).toBeInViewport({ ratio: 1 });
await expect(page.getByTestId("inline-review-editor")).toHaveCount(0);
await expect(
page.getByRole("button", { name: "Save review comment", exact: true }),
).toBeDisabled();
}
function feedbackButton(page: Page) {
return page.getByRole("button", { name: "Send feedback (1)", exact: true });
}
async function openFeedbackWithKeyboard(page: Page) {
await feedbackButton(page).focus();
await expectFeedbackFocused(page);
await reopenFeedbackWithKeyboard(page);
}
async function reopenFeedbackWithKeyboard(page: Page) {
await page.keyboard.press("Enter");
await expectRecipientMenu(page);
const recipient = page.getByRole("menuitem", { name: "Composer diff stat", exact: true });
await recipient.focus();
await expect(recipient).toBeFocused();
}
async function expectFeedbackFocused(page: Page) {
await expect(feedbackButton(page)).toBeFocused();
}
async function captureWideFeedbackAppearance(page: Page) {
for (const colorScheme of ["light", "dark"] as const) {
await page.emulateMedia({ colorScheme });
await expectSavedReview(page, "Review for second agent");
await captureFeedback(page, `cta-wide-${colorScheme}-closed`);
await sendFeedback(page, 1);
await expectRecipientMenu(page);
await captureFeedback(page, `cta-wide-${colorScheme}-open`);
await dismissRecipientMenu(page);
}
await page.emulateMedia({ colorScheme: "light" });
}
async function captureFeedback(page: Page, name: string) {
await page.screenshot({ path: `/tmp/phase2-feedback-${name}.png` });
}
async function closeAgentTab(page: Page, agentId: string) {
await page.getByTestId(`workspace-tab-agent_${agentId}`).click({ button: "right" });
await page.getByTestId(`workspace-tab-context-agent_${agentId}-close`).click();
await expect(page.getByTestId(`workspace-tab-agent_${agentId}`)).toHaveCount(0);
}
async function expectFeedbackUnavailable(page: Page) {
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeDisabled();
await expect(
page.getByText("Open an agent tab in this workspace to send feedback.", { exact: true }),
).toBeVisible();
}
async function createReviewRecipient(
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
title: string,
) {
return workspace.client.createAgent({
provider: "mock",
cwd: workspace.cwd,
workspaceId: workspace.workspaceId,
title,
modeId: "load-test",
model: "e2e-fast-stream",
});
}
async function expectRecipientMenu(page: Page) {
for (const name of ["Composer diff stat", "Second reviewer"]) {
await expect(page.getByRole("menuitem", { name, exact: true })).toBeInViewport({ ratio: 1 });
}
await expect(page.getByRole("menuitem")).toHaveCount(2);
}
async function dismissRecipientMenu(page: Page) {
await page.keyboard.press("Escape");
await expect(page.getByRole("menuitem")).toHaveCount(0);
}
async function chooseFeedbackRecipient(page: Page, name: string) {
await page.getByRole("menuitem", { name, exact: true }).click();
}
async function expectFeedbackFailure(page: Page) {
await expect(
page.getByText("Requested mock prompt rejection", { exact: true }).first(),
).toBeVisible();
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeEnabled();
}
async function expectFeedbackPending(page: Page, count = 1) {
await expect(
page.getByRole("button", { name: `Sending feedback (${count})`, exact: true }),
).toBeDisabled();
}
async function seedMultiFileReview() {
const workspace = await seedChangedAgent("review-feedback-navigation-", undefined, [
{ path: "z-last.ts", content: "export const final = 0;\n" },
]);
await writeFile(
path.join(workspace.cwd, "z-last.ts"),
Array.from({ length: 120 }, (_, index) => `export const value${index} = ${index};`).join("\n") +
"\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
return workspace;
}
async function expectFeedbackReachable(page: Page) {
await expect(page.getByRole("button", { name: "Send feedback (1)", exact: true })).toBeInViewport(
{ ratio: 1 },
);
}
async function expectReviewNavigationReachable(page: Page) {
await expectFeedbackReachable(page);
const jump = page.getByRole("button", { name: "Jump to file", exact: true });
await expect(jump).toBeInViewport({ ratio: 1 });
const navigationBounds = await jump.boundingBox();
const feedbackBounds = await page
.getByRole("button", { name: "Send feedback (1)", exact: true })
.boundingBox();
if (!navigationBounds || !feedbackBounds) throw new Error("Review actions have no bounds");
expect(feedbackBounds.y + feedbackBounds.height).toBeLessThanOrEqual(navigationBounds.y);
}
async function jumpToReviewFile(page: Page, name: string) {
await page.getByRole("button", { name: "Jump to file", exact: true }).click();
const sheet = page.getByTestId("changes-jump-to-file-sheet");
await page
.getByTestId("changes-file-tree")
.filter({ visible: true })
.getByText(name, { exact: true })
.click();
await expect(sheet).toHaveCount(0);
await expect(reviewFileHeader(compactReviewSurface(page), name)).toBeInViewport();
}
async function navigateWideReviewFile(page: Page, name: string) {
const explorer = await ensureExplorerSidebar(page);
await explorer.getByTestId("changes-tree-panel").getByText(name, { exact: true }).click();
await expect(reviewFileHeader(page.getByTestId("working-diff-panel"), name)).toBeInViewport();
}
async function setReadmeDiff(
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
changed: boolean,
) {
await writeFile(
path.join(workspace.cwd, "README.md"),
changed ? "# Temp Repo\nexport const one = 1;\nexport const two = 2;\n" : "# Temp Repo\n",
);
await workspace.client.checkoutRefresh(workspace.cwd);
}
async function expectUnmatchedFeedback(page: Page) {
await expect(page.getByRole("button", { name: "Send feedback (0)", exact: true })).toBeDisabled();
await expect(
page.getByText("Saved comments no longer match this diff.", { exact: true }),
).toBeVisible();
}
function compactReviewSurface(page: Page) {
return page.getByTestId("explorer-content-area").filter({ visible: true });
}
async function openCompactReviewExplorer(
page: Page,
workspace: Awaited<ReturnType<typeof seedChangedAgent>>,
) {
await showWideLayout(page);
await openAgentRoute(page, { workspaceId: workspace.workspaceId, agentId: workspace.agentId });
await selectAgentTab(page, workspace.agentId);
await showCompactLayout(page);
await composerChangesPill(page).click();
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
function reviewFileHeader(surface: Locator, name: string) {
return surface.locator(`[data-diff-header-path="${name}"]`).getByTestId(/^diff-file-\d+$/);
}
async function scrollReviewToLastLine(page: Page) {
const surface = compactReviewSurface(page);
const bounds = await surface.boundingBox();
if (!bounds) throw new Error("Compact review has no bounds");
await page.mouse.move(bounds.x + 120, bounds.y + 200);
await page.mouse.wheel(0, 10000);
await expect
.poll(async () => {
const body = await surface.getByTestId("diff-file-1-body").boundingBox();
const feedback = await page
.getByRole("button", { name: "Send feedback (1)", exact: true })
.boundingBox();
if (!body || !feedback) return false;
return body.y + body.height <= feedback.y;
})
.toBe(true);
}
async function revealCompactChanges(page: Page) {
await page.getByTestId("workspace-explorer-toggle").first().click();
await page.getByTestId("explorer-header").getByText("Changes", { exact: true }).click();
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
async function expectReviewEditorBody(page: Page, body: string) {
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
body,
);
}
@@ -6,8 +6,6 @@ type WebSocketMessage = string | Buffer;
interface SendAgentMessageRequest {
type: "send_agent_message_request";
requestId: string;
text: unknown;
attachments: unknown;
agentId: string;
/** Absent when the client sends into an idle agent. */
activeTurnBehavior?: string;
@@ -31,8 +29,6 @@ function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | n
return {
type: "send_agent_message_request",
requestId: request.requestId,
text: request.text,
attachments: request.attachments,
agentId: request.agentId,
activeTurnBehavior:
typeof request.activeTurnBehavior === "string" ? request.activeTurnBehavior : undefined,
@@ -78,7 +74,6 @@ export async function gateNextAgentMessage(page: Page) {
return {
waitForRequest,
requestCount: () => requests.length,
accept(index = 0) {
const heldMessage = heldMessages[index];
if (!serverSocket || !heldMessage) {
@@ -1,24 +0,0 @@
const { withAndroidManifest } = require("expo/config-plugins");
// Requesting CAMERA implies these features are required, which hides the app
// on Google Play for devices without a camera. Pairing works without QR scanning.
const OPTIONAL_CAMERA_FEATURES = ["android.hardware.camera", "android.hardware.camera.autofocus"];
function withAndroidOptionalCamera(config) {
return withAndroidManifest(config, (modConfig) => {
const manifest = modConfig.modResults.manifest;
const features = manifest["uses-feature"] ?? [];
for (const name of OPTIONAL_CAMERA_FEATURES) {
const existing = features.find((feature) => feature.$["android:name"] === name);
if (existing) {
existing.$["android:required"] = "false";
} else {
features.push({ $: { "android:name": name, "android:required": "false" } });
}
}
manifest["uses-feature"] = features;
return modConfig;
});
}
module.exports = withAndroidOptionalCamera;
@@ -174,7 +174,6 @@ export function Button({
...props
}: PropsWithChildren<
Omit<PressableProps, "style"> & {
ref?: React.Ref<View>;
variant?: ButtonVariant;
size?: ButtonSize;
leftIcon?: LeftIcon;
@@ -1,67 +1,47 @@
import { forwardRef, useMemo } from "react";
import { Text, type View } from "react-native";
import type { LucideIcon } from "lucide-react-native";
import { StyleSheet } from "react-native-unistyles";
import { PressHighlight } from "@/components/ui/press-highlight";
import { ICON_SIZE } from "@/styles/theme";
import { ICON_SIZE, SPACING } from "@/styles/theme";
import {
FLOATING_ACTION_BUTTON_SIZE,
FLOATING_ACTION_BUTTON_INSET,
} from "./floating-action-layout";
export {
FLOATING_ACTION_BUTTON_CLEARANCE,
floatingActionsClearance,
} from "./floating-action-layout";
const FLOATING_ACTION_BUTTON_SIZE = 56;
const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
/** Space a scrolling surface keeps below its content so the button traps nothing. */
export const FLOATING_ACTION_BUTTON_CLEARANCE =
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
export interface FloatingActionButtonProps {
icon: LucideIcon;
/** Names the action for assistive technology. */
/** Names the action; the button carries no label. */
accessibilityLabel: string;
onPress: () => void;
label?: string;
disabled?: boolean;
bottomInset?: number;
testID?: string;
}
/**
* Pins an action to the bottom-right of its nearest positioned ancestor.
* The surface reserves scroll clearance and uses bottomInset to separate stacked actions.
* The one floating action a scrolling surface can offer. It pins itself to the
* bottom-right of its nearest positioned ancestor, so the ancestor decides what
* the button may cover.
*/
export const FloatingActionButton = forwardRef<View, FloatingActionButtonProps>(
function FloatingActionButton(
{ icon: Icon, accessibilityLabel, onPress, label, disabled = false, bottomInset = 0, testID },
ref,
) {
const accessibilityState = useMemo(() => ({ disabled }), [disabled]);
const buttonStyle = useMemo(
() => [
styles.button,
label && styles.labeled,
disabled && styles.disabled,
{ bottom: FLOATING_ACTION_BUTTON_INSET + bottomInset },
],
[bottomInset, disabled, label],
);
return (
<PressHighlight
ref={ref}
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
accessibilityState={accessibilityState}
disabled={disabled}
onPress={onPress}
style={buttonStyle}
highlightStyle={styles.pressed}
testID={testID}
>
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
{label ? <Text style={styles.label}>{label}</Text> : null}
</PressHighlight>
);
},
);
export function FloatingActionButton({
icon: Icon,
accessibilityLabel,
onPress,
testID,
}: FloatingActionButtonProps) {
return (
<PressHighlight
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
onPress={onPress}
style={styles.button}
highlightStyle={styles.pressed}
testID={testID}
>
<Icon size={ICON_SIZE.lg} color={styles.glyph.color} />
</PressHighlight>
);
}
const styles = StyleSheet.create((theme) => ({
button: {
@@ -76,15 +56,6 @@ const styles = StyleSheet.create((theme) => ({
backgroundColor: theme.colors.surface3,
...theme.shadow.md,
},
labeled: {
width: "auto",
minWidth: FLOATING_ACTION_BUTTON_SIZE,
paddingHorizontal: theme.spacing[4],
flexDirection: "row",
gap: theme.spacing[2],
},
label: { color: theme.colors.foreground, fontSize: theme.fontSize.base },
disabled: { opacity: theme.opacity[50] },
pressed: {
backgroundColor: theme.colors.interactionHighlight,
},
@@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import {
floatingActionsClearance,
FLOATING_ACTION_BUTTON_CLEARANCE,
} from "./floating-action-layout";
describe("floating action scroll clearance", () => {
it("keeps the last diff line above Jump to file before and after feedback is added", () => {
const jump = FLOATING_ACTION_BUTTON_CLEARANCE;
const feedback = FLOATING_ACTION_BUTTON_CLEARANCE + 44;
expect(floatingActionsClearance([jump, 0], 34)).toBe(106);
expect(floatingActionsClearance([jump, feedback], 34)).toBe(222);
expect(floatingActionsClearance([0, feedback], 34)).toBe(150);
expect(floatingActionsClearance([jump, feedback], 0)).toBe(188);
expect(floatingActionsClearance([0, 0], 34)).toBe(0);
});
});
@@ -1,15 +0,0 @@
import { SPACING } from "@/styles/theme";
export const FLOATING_ACTION_BUTTON_SIZE = 56;
export const FLOATING_ACTION_BUTTON_INSET = SPACING[4];
export const FLOATING_ACTION_BUTTON_CLEARANCE =
FLOATING_ACTION_BUTTON_SIZE + FLOATING_ACTION_BUTTON_INSET;
/** Reserve the stacked actions and the safe-area inset once for their scrolling surface. */
export function floatingActionsClearance(
actionClearances: readonly number[],
bottomInset: number,
): number {
const clearance = actionClearances.reduce((total, action) => total + action, 0);
return clearance > 0 ? clearance + bottomInset : 0;
}
@@ -245,12 +245,3 @@ describe("bottom sheet visibility tracker", () => {
expect(context.backPress.press()).toBe(false);
});
});
it("does not cancel the owner's editor when the compact sheet leaves the tree on resize", () => {
const context = setup();
openSheet(context);
context.tracker.attachController(null);
context.tracker.handleSheetDismiss();
expect(context.closeCount()).toBe(0);
expect(context.backPress.press()).toBe(false);
});
@@ -121,9 +121,6 @@ export function createBottomSheetVisibilityTracker(opts: {
}
},
handleSheetDismiss() {
// Portal cleanup reports dismissal after the sheet leaves the tree. It is
// presentation teardown, so the owner keeps its desired state.
if (!controller) return;
if (visible) {
setPhase("dismissing");
notifyClose();
@@ -315,7 +315,6 @@ function reviewActionsForFirstAddition(): NonNullable<
commentsByTarget: new Map(),
editor: { target, commentId: null, body: "" },
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
@@ -389,7 +389,6 @@ describe("diff document model", () => {
commentId: null,
},
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
@@ -791,7 +790,6 @@ function reviewActionsWithEditor(
commentsByTarget: new Map(),
editor: target ? { target, body: "", commentId: null } : null,
onStartComment() {},
onChangeEditorBody() {},
onCancelEditor() {},
onSaveEditor() {},
onEditComment() {},
+20 -47
View File
@@ -1,5 +1,3 @@
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { floatingActionsClearance } from "@/components/ui/floating-action-button";
import { useState, useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
@@ -33,7 +31,6 @@ import {
import { type ParsedDiffFile } from "@/git/use-diff-query";
import type { ChangesState } from "@/panels/changes/state";
import { defaultChangesState } from "@/panels/changes/state";
import { ReviewCommentSheet, ReviewFeedbackAction } from "@/review";
import { DiffDocument, type WorkingDiffMode } from "@/git/diff-document";
import { ChangedFilesTree } from "@/git/changed-files-tree";
import { JUMP_TO_FILE_CLEARANCE, JumpToFile } from "@/git/jump-to-file";
@@ -1551,9 +1548,7 @@ export function ChangesSurface({
isDiffLoading,
reviewActions,
reviewAttachment,
reviewFeedback,
} = useWorkingDiff({
presentation,
serverId,
workspaceId: workspaceId ?? undefined,
cwd,
@@ -1734,7 +1729,6 @@ export function ChangesSurface({
],
);
const { bottom: bottomInset } = useSafeAreaInsets();
const hasChanges = files.length > 0;
const jumpToFileInset = jumpToFileClearance({
isCompact: isMobile,
@@ -1784,41 +1778,27 @@ export function ChangesSurface({
});
const diffContent: ReactElement = (
<View style={styles.diffContainer}>
<DiffBodyContent
isStatusLoading={isStatusLoading}
statusErrorMessage={statusErrorMessage}
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
diffTooLarge={diffTooLarge}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
emptyAction={emptyAction}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
>
<DiffDocument
files={files}
contentInsetBottom={floatingActionsClearance(
[jumpToFileInset, reviewFeedback.clearance],
bottomInset,
)}
collapseState={collapseState}
displayPreferences={sharedDisplayPreferences}
mode={workingMode}
/>
</DiffBodyContent>
<ReviewFeedbackAction
bottomOffset={jumpToFileInset}
enabled={enabled}
hasDiff={presentation !== "tree"}
model={reviewFeedback.model}
state={reviewFeedback.state}
serverId={serverId}
workspaceId={workspaceId}
<DiffBodyContent
isStatusLoading={isStatusLoading}
statusErrorMessage={statusErrorMessage}
notGit={notGit}
isDiffLoading={isDiffLoading}
diffErrorMessage={diffErrorMessage}
diffTooLarge={diffTooLarge}
hasChanges={hasChanges}
emptyMessage={emptyMessage}
emptyAction={emptyAction}
checkingRepositoryLabel={t("workspace.git.diff.checkingRepository")}
notRepositoryLabel={t("workspace.git.diff.notRepository")}
>
<DiffDocument
files={files}
contentInsetBottom={jumpToFileInset}
collapseState={collapseState}
displayPreferences={sharedDisplayPreferences}
mode={workingMode}
/>
</View>
</DiffBodyContent>
);
const bodyContent = (
<ChangesBody
@@ -1954,13 +1934,6 @@ export function ChangesSurface({
) : null}
</View>
<ReviewCommentSheet
enabled={enabled}
editor={reviewActions.sheetEditor}
reviewActions={reviewActions}
onChangeBody={reviewActions.onChangeEditorBody}
/>
<ChangesCommits
presentation={presentation}
serverId={serverId}
@@ -1,7 +1,6 @@
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { FolderTree } from "lucide-react-native";
import {
@@ -54,7 +53,6 @@ export interface JumpToFileProps {
/** Compact overview of a loaded diff: a floating action that opens the changed-files tree. */
export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) {
const { t } = useTranslation();
const { bottom } = useSafeAreaInsets();
const [isOpen, setIsOpen] = useState(false);
// The sheet is a transient overview, so it always opens fully expanded and
// never writes to the panel's persisted folder state.
@@ -85,7 +83,6 @@ export function JumpToFile({ files, mode, onSelectFile }: JumpToFileProps) {
<>
<FloatingActionButton
icon={FolderTree}
bottomInset={bottom}
accessibilityLabel={title}
onPress={open}
testID="changes-jump-to-file"
+1 -12
View File
@@ -7,7 +7,6 @@ import {
buildReviewDraftKey,
useInlineReviewController,
useReviewAttachmentSnapshot,
useReviewFeedback,
} from "@/review";
import { useCheckoutDiffQuery } from "@/git/use-diff-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query";
@@ -20,7 +19,6 @@ interface UseWorkingDiffOptions {
ignoreWhitespace: boolean;
enabled: boolean;
queryScope?: string;
presentation?: string;
}
export function useWorkingDiff({
@@ -30,7 +28,6 @@ export function useWorkingDiff({
ignoreWhitespace,
enabled,
queryScope,
presentation,
}: UseWorkingDiffOptions) {
const {
status,
@@ -84,7 +81,7 @@ export function useWorkingDiff({
}),
[baseRef, cwd, diffMode, ignoreWhitespace, serverId, workspaceId],
);
const reviewActions = useInlineReviewController({ reviewDraftKey, presentation });
const reviewActions = useInlineReviewController({ reviewDraftKey });
const reviewAttachment = useReviewAttachmentSnapshot({
key: reviewDraftKey,
diffFiles: files,
@@ -93,13 +90,6 @@ export function useWorkingDiff({
baseRef,
});
const reviewFeedback = useReviewFeedback({
serverId,
workspaceId,
reviewDraftKey,
attachment: reviewAttachment,
});
return {
status,
isStatusLoading,
@@ -117,7 +107,6 @@ export function useWorkingDiff({
isDiffLoading,
reviewActions,
reviewAttachment,
reviewFeedback,
};
}
-12
View File
@@ -30,7 +30,6 @@ export const ar: TranslationResources = {
back: "خلف",
loading: "تحميل...",
actions: {
save: "حفظ",
back: "خلف",
cancel: "يلغي",
close: "يغلق",
@@ -2006,17 +2005,6 @@ export const ar: TranslationResources = {
accessibility: "تم استخدام نافذة السياق{{percentage}}%",
},
review: {
feedback: {
send: "إرسال الملاحظات ({{count}})",
sending: "جارٍ إرسال الملاحظات ({{count}})",
chooseAgent: "اختر وكيلاً",
sent: "تم إرسال الملاحظات إلى {{recipient}}",
"no-agents": "افتح علامة تبويب وكيل في مساحة العمل هذه لإرسال الملاحظات.",
disconnected: "اتصل بالمضيف لإرسال الملاحظات.",
"no-context": "لم تعد التعليقات المحفوظة تتطابق مع هذا الفرق.",
failed: "تعذر إرسال الملاحظات. حاول مرة أخرى.",
prompt: "يرجى معالجة مراجعة الكود هذه.",
},
comment: {
add: "إضافة تعليق المراجعة",
edit: "تحرير تعليق المراجعة",
-12
View File
@@ -26,7 +26,6 @@ export const en = {
back: "Back",
loading: "Loading...",
actions: {
save: "Save",
back: "Back",
cancel: "Cancel",
close: "Close",
@@ -2031,17 +2030,6 @@ export const en = {
accessibility: "Context window {{percentage}}% used",
},
review: {
feedback: {
send: "Send feedback ({{count}})",
sending: "Sending feedback ({{count}})",
chooseAgent: "Choose an agent",
sent: "Feedback sent to {{recipient}}",
"no-agents": "Open an agent tab in this workspace to send feedback.",
disconnected: "Connect to the host to send feedback.",
"no-context": "Saved comments no longer match this diff.",
failed: "Failed to send feedback. Try again.",
prompt: "Please address this code review.",
},
comment: {
add: "Add review comment",
edit: "Edit review comment",
-12
View File
@@ -31,7 +31,6 @@ export const es: TranslationResources = {
back: "Atrás",
loading: "Cargando...",
actions: {
save: "Guardar",
back: "Atrás",
cancel: "Cancelar",
close: "Cerrar",
@@ -2054,17 +2053,6 @@ export const es: TranslationResources = {
accessibility: "Ventana de contexto{{percentage}}% utilizada",
},
review: {
feedback: {
send: "Enviar comentarios ({{count}})",
sending: "Enviando comentarios ({{count}})",
chooseAgent: "Elegir un agente",
sent: "Comentarios enviados a {{recipient}}",
"no-agents": "Abre una pestaña de agente en este espacio de trabajo para enviar comentarios.",
disconnected: "Conéctate al host para enviar comentarios.",
"no-context": "Los comentarios guardados ya no coinciden con este diff.",
failed: "No se pudieron enviar los comentarios. Inténtalo de nuevo.",
prompt: "Por favor, atiende esta revisión de código.",
},
comment: {
add: "Agregar comentario de revisión",
edit: "Editar comentario de revisión",
-13
View File
@@ -31,7 +31,6 @@ export const fr: TranslationResources = {
back: "Retour",
loading: "Chargement…",
actions: {
save: "Enregistrer",
back: "Retour",
cancel: "Annuler",
close: "Fermer",
@@ -2051,18 +2050,6 @@ export const fr: TranslationResources = {
accessibility: "Fenêtre de contexte : {{percentage}} % utilisés",
},
review: {
feedback: {
send: "Envoyer les commentaires ({{count}})",
sending: "Envoi des commentaires ({{count}})",
chooseAgent: "Choisir un agent",
sent: "Commentaires envoyés à {{recipient}}",
"no-agents":
"Ouvrez un onglet d’agent dans cet espace de travail pour envoyer les commentaires.",
disconnected: "Connectez-vous à l’hôte pour envoyer les commentaires.",
"no-context": "Les commentaires enregistrés ne correspondent plus à ce diff.",
failed: "Échec de l’envoi des commentaires. Réessayez.",
prompt: "Veuillez traiter cette revue de code.",
},
comment: {
add: "Ajouter un commentaire de revue",
edit: "Modifier le commentaire de revue",
-13
View File
@@ -31,7 +31,6 @@ export const ja: TranslationResources = {
back: "戻る",
loading: "読み込み中...",
actions: {
save: "保存",
back: "戻る",
cancel: "キャンセル",
close: "閉じる",
@@ -2026,18 +2025,6 @@ export const ja: TranslationResources = {
accessibility: "コンテキストウィンドウ{{percentage}}%使用",
},
review: {
feedback: {
send: "フィードバックを送信 ({{count}})",
sending: "フィードバックを送信中 ({{count}})",
chooseAgent: "エージェントを選択",
sent: "{{recipient}} にフィードバックを送信しました",
"no-agents":
"フィードバックを送信するには、このワークスペースでエージェントタブを開いてください。",
disconnected: "フィードバックを送信するにはホストに接続してください。",
"no-context": "保存したコメントはこの差分と一致しなくなりました。",
failed: "フィードバックを送信できませんでした。もう一度お試しください。",
prompt: "このコードレビューに対応してください。",
},
comment: {
add: "レビューコメントを追加",
edit: "レビューコメントを編集",
-12
View File
@@ -30,7 +30,6 @@ export const ko: TranslationResources = {
back: "뒤로",
loading: "불러오는 중...",
actions: {
save: "저장",
back: "뒤로",
cancel: "취소",
close: "닫기",
@@ -2016,17 +2015,6 @@ export const ko: TranslationResources = {
accessibility: "컨텍스트 윈도우 {{percentage}}% 사용됨",
},
review: {
feedback: {
send: "피드백 보내기 ({{count}})",
sending: "피드백 전송 중 ({{count}})",
chooseAgent: "에이전트 선택",
sent: "{{recipient}}에게 피드백을 보냈습니다",
"no-agents": "피드백을 보내려면 이 작업 공간에서 에이전트 탭을 여세요.",
disconnected: "피드백을 보내려면 호스트에 연결하세요.",
"no-context": "저장된 댓글이 더 이상 이 변경 사항과 일치하지 않습니다.",
failed: "피드백 전송에 실패했습니다. 다시 시도하세요.",
prompt: "이 코드 리뷰를 반영해 주세요.",
},
comment: {
add: "리뷰 댓글 추가",
edit: "리뷰 댓글 편집",
-12
View File
@@ -31,7 +31,6 @@ export const ptBR: TranslationResources = {
back: "Voltar",
loading: "Carregando...",
actions: {
save: "Salvar",
back: "Voltar",
cancel: "Cancelar",
close: "Fechar",
@@ -2039,17 +2038,6 @@ export const ptBR: TranslationResources = {
accessibility: "Janela de contexto {{percentage}}% usada",
},
review: {
feedback: {
send: "Enviar comentários ({{count}})",
sending: "Enviando comentários ({{count}})",
chooseAgent: "Escolher um agente",
sent: "Comentários enviados para {{recipient}}",
"no-agents": "Abra uma aba de agente neste espaço de trabalho para enviar comentários.",
disconnected: "Conecte-se ao host para enviar comentários.",
"no-context": "Os comentários salvos não correspondem mais a este diff.",
failed: "Falha ao enviar comentários. Tente novamente.",
prompt: "Por favor, atenda a esta revisão de código.",
},
comment: {
add: "Adicionar comentário de revisão",
edit: "Editar comentário de revisão",
-12
View File
@@ -31,7 +31,6 @@ export const ru: TranslationResources = {
back: "Назад",
loading: "Загрузка...",
actions: {
save: "Сохранить",
back: "Назад",
cancel: "Отмена",
close: "Закрыть",
@@ -2039,17 +2038,6 @@ export const ru: TranslationResources = {
accessibility: "Использовано {{percentage}}% контекстного окна",
},
review: {
feedback: {
send: "Отправить отзыв ({{count}})",
sending: "Отправка отзыва ({{count}})",
chooseAgent: "Выберите агента",
sent: "Отзыв отправлен: {{recipient}}",
"no-agents": "Откройте вкладку агента в этой рабочей области, чтобы отправить отзыв.",
disconnected: "Подключитесь к хосту, чтобы отправить отзыв.",
"no-context": "Сохранённые комментарии больше не соответствуют этому diff.",
failed: "Не удалось отправить отзыв. Повторите попытку.",
prompt: "Пожалуйста, учтите эту проверку кода.",
},
comment: {
add: "Добавить комментарий к ревью",
edit: "Изменить комментарий к ревью",
-12
View File
@@ -30,7 +30,6 @@ export const zhCN: TranslationResources = {
back: "返回",
loading: "加载中...",
actions: {
save: "保存",
back: "返回",
cancel: "取消",
close: "关闭",
@@ -1984,17 +1983,6 @@ export const zhCN: TranslationResources = {
accessibility: "上下文窗口已使用 {{percentage}}%",
},
review: {
feedback: {
send: "发送反馈 ({{count}})",
sending: "正在发送反馈 ({{count}})",
chooseAgent: "选择智能体",
sent: "已向 {{recipient}} 发送反馈",
"no-agents": "请在此工作区打开智能体标签页以发送反馈。",
disconnected: "请连接到主机以发送反馈。",
"no-context": "保存的评论不再匹配此差异。",
failed: "发送反馈失败。请重试。",
prompt: "请处理此代码审查。",
},
comment: {
add: "添加 review 评论",
edit: "编辑 review 评论",
-106
View File
@@ -1,106 +0,0 @@
import { useCallback, useMemo } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import { AdaptiveModalSheet } from "@/components/adaptive-modal-sheet";
import { FormTextInput } from "@/components/ui/form-field";
import { Button } from "@/components/ui/button";
import type { InlineReviewActions, InlineReviewEditorState } from "./geometry";
export function ReviewCommentSheet({
editor,
reviewActions,
onChangeBody,
enabled,
}: {
enabled: boolean | undefined;
editor: InlineReviewEditorState | null;
reviewActions: InlineReviewActions;
onChangeBody: (body: string) => void;
}) {
if (enabled === false || !editor) return null;
return (
<OpenReviewCommentSheet
editor={editor}
reviewActions={reviewActions}
onChangeBody={onChangeBody}
/>
);
}
function OpenReviewCommentSheet({
editor,
reviewActions,
onChangeBody,
}: {
editor: InlineReviewEditorState;
reviewActions: InlineReviewActions;
onChangeBody: (body: string) => void;
}) {
const { t } = useTranslation();
const context = `${editor.target.filePath} · ${editor.target.side === "old" ? "−" : "+"}${editor.target.lineNumber}`;
const header = useMemo(
() => ({
title: t("review.comment.label"),
subtitle: <Text style={styles.context}>{context}</Text>,
}),
[context, t],
);
const { onSaveEditor, onCancelEditor } = reviewActions;
const handleSave = useCallback(() => onSaveEditor(editor.body), [onSaveEditor, editor.body]);
const canSave = editor.body.trim().length > 0;
const footer = useMemo(
() => (
<View style={styles.actions}>
<Button
size="md"
variant="secondary"
accessibilityLabel={t("review.comment.cancelAccessibility")}
onPress={onCancelEditor}
>
{t("review.comment.cancel")}
</Button>
<Button
size="md"
variant="default"
accessibilityLabel={t("review.comment.saveAccessibility")}
disabled={!canSave}
onPress={handleSave}
>
{t("common.actions.save")}
</Button>
</View>
),
[canSave, handleSave, onCancelEditor, t],
);
return (
<AdaptiveModalSheet
visible
header={header}
onClose={onCancelEditor}
snapPoints={COMMENT_SNAP_POINTS}
scrollable={false}
testID="review-comment-sheet"
footer={footer}
>
<FormTextInput
key={editor.target.key + (editor.commentId ?? "new")}
size="md"
multiline
accessibilityLabel={t("review.comment.label")}
placeholder={t("review.comment.placeholder")}
initialValue={editor.body}
onChangeText={onChangeBody}
style={styles.input}
/>
</AdaptiveModalSheet>
);
}
const COMMENT_SNAP_POINTS = ["50%", "90%"];
const styles = StyleSheet.create((theme) => ({
context: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm },
input: { flex: 1, minHeight: 0, textAlignVertical: "top" },
actions: { flex: 1, flexDirection: "row", justifyContent: "flex-end", gap: theme.spacing[2] },
}));
@@ -1,145 +0,0 @@
import { describe, expect, it } from "vitest";
import { createReviewEditor, createReviewEditorScope, type ReviewCommentWriter } from "./index";
import type { ReviewDraftComment } from "../state";
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
const target: ReviewableDiffTarget = {
key: "src/example.ts:new:2",
filePath: "src/example.ts",
side: "new",
lineNumber: 2,
hunkHeader: "@@ -1,2 +1,2 @@",
hunkIndex: 0,
lineIndex: 2,
oldLineNumber: null,
newLineNumber: 2,
lineType: "add",
content: "const value = next;",
};
function memoryWriter() {
const drafts = new Map<string, ReviewDraftComment[]>();
let sequence = 0;
const writer: ReviewCommentWriter = {
saveComment: ({ key, id, comment }) => {
const current = drafts.get(key) ?? [];
if (id && current.some((saved) => saved.id === id)) {
drafts.set(
key,
current.map((saved) => (saved.id === id ? { ...saved, ...comment } : saved)),
);
} else {
const saved = {
...comment,
id: String(++sequence),
createdAt: "2026-10-09T00:00:00Z",
updatedAt: "2026-10-09T00:00:00Z",
};
drafts.set(key, [...current, saved]);
}
},
deleteComment: ({ key, id }) => {
drafts.set(
key,
(drafts.get(key) ?? []).filter((comment) => comment.id !== id),
);
},
};
return { writer, comments: (key: string) => drafts.get(key) ?? [] };
}
describe("review editor", () => {
it("saves trimmed comments, edits the same record, and cancels without altering saved comments", () => {
const storage = memoryWriter();
const editor = createReviewEditor("first", storage.writer);
editor.start(target);
editor.setBody(" First comment ");
editor.save(editor.getState()!.body);
expect(editor.getState()).toBeNull();
const saved = storage.comments("first")[0]!;
expect(saved).toMatchObject({
filePath: target.filePath,
side: "new",
lineNumber: 2,
body: "First comment",
});
editor.edit(target, saved);
editor.setBody("Discarded edit");
editor.cancel();
expect(storage.comments("first")).toEqual([saved]);
editor.edit(target, saved);
editor.save(" Updated comment ");
expect(storage.comments("first")).toEqual([{ ...saved, body: "Updated comment" }]);
editor.start(target);
editor.setBody("Discarded new comment");
editor.cancel();
expect(storage.comments("first")).toHaveLength(1);
});
it("keeps an open edit saveable after its submitted record is acknowledged", () => {
const storage = memoryWriter();
const editor = createReviewEditor("first", storage.writer);
editor.start(target);
editor.save("Submitted body");
const saved = storage.comments("first")[0]!;
editor.edit(target, saved);
editor.setBody("Typed before acknowledgement");
storage.writer.deleteComment({ key: "first", id: saved.id });
expect(editor.getState()!.body).toBe("Typed before acknowledgement");
editor.save(editor.getState()!.body);
expect(storage.comments("first")).toMatchObject([
{
body: "Typed before acknowledgement",
filePath: target.filePath,
lineNumber: target.lineNumber,
},
]);
});
it("rejects empty saves and repeated saves after the editor closes", () => {
const storage = memoryWriter();
const editor = createReviewEditor("first", storage.writer);
editor.start(target);
editor.save(" \n ");
expect(editor.getState()).toEqual({ target, commentId: null, body: "" });
expect(storage.comments("first")).toEqual([]);
editor.save("Saved once");
editor.save("Duplicate");
expect(storage.comments("first").map((comment) => comment.body)).toEqual(["Saved once"]);
});
it("isolates draft ownership and closes an editor when its saved comment is deleted", () => {
const storage = memoryWriter();
const first = createReviewEditor("first", storage.writer);
const second = createReviewEditor("second", storage.writer);
first.start(target);
first.save("First workspace");
const saved = storage.comments("first")[0]!;
first.edit(target, saved);
expect(second.getState()).toBeNull();
second.start(target);
second.save("Second workspace");
first.delete(saved.id);
expect(first.getState()).toBeNull();
expect(storage.comments("first")).toEqual([]);
expect(storage.comments("second").map((comment) => comment.body)).toEqual(["Second workspace"]);
});
});
it("retains an open editor through remounts without exposing it on another surface or draft", async () => {
const storage = memoryWriter();
const scope = createReviewEditorScope(storage.writer);
const editor = scope.forDraft("first", "diff");
const unsubscribe = editor.subscribe(() => {});
editor.start(target);
editor.setBody("Unsaved wide body");
unsubscribe();
await Promise.resolve();
const compact = scope.forDraft("first", "diff");
expect(compact.getState()!.body).toBe("Unsaved wide body");
expect(scope.forDraft("first", "combined").getState()).toBeNull();
expect(scope.forDraft("second", "diff").getState()).toBeNull();
compact.save(compact.getState()!.body);
expect(storage.comments("first")[0]!.body).toBe("Unsaved wide body");
expect(scope.forDraft("first", "diff").getState()).toBeNull();
});
-84
View File
@@ -1,84 +0,0 @@
import type { ReviewableDiffTarget } from "@/utils/diff-layout";
import type { InlineReviewEditorState } from "../geometry";
import type { ReviewDraftComment } from "../state";
export interface ReviewCommentWriter {
saveComment: (input: {
key: string;
id: string | null;
comment: Pick<ReviewDraftComment, "filePath" | "side" | "lineNumber" | "body">;
}) => void;
deleteComment: (input: { key: string; id: string }) => void;
}
/** One editor belongs to one review draft; replacing the draft creates a fresh editor. */
export function createReviewEditor(key: string, writer: ReviewCommentWriter) {
return buildReviewEditor(key, writer, () => {});
}
function buildReviewEditor(key: string, writer: ReviewCommentWriter, onUnused: () => void) {
let editor: InlineReviewEditorState | null = null;
const listeners = new Set<() => void>();
function publish(next: InlineReviewEditorState | null) {
editor = next;
listeners.forEach((listener) => listener());
if (!editor && listeners.size === 0) onUnused();
}
return {
getState: () => editor,
subscribe: (listener: () => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
queueMicrotask(() => {
if (!editor && listeners.size === 0) onUnused();
});
};
},
start: (target: ReviewableDiffTarget) => publish({ target, commentId: null, body: "" }),
edit: (target: ReviewableDiffTarget, comment: ReviewDraftComment) =>
publish({ target, commentId: comment.id, body: comment.body }),
setBody: (body: string) => {
if (editor) publish({ ...editor, body });
},
cancel: () => publish(null),
save: (body: string) => {
const trimmedBody = body.trim();
if (!editor || !trimmedBody) return;
writer.saveComment({
key,
id: editor.commentId,
comment: {
filePath: editor.target.filePath,
side: editor.target.side,
lineNumber: editor.target.lineNumber,
body: trimmedBody,
},
});
publish(null);
},
delete: (id: string) => {
writer.deleteComment({ key, id });
if (editor?.commentId === id) publish(null);
},
};
}
/** An open draft survives presentation remounts; each presentation owns its editor for that draft. */
export function createReviewEditorScope(writer: ReviewCommentWriter) {
const editors = new Map<string, ReturnType<typeof createReviewEditor>>();
return {
forDraft(key: string, presentation: string) {
const owner = JSON.stringify([key, presentation]);
let model = editors.get(owner);
if (!model) {
const created = buildReviewEditor(key, writer, () => {
if (editors.get(owner) === created) editors.delete(owner);
});
model = created;
editors.set(owner, model);
}
return model;
},
};
}
@@ -1,272 +0,0 @@
import { describe, expect, it } from "vitest";
import {
createReviewFeedback,
createReviewFeedbackScope,
type ReviewFeedbackPorts,
type ReviewFeedbackSnapshot,
type FeedbackAttachment,
type FeedbackRecipient,
} from "./index";
import type { ReviewDraftComment } from "../state";
function makeComment(id: string, body = id): ReviewDraftComment {
return {
id,
body,
filePath: "sample.ts",
side: "new",
lineNumber: 2,
createdAt: "2026-10-09T00:00:00Z",
updatedAt: "2026-10-09T00:00:00Z",
};
}
function makeRecipient(agentId: string): FeedbackRecipient {
return {
agentId,
tab: {
key: `agent_${agentId}`,
tabId: `agent_${agentId}`,
kind: "agent",
target: { kind: "agent", agentId },
},
};
}
function makeAttachment(comments: readonly ReviewDraftComment[]): FeedbackAttachment | null {
if (!comments.length) return null;
const line = {
type: "add" as const,
content: "const value = 1",
oldLineNumber: null,
newLineNumber: 2,
};
return {
kind: "review",
reviewDraftKey: "draft",
commentCount: comments.length,
attachment: {
type: "review",
mimeType: "application/paseo-review",
cwd: "/workspace",
mode: "uncommitted",
baseRef: null,
comments: comments.map((comment) => ({
filePath: comment.filePath,
side: comment.side,
lineNumber: comment.lineNumber,
body: comment.body,
context: { hunkHeader: "@@ -1 +1 @@", targetLine: line, lines: [line] },
})),
},
};
}
function setup(agentIds: string[], unavailableIds: string[] = [], create = createReviewFeedback) {
let comments = [makeComment("first")];
let recipients = agentIds.map(makeRecipient);
let connected = true;
let rejectSend: ((error: Error) => void) | null = null;
let resolveSend: (() => void) | null = null;
const sends: Array<{ recipient: FeedbackRecipient; attachment: FeedbackAttachment }> = [];
const read = (): ReviewFeedbackSnapshot => ({
comments,
recipients,
connected,
attachment: makeAttachment(comments.filter((comment) => !unavailableIds.includes(comment.id))),
});
const ports: ReviewFeedbackPorts = {
read,
send: (recipient, attachment) => {
sends.push({ recipient, attachment });
return new Promise<void>((resolve, reject) => {
resolveSend = resolve;
rejectSend = reject;
});
},
clearSent: (sent) => {
comments = comments.filter(
(current) =>
!sent.some(
(prior) =>
prior.id === current.id &&
prior.updatedAt === current.updatedAt &&
prior.body === current.body,
),
);
},
failedMessage: () => "Failed to send",
};
const model = create(ports);
return {
model,
ports,
sends,
comments: () => comments,
setComments: (next: ReviewDraftComment[]) => {
comments = next;
model.refresh();
},
setRecipients: (ids: string[]) => {
recipients = ids.map(makeRecipient);
model.refresh();
},
disconnect: () => {
connected = false;
model.refresh();
},
succeed: () => resolveSend!(),
fail: () => rejectSend!(new Error("Host rejected feedback")),
};
}
describe("review feedback", () => {
it("counts only attached comments, freezes the sending count, and retains orphans", async () => {
const feedback = setup(["one"], ["orphan"]);
feedback.setComments([makeComment("first"), makeComment("orphan")]);
expect(feedback.model.getState()).toMatchObject({ commentCount: 2, sendableCommentCount: 1 });
const pending = feedback.model.press();
feedback.setComments([makeComment("first"), makeComment("orphan"), makeComment("added")]);
expect(feedback.model.getState()).toMatchObject({
phase: { kind: "sending", commentCount: 1 },
sendableCommentCount: 2,
});
expect(feedback.sends[0]!.attachment.commentCount).toBe(1);
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([makeComment("orphan"), makeComment("added")]);
});
it("retains an entirely orphaned draft without claiming any comments are sendable", async () => {
const feedback = setup(["one"], ["first"]);
expect(feedback.model.getState()).toMatchObject({
commentCount: 1,
sendableCommentCount: 0,
disabledReason: "no-context",
canSend: false,
});
await feedback.model.press();
expect(feedback.sends).toEqual([]);
expect(feedback.comments()).toEqual([makeComment("first")]);
});
it("retains comments and explains why no workspace agent can receive them", async () => {
const feedback = setup([]);
await feedback.model.press();
expect(feedback.model.getState()).toMatchObject({
canSend: false,
disabledReason: "no-agents",
commentCount: 1,
});
expect(feedback.sends).toEqual([]);
expect(feedback.comments()).toEqual([makeComment("first")]);
});
it("sends once to the only recipient and clears only sent versions", async () => {
const feedback = setup(["one"]);
const pending = feedback.model.press();
await feedback.model.press();
await feedback.model.send("one");
expect(feedback.sends).toEqual([
{ recipient: makeRecipient("one"), attachment: makeAttachment([makeComment("first")]) },
]);
feedback.setComments([makeComment("first", "Edited during send"), makeComment("added")]);
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([
makeComment("first", "Edited during send"),
makeComment("added"),
]);
expect(feedback.model.getState().phase).toEqual({
kind: "sent",
recipient: makeRecipient("one"),
});
});
it("clears unchanged sent comments while preserving additions", async () => {
const feedback = setup(["one"]);
const pending = feedback.model.press();
feedback.setComments([makeComment("first"), makeComment("added")]);
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([makeComment("added")]);
});
it("asks for a recipient when several tabs exist and validates the selection again", async () => {
const feedback = setup(["one", "two"]);
await feedback.model.press();
expect(feedback.model.getState().phase).toEqual({ kind: "choosing" });
expect(feedback.sends).toEqual([]);
feedback.setRecipients(["one"]);
await feedback.model.send("two");
expect(feedback.sends).toEqual([]);
const pending = feedback.model.send("one");
feedback.succeed();
await pending;
expect(feedback.comments()).toEqual([]);
});
it("retains comments on failure and retries using the latest saved version", async () => {
const feedback = setup(["one"]);
const failed = feedback.model.press();
feedback.fail();
await failed;
expect(feedback.model.getState().phase).toEqual({
kind: "failed",
message: "Host rejected feedback",
});
expect(feedback.comments()).toEqual([makeComment("first")]);
feedback.setComments([makeComment("first", "Corrected before retry")]);
const retried = feedback.model.press();
feedback.succeed();
await retried;
expect(feedback.sends).toHaveLength(2);
expect(feedback.sends[1]!.attachment.attachment.comments[0]!.body).toBe(
"Corrected before retry",
);
expect(feedback.comments()).toEqual([]);
});
it("keeps workspace drafts independent and prevents disconnected sends", async () => {
const first = setup(["one"]);
const second = setup(["other"]);
first.disconnect();
await first.model.press();
expect(first.model.getState().disabledReason).toBe("disconnected");
const pending = second.model.press();
second.succeed();
await pending;
expect(first.comments()).toEqual([makeComment("first")]);
expect(first.sends).toEqual([]);
expect(second.comments()).toEqual([]);
});
});
describe("shared review submission ownership", () => {
it("blocks another surface and reconnects to the pending draft while other drafts remain usable", async () => {
const scope = createReviewFeedbackScope();
const first = setup(["one"], [], (ports) => scope.connect("workspace-one", ports));
const second = scope.connect("workspace-one", first.ports);
const other = setup(["other"], [], (ports) => scope.connect("workspace-other", ports));
const unsubscribe = first.model.subscribe(() => {});
const pending = first.model.press();
unsubscribe();
await Promise.resolve();
const reconnected = scope.connect("workspace-one", first.ports);
expect(second.getState()).toMatchObject({ canSend: false, phase: { kind: "sending" } });
await second.press();
await reconnected.send("one");
expect(first.sends).toHaveLength(1);
const otherPending = other.model.press();
expect(other.sends).toHaveLength(1);
first.fail();
await pending;
expect(second.getState().phase).toEqual({ kind: "failed", message: "Host rejected feedback" });
const retry = second.press();
first.succeed();
await retry;
expect(first.sends).toHaveLength(2);
other.succeed();
await otherPending;
});
it("opens only the active surface's recipient chooser", async () => {
const scope = createReviewFeedbackScope();
const first = setup(["one", "two"], [], (ports) => scope.connect("same", ports));
const second = scope.connect("same", first.ports);
await first.model.press();
expect(first.model.getState().phase).toEqual({ kind: "choosing" });
expect(second.getState().phase).toEqual({ kind: "idle" });
first.model.closeMenu();
expect(first.model.getState().phase).toEqual({ kind: "idle" });
});
});
-177
View File
@@ -1,177 +0,0 @@
import type { ComposerAttachment } from "@/attachments/types";
import type { ReviewDraftComment } from "../state";
import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-types";
export interface FeedbackRecipient {
agentId: string;
tab: WorkspaceTabDescriptor;
}
export type FeedbackAttachment = Extract<ComposerAttachment, { kind: "review" }>;
export interface ReviewFeedbackSnapshot {
comments: readonly ReviewDraftComment[];
attachment: FeedbackAttachment | null;
recipients: readonly FeedbackRecipient[];
connected: boolean;
}
export interface ReviewFeedbackPorts {
read: () => ReviewFeedbackSnapshot;
send: (recipient: FeedbackRecipient, attachment: FeedbackAttachment) => Promise<void>;
clearSent: (comments: readonly ReviewDraftComment[]) => void;
failedMessage: () => string;
}
export type FeedbackPhase =
| { kind: "idle" }
| { kind: "choosing" }
| { kind: "sending"; recipient: FeedbackRecipient; commentCount: number }
| { kind: "failed"; message: string }
| { kind: "sent"; recipient: FeedbackRecipient };
export interface ReviewFeedbackState {
phase: FeedbackPhase;
commentCount: number;
sendableCommentCount: number;
recipients: readonly FeedbackRecipient[];
disabledReason: "no-agents" | "disconnected" | "no-context" | null;
canSend: boolean;
}
export function createReviewFeedback(ports: ReviewFeedbackPorts) {
return createFeedbackView(ports, createFeedbackWorkflow());
}
/** Surfaces share submission ownership while keeping their chooser and live inputs local. */
export function createReviewFeedbackScope() {
const workflows = new Map<string, ReturnType<typeof createFeedbackWorkflow>>();
return {
connect(key: string, ports: ReviewFeedbackPorts) {
let workflow = workflows.get(key);
if (!workflow) {
const created = createFeedbackWorkflow(() => {
if (workflows.get(key) === created) workflows.delete(key);
});
workflow = created;
workflows.set(key, workflow);
}
return createFeedbackView(ports, workflow);
},
};
}
function createFeedbackWorkflow(onUnused: () => void = () => {}) {
let phase: FeedbackPhase = { kind: "idle" };
const observers = new Set<() => void>();
function releaseIfUnused() {
queueMicrotask(() => {
if (observers.size === 0 && phase.kind !== "sending") onUnused();
});
}
return {
getPhase: () => phase,
changePhase(next: FeedbackPhase) {
phase = next;
observers.forEach((observer) => observer());
releaseIfUnused();
},
subscribe(observer: () => void) {
observers.add(observer);
return () => {
observers.delete(observer);
releaseIfUnused();
};
},
};
}
function createFeedbackView(
ports: ReviewFeedbackPorts,
workflow: ReturnType<typeof createFeedbackWorkflow>,
) {
let choosing = false;
const choosingPhase: FeedbackPhase = { kind: "choosing" };
const getPhase = () => (choosing ? choosingPhase : workflow.getPhase());
let state = deriveState(ports.read(), getPhase());
const listeners = new Set<() => void>();
function publish() {
state = deriveState(ports.read(), getPhase());
listeners.forEach((listener) => listener());
}
async function send(agentId: string) {
const snapshot = ports.read();
const current = deriveState(snapshot, workflow.getPhase());
const recipient = snapshot.recipients.find((candidate) => candidate.agentId === agentId);
if (!current.canSend || !recipient || !snapshot.attachment) return;
const attachment = snapshot.attachment;
const sentComments = snapshot.comments.filter((comment) =>
attachment.attachment.comments.some(
(sent) =>
sent.filePath === comment.filePath &&
sent.side === comment.side &&
sent.lineNumber === comment.lineNumber &&
sent.body === comment.body,
),
);
choosing = false;
workflow.changePhase({ kind: "sending", recipient, commentCount: attachment.commentCount });
publish();
try {
await ports.send(recipient, attachment);
ports.clearSent(sentComments);
workflow.changePhase({ kind: "sent", recipient });
} catch (error) {
workflow.changePhase({
kind: "failed",
message: error instanceof Error ? error.message : ports.failedMessage(),
});
}
publish();
}
return {
getState: () => {
if (state.phase !== getPhase()) state = deriveState(ports.read(), getPhase());
return state;
},
subscribe: (listener: () => void) => {
listeners.add(listener);
const unsubscribe = workflow.subscribe(() => {
choosing = false;
publish();
});
return () => {
listeners.delete(listener);
unsubscribe();
};
},
refresh: publish,
press: async () => {
const snapshot = ports.read();
if (!deriveState(snapshot, workflow.getPhase()).canSend) return;
if (snapshot.recipients.length === 1) {
await send(snapshot.recipients[0]!.agentId);
} else {
choosing = true;
publish();
}
},
closeMenu: () => {
if (choosing) {
choosing = false;
publish();
}
},
send,
};
}
function deriveState(snapshot: ReviewFeedbackSnapshot, phase: FeedbackPhase): ReviewFeedbackState {
let disabledReason: ReviewFeedbackState["disabledReason"] = null;
if (snapshot.recipients.length === 0) disabledReason = "no-agents";
else if (!snapshot.connected) disabledReason = "disconnected";
else if (!snapshot.attachment) disabledReason = "no-context";
return {
phase,
commentCount: snapshot.comments.length,
sendableCommentCount: snapshot.attachment?.commentCount ?? 0,
recipients: snapshot.recipients,
disabledReason,
canSend: snapshot.comments.length > 0 && disabledReason === null && phase.kind !== "sending",
};
}
@@ -1,284 +0,0 @@
import { useCallback, useMemo, type ReactNode } from "react";
import { Text, View } from "react-native";
import { Send } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { StyleSheet } from "react-native-unistyles";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { useMenuContext } from "@/components/ui/menu";
import { Button } from "@/components/ui/button";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { useIsCompactFormFactor } from "@/constants/layout";
import { SPACING } from "@/styles/theme";
import { FloatingActionButton } from "@/components/ui/floating-action-button";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import type { FeedbackRecipient, ReviewFeedbackState, createReviewFeedback } from "./index";
type FeedbackModel = ReturnType<typeof createReviewFeedback>;
interface ReviewFeedbackActionProps {
bottomOffset: number;
model: FeedbackModel;
state: ReviewFeedbackState;
serverId: string;
workspaceId: string;
}
export function ReviewFeedbackAction({
enabled,
hasDiff,
...props
}: Omit<ReviewFeedbackActionProps, "workspaceId"> & {
workspaceId?: string | null;
enabled: boolean | undefined;
hasDiff: boolean;
}) {
if (enabled === false || !hasDiff) return null;
return <OpenReviewFeedbackAction {...props} workspaceId={props.workspaceId ?? ""} />;
}
function OpenReviewFeedbackAction({
model,
state,
serverId,
workspaceId,
bottomOffset,
}: ReviewFeedbackActionProps) {
const { t } = useTranslation();
const { bottom } = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor();
const onOpenChange = useCallback(
(open: boolean) => {
if (!open) model.closeMenu();
},
[model],
);
const noteStyle = useMemo(
() => [
styles.note,
!isCompact && styles.centered,
{
bottom:
bottom +
bottomOffset +
(isCompact ? 88 : buttonControlHeight.sm + SPACING[4] + SPACING[2]),
},
],
[bottom, bottomOffset, isCompact],
);
const sending = state.phase.kind === "sending";
const label = t(sending ? "review.feedback.sending" : "review.feedback.send", {
count: state.phase.kind === "sending" ? state.phase.commentCount : state.sendableCommentCount,
});
const unavailableNotice =
state.disabledReason && state.commentCount > 0 ? (
<Text style={styles.text}>{t(`review.feedback.${state.disabledReason}`)}</Text>
) : null;
let notice: ReactNode = unavailableNotice;
if (state.phase.kind === "failed")
notice = <Text style={styles.error}>{state.phase.message}</Text>;
else if (state.phase.kind === "sent")
notice = (
<>
<SentNotice
recipient={state.phase.recipient}
serverId={serverId}
workspaceId={workspaceId}
/>
{unavailableNotice}
</>
);
return (
<>
{state.commentCount > 0 ? (
<DropdownMenu
open={state.phase.kind === "choosing"}
onOpenChange={onOpenChange}
compactMode="sheet"
>
<FeedbackTrigger
model={model}
label={label}
disabled={!state.canSend}
bottomInset={bottom + bottomOffset}
/>
<DropdownMenuContent
side="top"
align="center"
sheetTitle={t("review.feedback.chooseAgent")}
>
{state.recipients.map((recipient) => (
<RecipientOption
key={recipient.tab.key}
recipient={recipient}
model={model}
serverId={serverId}
workspaceId={workspaceId}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
{notice ? (
<View style={noteStyle} accessibilityRole="alert">
{notice}
</View>
) : null}
</>
);
}
function FeedbackTrigger({
model,
label,
disabled,
bottomInset,
}: {
model: FeedbackModel;
label: string;
disabled: boolean;
bottomInset: number;
}) {
const { triggerRef } = useMenuContext("FeedbackTrigger");
const isCompact = useIsCompactFormFactor();
const press = useCallback(() => {
void model.press();
}, [model]);
if (!isCompact) {
return (
<View
pointerEvents="box-none"
style={[styles.wideAction, { bottom: bottomInset + SPACING[4] }]}
>
<Button
ref={triggerRef}
variant="default"
size="sm"
leftIcon={Send}
accessibilityLabel={label}
disabled={disabled}
onPress={press}
>
{label}
</Button>
</View>
);
}
return (
<FloatingActionButton
ref={triggerRef}
icon={Send}
label={label}
accessibilityLabel={label}
disabled={disabled}
bottomInset={bottomInset}
onPress={press}
/>
);
}
function RecipientOption({
recipient,
model,
serverId,
workspaceId,
}: {
recipient: FeedbackRecipient;
model: FeedbackModel;
serverId: string;
workspaceId: string;
}) {
const choose = useCallback(() => {
void model.send(recipient.agentId);
}, [model, recipient.agentId]);
const render = useCallback(
(presentation: WorkspaceTabPresentation) => (
<RecipientRow presentation={presentation} onSelect={choose} />
),
[choose],
);
return (
<WorkspaceTabPresentationResolver
tab={recipient.tab}
serverId={serverId}
workspaceId={workspaceId}
>
{render}
</WorkspaceTabPresentationResolver>
);
}
function RecipientRow({
presentation,
onSelect,
}: {
presentation: WorkspaceTabPresentation;
onSelect: () => void;
}) {
const leading = useMemo(
() => <WorkspaceTabIcon presentation={presentation} backdrop="surface0" />,
[presentation],
);
return (
<DropdownMenuItem leading={leading} onSelect={onSelect}>
{presentation.label}
</DropdownMenuItem>
);
}
function SentNotice({
recipient,
serverId,
workspaceId,
}: {
recipient: FeedbackRecipient;
serverId: string;
workspaceId: string;
}) {
const { t } = useTranslation();
const render = useCallback(
(presentation: WorkspaceTabPresentation) => (
<Text style={styles.text}>
{t("review.feedback.sent", { recipient: presentation.label })}
</Text>
),
[t],
);
return (
<WorkspaceTabPresentationResolver
tab={recipient.tab}
serverId={serverId}
workspaceId={workspaceId}
>
{render}
</WorkspaceTabPresentationResolver>
);
}
const styles = StyleSheet.create((theme) => ({
wideAction: { position: "absolute", left: 0, right: 0, alignItems: "center" },
centered: { alignItems: "center" },
note: {
position: "absolute",
right: theme.spacing[4],
left: theme.spacing[4],
alignItems: "flex-end",
},
text: {
color: theme.colors.foreground,
fontSize: theme.fontSize.sm,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
error: {
color: theme.colors.destructive,
fontSize: theme.fontSize.sm,
backgroundColor: theme.colors.surface1,
padding: theme.spacing[2],
borderRadius: theme.borderRadius.md,
},
}));
@@ -1,120 +0,0 @@
import { useEffect, useMemo, useRef, useSyncExternalStore } from "react";
import {
useReviewDraftComments,
getReviewDraftComments,
clearSentReviewDraftComments,
} from "../store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useAppSettings } from "@/hooks/use-settings";
import { dispatchComposerAgentMessage } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { buttonControlHeight } from "@/components/ui/control-geometry";
import { SPACING } from "@/styles/theme";
import { i18n } from "@/i18n/i18next";
import {
createReviewFeedbackScope,
type FeedbackAttachment,
type FeedbackRecipient,
} from "./index";
const feedbackScope = createReviewFeedbackScope();
export function useReviewFeedback(input: {
reviewDraftKey: string;
serverId: string;
workspaceId?: string;
attachment: FeedbackAttachment | null;
}) {
const { serverId, reviewDraftKey, workspaceId } = input;
const isCompact = useIsCompactFormFactor();
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
: null;
const client = useHostRuntimeClient(serverId);
const connected = useHostRuntimeIsConnected(serverId);
const { settings } = useAppSettings();
const comments = useReviewDraftComments(reviewDraftKey);
const layout = useWorkspaceLayoutStore((store) =>
workspaceKey ? store.layoutByWorkspace[workspaceKey] : undefined,
);
// The adapter keeps live host and diff inputs available to commands without reconstructing the workflow.
const live = useRef({
client,
connected,
attachment: input.attachment,
sendBehavior: settings.sendBehavior,
});
live.current = {
client,
connected,
attachment: input.attachment,
sendBehavior: settings.sendBehavior,
};
const model = useMemo(
() =>
feedbackScope.connect(reviewDraftKey, {
read: () => ({
comments: getReviewDraftComments(reviewDraftKey) ?? [],
attachment: live.current.attachment,
connected: live.current.connected,
recipients: readRecipients(workspaceKey),
}),
send: async (recipient, attachment) => {
const { client: targetClient, sendBehavior } = live.current;
if (!targetClient) throw new Error(i18n.t("workspace.terminal.hostDisconnected"));
const activeTurnBehavior = sendBehavior === "steer" ? "steer" : "interrupt";
await dispatchComposerAgentMessage({
client: targetClient,
agentId: recipient.agentId,
text: i18n.t("review.feedback.prompt"),
attachments: [attachment],
encodeImages: async () => [],
submission: createMessageSubmissionWriter(serverId),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
recipient.agentId,
).turnId ?? undefined)
: undefined,
});
},
clearSent: (sentComments) =>
clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }),
failedMessage: () => i18n.t("review.feedback.failed"),
}),
[reviewDraftKey, serverId, workspaceKey],
);
useEffect(() => {
model.refresh();
}, [model, comments, layout, connected, input.attachment]);
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
return {
model,
state,
clearance:
state.commentCount > 0
? (isCompact ? FLOATING_ACTION_BUTTON_CLEARANCE : buttonControlHeight.sm + SPACING[4]) + 44
: 0,
};
}
function readRecipients(workspaceKey: string | null): FeedbackRecipient[] {
const tabs = workspaceKey
? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)
: [];
const descriptors = deriveWorkspaceTabModel({ tabs }).tabs;
const recipients: FeedbackRecipient[] = [];
for (const { descriptor } of descriptors) {
if (descriptor.target.kind === "agent")
recipients.push({ agentId: descriptor.target.agentId, tab: descriptor });
}
return recipients;
}
-1
View File
@@ -19,7 +19,6 @@ export interface InlineReviewActions {
onStartComment: (target: ReviewableDiffTarget) => void;
onEditComment: (target: ReviewableDiffTarget, comment: ReviewDraftComment) => void;
onCancelEditor: () => void;
onChangeEditorBody: (body: string) => void;
onSaveEditor: (body: string) => void;
onDeleteComment: (id: string) => void;
}
-5
View File
@@ -31,8 +31,3 @@ export {
SMALL_ACTION_HIT_SLOP,
useInlineReviewController,
} from "./surface";
export { ReviewCommentSheet } from "./comment-sheet";
export { useReviewFeedback } from "./feedback/use-feedback";
export { ReviewFeedbackAction } from "./feedback/surface";
+1 -56
View File
@@ -2,16 +2,7 @@ import { describe, expect, it } from "vitest";
import type { StateStorage } from "zustand/middleware";
import type { ParsedDiffFile } from "@/git/use-diff-query";
import { createValidatedPersistStorage } from "@/storage/validated-persist-storage";
import {
buildReviewAttachmentSnapshot,
buildReviewDraftKey,
addReviewDraftComment,
saveReviewDraftComment,
clearSentReviewDraftComments,
getReviewDraftComments,
resetReviewDraftStore,
useReviewDraftStore,
} from "./store";
import { buildReviewAttachmentSnapshot, buildReviewDraftKey } from "./store";
import {
addCommentToState,
clearReviewInState,
@@ -313,49 +304,3 @@ describe("buildReviewAttachmentSnapshot", () => {
});
});
});
describe("successful feedback cleanup", () => {
it("clears sent versions only, preserving edits, additions and other workspace drafts", () => {
resetReviewDraftStore();
const first = addReviewDraftComment({ key: "first", comment: makeComment() });
const edited = addReviewDraftComment({
key: "first",
comment: makeComment({ id: "edited", body: "Before send" }),
});
const other = addReviewDraftComment({ key: "other", comment: makeComment({ id: "other" }) });
useReviewDraftStore.getState().updateComment({
key: "first",
id: edited.id,
updates: { body: "After send" },
updatedAt: edited.updatedAt,
});
const added = addReviewDraftComment({ key: "first", comment: makeComment({ id: "added" }) });
clearSentReviewDraftComments({ key: "first", comments: [first, edited] });
expect(getReviewDraftComments("first")).toEqual([{ ...edited, body: "After send" }, added]);
expect(getReviewDraftComments("other")).toEqual([other]);
resetReviewDraftStore();
});
});
it("saves an acknowledged open edit as a new version and keeps subsequent edits on that record", () => {
resetReviewDraftStore();
const submitted = addReviewDraftComment({ key: "first", comment: makeComment() });
clearSentReviewDraftComments({ key: "first", comments: [submitted] });
saveReviewDraftComment({
key: "first",
id: submitted.id,
comment: { ...submitted, body: "Open edit after acknowledgement" },
});
const restored = getReviewDraftComments("first")![0]!;
expect(restored.body).toBe("Open edit after acknowledgement");
expect(restored.id).not.toBe(submitted.id);
clearSentReviewDraftComments({ key: "first", comments: [submitted] });
expect(getReviewDraftComments("first")).toEqual([restored]);
saveReviewDraftComment({
key: "first",
id: restored.id,
comment: { ...restored, body: "Saved again" },
});
expect(getReviewDraftComments("first")).toMatchObject([{ id: restored.id, body: "Saved again" }]);
resetReviewDraftStore();
});
-40
View File
@@ -320,43 +320,3 @@ export function useReviewAttachmentSnapshot(input: {
[comments, input.key, input.cwd, input.mode, input.baseRef, input.diffFiles],
);
}
/** Acknowledgement removes only the versions included in that submission. */
export function clearSentReviewDraftComments(input: {
key: string;
comments: readonly ReviewDraftComment[];
}): void {
const sentById = new Map(input.comments.map((comment) => [comment.id, comment]));
useReviewDraftStore.setState((state) => {
const current = state.drafts[input.key];
if (!current) return state;
const remaining = current.filter((comment) => {
const sent = sentById.get(comment.id);
return !sent || sent.updatedAt !== comment.updatedAt || sent.body !== comment.body;
});
if (remaining.length === current.length) return state;
return { drafts: { ...state.drafts, [input.key]: remaining } };
});
}
/** Acknowledgement can remove the original while the user still has an edit open. */
export function saveReviewDraftComment(input: {
key: string;
id: string | null;
comment: Pick<ReviewDraftComment, "filePath" | "side" | "lineNumber" | "body">;
}): void {
const store = useReviewDraftStore.getState();
if (input.id && store.drafts[input.key]?.some((comment) => comment.id === input.id)) {
store.updateComment({ key: input.key, id: input.id, updates: { body: input.comment.body } });
} else {
store.addComment({
key: input.key,
comment: {
filePath: input.comment.filePath,
side: input.comment.side,
lineNumber: input.comment.lineNumber,
body: input.comment.body,
},
});
}
}
+56 -40
View File
@@ -9,24 +9,45 @@ import { useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
getInlineReviewThreadState,
getSplitInlineReviewThreadState,
type InlineReviewActions,
} from "./geometry";
import {
getInlineReviewThreadViewportStyle,
getSplitInlineReviewThreadState,
groupInlineReviewCommentsByTarget,
InlineReviewEditor,
InlineReviewGutterCell,
InlineReviewThread,
SMALL_ACTION_HIT_SLOP,
useInlineReviewController,
} from "./surface";
type InlineReviewActions,
} from "./index";
void testI18n;
const { pressablePropsByLabel } = vi.hoisted(() => {
const { theme, pressablePropsByLabel } = vi.hoisted(() => {
Object.assign(globalThis, { __DEV__: false });
return { pressablePropsByLabel: new Map<string, Record<string, unknown>>() };
return {
theme: {
spacing: { 1: 4, 2: 8, 3: 12 },
borderWidth: { 1: 1 },
borderRadius: { base: 4, md: 6, lg: 8, xl: 12, full: 999 },
opacity: { 50: 0.5 },
fontSize: { xs: 11, sm: 13 },
fontWeight: { normal: "400", medium: "500" },
lineHeight: { diff: 18 },
colors: {
accent: "#0a84ff",
accentForeground: "#fff",
border: "#555",
destructive: "#ff453a",
foreground: "#fff",
foregroundMuted: "#aaa",
surface1: "#111",
surface2: "#222",
surface3: "#333",
palette: { white: "#fff" },
},
},
pressablePropsByLabel: new Map<string, Record<string, unknown>>(),
};
});
vi.mock("react-native", async (importOriginal) => {
@@ -67,16 +88,13 @@ vi.mock("react-native", async (importOriginal) => {
};
});
vi.mock("react-native-unistyles", async () => {
const { darkTheme: theme } = await import("@/styles/theme");
return {
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
};
});
vi.mock("react-native-unistyles", () => ({
StyleSheet: {
create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory),
},
withUnistyles: <T,>(component: T) => component,
useUnistyles: () => ({ theme, rt: { breakpoint: "md" } }),
}));
vi.mock("@/constants/platform", () => ({
getIsElectron: () => false,
@@ -85,6 +103,19 @@ vi.mock("@/constants/platform", () => ({
isWeb: true,
}));
vi.mock("lucide-react-native", () => {
const createIcon = (name: string) => (props: Record<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
Check: createIcon("Check"),
CircleDot: createIcon("CircleDot"),
Pencil: createIcon("Pencil"),
Plus: createIcon("Plus"),
Trash2: createIcon("Trash2"),
X: createIcon("X"),
};
});
function target(overrides: Partial<ReviewableDiffTarget> = {}): ReviewableDiffTarget {
return {
filePath: "src/example.ts",
@@ -116,7 +147,6 @@ function buildReviewActions(overrides: Partial<InlineReviewActions> = {}): Inlin
onStartComment: vi.fn(),
onEditComment: vi.fn(),
onCancelEditor: vi.fn(),
onChangeEditorBody: () => {},
onSaveEditor: vi.fn(),
onDeleteComment: vi.fn(),
...overrides,
@@ -282,9 +312,8 @@ describe("git diff inline review helpers", () => {
it("keeps the line number visible and only floats the plus for line hover", () => {
const reviewTarget = target();
const { queryByTestId, queryByText, rerender } = render(
const { container, queryByText, rerender } = render(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen={false}
@@ -295,11 +324,10 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={COMMENT_LIST}
isEditorOpen={false}
@@ -310,11 +338,10 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={EMPTY_COMMENTS}
isEditorOpen
@@ -325,11 +352,10 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeNull();
expect(container.querySelector("[data-icon='Plus']")).toBeNull();
rerender(
<InlineReviewGutterCell
actionTestID="add-comment-glyph"
reviewTarget={reviewTarget}
comments={COMMENT_LIST}
isEditorOpen={false}
@@ -341,7 +367,7 @@ describe("git diff inline review helpers", () => {
);
expect(queryByText("2")).toBeTruthy();
expect(queryByTestId("add-comment-glyph")).toBeTruthy();
expect(container.querySelector("[data-icon='Plus']")).toBeTruthy();
});
});
@@ -358,7 +384,7 @@ describe("InlineReviewEditor", () => {
const onCancel = vi.fn();
const onSave = vi.fn();
const { getByTestId } = render(
<ReviewEditorFixture
<InlineReviewEditor
initialBody=" initial "
onCancel={onCancel}
onSave={onSave}
@@ -378,7 +404,7 @@ describe("InlineReviewEditor", () => {
const onCancel = vi.fn();
const onSave = vi.fn();
const { getByTestId } = render(
<ReviewEditorFixture
<InlineReviewEditor
initialBody="ready"
onCancel={onCancel}
onSave={onSave}
@@ -402,7 +428,7 @@ describe("InlineReviewEditor", () => {
removeEventListener: vi.fn(),
});
const { getByTestId, queryByText } = render(
<ReviewEditorFixture
<InlineReviewEditor
initialBody="ready"
onCancel={vi.fn()}
onSave={vi.fn()}
@@ -446,13 +472,3 @@ describe("InlineReviewThread", () => {
expect(actions.onDeleteComment).toHaveBeenCalledWith("comment-1");
});
});
function ReviewEditorFixture({
initialBody,
...props
}: Omit<React.ComponentProps<typeof InlineReviewEditor>, "body" | "onChangeBody"> & {
initialBody: string;
}) {
const [body, onChangeBody] = React.useState(initialBody);
return <InlineReviewEditor {...props} body={body} onChangeBody={onChangeBody} />;
}
+84 -50
View File
@@ -1,11 +1,4 @@
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
} from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pencil, Plus, Trash2 } from "lucide-react-native";
@@ -24,17 +17,10 @@ import {
EditingTextInput as TextInput,
type EditingTextInputHandle,
} from "@/components/ui/text-input";
import { useIsCompactFormFactor } from "@/constants/layout";
import { createReviewEditorScope } from "./editor";
import { isWeb } from "@/constants/platform";
import { inlineUnistylesStyle } from "@/styles/unistyles-inline-style";
import type { Theme } from "@/styles/theme";
import {
useReviewDraftComments,
useReviewDraftStore,
saveReviewDraftComment,
type ReviewDraftComment,
} from "./store";
import { useReviewDraftComments, useReviewDraftStore, type ReviewDraftComment } from "./store";
import { buildReviewableDiffTargetKey, type ReviewableDiffTarget } from "@/utils/diff-layout";
import {
INLINE_REVIEW_EDITOR_HEIGHT,
@@ -112,42 +98,92 @@ export function groupInlineReviewCommentsByTarget(
return grouped;
}
const reviewEditors = createReviewEditorScope({
saveComment: saveReviewDraftComment,
deleteComment: (input) => useReviewDraftStore.getState().deleteComment(input),
});
export function useInlineReviewController(input: {
reviewDraftKey: string;
presentation?: string;
}): InlineReviewActions & {
sheetEditor: InlineReviewEditorState | null;
onChangeEditorBody: (body: string) => void;
} {
const isCompact = useIsCompactFormFactor();
export function useInlineReviewController(input: { reviewDraftKey: string }): InlineReviewActions {
const reviewComments = useReviewDraftComments(input.reviewDraftKey);
const commentsByTarget = useMemo(
() => groupInlineReviewCommentsByTarget(reviewComments),
[reviewComments],
);
const model = useMemo(
() => reviewEditors.forDraft(input.reviewDraftKey, input.presentation ?? "diff"),
[input.reviewDraftKey, input.presentation],
const [editor, setEditor] = useState<InlineReviewEditorState | null>(null);
const addComment = useReviewDraftStore((state) => state.addComment);
const updateComment = useReviewDraftStore((state) => state.updateComment);
const deleteComment = useReviewDraftStore((state) => state.deleteComment);
useEffect(() => {
setEditor(null);
}, [input.reviewDraftKey]);
const handleStartComment = useCallback((target: ReviewableDiffTarget) => {
setEditor({ target, commentId: null, body: "" });
}, []);
const handleEditComment = useCallback(
(target: ReviewableDiffTarget, comment: ReviewDraftComment) => {
setEditor({ target, commentId: comment.id, body: comment.body });
},
[],
);
const editor = useSyncExternalStore(model.subscribe, model.getState, model.getState);
return useMemo(
const handleCancelEditor = useCallback(() => {
setEditor(null);
}, []);
const handleSaveEditor = useCallback(
(body: string) => {
const trimmedBody = body.trim();
if (!editor || trimmedBody.length === 0) {
return;
}
if (editor.commentId) {
updateComment({
key: input.reviewDraftKey,
id: editor.commentId,
updates: { body: trimmedBody },
});
} else {
addComment({
key: input.reviewDraftKey,
comment: {
filePath: editor.target.filePath,
side: editor.target.side,
lineNumber: editor.target.lineNumber,
body: trimmedBody,
},
});
}
setEditor(null);
},
[addComment, editor, input.reviewDraftKey, updateComment],
);
const handleDeleteComment = useCallback(
(id: string) => {
deleteComment({ key: input.reviewDraftKey, id });
setEditor((current) => (current?.commentId === id ? null : current));
},
[deleteComment, input.reviewDraftKey],
);
return useMemo<InlineReviewActions>(
() => ({
commentsByTarget,
editor: isCompact ? null : editor,
sheetEditor: isCompact ? editor : null,
onChangeEditorBody: model.setBody,
onStartComment: model.start,
onEditComment: model.edit,
onCancelEditor: model.cancel,
onSaveEditor: model.save,
onDeleteComment: model.delete,
editor,
onStartComment: handleStartComment,
onEditComment: handleEditComment,
onCancelEditor: handleCancelEditor,
onSaveEditor: handleSaveEditor,
onDeleteComment: handleDeleteComment,
}),
[commentsByTarget, editor, isCompact, model],
[
commentsByTarget,
editor,
handleCancelEditor,
handleDeleteComment,
handleEditComment,
handleSaveEditor,
handleStartComment,
],
);
}
@@ -285,8 +321,7 @@ export function InlineReviewThread({
const editorElement = editor ? (
<InlineReviewEditor
key={editingCommentId ?? "new"}
body={editor.body}
onChangeBody={reviewActions.onChangeEditorBody}
initialBody={editor.body}
onCancel={reviewActions.onCancelEditor}
onSave={reviewActions.onSaveEditor}
testID="inline-review-editor"
@@ -393,20 +428,19 @@ export function getInlineReviewThreadViewportStyle({
}
export function InlineReviewEditor({
body,
onChangeBody,
initialBody,
onCancel,
onSave,
testID,
}: {
body: string;
onChangeBody: (body: string) => void;
initialBody: string;
onCancel: () => void;
onSave: (body: string) => void;
testID?: string;
}) {
const { t } = useTranslation();
const inputRef = useRef<EditingTextInputHandle | null>(null);
const [body, setBody] = useState(initialBody);
const [isFocused, setIsFocused] = useState(false);
const trimmedBody = body.trim();
const canSave = trimmedBody.length > 0;
@@ -472,7 +506,7 @@ export function InlineReviewEditor({
placeholderTextColor={styles.placeholderColor.color}
multiline
initialValue={body}
onChangeText={onChangeBody}
onChangeText={setBody}
onFocus={handleFocus}
onBlur={handleBlur}
style={inputStyle}
+1 -5
View File
@@ -22,11 +22,7 @@ export default defineConfig({
test: {
name: "unit",
environment: "node",
include: [
"src/**/*.{test,spec}.{ts,tsx}",
"native-release-version.test.ts",
"app.config.test.ts",
],
include: ["src/**/*.{test,spec}.{ts,tsx}", "native-release-version.test.ts"],
setupFiles: [path.resolve(__dirname, "vitest.setup.ts")],
exclude: [...configDefaults.exclude, "e2e/**", "src/**/*.browser.{test,spec}.{ts,tsx}"],
},
-2
View File
@@ -3,7 +3,6 @@ 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";
@@ -149,7 +148,6 @@ export function createCli(): Command {
// Provider commands
program.addCommand(createProviderCommand());
program.addCommand(createUsageCommand());
program.addCommand(createPluginCommand());
// Speech model commands
@@ -1,279 +0,0 @@
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>");
});
});
-95
View File
@@ -1,95 +0,0 @@
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;
}
@@ -1,137 +0,0 @@
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);
},
};
-152
View File
@@ -1,152 +0,0 @@
#!/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();
}
@@ -426,14 +426,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
try {
const client = new ClaudeAgentClient({
logger,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.219",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
@@ -446,7 +438,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5",
"claude-mythos-5-1",
"claude-fable-5-1",
"claude-fable-5",
"claude-fable-5[1m]",
@@ -481,14 +472,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
try {
const client = new ClaudeAgentClient({
logger,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
resolveVersion: async () => {
throw new Error("unrecognized version output");
},
@@ -512,14 +495,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
try {
const client = new ClaudeAgentClient({
logger,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
resolveBinary: async () => "/test/claude/bin",
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: emptyConfigDir } },
@@ -534,14 +509,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
};
expect(getThinkingIds("claude-opus-5")).toContain("ultracode");
expect(getThinkingIds("claude-mythos-5-1")).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
"ultracode",
]);
expect(getThinkingIds("claude-fable-5-1")).toContain("ultracode");
expect(getThinkingIds("claude-fable-5")).toContain("ultracode");
expect(getThinkingIds("claude-opus-4-8[1m]")).toContain("ultracode");
@@ -1265,7 +1232,6 @@ describe("ClaudeAgentSession features", () => {
test.each([
["supported model", "claude-opus-4-8", { type: "disabled" }, undefined],
["Mythos 5.1", "claude-mythos-5-1", { type: "adaptive", display: "summarized" }, "high"],
["Haiku 5.5", "claude-haiku-5-5", { type: "disabled" }, undefined],
["unsupported model", "claude-fable-5", { type: "adaptive", display: "summarized" }, "high"],
["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined],
@@ -76,7 +76,6 @@ import {
type ClaudeProviderOptions,
} from "./options.js";
import { renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { discoverClaudeModels } from "./model-discovery.js";
import { claudeQuery, type ClaudeOptions, type ClaudeQueryFactory } from "./query.js";
import {
realClaudeRewindSdk,
@@ -413,7 +412,6 @@ export interface ClaudeContentChunk {
}
interface ClaudeAgentClientOptions {
discoverModels?: typeof discoverClaudeModels;
defaults?: { agents?: Record<string, AgentDefinition> };
logger: Logger;
runtimeSettings?: ProviderRuntimeSettings;
@@ -1516,7 +1514,6 @@ export function readEventIdentifiers(message: SDKMessage): EventIdentifiers {
}
export class ClaudeAgentClient implements AgentClient {
private readonly discoverModels: typeof discoverClaudeModels;
readonly provider = "claude" as const;
readonly capabilities = CLAUDE_CAPABILITIES;
@@ -1529,7 +1526,6 @@ export class ClaudeAgentClient implements AgentClient {
private readonly rewindSdk: ClaudeRewindSdk;
constructor(options: ClaudeAgentClientOptions) {
this.discoverModels = options.discoverModels ?? discoverClaudeModels;
this.defaults = options.defaults;
this.logger = options.logger.child({ module: "agent", provider: "claude" });
this.runtimeSettings = options.runtimeSettings;
@@ -1612,35 +1608,8 @@ export class ClaudeAgentClient implements AgentClient {
this.logger.warn({ err: error }, "Failed to resolve Claude Code version for model catalog");
}
const env = this.buildProviderEnv();
let discoveredModelIds = new Set<string>();
try {
const advertisedModels = await runProviderRefreshActivity(context, "models", () =>
this.discoverModels({
resolveBinary: this.resolveBinary,
runtimeSettings: this.runtimeSettings,
env,
signal: context?.signal,
}),
);
discoveredModelIds = new Set(
advertisedModels
.map((model) => normalizeClaudeRuntimeModelId(model.resolvedModel ?? model.value))
.filter((id): id is string => id !== null),
);
} catch (error) {
context?.signal.throwIfAborted();
this.logger.warn(
{ err: error },
"Claude model discovery failed; omitting discovery-required models",
);
}
const models = await runProviderRefreshActivity(context, "settings", () =>
getClaudeModelsWithSettings(
this.logger,
claudeConfigDir(env),
claudeCodeVersion,
discoveredModelIds,
),
getClaudeModelsWithSettings(this.logger, claudeConfigDir(env), claudeCodeVersion),
);
const modeCatalog = claudeModeCatalog(env);
return {
@@ -1,89 +0,0 @@
import type { ChildProcess } from "node:child_process";
import os from "node:os";
import type { ModelInfo, Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
import { raceProviderRefreshAbort } from "../../provider-refresh-deadline.js";
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
import { terminateWithTreeKill } from "../../../../utils/tree-kill.js";
import { claudeQuery, type ClaudeQueryInput, type ClaudeQueryContext } from "./query.js";
interface ClaudeModelDiscoveryOptions {
resolveBinary: () => Promise<string>;
runtimeSettings?: ProviderRuntimeSettings;
env: NodeJS.ProcessEnv;
signal?: AbortSignal;
}
type OpenDiscoveryQuery = (
input: ClaudeQueryInput,
context: ClaudeQueryContext,
) => Pick<Query, "supportedModels" | "close">;
// The curated catalog survives a failed or slow probe. This process never receives
// a prompt and must not persist a session or launch user MCP servers/hooks.
export async function discoverClaudeModels(
options: ClaudeModelDiscoveryOptions,
openQuery: OpenDiscoveryQuery = claudeQuery,
): Promise<ModelInfo[]> {
const controller = new AbortController();
const signal = options.signal
? AbortSignal.any([options.signal, controller.signal])
: controller.signal;
const timer = setTimeout(
() => controller.abort(new Error("Claude model discovery timed out")),
5_000,
);
let endInput!: () => void;
const ended = new Promise<void>((resolve) => {
endInput = resolve;
});
const input: AsyncIterable<SDKUserMessage> = {
[Symbol.asyncIterator]() {
return {
next: async () => {
await ended;
return { done: true, value: undefined };
},
};
},
};
let query: ReturnType<OpenDiscoveryQuery> | undefined;
let child: ChildProcess | undefined;
try {
signal.throwIfAborted();
const binary = await raceProviderRefreshAbort(signal, options.resolveBinary());
query = openQuery(
{
prompt: input,
options: {
pathToClaudeCodeExecutable: binary,
cwd: os.homedir(),
env: options.env,
persistSession: false,
tools: [],
mcpServers: {},
strictMcpConfig: true,
settingSources: ["user"],
settings: { disableAllHooks: true },
abortController: controller,
},
},
{
runtimeSettings: options.runtimeSettings,
onChildProcess: (process) => {
child = process;
},
},
);
return await raceProviderRefreshAbort(signal, query.supportedModels());
} finally {
clearTimeout(timer);
endInput();
try {
query?.close();
} finally {
if (child) {
await terminateWithTreeKill(child, { gracefulTimeoutMs: 200, forceTimeoutMs: 200 });
}
}
}
}
@@ -9,7 +9,6 @@ interface ClaudeModelManifestEntry {
description: string;
defaultPriority?: number;
minimumClaudeCodeVersion?: string;
requiresDiscovery?: boolean;
contextWindowMaxTokens?: number;
effortLevels?: readonly ClaudeEffortLevel[];
defaultThinkingOptionId?: ClaudeEffortLevel;
@@ -58,14 +57,6 @@ export const CLAUDE_MODEL_MANIFEST = [
supportsThinkingDisabled: true,
supportsFastMode: true,
},
{
id: "claude-mythos-5-1",
label: "Mythos 5.1",
requiresDiscovery: true,
description: "Mythos 5.1 · Requires verified access",
contextWindowMaxTokens: 1_000_000,
effortLevels: CLAUDE_EFFORT_LEVELS.xhigh,
},
{
id: "claude-fable-5-1",
label: "Fable 5.1",
@@ -226,16 +217,9 @@ function buildThinkingOptions(
return options;
}
export function getClaudeManifestModels(
claudeCodeVersion?: string,
discoveredModelIds?: ReadonlySet<string>,
): AgentModelDefinition[] {
export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelDefinition[] {
const availableModels: readonly ClaudeModelManifestEntry[] = CLAUDE_MODEL_MANIFEST.filter(
(model) =>
isModelAvailableInClaudeCode(model, claudeCodeVersion) &&
(discoveredModelIds === undefined ||
!("requiresDiscovery" in model && model.requiresDiscovery) ||
discoveredModelIds.has(model.id)),
(model) => isModelAvailableInClaudeCode(model, claudeCodeVersion),
);
const defaultModel = availableModels.reduce<ClaudeModelManifestEntry | undefined>(
(selected, candidate) =>
@@ -375,7 +359,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
}
const singleSegmentMatch = trimmed.match(
/^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
);
if (singleSegmentMatch) {
return normalizeSingleSegmentClaudeModelId(
@@ -386,7 +370,7 @@ export function normalizeClaudeManifestModelId(value: string | null | undefined)
}
const runtimeMatch = trimmed.match(
/^(?:claude[-_ ])?(mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
/^(?:claude[-_ ])?(fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(?:\[1m\])?(?:[-_ ]+\d{8})?(?:\[1m\])?$/i,
);
if (!runtimeMatch) {
return null;
@@ -419,7 +403,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
}
const runtimeMatch = trimmed.match(
/claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)[-.](\d+)(\[1m\])?/i,
);
if (runtimeMatch) {
const normalizedModelId = normalizeMajorMinorClaudeModelId(
@@ -434,7 +418,7 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined):
}
const singleSegmentMatch = trimmed.match(
/claude[-_ ](mythos|fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
/claude[-_ ](fable|opus|sonnet|haiku)[-_ ]+(\d+)(\[1m\])?/i,
);
if (!singleSegmentMatch) {
return null;
@@ -13,8 +13,6 @@ import {
parseClaudeCodeVersion,
resolveClaudeDisabledThinkingForModel,
} from "./model-manifest.js";
import { discoverClaudeModels } from "./model-discovery.js";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import { findClaudeModel, getClaudeModels, normalizeClaudeRuntimeModelId } from "./models.js";
const createdClaudeConfigDirs: string[] = [];
@@ -45,14 +43,6 @@ function createCatalogClient(claudeCodeVersion = "2.1.293"): ClaudeAgentClient {
return new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => claudeCodeVersion,
discoverModels: async () => [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
],
});
}
@@ -62,7 +52,6 @@ describe("getClaudeModels", () => {
expect(models.map((m) => m.id)).toEqual([
"claude-opus-5-5",
"claude-opus-5",
"claude-mythos-5-1",
"claude-fable-5-1",
"claude-fable-5",
"claude-fable-5[1m]",
@@ -98,7 +87,6 @@ describe("getClaudeModels", () => {
new Map([
["claude-opus-5-5", 1_000_000],
["claude-opus-5", 1_000_000],
["claude-mythos-5-1", 1_000_000],
["claude-fable-5-1", 1_000_000],
["claude-fable-5", 1_000_000],
["claude-fable-5[1m]", 1_000_000],
@@ -233,118 +221,6 @@ describe("getClaudeModels", () => {
});
describe("ClaudeAgentClient.fetchCatalog", () => {
it.each([
{ value: "mythos", resolvedModel: "claude-mythos-5-1", expected: true },
{ value: "claude-mythos-5-1", expected: true },
{ value: "claude-mythos-5-1[1m]", expected: true },
{ value: "global.anthropic.claude-mythos-5-1", expected: true },
{ value: "mythos", expected: false },
{ value: "mythos", resolvedModel: "claude-mythos-5-2", expected: false },
{ value: "claude-mythos-5-1", resolvedModel: "claude-fable-5-1", expected: false },
])(
"uses advertised identity $value / $resolvedModel to gate Mythos",
async ({ value, resolvedModel, expected }) => {
const configDir = await createClaudeConfigDir({});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => [
{ value, resolvedModel, displayName: "Untrusted label", description: "" },
],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual(
expected ? findClaudeModel("claude-mythos-5-1") : undefined,
);
},
);
it("preserves the curated catalog and explicitly configured models after discovery failure", async () => {
const configDir = await createClaudeConfigDir({ model: "claude-mythos-5-1" });
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => {
throw new Error("discovery unavailable");
},
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.filter((model) => model.id !== "claude-mythos-5-1")).toEqual(
getClaudeModels("2.1.293").filter((model) => model.id !== "claude-mythos-5-1"),
);
expect(models.find((model) => model.id === "claude-mythos-5-1")).toEqual({
...findClaudeModel("claude-mythos-5-1"),
label: "claude-mythos-5-1",
description: "From Claude settings.json model",
});
});
it("omits unconfirmed Mythos after a failed probe and retries on refresh", async () => {
const configDir = await createClaudeConfigDir({});
let fail = true;
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => {
if (fail) throw new Error("discovery unavailable");
return [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
];
},
});
const options = { scope: "workspace" as const, cwd: configDir, force: true };
expect(
(await client.fetchCatalog(options)).models.some((model) => model.id === "claude-mythos-5-1"),
).toBe(false);
fail = false;
expect(
(await client.fetchCatalog(options)).models.find((model) => model.id === "claude-mythos-5-1"),
).toEqual(findClaudeModel("claude-mythos-5-1"));
});
it("keeps the curated catalog when discovery does not advertise Mythos", async () => {
const configDir = await createClaudeConfigDir({});
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.293",
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: configDir } },
discoverModels: async () => [
{
value: "opus",
resolvedModel: "claude-opus-5-5",
displayName: "Opus",
description: "Latest",
},
],
});
const { models } = await client.fetchCatalog({
scope: "workspace",
cwd: configDir,
force: false,
});
expect(models.map((model) => model.id)).toEqual(
getClaudeModels("2.1.293")
.filter((model) => model.id !== "claude-mythos-5-1")
.map((model) => model.id),
);
});
it("appends concrete models from Claude settings.json", async () => {
const configDir = await createClaudeConfigDir({
model: "us.anthropic.claude-opus-4-7[1m]",
@@ -422,7 +298,6 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
resolveVersion: async () => "2.1.280",
discoverModels: async () => [],
runtimeSettings: { env: { CLAUDE_CONFIG_DIR: providerConfigDir } },
});
@@ -849,124 +724,3 @@ describe("claudeManifestModelSupportsFastMode", () => {
expect(claudeManifestModelSupportsFastMode("claude-fable-5-1")).toBe(false);
});
});
describe("Claude Mythos 5.1 catalog", () => {
it("offers one 1M model with high default effort and always-on thinking", () => {
const models = getClaudeModels().filter((model) => model.id.startsWith("claude-mythos"));
expect(models).toHaveLength(1);
expect(models[0]).toMatchObject({
id: "claude-mythos-5-1",
label: "Mythos 5.1",
contextWindowMaxTokens: 1_000_000,
defaultThinkingOptionId: "high",
});
expect(models[0].thinkingOptions?.map((option) => option.id)).toEqual([
"low",
"medium",
"high",
"xhigh",
"max",
CLAUDE_ULTRACODE_THINKING_OPTION_ID,
]);
expect(
models[0].thinkingOptions?.filter((option) => option.isDefault).map((option) => option.id),
).toEqual(["high"]);
expect(resolveClaudeDisabledThinkingForModel("claude-mythos-5-1")).toEqual({
supported: false,
fallbackThinkingOptionId: "high",
});
expect(claudeManifestModelSupportsFastMode("claude-mythos-5-1")).toBe(false);
expect(getClaudeModels().find((model) => model.isDefault)?.id).toBe("claude-opus-5-5");
});
it.each([
"claude-mythos-5-1[1m]",
"claude-mythos-5-1-20260901",
"claude-mythos-5-1-20260901[1m]",
"Mythos 5.1",
])("resolves %s to the single catalog entry", (id) => {
expect(findClaudeModel(id)?.id).toBe("claude-mythos-5-1");
});
it.each(["anthropic/claude-mythos-5-1", "us.anthropic.claude-mythos-5-1-v1:0"])(
"normalizes runtime identity without granting first-party capabilities: %s",
(id) => {
expect(normalizeClaudeRuntimeModelId(id)).toBe("claude-mythos-5-1");
expect(normalizeClaudeManifestModelId(id)).toBeNull();
},
);
});
describe("Claude model discovery process", () => {
it("does not send a prompt or persist a session and closes after discovery", async () => {
let closed = false;
let promptEnded: Promise<IteratorResult<unknown>> | undefined;
const advertised: ModelInfo[] = [
{
value: "mythos",
resolvedModel: "claude-mythos-5-1",
displayName: "Mythos",
description: "",
},
];
const models = await discoverClaudeModels(
{ resolveBinary: async () => "/claude", env: { CLAUDE_CONFIG_DIR: "/provider-config" } },
(input) => {
expect(input.options).toMatchObject({
pathToClaudeCodeExecutable: "/claude",
env: { CLAUDE_CONFIG_DIR: "/provider-config" },
persistSession: false,
tools: [],
mcpServers: {},
strictMcpConfig: true,
settingSources: ["user"],
settings: { disableAllHooks: true },
});
if (typeof input.prompt === "string") throw new Error("unexpected inference prompt");
promptEnded = input.prompt[Symbol.asyncIterator]().next();
return {
supportedModels: async () => advertised,
close: () => {
closed = true;
},
};
},
);
expect(models).toEqual(advertised);
expect(closed).toBe(true);
await expect(promptEnded).resolves.toEqual({ done: true, value: undefined });
});
it("times out and closes an unresponsive discovery process", async () => {
let closed = false;
await expect(
discoverClaudeModels({ resolveBinary: async () => "/claude", env: {} }, () => ({
supportedModels: () => new Promise(() => {}),
close: () => {
closed = true;
},
})),
).rejects.toThrow("Claude model discovery timed out");
expect(closed).toBe(true);
}, 10_000);
it("closes a stalled probe when its refresh is canceled", async () => {
const controller = new AbortController();
const failure = new Error("refresh canceled");
let closed = false;
const result = discoverClaudeModels(
{ resolveBinary: async () => "/claude", env: {}, signal: controller.signal },
() => ({
supportedModels: () => {
controller.abort(failure);
return new Promise(() => {});
},
close: () => {
closed = true;
},
}),
);
await expect(result).rejects.toBe(failure);
expect(closed).toBe(true);
});
});
@@ -52,9 +52,8 @@ export async function getClaudeModelsWithSettings(
logger: Logger,
configDir: string,
claudeCodeVersion?: string,
discoveredModelIds: ReadonlySet<string> = new Set(),
): Promise<AgentModelDefinition[]> {
const hardcodedModels = getClaudeManifestModels(claudeCodeVersion, discoveredModelIds);
const hardcodedModels = getClaudeModels(claudeCodeVersion);
const settingsModels = await readClaudeSettingsModels(logger, configDir);
if (settingsModels.length === 0) {
return hardcodedModels;
@@ -71,10 +70,7 @@ export async function getClaudeModelsWithSettings(
}
continue;
}
const manifestModel = getClaudeModels(claudeCodeVersion).find(
(candidate) => candidate.id === model.id,
);
models.push({ ...manifestModel, ...model });
models.push(model);
}
return models;
@@ -422,12 +422,12 @@ describe.skipIf(isPlatform("win32"))("worktree-bootstrap POSIX-only", () => {
api: {
type: "service",
command:
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('api-env.json.tmp', 'api-env.json'); setTimeout(()=>{}, 30000)\"",
"node -e \"const fs=require('fs'); fs.writeFileSync('api-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
},
web: {
type: "service",
command:
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json.tmp', JSON.stringify(process.env)); fs.renameSync('web-env.json.tmp', 'web-env.json'); setTimeout(()=>{}, 30000)\"",
"node -e \"const fs=require('fs'); fs.writeFileSync('web-env.json', JSON.stringify(process.env)); setTimeout(()=>{}, 30000)\"",
},
},
}),
@@ -298,35 +298,6 @@ 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().nullable().optional(),
references: z.object({ full: z.string().optional() }).passthrough().optional(),
head_pipeline: GitLabPipelineSchema.nullable().optional(),
})
.passthrough();
@@ -0,0 +1,55 @@
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
// Opt-in reproduction: prepare the directory tree with scripts/repro-directory-search-memory.py,
// then pass its location as PASEO_REPRO_SEARCH_ROOT. This exercises real filesystem calls.
it.skipIf(!process.env.PASEO_REPRO_SEARCH_ROOT)(
"finishes overlapping directory searches without exhausting the Node heap",
async () => {
const modulePath = fileURLToPath(new URL("./directory-suggestions.ts", import.meta.url));
const source = `
const { searchDirectoryEntries } = await import(${JSON.stringify(modulePath)});
const query = "zzzzzzzzzzzzzz";
await Promise.all(Array.from({ length: query.length }, (_, index) =>
searchDirectoryEntries({
root: process.env.PASEO_REPRO_SEARCH_ROOT,
query: query.slice(0, index + 1),
pathFormat: "absolute",
includeFiles: false,
includeDirectories: true,
pathQueryPolicy: "rooted",
rootAliases: ["~"],
blankQueryBehavior: "none",
confidentResultScanThreshold: 5000,
limit: 30,
})
));
`;
const result = await new Promise<{
code: number | null;
signal: string | null;
stderr: string;
}>((resolve, reject) => {
const child = spawn(
process.execPath,
["--import", "tsx", "--input-type=module", "--eval", source],
{
stdio: ["ignore", "ignore", "pipe"],
env: process.env,
},
);
let stderr = "";
child.stderr.setEncoding("utf8");
child.stderr.on("data", (data: string) => {
stderr += data;
});
child.on("error", reject);
child.on("close", (code, signal) => resolve({ code, signal, stderr }));
});
expect(result.stderr).not.toContain("JavaScript heap out of memory");
expect(result.signal).toBeNull();
expect(result.code).toBe(0);
},
120_000,
);
@@ -1,7 +1,6 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, beforeEach, expect, test } from "vitest";
import { discover, fetchUsage } from "./usage.js";
@@ -44,6 +43,7 @@ async function logins(options: Parameters<typeof discover>[1]) {
}
async function database(path: string) {
await mkdir((await import("node:path")).dirname(path), { recursive: true });
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.exec(
"CREATE TABLE auth_credentials (id INTEGER PRIMARY KEY, provider TEXT, credential_type TEXT, data TEXT, disabled_cause TEXT)",
@@ -248,6 +248,7 @@ test("OMP fetch re-reads its row after rotation and skips a newly disabled login
const path = join(home, ".omp", "agent", "agent.db");
await database(path);
const [input] = await logins(lookup());
const { DatabaseSync } = await import("node:sqlite");
const db = new DatabaseSync(path);
db.prepare("UPDATE auth_credentials SET data = ? WHERE id = 1").run(
JSON.stringify({ access: "fixture-rotated-omp", accountId: "omp-account", expires: 2000 }),
-41
View File
@@ -21,49 +21,8 @@ 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:
+25
View File
@@ -0,0 +1,25 @@
"""Create the real directory tree for the opt-in directory-search memory regression test.
Run from the checkout root, then run:
PASEO_REPRO_SEARCH_ROOT="$PWD/.diagnose-4879/home" npm exec --workspace=@getpaseo/server -- vitest run src/utils/directory-suggestions-memory.local.e2e.test.ts
The fixture needs approximately 6 GB of disk space. No heap limit or delay is injected.
"""
import os
import time
root = os.path.join(os.getcwd(), ".diagnose-4879", "home")
os.makedirs(root, exist_ok=True)
start = time.monotonic()
for branch_index in range(100):
branch = os.path.join(root, f"repos-{branch_index:03}")
os.makedirs(branch, exist_ok=True)
for package_index in range(140):
package = os.path.join(branch, f"packages-{package_index:03}")
os.makedirs(package, exist_ok=True)
for component_index in range(100):
os.makedirs(os.path.join(package, f"component-{component_index:03}"), exist_ok=True)
if branch_index % 10 == 9:
print(f"{branch_index + 1} branches built in {time.monotonic() - start:.1f}s", flush=True)
for name in ["ai-credential-refresh", "ai-engineer-reference", "ai-tevv", "gmail-local-sync"]:
os.makedirs(os.path.join(root, "workspace", "projects", name), exist_ok=True)
print("Created 1,414,106 directories", flush=True)