From b4b6fa65f1f3c3af6e931b60e572d578f86ef928 Mon Sep 17 00:00:00 2001 From: OpenHands Date: Wed, 24 Jun 2026 19:39:52 -0500 Subject: [PATCH 1/5] Cover the policies surface with lifecycle, landing, and UI round-trip scenarios The existing policies.test.ts pins create and list only. The existing policies-ui.test.ts authors rules from the integration's tool tree, not from the policies page itself. Three gaps fill in here: - policies-lifecycle: the typed API's update (full and partial payloads) and remove, plus an assertion that create returns a non-empty fractional- indexing position (the field the page's sort and reorder math both lean on). - policies-landing: a fresh workspace renders the explainer empty state with a gated add form. The disabled attribute is read as its value so a regression that ungates the submit prints the actual state. - policies-round-trip: the page itself is an authoring surface, add a rule, flip the action via the row badge, remove via the overflow menu, watch the empty state return. Server-side list reads zero rows for the suffix after, so the UI remove isn't just optimistic-cache fiction. Each scenario carries a per-run pattern suffix and an Effect.ensuring finalizer that removes any row carrying it via the typed API, so a mid-test failure on selfhost (shared bootstrap admin) can't leak state. --- e2e/scenarios/policies-landing.test.ts | 79 +++++++++++++ e2e/scenarios/policies-lifecycle.test.ts | 106 +++++++++++++++++ e2e/scenarios/policies-round-trip.test.ts | 138 ++++++++++++++++++++++ 3 files changed, 323 insertions(+) create mode 100644 e2e/scenarios/policies-landing.test.ts create mode 100644 e2e/scenarios/policies-lifecycle.test.ts create mode 100644 e2e/scenarios/policies-round-trip.test.ts diff --git a/e2e/scenarios/policies-landing.test.ts b/e2e/scenarios/policies-landing.test.ts new file mode 100644 index 000000000..bd8050276 --- /dev/null +++ b/e2e/scenarios/policies-landing.test.ts @@ -0,0 +1,79 @@ +// Cross-target (browser): a fresh workspace lands on `/policies` with an +// explainer empty state and a gated add form. The existing `policies-ui` +// scenario covers authoring rules from the tool tree; this scenario only +// pins the landing surface for a workspace that has never authored a rule. +// +// Asserts on: +// +// 1. The page renders the "Policies" heading and the rationale paragraph +// under it (scoped to its `

`, not a bare text match). +// 2. The Active policies card-stack header is present, with the empty- +// state explainer reading "No policies yet. Tools fall back to their +// plugin's default approval behavior.", the product's guarantee that +// absence-of-rule is a resolved default, not a loading state. +// 3. The add-policy form's pattern input exists and the submit button is +// gated. Asserted as a value read of the `disabled` attribute, a +// regression prints the actual element state instead of `false`. +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; + +import { scenario } from "../src/scenario"; +import { Browser, Target } from "../src/services"; + +scenario( + "Policies · a fresh workspace lands on an explainer empty state with a gated add form", + { timeout: 90_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const identity = yield* target.newIdentity(); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Open the policies page on a fresh workspace", async () => { + await page.goto("/policies", { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Policies", exact: true }).waitFor(); + }); + + await step("The rationale paragraph explains what policies do", async () => { + await page + .locator("p") + .filter({ + hasText: + "Override default approval behavior for tools. The most restrictive matched action wins.", + }) + .waitFor(); + }); + + await step("The Active policies card stack carries the empty-state explainer", async () => { + await page + .locator('[data-slot="card-stack-header"]') + .filter({ hasText: "Active policies" }) + .waitFor(); + // Scope to the card stack's content area: a regression where the + // explainer text leaks out of the empty state into a row body would + // still satisfy a bare `getByText`. + await page + .locator('[data-slot="card-stack-content"]') + .getByText( + "No policies yet. Tools fall back to their plugin's default approval behavior.", + { exact: true }, + ) + .waitFor(); + }); + + await step("The add form is reachable and its submit is gated", async () => { + const patternInput = page.getByPlaceholder("vercel.dns.* or *"); + await patternInput.waitFor(); + const addButton = page.getByRole("button", { name: "Add policy", exact: true }); + await addButton.waitFor(); + // Read the actual `disabled` attribute, a present attribute serializes + // as the empty string; a regression that ungates the button drops the + // attribute and this reads back as `null`. + expect( + await addButton.getAttribute("disabled"), + "Add policy is disabled until a valid pattern is typed", + ).toBe(""); + }); + }); + }), +); diff --git a/e2e/scenarios/policies-lifecycle.test.ts b/e2e/scenarios/policies-lifecycle.test.ts new file mode 100644 index 000000000..a144b397a --- /dev/null +++ b/e2e/scenarios/policies-lifecycle.test.ts @@ -0,0 +1,106 @@ +// Cross-target: the full policies CRUD round-trip through the typed +// HttpApiClient. The existing `policies.test.ts` scenario pins create + list +// only, the gaps this scenario closes are `update` (both full-payload and +// action-only partial, the shape the row badge's `handleUpdate` sends) and +// `remove`. Asserts on: +// +// 1. The create response carries a non-empty `position` (the fractional- +// indexing key the policies page's sort and reorder math both depend +// on, a regression that drops it would silently break ordering). +// 2. A full update returns the new pattern + action; a subsequent partial +// update (action only, no pattern in the payload) flips the action and +// leaves the pattern intact. +// 3. The list reflects the latest server-side values between writes. +// 4. After remove, list returns success and does NOT contain the id, +// asserted as `expect(ids).not.toContain(created.id)` so a regression +// prints the leaked ids instead of `false`. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const coreApi = composePluginApi([] as const); + +scenario( + "Policies · an existing policy can be re-targeted, partially edited, and removed", + {}, + Effect.gen(function* () { + const target = yield* Target; + const { client: apiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiClient(coreApi, identity); + + // Selfhost shares one bootstrap-admin workspace across scenarios, so + // every pattern carries a per-run suffix and the finalizer removes any + // row carrying it, even if a mid-test failure skips the explicit remove. + const suffix = randomBytes(4).toString("hex"); + const prefix = `policies-lc-${suffix}.`; + const initialPattern = `${prefix}alpha`; + const renamedPattern = `${prefix}beta`; + + const cleanup = Effect.gen(function* () { + const policies = yield* client.policies.list().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + policies.filter((p) => p.pattern.startsWith(prefix)), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: p.owner } }) + .pipe(Effect.ignore), + ); + }).pipe(Effect.ignore); + + yield* Effect.gen(function* () { + const created = yield* client.policies.create({ + payload: { owner: "org", pattern: initialPattern, action: "block" }, + }); + expect(created.pattern, "create response echoes the requested pattern").toBe(initialPattern); + expect(created.action, "create response echoes the requested action").toBe("block"); + // The page's sort and reorder math both depend on a non-empty position; + // a regression that ever leaves it blank would silently break ordering + // without raising on any single create. + expect(created.position, "create response carries a fractional-indexing key").not.toBe(""); + + // Full payload: pattern AND action change in one update, the path the + // page itself never sends today, but `policies.update` advertises. + const renamed = yield* client.policies.update({ + params: { policyId: created.id }, + payload: { owner: "org", pattern: renamedPattern, action: "approve" }, + }); + expect(renamed.pattern, "full update applied the new pattern").toBe(renamedPattern); + expect(renamed.action, "full update applied the new action").toBe("approve"); + + // Partial payload: action only, no pattern, the exact shape the row + // badge's `handleUpdate` sends. The server should flip the action and + // leave the pattern intact. + const switched = yield* client.policies.update({ + params: { policyId: created.id }, + payload: { owner: "org", action: "require_approval" }, + }); + expect(switched.action, "partial update flipped the action").toBe("require_approval"); + expect(switched.pattern, "partial update preserved the pattern").toBe(renamedPattern); + + // List reflects the latest values between writes. + const afterEdit = yield* client.policies.list(); + const myEntry = afterEdit.find((p) => p.id === created.id); + expect(myEntry, "the edited row appears in list with the latest values").toMatchObject({ + pattern: renamedPattern, + action: "require_approval", + }); + + yield* client.policies.remove({ + params: { policyId: created.id }, + payload: { owner: "org" }, + }); + + const afterRemove = yield* client.policies.list(); + expect( + afterRemove.map((p) => p.id), + "the removed id is gone from the list", + ).not.toContain(created.id); + }).pipe(Effect.ensuring(cleanup)); + }), +); diff --git a/e2e/scenarios/policies-round-trip.test.ts b/e2e/scenarios/policies-round-trip.test.ts new file mode 100644 index 000000000..89e32aa06 --- /dev/null +++ b/e2e/scenarios/policies-round-trip.test.ts @@ -0,0 +1,138 @@ +// Cross-target (browser): the full UI lifecycle of a policy. The user opens +// `/policies` on an empty workspace, submits a Require approval rule through +// the add form, flips its action to Always run via the row's inline badge +// select, then removes it via the row's overflow menu, and watches the +// empty state return. Covers the surfaces `policies-lifecycle` (API) and +// `policies-landing` (empty state) intentionally leave out: that the +// rendered page itself is the authoring surface, not just a read view. +// +// Selfhost shares one bootstrap-admin workspace, so the pattern carries a +// per-run suffix and the finalizer removes any row that survived a mid-test +// failure via the API. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const coreApi = composePluginApi([] as const); + +scenario( + "Policies · a user can add, re-target, and remove a rule from the policies page", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiClient(coreApi, identity); + + const suffix = randomBytes(4).toString("hex"); + const prefix = `policies-rt-${suffix}.`; + const pattern = `${prefix}alpha`; + + const cleanup = Effect.gen(function* () { + const policies = yield* client.policies.list().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + policies.filter((p) => p.pattern.startsWith(prefix)), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: p.owner } }) + .pipe(Effect.ignore), + ); + }).pipe(Effect.ignore); + + yield* Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + const cardContent = page.locator('[data-slot="card-stack-content"]'); + const row = () => + cardContent.locator('[data-slot="card-stack-entry"]').filter({ hasText: pattern }); + + await step("Open the policies page on a fresh workspace", async () => { + await page.goto("/policies", { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Policies", exact: true }).waitFor(); + // The empty-state explainer guarantees this workspace has never + // authored a rule, the precondition this scenario depends on. + await cardContent + .getByText( + "No policies yet. Tools fall back to their plugin's default approval behavior.", + { exact: true }, + ) + .waitFor(); + }); + + await step("Submit a Require approval rule through the add form", async () => { + await page.getByPlaceholder("vercel.dns.* or *").fill(pattern); + // The form's action select defaults to Require approval, submit + // the form as-is to also pin that default. + await page.getByRole("button", { name: "Add policy", exact: true }).click(); + }); + + await step("The new row appears with the pattern and Require approval badge", async () => { + await row().waitFor(); + // The row's inline badge select is the first combobox inside the + // card-stack content (the add form's combobox sits outside it). + const rowBadge = row().getByRole("combobox"); + await rowBadge.waitFor(); + expect( + await rowBadge.textContent(), + "the row badge shows the rule's current action verbatim", + ).toContain("Require approval"); + }); + + await step("Flip the action to Always run via the row badge select", async () => { + await row().getByRole("combobox").click(); + // "Always run" is the verb label for the `approve` action. + await page.getByRole("option", { name: "Always run", exact: true }).click(); + }); + + await step("The badge reflects the new Always run action", async () => { + const rowBadge = row().getByRole("combobox"); + // Wait for the badge to actually flip (optimistic updates can + // take a frame); reading textContent at the right moment is the + // assertion. + await expect + .poll(async () => rowBadge.textContent(), { + message: "the row badge flipped to the new action", + timeout: 5_000, + }) + .toContain("Always run"); + }); + + await step("Remove the rule via the row's overflow menu", async () => { + // The overflow trigger is `opacity-0` until hover/focus; `force` + // bypasses the visibility heuristic since Playwright can still + // dispatch the click. + const overflow = row() + .locator('[data-slot="card-stack-entry-actions"]') + .getByRole("button") + .last(); + await overflow.click({ force: true }); + await page.getByRole("menuitem", { name: "Remove", exact: true }).click(); + }); + + await step("The row disappears and the empty state returns", async () => { + await row().waitFor({ state: "detached" }); + await cardContent + .getByText( + "No policies yet. Tools fall back to their plugin's default approval behavior.", + { exact: true }, + ) + .waitFor(); + }); + }); + + // Server-side: a fresh read shows zero rows carrying this scenario's + // prefix. Pins that the UI's remove path actually deleted (vs. just + // optimistic-updated the cache). + const policies = yield* client.policies.list(); + expect( + policies.filter((p) => p.pattern.startsWith(prefix)).map((p) => p.pattern), + "no rows from this scenario survive on the server", + ).toEqual([]); + }).pipe(Effect.ensuring(cleanup)); + }), +); From 48cc867b781eba73f2b738ece585cad8a184978f Mon Sep 17 00:00:00 2001 From: OpenHands Date: Wed, 24 Jun 2026 19:42:51 -0500 Subject: [PATCH 2/5] Add a Duplicate row action to the policies page, pinned by its scenario The row's overflow menu had Move up / Move down / Remove. Cloning a rule to a sibling pattern meant retyping the pattern and re-picking the action from a still-loaded form. The new Duplicate item copies the source row's pattern, action, and owner into the add form, focuses the pattern input with its content selected, and lets the user save the clone in one keystroke. Mechanics: - AddPolicyForm gains an optional `prefill` prop (pattern + action + nonce). The nonce is a monotonic counter, not the source id, so duplicating the SAME row twice still re-syncs the form, patterns commonly get tweaked by one character between clicks. - The focus + select call runs in a setTimeout(0) inside the effect, which lands AFTER React's commit. The row's DropdownMenuContent also passes `onCloseAutoFocus={(e) => e.preventDefault()}` so Radix's default restore-focus-to-trigger doesn't yank focus out of the input the Duplicate flow is sending it to. The trigger is opacity-0 until hover anyway, so nothing visual is lost by skipping the restoration. - A `policy_duplicated` analytics event is added to the catalog. It fires on the prefill, BEFORE the user submits, Duplicate is purely a client- side form-fill today, with the eventual server write going through the existing `policies.create` path. The scenario walks the surface end-to-end: add a Block rule through the form, click Duplicate on its row, assert the form carries the pattern + the Block action, assert the pattern input is the document's active element, edit the pattern to a sibling, submit, see both rows. Server-side policies.list confirms both rules persisted with the source's action. --- e2e/scenarios/policies-duplicate.test.ts | 140 +++++++++++++++++++++++ packages/react/src/api/analytics.tsx | 16 ++- packages/react/src/pages/policies.tsx | 73 +++++++++++- 3 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 e2e/scenarios/policies-duplicate.test.ts diff --git a/e2e/scenarios/policies-duplicate.test.ts b/e2e/scenarios/policies-duplicate.test.ts new file mode 100644 index 000000000..d49eaeed1 --- /dev/null +++ b/e2e/scenarios/policies-duplicate.test.ts @@ -0,0 +1,140 @@ +// Cross-target (browser): the row's Duplicate menu prefills the add form +// with the source row's pattern, action, and owner, and focuses the pattern +// input with its content selected so the user can tweak the pattern in one +// keystroke. Submitting the form then writes the duplicated rule as its own +// row. The product guarantees this scenario pins: +// +// 1. The Duplicate menu item exists on every row's overflow menu. +// 2. Clicking it copies the row's `pattern` and `action` into the add form +// (the action select reflects the source row's verb label). +// 3. The pattern input is focused after the prefill, the UX promise that +// "I can just type" instead of "I have to click the input first". +// 4. After editing the pattern and submitting, BOTH rows appear in the +// list and the server-side `policies.list` reflects both. +// +// This is the only UI surface today that exercises the form's `prefill` +// prop. Regressions to the prefill nonce, the focus timing, or the field +// copy logic all surface as a `waitFor` timeout in this scenario. +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; + +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; + +const coreApi = composePluginApi([] as const); + +scenario( + "Policies · the row's Duplicate menu prefills the add form for a one-keystroke clone", + { timeout: 120_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: apiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* apiClient(coreApi, identity); + + const suffix = randomBytes(4).toString("hex"); + const prefix = `policies-dup-${suffix}.`; + const originalPattern = `${prefix}alpha`; + const copyPattern = `${prefix}beta`; + + const cleanup = Effect.gen(function* () { + const policies = yield* client.policies.list().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + policies.filter((p) => p.pattern.startsWith(prefix)), + (p) => + client.policies + .remove({ params: { policyId: p.id }, payload: { owner: p.owner } }) + .pipe(Effect.ignore), + ); + }).pipe(Effect.ignore); + + yield* Effect.gen(function* () { + yield* browser.session(identity, async ({ page, step }) => { + const patternInput = page.getByPlaceholder("vercel.dns.* or *"); + const formActionSelect = page.locator("form").getByRole("combobox").first(); + const cardContent = page.locator('[data-slot="card-stack-content"]'); + const row = (text: string) => + cardContent.locator('[data-slot="card-stack-entry"]').filter({ hasText: text }); + + await step("Open the policies page and add a Block rule", async () => { + await page.goto("/policies", { waitUntil: "networkidle" }); + await page.getByRole("heading", { name: "Policies", exact: true }).waitFor(); + await patternInput.fill(originalPattern); + // Switch the form's action to Block so the duplicate prefill has a + // non-default action to verify. + await formActionSelect.click(); + await page.getByRole("option", { name: "Block", exact: true }).click(); + await page.getByRole("button", { name: "Add policy", exact: true }).click(); + await row(originalPattern).waitFor(); + }); + + await step("Open the row's overflow menu and click Duplicate", async () => { + // Hover the row to materialize the opacity-0 overflow trigger, then + // target it by data-slot rather than a positional `getByRole`, + // the row also contains the badge's role="combobox" trigger, which + // can change the last-button heuristic if the DOM order ever moves. + await row(originalPattern).hover(); + await row(originalPattern) + .locator('[data-slot="dropdown-menu-trigger"]') + .click({ force: true }); + // The DropdownMenuContent is portaled to body, not the row; wait + // for it to mount before targeting the menu item, so a timeout + // here means "the menu never opened", not "the item is missing". + const menu = page.locator('[data-slot="dropdown-menu-content"]'); + await menu.waitFor(); + await menu.getByRole("menuitem", { name: "Duplicate", exact: true }).click(); + }); + + await step("The form prefilled with the source pattern and action", async () => { + expect( + await patternInput.inputValue(), + "the pattern input carries the source row's pattern verbatim", + ).toBe(originalPattern); + expect( + await formActionSelect.textContent(), + "the action select carries the source row's verb label", + ).toContain("Block"); + }); + + await step("The pattern input is focused, ready to be tweaked", async () => { + // Asserting on the focused element rather than calling `.focus()` + // ourselves, the product's promise is that Duplicate does the + // focusing. + await expect + .poll(() => patternInput.evaluate((el) => el === document.activeElement), { + message: "Duplicate focused the pattern input on its own", + timeout: 2_000, + }) + .toBe(true); + }); + + await step("Tweak the pattern and submit the copy", async () => { + await patternInput.fill(copyPattern); + await page.getByRole("button", { name: "Add policy", exact: true }).click(); + await row(copyPattern).waitFor(); + }); + + await step("Both rows appear in the rendered list", async () => { + await row(originalPattern).waitFor(); + await row(copyPattern).waitFor(); + }); + }); + + // Server-side truth on a fresh read: both rules persisted, both + // org-owned, both carrying the action the form was holding at submit + // (Block was set before the first add and the prefill preserved it). + const policies = yield* client.policies.list(); + const mine = policies + .filter((p) => p.pattern.startsWith(prefix)) + .map((p) => `${p.owner} ${p.pattern} ${p.action}`) + .sort(); + expect(mine, "both duplicated rules persisted with the source's action").toEqual( + [`org ${originalPattern} block`, `org ${copyPattern} block`].sort(), + ); + }).pipe(Effect.ensuring(cleanup)); + }), +); diff --git a/packages/react/src/api/analytics.tsx b/packages/react/src/api/analytics.tsx index b4f0f66a9..d7b57b005 100644 --- a/packages/react/src/api/analytics.tsx +++ b/packages/react/src/api/analytics.tsx @@ -1,7 +1,7 @@ import * as React from "react"; // --------------------------------------------------------------------------- -// Product-analytics seam — same DI shape as ./error-reporting: a module-level +// Product-analytics seam, same DI shape as ./error-reporting: a module-level // client set by a provider the HOST mounts, and a free `trackEvent` function // callsites import directly (works outside React, e.g. oauth-popup callbacks). // No client mounted (local, self-host, cloudflare, tests) → every call is a @@ -15,14 +15,14 @@ import * as React from "react"; // BROWSER-ONLY by design: during SSR the host mounts no client (cloud's is // undefined when `window` is absent), and on shared-module-scope runtimes // (Cloudflare Workers) every SSR render resets the singleton to null. Server- -// side product events need their own seam — do not route them through this one. +// side product events need their own seam, do not route them through this one. // -// PROPERTY RULES — properties must never carry: +// PROPERTY RULES, properties must never carry: // - secrets, tokens, credential values, copied clipboard contents // - emails, person/org names, or any user-entered free text // - connection names or tool ADDRESSES (both embed user-entered label text; // integration slugs and spec-derived tool names are fine) -// - policy patterns (user-entered globs) — use `pattern_kind` instead +// - policy patterns (user-entered globs), use `pattern_kind` instead // Identity attaches via posthog identify/group (the host's concern), never as // event properties. // --------------------------------------------------------------------------- @@ -103,6 +103,10 @@ export interface AnalyticsEvents { policy_action_changed: { action: string; owner: Owner; success: boolean }; policy_removed: { owner: Owner; success: boolean }; policy_reordered: { owner: Owner; direction: "up" | "down"; success: boolean }; + // Row's Duplicate menu, fires when the form is prefilled, BEFORE the + // duplicated row is submitted. Drop in `success` if Duplicate ever becomes + // a server call (today the form-fill is purely client-side). + policy_duplicated: { action: string; owner: Owner }; // ── API keys ───────────────────────────────────────────────────────────── api_key_created: { success: boolean }; @@ -178,7 +182,7 @@ export type AnalyticsClient = ( let currentAnalyticsClient: AnalyticsClient | null = null; /** - * Imperative injection point — what `AnalyticsProvider` uses, and the hook for + * Imperative injection point, what `AnalyticsProvider` uses, and the hook for * non-React hosts (or tests). Pass `null` to restore the no-op default. */ export const setAnalyticsClient = (client: AnalyticsClient | null): void => { @@ -200,7 +204,7 @@ export const trackEvent = ( }; /** - * Declarative mount for React hosts — sets the module-level client during + * Declarative mount for React hosts, sets the module-level client during * render, exactly like `FrontendErrorReporterProvider` does for error * reporting. Mount once at the app root, ABOVE any tree that fires events * (in cloud that is the document root, not ExecutorProvider, because the diff --git a/packages/react/src/pages/policies.tsx b/packages/react/src/pages/policies.tsx index e709e111e..ae2412bba 100644 --- a/packages/react/src/pages/policies.tsx +++ b/packages/react/src/pages/policies.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Exit from "effect/Exit"; @@ -68,7 +68,7 @@ const POLICY_OWNERS: readonly { readonly owner: Owner; readonly label: string }[ ]; // --------------------------------------------------------------------------- -// Sort comparator — owner rank, then fractional-indexing key, then id as a +// Sort comparator, owner rank, then fractional-indexing key, then id as a // stable tiebreak. Identical positions can briefly happen across racing // inserts; without the tiebreak the rendered order flips between refetches, and // `generateKeyBetween` would also throw if asked to insert "between" two equal @@ -85,7 +85,7 @@ const comparePolicy = (posA: string, idA: string, posB: string, idB: string): nu // Pattern matching + validation come from the SDK so the UI's "matches N tools" // preview and the add-policy validation use the EXACT same grammar the executor -// enforces — including mid-segment wildcards (`integration.*.*.tool`), which the +// enforces, including mid-segment wildcards (`integration.*.*.tool`), which the // connection-aware policy model now relies on. (Re-exported below for callers.) const matchesPattern = matchPattern; @@ -98,10 +98,37 @@ function AddPolicyForm(props: { owner: Owner; onOwnerChange: (owner: Owner) => void; busy: boolean; + /** Each click on a row's Duplicate menu bumps `nonce` so the effect below + * re-syncs even when the source policy's pattern matches the form's + * current value. */ + prefill?: { pattern: string; action: ToolPolicyAction; nonce: number }; }) { const [pattern, setPattern] = useState(""); const [action, setAction] = useState("require_approval"); + const patternInputRef = useRef(null); const valid = isValidPattern(pattern); + + // When a row's Duplicate menu fires, copy its pattern + action into the + // form and focus the pattern input with its content selected so the user + // can tweak the pattern in one keystroke. Selecting (not just focusing) is + // the difference between "I have to clear it first" and "I can just type". + const prefillNonce = props.prefill?.nonce; + useEffect(() => { + if (props.prefill === undefined) return; + setPattern(props.prefill.pattern); + setAction(props.prefill.action); + // setTimeout instead of requestAnimationFrame so this fires AFTER + // Radix's DropdownMenu close (which also uses setTimeout(0) to restore + // focus to its trigger), same task queue, but this is enqueued in the + // commit phase, so it lands after Radix's queued restoration. Combined + // with `onCloseAutoFocus` preventing restoration in the row's menu, + // this leaves the input as the only thing fighting for focus. + const id = setTimeout(() => { + patternInputRef.current?.focus(); + patternInputRef.current?.select(); + }, 0); + return () => clearTimeout(id); + }, [prefillNonce, props.prefill]); // Non-org hosts (local/desktop) have one local workspace. New local policies // are org-owned internally to match the v1->v2 migration. const ownerDisplay = useOwnerDisplay(); @@ -129,6 +156,7 @@ function AddPolicyForm(props: { setPattern(e.target.value)} @@ -203,6 +231,7 @@ function PolicyRow(props: { onChangeAction: (action: ToolPolicyAction) => void; onMoveUp: () => void; onMoveDown: () => void; + onDuplicate: () => void; showOwnerLabel: boolean; }) { return ( @@ -255,13 +284,23 @@ function PolicyRow(props: { - + e.preventDefault()} + > Move up Move down + Duplicate