# 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 | 10 | const ExecuteRequest = Schema.Struct({ |
| 11 | 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 | 18 | const CompletedResult = Schema.Struct({ |
| @@ -34,7 +34,9 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 33 unmodified lines | ||
| 34 | 34 | capture( |
| 35 | 35 | Effect.gen(function* () { |
| 36 | 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 | 41 | if (outcome.status === "completed") { |
| 40 | 42 | const formatted = formatExecuteResult(outcome.result); |
| @@ -53,6 +53,10 @@ export type ResumeResponse = { · 52 unmodified lines | ||
| 53 | 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 | 61 | // Result formatting |
| 58 | 62 | // --------------------------------------------------------------------------- |
| @@ -370,8 +374,17 @@ export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> · 311 unmodified lines | ||
| 370 | 374 | * Execute code, intercepting the first elicitation as a pause point. |
| 371 | 375 | * Use this when the host doesn't support inline elicitation. |
| 372 | 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 | 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 | 468 | * The sandbox is forked as a daemon because paused executions can outlive the |
| 456 | 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 | 475 | yield* Effect.annotateCurrentSpan({ |
| 460 | 476 | "mcp.execute.mode": "pausable", |
| 461 | 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 | 489 | // Queue preserves pauses that arrive before the previous approval has |
| 465 | 490 | // returned to the caller, which can happen with concurrent tool calls. |
| 466 | 491 | const pauseQueue = yield* Queue.unbounded<InternalPausedExecution<E>>(); |
| @@ -41,7 +41,10 @@ export type ExecutionEngine = { · 40 unmodified lines | ||
| 41 | 41 | code: string, |
| 42 | 42 | options: { readonly onElicitation: ElicitationHandler }, |
| 43 | 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 | 48 | readonly resume: ( |
| 46 | 49 | executionId: string, |
| 47 | 50 | response: ResumeResponse, |
| @@ -149,7 +152,7 @@ export const toPromiseExecutionEngine = <E extends Cause.YieldableError>( · 101 unmodified lines | ||
| 149 | 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 | 156 | resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)), |
| 154 | 157 | getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)), |
| 155 | 158 | getDescription: () => Effect.runPromise(engine.getDescription), |
| @@ -1346,6 +1346,26 @@ describe("pause/resume with multiple elicitations", () => { · 1345 unmodified lines | ||
| 1346 | 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 | 1369 | // live clock: the sandbox timeout is a real timer, so the test must |
| 1350 | 1370 | // actually wait for it rather than suspend on the virtual TestClock. |
| 1351 | 1371 | it.live( |
| @@ -9,7 +9,6 @@ import { · 8 unmodified lines | ||
| 9 | 9 | } from "@executor-js/react/plugins/integration-identity"; |
| 10 | 10 | import { Button } from "@executor-js/react/components/button"; |
| 11 | 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 12 | −import { Spinner } from "@executor-js/react/components/spinner"; | |
| 13 | 12 | import { |
| 14 | 13 | addIntegrationErrorMessage, |
| 15 | 14 | FormErrorAlert, |
| @@ -170,9 +169,8 @@ export default function AddGoogleSource(props: { · 154 unmodified lines | ||
| 170 | 169 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> |
| 171 | 170 | Cancel |
| 172 | 171 | </Button> |
| 173 | − <Button onClick={() => void handleAdd()} disabled={!canAdd || adding}> | |
| 174 | − {adding && <Spinner className="size-3.5" />} | |
| 175 | − {adding ? "Adding…" : "Connect Google"} | |
| 172 | + <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | |
| 173 | + Connect Google | |
| 176 | 174 | </Button> |
| 177 | 175 | </FloatActions> |
| 178 | 176 | </div> |
| @@ -16,7 +16,6 @@ import { · 15 unmodified lines | ||
| 16 | 16 | type AuthMethodSeed, |
| 17 | 17 | } from "@executor-js/react/components/auth-method-list-editor"; |
| 18 | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | −import { Spinner } from "@executor-js/react/components/spinner"; | |
| 20 | 19 | import { |
| 21 | 20 | addIntegrationErrorMessage, |
| 22 | 21 | FormErrorAlert, |
| @@ -156,9 +155,8 @@ export default function AddGraphqlSource(props: { · 133 unmodified lines | ||
| 156 | 155 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> |
| 157 | 156 | Cancel |
| 158 | 157 | </Button> |
| 159 | − <Button onClick={() => void handleAdd()} disabled={!canAdd}> | |
| 160 | − {adding && <Spinner className="size-3.5" />} | |
| 161 | − {adding ? "Adding..." : "Add source"} | |
| 158 | + <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | |
| 159 | + Add source | |
| 162 | 160 | </Button> |
| 163 | 161 | </FloatActions> |
| 164 | 162 | </div> |
| @@ -17,7 +17,6 @@ import { · 16 unmodified lines | ||
| 17 | 17 | } from "@executor-js/react/components/card-stack"; |
| 18 | 18 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 19 | 19 | import { Input } from "@executor-js/react/components/input"; |
| 20 | −import { Spinner } from "@executor-js/react/components/spinner"; | |
| 21 | 20 | import { TagInput } from "@executor-js/react/components/tag-input"; |
| 22 | 21 | import { |
| 23 | 22 | integrationDisplayNameFromStdio, |
| @@ -473,14 +472,8 @@ export default function AddMcpSource(props: { · 449 unmodified lines | ||
| 473 | 472 | Cancel |
| 474 | 473 | </Button> |
| 475 | 474 | {(probe || isProbing) && ( |
| 476 | − <Button type="button" onClick={handleAddRemote} disabled={!canAdd}> | |
| 477 | − {isAdding ? ( | |
| 478 | − <> | |
| 479 | − <Spinner className="size-3.5" /> Adding… | |
| 480 | − </> | |
| 481 | − ) : ( | |
| 482 | − "Add source" | |
| 483 | − )} | |
| 475 | + <Button type="button" onClick={handleAddRemote} disabled={!canAdd} loading={isAdding}> | |
| 476 | + Add source | |
| 484 | 477 | </Button> |
| 485 | 478 | )} |
| 486 | 479 | </FloatActions> |
| @@ -546,15 +539,10 @@ export default function AddMcpSource(props: { · 59 unmodified lines | ||
| 546 | 539 | <Button |
| 547 | 540 | type="button" |
| 548 | 541 | onClick={handleAddStdio} |
| 549 | − disabled={!stdioCommand.trim() || stdioAdding || stdioSlugExists} | |
| 542 | + disabled={!stdioCommand.trim() || stdioSlugExists} | |
| 543 | + loading={stdioAdding} | |
| 550 | 544 | > |
| 551 | − {stdioAdding ? ( | |
| 552 | − <> | |
| 553 | − <Spinner className="size-3.5" /> Adding… | |
| 554 | − </> | |
| 555 | − ) : ( | |
| 556 | − "Add source" | |
| 557 | − )} | |
| 545 | + Add source | |
| 558 | 546 | </Button> |
| 559 | 547 | </FloatActions> |
| 560 | 548 | </> |
| @@ -9,7 +9,6 @@ import { · 8 unmodified lines | ||
| 9 | 9 | } from "@executor-js/react/plugins/integration-identity"; |
| 10 | 10 | import { Button } from "@executor-js/react/components/button"; |
| 11 | 11 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 12 | −import { Spinner } from "@executor-js/react/components/spinner"; | |
| 13 | 12 | import { |
| 14 | 13 | addIntegrationErrorMessage, |
| 15 | 14 | FormErrorAlert, |
| @@ -147,9 +146,8 @@ export default function AddMicrosoftSource(props: { · 131 unmodified lines | ||
| 147 | 146 | <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}> |
| 148 | 147 | Cancel |
| 149 | 148 | </Button> |
| 150 | − <Button onClick={() => void handleAdd()} disabled={!canAdd || adding}> | |
| 151 | − {adding && <Spinner className="size-3.5" />} | |
| 152 | − {adding ? "Adding..." : "Connect Microsoft"} | |
| 149 | + <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | |
| 150 | + Connect Microsoft | |
| 153 | 151 | </Button> |
| 154 | 152 | </FloatActions> |
| 155 | 153 | </div> |
| @@ -21,7 +21,7 @@ import { CardStack, CardStackContent } from "@executor-js/react/components/card- · 20 unmodified lines | ||
| 21 | 21 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 22 | 22 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 23 | 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 | 25 | import { |
| 26 | 26 | addIntegrationErrorMessage, |
| 27 | 27 | errorMessageFromExit, |
| @@ -376,9 +376,8 @@ export default function AddOpenApiSource(props: { · 348 unmodified lines | ||
| 376 | 376 | Cancel |
| 377 | 377 | </Button> |
| 378 | 378 | {preview && ( |
| 379 | − <Button onClick={() => void handleAdd()} disabled={!canAdd || adding}> | |
| 380 | − {adding && <Spinner className="size-3.5" />} | |
| 381 | − {adding ? "Adding..." : "Add integration"} | |
| 379 | + <Button onClick={() => void handleAdd()} disabled={!canAdd} loading={adding}> | |
| 380 | + Add integration | |
| 382 | 381 | </Button> |
| 383 | 382 | )} |
| 384 | 383 | </FloatActions> |
| @@ -356,6 +356,23 @@ export function AccountsSection(props: { · 355 unmodified lines | ||
| 356 | 356 | |
| 357 | 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 | 376 | const modalState = reconnectHandoff ?? accountHandoff; |
| 360 | 377 | const modal = ( |
| 361 | 378 | <AddAccountModal |
| @@ -379,24 +396,17 @@ export function AccountsSection(props: { · 17 unmodified lines | ||
| 379 | 396 | <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> |
| 380 | 397 | Connections |
| 381 | 398 | </h3> |
| 382 | − <Button | |
| 383 | − type="button" | |
| 384 | − variant="outline" | |
| 385 | − size="sm" | |
| 386 | − onClick={() => { | |
| 387 | − trackEvent("connection_add_opened", { | |
| 388 | − integration_slug: String(integration), | |
| 389 | − has_oauth_method: methods.some((m: AuthMethod) => m.kind === "oauth"), | |
| 390 | − has_api_key_method: methods.some( | |
| 391 | − (m: AuthMethod) => m.kind !== "oauth" && m.kind !== "none", | |
| 392 | − ), | |
| 393 | − }); | |
| 394 | − setAdding(true); | |
| 395 | − }} | |
| 396 | − disabled={!canAddConnection} | |
| 397 | − > | |
| 398 | − Add connection | |
| 399 | − </Button> | |
| 399 | + {!showEmptyState ? ( | |
| 400 | + <Button | |
| 401 | + type="button" | |
| 402 | + variant="outline" | |
| 403 | + size="sm" | |
| 404 | + onClick={openAddConnection} | |
| 405 | + disabled={!canAddConnection} | |
| 406 | + > | |
| 407 | + Add connection | |
| 408 | + </Button> | |
| 409 | + ) : null} | |
| 400 | 410 | </div> |
| 401 | 411 | |
| 402 | 412 | {loading ? ( |
| @@ -404,7 +414,7 @@ export function AccountsSection(props: { · 1 unmodified line | ||
| 404 | 414 | <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" /> |
| 405 | 415 | <p className="text-sm text-muted-foreground">Loading accounts…</p> |
| 406 | 416 | </div> |
| 407 | − ) : totalCount === 0 ? ( | |
| 417 | + ) : showEmptyState ? ( | |
| 408 | 418 | <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center"> |
| 409 | 419 | <p className="text-sm font-medium text-foreground">No connections yet</p> |
| 410 | 420 | <p className="mt-1 text-sm text-muted-foreground"> |
| @@ -414,19 +424,10 @@ export function AccountsSection(props: { · 3 unmodified lines | ||
| 414 | 424 | type="button" |
| 415 | 425 | className="mt-4" |
| 416 | 426 | size="sm" |
| 417 | − onClick={() => { | |
| 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 | + onClick={openAddConnection} | |
| 427 | 428 | disabled={!canAddConnection} |
| 428 | 429 | > |
| 429 | − Add a connection | |
| 430 | + Add connection | |
| 430 | 431 | </Button> |
| 431 | 432 | </div> |
| 432 | 433 | ) : ( |
| @@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"; · 2 unmodified lines | ||
| 3 | 3 | import { Slot } from "radix-ui"; |
| 4 | 4 | |
| 5 | 5 | import { cn } from "../lib/utils"; |
| 6 | +import { Spinner } from "./spinner"; | |
| 6 | 7 | |
| 7 | 8 | const buttonVariants = cva( |
| 8 | 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 | 42 | variant = "default", |
| 42 | 43 | size = "default", |
| 43 | 44 | asChild = false, |
| 45 | + loading = false, | |
| 46 | + disabled, | |
| 47 | + children, | |
| 44 | 48 | ...props |
| 45 | 49 | }: React.ComponentProps<"button"> & |
| 46 | 50 | VariantProps<typeof buttonVariants> & { |
| 47 | 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 | 57 | const Comp = asChild ? Slot.Root : "button"; |
| 58 | + const showLoading = loading && !asChild; | |
| 50 | 59 | |
| 51 | 60 | return ( |
| 52 | 61 | <Comp |
| 53 | 62 | data-slot="button" |
| 54 | 63 | data-variant={variant} |
| 55 | 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 | 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 | 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 | 41 | <div className="flex items-center justify-end gap-3 px-4 py-3">{children}</div> |
| 31 | 42 | </CardStack> |
| 32 | 43 | ); |
| @@ -215,8 +215,10 @@ export function ToolRunPanel(props: { · 214 unmodified lines | ||
| 215 | 215 | |
| 216 | 216 | // Read-only invoke: pass the validated args through as JSON. Empty |
| 217 | 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 | 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 | 222 | setRunning(false); |
| 221 | 223 | |
| 222 | 224 | if (Exit.isFailure(exit)) { |