# Conflicts: # packages/react/src/components/accounts-section.tsx
Unified · Split
| @@ -0,0 +1,85 @@ | |||
| 1 | // Cross-target (browser): the Add OpenAPI source form's floating action bar. | ||
| 2 | // Rohil (shared-tcc) reported the "Add integration" button rendering doubled / | ||
| 3 | // ghosted on click. This pins the flow: paste a spec, click Add integration, | ||
| 4 | // land on the created integration. The trace's DOM snapshots are the artifact | ||
| 5 | // we use to confirm the bar is a single node (a paint artifact, not a | ||
| 6 | // double-render). Skips on targets without a browser surface (selfhost today). | ||
| 7 | import { expect } from "@effect/vitest"; | ||
| 8 | import { Effect } from "effect"; | ||
| 9 | |||
| 10 | import { scenario } from "../src/scenario"; | ||
| 11 | import { Browser, Target } from "../src/services"; | ||
| 12 | |||
| 13 | // One server so "Add integration" is enabled straight from the preview (no | ||
| 14 | // base-URL picker to resolve first). | ||
| 15 | const singleServerSpec = JSON.stringify({ | ||
| 16 | openapi: "3.0.3", | ||
| 17 | info: { title: "Ping API", version: "1.0.0" }, | ||
| 18 | servers: [{ url: "https://api.example.com", description: "Production" }], | ||
| 19 | paths: { | ||
| 20 | "/ping": { get: { operationId: "ping", responses: { "200": { description: "pong" } } } }, | ||
| 21 | }, | ||
| 22 | }); | ||
| 23 | |||
| 24 | scenario( | ||
| 25 | "OpenAPI · Add integration commits the source and the action bar is a single node", | ||
| 26 | {}, | ||
| 27 | Effect.scoped( | ||
| 28 | Effect.gen(function* () { | ||
| 29 | const target = yield* Target; | ||
| 30 | const browser = yield* Browser; | ||
| 31 | const identity = yield* target.newIdentity(); | ||
| 32 | |||
| 33 | yield* browser.session(identity, async ({ page, step }) => { | ||
| 34 | await step("Open the Add OpenAPI source form", async () => { | ||
| 35 | await page.goto("/integrations/add/openapi", { waitUntil: "networkidle" }); | ||
| 36 | await page.getByPlaceholder("https://api.example.com/openapi.json").waitFor(); | ||
| 37 | }); | ||
| 38 | |||
| 39 | await step("Paste a single-server spec so Add integration is enabled", async () => { | ||
| 40 | await page.getByPlaceholder("https://api.example.com/openapi.json").fill(singleServerSpec); | ||
| 41 | await page.getByRole("button", { name: "Add integration" }).waitFor({ timeout: 20_000 }); | ||
| 42 | }); | ||
| 43 | |||
| 44 | await step("Exactly one action bar / Add integration button is in the DOM", async () => { | ||
| 45 | // The reported ghosting looked like two overlapping bars. If that were | ||
| 46 | // a real double-render the DOM would carry two buttons; assert it does | ||
| 47 | // not (so the artifact is paint-level, fixed by isolating the layer). | ||
| 48 | const count = await page.getByRole("button", { name: "Add integration" }).count(); | ||
| 49 | expect(count, "a single Add integration button is rendered").toBe(1); | ||
| 50 | }); | ||
| 51 | |||
| 52 | await step("Submitting does not reflow the bar, then lands on the integration", async () => { | ||
| 53 | // The reported ghost was the bar painting doubled when the submit | ||
| 54 | // button changed width on click. With a stable-width loading button the | ||
| 55 | // row must not move: Cancel stays put while the add is in flight. | ||
| 56 | const cancel = page.getByRole("button", { name: "Cancel" }); | ||
| 57 | const before = await cancel.boundingBox(); | ||
| 58 | await page.getByRole("button", { name: "Add integration" }).click(); | ||
| 59 | // The submit button marks itself data-loading synchronously on click. | ||
| 60 | await page.locator('[data-slot="button"][data-loading]').first().waitFor({ timeout: 5_000 }); | ||
| 61 | const during = await cancel.boundingBox(); | ||
| 62 | expect(Math.round(during?.x ?? -1), "Cancel does not move when submitting").toBe( | ||
| 63 | Math.round(before?.x ?? -2), | ||
| 64 | ); | ||
| 65 | await page.waitForURL(/\/integrations\/(?!add\b)[^/?]+$/, { timeout: 30_000 }); | ||
| 66 | await page.getByText("Connections").first().waitFor(); | ||
| 67 | }); | ||
| 68 | |||
| 69 | await step("The empty connections state renders", async () => { | ||
| 70 | await page.getByText("No connections yet").waitFor({ timeout: 20_000 }); | ||
| 71 | }); | ||
| 72 | |||
| 73 | await step("The empty state shows a single add-connection CTA", async () => { | ||
| 74 | // Regression cover for the doubled-button report: the empty state used | ||
| 75 | // to render both a header "Add connection" and a card "Add a | ||
| 76 | // connection". Match either label so a regression is actually counted. | ||
| 77 | const count = await page | ||
| 78 | .getByRole("button", { name: /^Add (a )?connection$/i }) | ||
| 79 | .count(); | ||
| 80 | expect(count, "empty state shows exactly one add-connection button").toBe(1); | ||
| 81 | }); | ||
| 82 | }); | ||
| 83 | }), | ||
| 84 | ), | ||
| 85 | ); | ||
| @@ -0,0 +1,101 @@ | |||
| 1 | // Cross-target: the Run/Test panel's backend (`POST /executions`) auto-approves | ||
| 2 | // approval-gated tools when the operator invokes them, because clicking Run in | ||
| 3 | // the panel IS the human approval. | ||
| 4 | // | ||
| 5 | // The panel sends `autoApprove: true`. Here we drive the same HTTP endpoint the | ||
| 6 | // panel uses and prove both halves of the contract against ONE tool that gates | ||
| 7 | // itself: the `policies.create` core tool carries a `requiresApproval` | ||
| 8 | // annotation, so with no matching policy in play the annotation is the only | ||
| 9 | // thing that can pause the call. | ||
| 10 | // | ||
| 11 | // - without `autoApprove`: the call pauses (the panel would have dead-ended | ||
| 12 | // on "This tool requires approval"), and the policy is not written. | ||
| 13 | // - with `autoApprove`: the call runs to completion and the policy is written. | ||
| 14 | // | ||
| 15 | // The created policy is a `block` rule on a unique, non-matching pattern, so a | ||
| 16 | // leak cannot gate another scenario's tools; it is removed in an `ensuring` | ||
| 17 | // finalizer regardless. | ||
| 18 | import { randomUUID } from "node:crypto"; | ||
| 19 | |||
| 20 | import { expect } from "@effect/vitest"; | ||
| 21 | import { Effect } from "effect"; | ||
| 22 | import { composePluginApi } from "@executor-js/api/server"; | ||
| 23 | |||
| 24 | import { scenario } from "../src/scenario"; | ||
| 25 | import { Api, Target } from "../src/services"; | ||
| 26 | |||
| 27 | const coreApi = composePluginApi([] as const); | ||
| 28 | |||
| 29 | /** Sandbox code that creates a policy through the approval-gated core tool. The | ||
| 30 | * pattern is unique-per-run and matches no real tool, so the rule is inert. */ | ||
| 31 | const createPolicyCode = (pattern: string) => ` | ||
| 32 | return await tools.executor.coreTools.policies.create({ | ||
| 33 | owner: "user", | ||
| 34 | pattern: ${JSON.stringify(pattern)}, | ||
| 35 | action: "block", | ||
| 36 | }); | ||
| 37 | `; | ||
| 38 | |||
| 39 | scenario( | ||
| 40 | "Run panel · autoApprove runs an approval-gated tool that otherwise pauses", | ||
| 41 | {}, | ||
| 42 | Effect.gen(function* () { | ||
| 43 | const target = yield* Target; | ||
| 44 | const apiSurface = yield* Api; | ||
| 45 | const identity = yield* target.newIdentity(); | ||
| 46 | const client = yield* apiSurface.client(coreApi, identity); | ||
| 47 | const pattern = `run-auto-approve-${randomUUID().slice(0, 8)}.*`; | ||
| 48 | const code = createPolicyCode(pattern); | ||
| 49 | |||
| 50 | const cleanup = client.policies.list().pipe( | ||
| 51 | Effect.flatMap((list) => | ||
| 52 | Effect.forEach( | ||
| 53 | list.filter((p) => p.pattern === pattern), | ||
| 54 | (p) => | ||
| 55 | client.policies | ||
| 56 | .remove({ params: { policyId: p.id }, payload: { owner: "user" } }) | ||
| 57 | .pipe(Effect.ignore), | ||
| 58 | ), | ||
| 59 | ), | ||
| 60 | Effect.ignore, | ||
| 61 | ); | ||
| 62 | |||
| 63 | yield* Effect.gen(function* () { | ||
| 64 | // Baseline: without autoApprove the gated tool pauses (the panel's old | ||
| 65 | // dead-end), and the side effect must not have happened. | ||
| 66 | const gated = yield* client.executions.execute({ payload: { code } }); | ||
| 67 | expect(gated.status, "a gated tool pauses without autoApprove").toBe("paused"); | ||
| 68 | |||
| 69 | const beforeApproval = yield* client.policies.list(); | ||
| 70 | expect( | ||
| 71 | beforeApproval.some((p) => p.pattern === pattern), | ||
| 72 | "the policy is not written while the call is paused for approval", | ||
| 73 | ).toBe(false); | ||
| 74 | |||
| 75 | // Release the paused fiber so it does not linger waiting on a response. | ||
| 76 | if (gated.status === "paused") { | ||
| 77 | const executionId = (gated.structured as { readonly executionId?: string }).executionId; | ||
| 78 | if (executionId) { | ||
| 79 | yield* client.executions | ||
| 80 | .resume({ params: { executionId }, payload: { action: "cancel" } }) | ||
| 81 | .pipe(Effect.ignore); | ||
| 82 | } | ||
| 83 | } | ||
| 84 | |||
| 85 | // With autoApprove the operator IS the approver: the same call runs to | ||
| 86 | // completion and the side effect lands. | ||
| 87 | const approved = yield* client.executions.execute({ | ||
| 88 | payload: { code, autoApprove: true }, | ||
| 89 | }); | ||
| 90 | expect(approved.status, "autoApprove runs the gated tool to completion").toBe("completed"); | ||
| 91 | if (approved.status !== "completed") return; // narrowing only | ||
| 92 | expect(approved.isError, "the auto-approved run is not an error").toBe(false); | ||
| 93 | |||
| 94 | const afterApproval = yield* client.policies.list(); | ||
| 95 | expect( | ||
| 96 | afterApproval.some((p) => p.pattern === pattern), | ||
| 97 | "the policy is written once autoApprove runs the gated tool", | ||
| 98 | ).toBe(true); | ||
| 99 | }).pipe(Effect.ensuring(cleanup)); | ||
| 100 | }), | ||
| 101 | ); | ||
| @@ -9,6 +9,10 @@ import { InternalError } from "@executor-js/sdk/shared"; · 8 unmodified lines | |||
| 9 | 9 | ||
| 10 | const ExecuteRequest = Schema.Struct({ | 10 | const ExecuteRequest = Schema.Struct({ |
| 11 | code: Schema.String, | 11 | code: Schema.String, |
| 12 | // When true the caller is the human approver: approval-gated tools run to | ||
| 13 | // completion instead of pausing. Set by the operator-facing Run/Test panel, | ||
| 14 | // where clicking Run is itself the approval. `block` policies still apply. | ||
| 15 | autoApprove: Schema.optional(Schema.Boolean), | ||
| 12 | }); | 16 | }); |
| 13 | 17 | ||
| 14 | const CompletedResult = Schema.Struct({ | 18 | const CompletedResult = Schema.Struct({ |
| @@ -34,7 +34,9 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 33 unmodified lines | |||
| 34 | capture( | 34 | capture( |
| 35 | Effect.gen(function* () { | 35 | Effect.gen(function* () { |
| 36 | const engine = yield* ExecutionEngineService; | 36 | const engine = yield* ExecutionEngineService; |
| 37 | const outcome = yield* captureEngineError(engine.executeWithPause(payload.code)); | 37 | const outcome = yield* captureEngineError( |
| 38 | engine.executeWithPause(payload.code, { autoApprove: payload.autoApprove }), | ||
| 39 | ); | ||
| 38 | 40 | ||
| 39 | if (outcome.status === "completed") { | 41 | if (outcome.status === "completed") { |
| 40 | const formatted = formatExecuteResult(outcome.result); | 42 | const formatted = formatExecuteResult(outcome.result); |
| @@ -53,6 +53,10 @@ export type ResumeResponse = { · 52 unmodified lines | |||
| 53 | readonly content?: Record<string, unknown>; | 53 | readonly content?: Record<string, unknown>; |
| 54 | }; | 54 | }; |
| 55 | 55 | ||
| 56 | // Auto-accept every elicitation. Used by the `autoApprove` path where the | ||
| 57 | // caller is itself the human approver (the operator-facing Run/Test panel). | ||
| 58 | const acceptAllHandler: ElicitationHandler = () => Effect.succeed({ action: "accept" }); | ||
| 59 | |||
| 56 | // --------------------------------------------------------------------------- | 60 | // --------------------------------------------------------------------------- |
| 57 | // Result formatting | 61 | // Result formatting |
| 58 | // --------------------------------------------------------------------------- | 62 | // --------------------------------------------------------------------------- |
| @@ -370,8 +374,17 @@ export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> · 311 unmodified lines | |||
| 370 | * Execute code, intercepting the first elicitation as a pause point. | 374 | * Execute code, intercepting the first elicitation as a pause point. |
| 371 | * Use this when the host doesn't support inline elicitation. | 375 | * Use this when the host doesn't support inline elicitation. |
| 372 | * Returns either a completed result or a paused execution that can be resumed. | 376 | * Returns either a completed result or a paused execution that can be resumed. |
| 377 | * | ||
| 378 | * `options.autoApprove` treats the caller as the human in the loop: every | ||
| 379 | * elicitation is accepted inline, so an approval-gated tool runs to | ||
| 380 | * completion instead of pausing. The operator-facing Run/Test panel sets | ||
| 381 | * this because clicking Run IS the approval. `block` policies still fail | ||
| 382 | * before any elicitation, so this never bypasses a hard block. | ||
| 373 | */ | 383 | */ |
| 374 | readonly executeWithPause: (code: string) => Effect.Effect<ExecutionResult, E>; | 384 | readonly executeWithPause: ( |
| 385 | code: string, | ||
| 386 | options?: { readonly autoApprove?: boolean }, | ||
| 387 | ) => Effect.Effect<ExecutionResult, E>; | ||
| 375 | 388 | ||
| 376 | /** | 389 | /** |
| 377 | * Resume a paused execution. Returns a completed result, a new pause, or | 390 | * Resume a paused execution. Returns a completed result, a new pause, or |
| @@ -455,12 +468,24 @@ export const createExecutionEngine = <E extends Cause.YieldableError = CodeExecu · 77 unmodified lines | |||
| 455 | * The sandbox is forked as a daemon because paused executions can outlive the | 468 | * The sandbox is forked as a daemon because paused executions can outlive the |
| 456 | * caller scope that returned the first pause, such as an HTTP request handler. | 469 | * caller scope that returned the first pause, such as an HTTP request handler. |
| 457 | */ | 470 | */ |
| 458 | const startPausableExecution = Effect.fn("mcp.execute")(function* (code: string) { | 471 | const startPausableExecution = Effect.fn("mcp.execute")(function* ( |
| 472 | code: string, | ||
| 473 | options?: { readonly autoApprove?: boolean }, | ||
| 474 | ) { | ||
| 459 | yield* Effect.annotateCurrentSpan({ | 475 | yield* Effect.annotateCurrentSpan({ |
| 460 | "mcp.execute.mode": "pausable", | 476 | "mcp.execute.mode": "pausable", |
| 461 | "mcp.execute.code_length": code.length, | 477 | "mcp.execute.code_length": code.length, |
| 462 | }); | 478 | }); |
| 463 | 479 | ||
| 480 | // Operator-approved invoke: run through the inline path with an accept-all | ||
| 481 | // handler so an approval gate resolves itself instead of pausing. Never | ||
| 482 | // pauses, so the caller always gets a completed result. | ||
| 483 | if (options?.autoApprove) { | ||
| 484 | yield* Effect.annotateCurrentSpan({ "mcp.execute.auto_approve": true }); | ||
| 485 | const result = yield* runInlineExecution(code, { onElicitation: acceptAllHandler }); | ||
| 486 | return { status: "completed", result } satisfies ExecutionResult; | ||
| 487 | } | ||
| 488 | |||
| 464 | // Queue preserves pauses that arrive before the previous approval has | 489 | // Queue preserves pauses that arrive before the previous approval has |
| 465 | // returned to the caller, which can happen with concurrent tool calls. | 490 | // returned to the caller, which can happen with concurrent tool calls. |
| 466 | const pauseQueue = yield* Queue.unbounded<InternalPausedExecution<E>>(); | 491 | const pauseQueue = yield* Queue.unbounded<InternalPausedExecution<E>>(); |
| @@ -41,7 +41,10 @@ export type ExecutionEngine = { · 40 unmodified lines | |||
| 41 | code: string, | 41 | code: string, |
| 42 | options: { readonly onElicitation: ElicitationHandler }, | 42 | options: { readonly onElicitation: ElicitationHandler }, |
| 43 | ) => Promise<ExecuteResult>; | 43 | ) => Promise<ExecuteResult>; |
| 44 | readonly executeWithPause: (code: string) => Promise<ExecutionResult>; | 44 | readonly executeWithPause: ( |
| 45 | code: string, | ||
| 46 | options?: { readonly autoApprove?: boolean }, | ||
| 47 | ) => Promise<ExecutionResult>; | ||
| 45 | readonly resume: ( | 48 | readonly resume: ( |
| 46 | executionId: string, | 49 | executionId: string, |
| 47 | response: ResumeResponse, | 50 | response: ResumeResponse, |
| @@ -149,7 +152,7 @@ export const toPromiseExecutionEngine = <E extends Cause.YieldableError>( · 101 unmodified lines | |||
| 149 | Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie), | 152 | Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie), |
| 150 | }), | 153 | }), |
| 151 | ), | 154 | ), |
| 152 | executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)), | 155 | executeWithPause: (code, options) => Effect.runPromise(engine.executeWithPause(code, options)), |
| 153 | resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)), | 156 | resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)), |
| 154 | getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)), | 157 | getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)), |
| 155 | getDescription: () => Effect.runPromise(engine.getDescription), | 158 | getDescription: () => Effect.runPromise(engine.getDescription), |
| @@ -1346,6 +1346,26 @@ describe("pause/resume with multiple elicitations", () => { · 1345 unmodified lines | |||
| 1346 | { timeout: 10000 }, | 1346 | { timeout: 10000 }, |
| 1347 | ); | 1347 | ); |
| 1348 | 1348 | ||
| 1349 | it.effect( | ||
| 1350 | "autoApprove runs an eliciting tool to completion instead of pausing", | ||
| 1351 | () => | ||
| 1352 | Effect.gen(function* () { | ||
| 1353 | const executor = yield* makeElicitingExecutor(); | ||
| 1354 | const engine = createExecutionEngine({ executor, codeExecutor }); | ||
| 1355 | const code = "return await tools.api.org.main.singleApproval({});"; | ||
| 1356 | |||
| 1357 | // Same tool that pauses without autoApprove (see the tests above) runs | ||
| 1358 | // straight through when the caller is the approver: no pause, no | ||
| 1359 | // executionId to resume, just the side effect's result. | ||
| 1360 | const outcome = yield* engine.executeWithPause(code, { autoApprove: true }); | ||
| 1361 | expect(outcome.status, "autoApprove never pauses").toBe("completed"); | ||
| 1362 | if (outcome.status !== "completed") return; | ||
| 1363 | expect(outcome.result.error).toBeUndefined(); | ||
| 1364 | expect(outcome.result.result).toMatchObject({ ok: true }); | ||
| 1365 | }), | ||
| 1366 | { timeout: 10000 }, | ||
| 1367 | ); | ||
| 1368 | |||
| 1349 | // live clock: the sandbox timeout is a real timer, so the test must | 1369 | // live clock: the sandbox timeout is a real timer, so the test must |
| 1350 | // actually wait for it rather than suspend on the virtual TestClock. | 1370 | // actually wait for it rather than suspend on the virtual TestClock. |
| 1351 | it.live( | 1371 | it.live( |
| @@ -9,7 +9,6 @@ import { · 8 unmodified lines | |||
| 9 | } from "@executor-js/react/plugins/integration-identity"; | 9 | } from "@executor-js/react/plugins/integration-identity"; |
| 10 | import { Button } from "@executor-js/react/components/button"; | 10 | import { Button } from "@executor-js/react/components/button"; |
| 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 12 | import { Spinner } from "@executor-js/react/components/spinner"; | ||
| 13 | import { | 12 | import { |
| 14 | addIntegrationErrorMessage, | 13 | addIntegrationErrorMessage, |
| 15 | FormErrorAlert, | 14 | FormErrorAlert, |
| @@ -170,9 +169,8 @@ export default function AddGoogleSource(props: { · 154 unmodified lines | |||
| 170 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | 169 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> |
| 171 | Cancel | 170 | Cancel |
| 172 | </Button> | 171 | </Button> |
| 173 | <Button onClick={() => void handleAdd()} disabled={!canAdd || adding}> | 172 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> |
| 174 | {adding && <Spinner className="size-3.5" />} | 173 | Connect Google |
| 175 | {adding ? "Adding…" : "Connect Google"} | ||
| 176 | </Button> | 174 | </Button> |
| 177 | </FloatActions> | 175 | </FloatActions> |
| 178 | </div> | 176 | </div> |
| @@ -16,7 +16,6 @@ import { · 15 unmodified lines | |||
| 16 | type AuthMethodSeed, | 16 | type AuthMethodSeed, |
| 17 | } from "@executor-js/react/components/auth-method-list-editor"; | 17 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | import { Spinner } from "@executor-js/react/components/spinner"; | ||
| 20 | import { | 19 | import { |
| 21 | addIntegrationErrorMessage, | 20 | addIntegrationErrorMessage, |
| 22 | FormErrorAlert, | 21 | FormErrorAlert, |
| @@ -156,9 +155,8 @@ export default function AddGraphqlSource(props: { · 133 unmodified lines | |||
| 156 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | 155 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> |
| 157 | Cancel | 156 | Cancel |
| 158 | </Button> | 157 | </Button> |
| 159 | <Button onClick={() => void handleAdd()} disabled={!canAdd}> | 158 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> |
| 160 | {adding && <Spinner className="size-3.5" />} | 159 | Add source |
| 161 | {adding ? "Adding..." : "Add source"} | ||
| 162 | </Button> | 160 | </Button> |
| 163 | </FloatActions> | 161 | </FloatActions> |
| 164 | </div> | 162 | </div> |
| @@ -17,7 +17,6 @@ import { · 16 unmodified lines | |||
| 17 | } from "@executor-js/react/components/card-stack"; | 17 | } from "@executor-js/react/components/card-stack"; |
| 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | import { Input } from "@executor-js/react/components/input"; | 19 | import { Input } from "@executor-js/react/components/input"; |
| 20 | import { Spinner } from "@executor-js/react/components/spinner"; | ||
| 21 | import { TagInput } from "@executor-js/react/components/tag-input"; | 20 | import { TagInput } from "@executor-js/react/components/tag-input"; |
| 22 | import { | 21 | import { |
| 23 | integrationDisplayNameFromStdio, | 22 | integrationDisplayNameFromStdio, |
| @@ -473,14 +472,8 @@ export default function AddMcpSource(props: { · 449 unmodified lines | |||
| 473 | Cancel | 472 | Cancel |
| 474 | </Button> | 473 | </Button> |
| 475 | {(probe || isProbing) && ( | 474 | {(probe || isProbing) && ( |
| 476 | <Button type="button" onClick={handleAddRemote} disabled={!canAdd}> | 475 | <Button type="button" onClick={handleAddRemote} disabled={!canAdd} loading={isAdding}> |
| 477 | {isAdding ? ( | 476 | Add source |
| 478 | <> | ||
| 479 | <Spinner className="size-3.5" /> Adding… | ||
| 480 | </> | ||
| 481 | ) : ( | ||
| 482 | "Add source" | ||
| 483 | )} | ||
| 484 | </Button> | 477 | </Button> |
| 485 | )} | 478 | )} |
| 486 | </FloatActions> | 479 | </FloatActions> |
| @@ -546,15 +539,10 @@ export default function AddMcpSource(props: { · 59 unmodified lines | |||
| 546 | <Button | 539 | <Button |
| 547 | type="button" | 540 | type="button" |
| 548 | onClick={handleAddStdio} | 541 | onClick={handleAddStdio} |
| 549 | disabled={!stdioCommand.trim() || stdioAdding || stdioSlugExists} | 542 | disabled={!stdioCommand.trim() || stdioSlugExists} |
| 543 | loading={stdioAdding} | ||
| 550 | > | 544 | > |
| 551 | {stdioAdding ? ( | 545 | Add source |
| 552 | <> | ||
| 553 | <Spinner className="size-3.5" /> Adding… | ||
| 554 | </> | ||
| 555 | ) : ( | ||
| 556 | "Add source" | ||
| 557 | )} | ||
| 558 | </Button> | 546 | </Button> |
| 559 | </FloatActions> | 547 | </FloatActions> |
| 560 | </> | 548 | </> |
| @@ -9,7 +9,6 @@ import { · 8 unmodified lines | |||
| 9 | } from "@executor-js/react/plugins/integration-identity"; | 9 | } from "@executor-js/react/plugins/integration-identity"; |
| 10 | import { Button } from "@executor-js/react/components/button"; | 10 | import { Button } from "@executor-js/react/components/button"; |
| 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 12 | import { Spinner } from "@executor-js/react/components/spinner"; | ||
| 13 | import { | 12 | import { |
| 14 | addIntegrationErrorMessage, | 13 | addIntegrationErrorMessage, |
| 15 | FormErrorAlert, | 14 | FormErrorAlert, |
| @@ -147,9 +146,8 @@ export default function AddMicrosoftSource(props: { · 131 unmodified lines | |||
| 147 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> | 146 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> |
| 148 | Cancel | 147 | Cancel |
| 149 | </Button> | 148 | </Button> |
| 150 | <Button onClick={() => void handleAdd()} disabled={!canAdd || adding}> | 149 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> |
| 151 | {adding && <Spinner className="size-3.5" />} | 150 | Connect Microsoft |
| 152 | {adding ? "Adding..." : "Connect Microsoft"} | ||
| 153 | </Button> | 151 | </Button> |
| 154 | </FloatActions> | 152 | </FloatActions> |
| 155 | </div> | 153 | </div> |
| @@ -21,7 +21,7 @@ import { CardStack, CardStackContent } from "@executor-js/react/components/card- · 20 unmodified lines | |||
| 21 | import { FieldLabel } from "@executor-js/react/components/field"; | 21 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 22 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 22 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 23 | import { Textarea } from "@executor-js/react/components/textarea"; | 23 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 24 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 24 | import { IOSSpinner } from "@executor-js/react/components/spinner"; |
| 25 | import { | 25 | import { |
| 26 | addIntegrationErrorMessage, | 26 | addIntegrationErrorMessage, |
| 27 | errorMessageFromExit, | 27 | errorMessageFromExit, |
| @@ -376,9 +376,8 @@ export default function AddOpenApiSource(props: { · 348 unmodified lines | |||
| 376 | Cancel | 376 | Cancel |
| 377 | </Button> | 377 | </Button> |
| 378 | {preview && ( | 378 | {preview && ( |
| 379 | <Button onClick={() => void handleAdd()} disabled={!canAdd || adding}> | 379 | <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> |
| 380 | {adding && <Spinner className="size-3.5" />} | 380 | Add integration |
| 381 | {adding ? "Adding..." : "Add integration"} | ||
| 382 | </Button> | 381 | </Button> |
| 383 | )} | 382 | )} |
| 384 | </FloatActions> | 383 | </FloatActions> |
| @@ -356,6 +356,23 @@ export function AccountsSection(props: { · 355 unmodified lines | |||
| 356 | 356 | ||
| 357 | const loading = !AsyncResult.isSuccess(orgConnections) && !AsyncResult.isSuccess(userConnections); | 357 | const loading = !AsyncResult.isSuccess(orgConnections) && !AsyncResult.isSuccess(userConnections); |
| 358 | 358 | ||
| 359 | // When there are zero connections the dashed empty-state card below carries | ||
| 360 | // its own "Add connection" CTA, so the header button would be a redundant | ||
| 361 | // second copy of the same action. Show the header button only outside that | ||
| 362 | // state (populated, or still loading). | ||
| 363 | const showEmptyState = !loading && totalCount === 0; | ||
| 364 | |||
| 365 | const openAddConnection = () => { | ||
| 366 | trackEvent("connection_add_opened", { | ||
| 367 | integration_slug: String(integration), | ||
| 368 | has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"), | ||
| 369 | has_api_key_method: methods.some( | ||
| 370 | (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none", | ||
| 371 | ), | ||
| 372 | }); | ||
| 373 | setAdding(true); | ||
| 374 | }; | ||
| 375 | |||
| 359 | const modalState = reconnectHandoff ?? accountHandoff; | 376 | const modalState = reconnectHandoff ?? accountHandoff; |
| 360 | const modal = ( | 377 | const modal = ( |
| 361 | <AddAccountModal | 378 | <AddAccountModal |
| @@ -379,24 +396,17 @@ export function AccountsSection(props: { · 17 unmodified lines | |||
| 379 | <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> | 396 | <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> |
| 380 | Connections | 397 | Connections |
| 381 | </h3> | 398 | </h3> |
| 382 | <Button | 399 | {!showEmptyState ? ( |
| 383 | type="button" | 400 | <Button |
| 384 | variant="outline" | 401 | type="button" |
| 385 | size="sm" | 402 | variant="outline" |
| 386 | onClick={() => { | 403 | size="sm" |
| 387 | trackEvent("connection_add_opened", { | 404 | onClick={openAddConnection} |
| 388 | integration_slug: String(integration), | 405 | disabled={!canAddConnection} |
| 389 | has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"), | 406 | > |
| 390 | has_api_key_method: methods.some( | 407 | Add connection |
| 391 | (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none", | 408 | </Button> |
| 392 | ), | 409 | ) : null} |
| 393 | }); | ||
| 394 | setAdding(true); | ||
| 395 | }} | ||
| 396 | disabled={!canAddConnection} | ||
| 397 | > | ||
| 398 | Add connection | ||
| 399 | </Button> | ||
| 400 | </div> | 410 | </div> |
| 401 | 411 | ||
| 402 | {loading ? ( | 412 | {loading ? ( |
| @@ -404,7 +414,7 @@ export function AccountsSection(props: { · 1 unmodified line | |||
| 404 | <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" /> | 414 | <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" /> |
| 405 | <p className="text-sm text-muted-foreground">Loading accounts…</p> | 415 | <p className="text-sm text-muted-foreground">Loading accounts…</p> |
| 406 | </div> | 416 | </div> |
| 407 | ) : totalCount === 0 ? ( | 417 | ) : showEmptyState ? ( |
| 408 | <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center"> | 418 | <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center"> |
| 409 | <p className="text-sm font-medium text-foreground">No connections yet</p> | 419 | <p className="text-sm font-medium text-foreground">No connections yet</p> |
| 410 | <p className="mt-1 text-sm text-muted-foreground"> | 420 | <p className="mt-1 text-sm text-muted-foreground"> |
| @@ -414,19 +424,10 @@ export function AccountsSection(props: { · 3 unmodified lines | |||
| 414 | type="button" | 424 | type="button" |
| 415 | className="mt-4" | 425 | className="mt-4" |
| 416 | size="sm" | 426 | size="sm" |
| 417 | onClick={() => { | 427 | onClick={openAddConnection} |
| 418 | trackEvent("connection_add_opened", { | ||
| 419 | integration_slug: String(integration), | ||
| 420 | has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"), | ||
| 421 | has_api_key_method: methods.some( | ||
| 422 | (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none", | ||
| 423 | ), | ||
| 424 | }); | ||
| 425 | setAdding(true); | ||
| 426 | }} | ||
| 427 | disabled={!canAddConnection} | 428 | disabled={!canAddConnection} |
| 428 | > | 429 | > |
| 429 | Add a connection | 430 | Add connection |
| 430 | </Button> | 431 | </Button> |
| 431 | </div> | 432 | </div> |
| 432 | ) : ( | 433 | ) : ( |
| @@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"; · 2 unmodified lines | |||
| 3 | import { Slot } from "radix-ui"; | 3 | import { Slot } from "radix-ui"; |
| 4 | 4 | ||
| 5 | import { cn } from "../lib/utils"; | 5 | import { cn } from "../lib/utils"; |
| 6 | import { Spinner } from "./spinner"; | ||
| 6 | 7 | ||
| 7 | const buttonVariants = cva( | 8 | const buttonVariants = cva( |
| 8 | "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", | 9 | "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", |
| @@ -41,21 +42,44 @@ function Button({ · 32 unmodified lines | |||
| 41 | variant = "default", | 42 | variant = "default", |
| 42 | size = "default", | 43 | size = "default", |
| 43 | asChild = false, | 44 | asChild = false, |
| 45 | loading = false, | ||
| 46 | disabled, | ||
| 47 | children, | ||
| 44 | ...props | 48 | ...props |
| 45 | }: React.ComponentProps<"button"> & | 49 | }: React.ComponentProps<"button"> & |
| 46 | VariantProps<typeof buttonVariants> & { | 50 | VariantProps<typeof buttonVariants> & { |
| 47 | asChild?: boolean; | 51 | asChild?: boolean; |
| 52 | /** Show a centered spinner while keeping the label's footprint, so the | ||
| 53 | * button (and any row it sits in) does not change width on entering the | ||
| 54 | * loading state. Ignored with `asChild` (Slot requires a single child). */ | ||
| 55 | loading?: boolean; | ||
| 48 | }) { | 56 | }) { |
| 49 | const Comp = asChild ? Slot.Root : "button"; | 57 | const Comp = asChild ? Slot.Root : "button"; |
| 58 | const showLoading = loading && !asChild; | ||
| 50 | 59 | ||
| 51 | return ( | 60 | return ( |
| 52 | <Comp | 61 | <Comp |
| 53 | data-slot="button" | 62 | data-slot="button" |
| 54 | data-variant={variant} | 63 | data-variant={variant} |
| 55 | data-size={size} | 64 | data-size={size} |
| 56 | className={cn(buttonVariants({ variant, size, className }))} | 65 | data-loading={showLoading ? "" : undefined} |
| 66 | className={cn(buttonVariants({ variant, size, className }), showLoading && "relative")} | ||
| 67 | disabled={disabled || loading} | ||
| 57 | {...props} | 68 | {...props} |
| 58 | /> | 69 | > |
| 70 | {showLoading ? ( | ||
| 71 | <> | ||
| 72 | {/* Reserve the label's width so the box stays the same size; overlay | ||
| 73 | the spinner centered on top. */} | ||
| 74 | <span className="invisible">{children}</span> | ||
| 75 | <span className="absolute inset-0 flex items-center justify-center"> | ||
| 76 | <Spinner className="size-3.5" /> | ||
| 77 | </span> | ||
| 78 | </> | ||
| 79 | ) : ( | ||
| 80 | children | ||
| 81 | )} | ||
| 82 | </Comp> | ||
| 59 | ); | 83 | ); |
| 60 | } | 84 | } |
| 61 | 85 | ||
| @@ -26,7 +26,18 @@ function FloatActions({ className, children }: { className?: string; children: R · 25 unmodified lines | |||
| 26 | } | 26 | } |
| 27 | 27 | ||
| 28 | return ( | 28 | return ( |
| 29 | <CardStack className={cn("sticky shadow-lg bottom-4 left-0 right-0 mt-auto w-full", className)}> | 29 | // `transform-gpu` promotes the bar to its own compositing layer. Without it, |
| 30 | // when an action button changes width (e.g. "Add integration" -> "Adding...") | ||
| 31 | // the right-aligned row reflows and Chromium can leave a stale paint of the | ||
| 32 | // sticky bar, rendering it doubled/ghosted. A dedicated layer repaints | ||
| 33 | // atomically and avoids the artifact. Transforming the sticky element itself | ||
| 34 | // does not affect its stickiness. | ||
| 35 | <CardStack | ||
| 36 | className={cn( | ||
| 37 | "sticky shadow-lg bottom-4 left-0 right-0 mt-auto w-full transform-gpu", | ||
| 38 | className, | ||
| 39 | )} | ||
| 40 | > | ||
| 30 | <div className="flex items-center justify-end gap-3 px-4 py-3">{children}</div> | 41 | <div className="flex items-center justify-end gap-3 px-4 py-3">{children}</div> |
| 31 | </CardStack> | 42 | </CardStack> |
| 32 | ); | 43 | ); |
| @@ -215,8 +215,10 @@ export function ToolRunPanel(props: { · 214 unmodified lines | |||
| 215 | 215 | ||
| 216 | // Read-only invoke: pass the validated args through as JSON. Empty | 216 | // Read-only invoke: pass the validated args through as JSON. Empty |
| 217 | // `reactivityKeys` — invoking a tool doesn't mutate `tools.list`. | 217 | // `reactivityKeys` — invoking a tool doesn't mutate `tools.list`. |
| 218 | // `autoApprove` because the operator clicked Run: that IS the approval, so | ||
| 219 | // an approval-gated tool should run here instead of dead-ending on a pause. | ||
| 218 | const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`; | 220 | const code = `return await tools[${JSON.stringify(addressNoPrefix)}](${JSON.stringify(parsed)});`; |
| 219 | const exit = await doExecute({ payload: { code }, reactivityKeys: [] }); | 221 | const exit = await doExecute({ payload: { code, autoApprove: true }, reactivityKeys: [] }); |
| 220 | setRunning(false); | 222 | setRunning(false); |
| 221 | 223 | ||
| 222 | if (Exit.isFailure(exit)) { | 224 | if (Exit.isFailure(exit)) { |