History

Merge remote-tracking branch 'origin/main' into merge-main-fmt

# Conflicts: # packages/react/src/components/accounts-section.tsx

Rhys Sullivan committed 4 months ago

Commit 5bc93f82 parents 5d6b26c 807496e16 files · +331 −72Browse files

Unified · Split

16 files
  1. e2e/scenarios/openapi-add-integration-action-bar.test.ts +85 −0
  2. e2e/scenarios/run-panel-auto-approve.test.ts +101 −0
  3. packages/core/api/src/executions/api.ts +4 −0
  4. packages/core/api/src/handlers/executions.ts +3 −1
  5. packages/core/execution/src/engine.ts +27 −2
  6. packages/core/execution/src/promise.ts +5 −2
  7. packages/core/execution/src/tool-invoker.test.ts +20 −0
  8. packages/plugins/google/src/react/AddGoogleSource.tsx +2 −4
  9. packages/plugins/graphql/src/react/AddGraphqlSource.tsx +2 −4
  10. packages/plugins/mcp/src/react/AddMcpSource.tsx +5 −17
  11. packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx +2 −4
  12. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +3 −4
  13. packages/react/src/components/accounts-section.tsx +31 −30
  14. packages/react/src/components/button.tsx +26 −2
  15. packages/react/src/components/float-actions.tsx +12 −1
  16. packages/react/src/components/tool-run-panel.tsx +3 −1
e2e/scenarios/openapi-add-integration-action-bar.test.ts+85−0
@@ -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+);
e2e/scenarios/run-panel-auto-approve.test.ts+101−0
@@ -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+);
packages/core/api/src/executions/api.ts+4−0
@@ -9,6 +9,10 @@ import { InternalError } from "@executor-js/sdk/shared"; · 8 unmodified lines
99
1010 const ExecuteRequest = Schema.Struct({
1111 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),
1216 });
1317
1418 const CompletedResult = Schema.Struct({
packages/core/api/src/handlers/executions.ts+3−1
@@ -34,7 +34,9 @@ export const ExecutionsHandlers = HttpApiBuilder.group(ExecutorApi, "executions" · 33 unmodified lines
3434 capture(
3535 Effect.gen(function* () {
3636 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+ );
3840
3941 if (outcome.status === "completed") {
4042 const formatted = formatExecuteResult(outcome.result);
packages/core/execution/src/engine.ts+27−2
@@ -53,6 +53,10 @@ export type ResumeResponse = { · 52 unmodified lines
5353 readonly content?: Record<string, unknown>;
5454 };
5555
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+
5660 // ---------------------------------------------------------------------------
5761 // Result formatting
5862 // ---------------------------------------------------------------------------
@@ -370,8 +374,17 @@ export type ExecutionEngine<E extends Cause.YieldableError = CodeExecutionError> · 311 unmodified lines
370374 * Execute code, intercepting the first elicitation as a pause point.
371375 * Use this when the host doesn't support inline elicitation.
372376 * 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.
373383 */
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>;
375388
376389 /**
377390 * 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
455468 * The sandbox is forked as a daemon because paused executions can outlive the
456469 * caller scope that returned the first pause, such as an HTTP request handler.
457470 */
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+ ) {
459475 yield* Effect.annotateCurrentSpan({
460476 "mcp.execute.mode": "pausable",
461477 "mcp.execute.code_length": code.length,
462478 });
463479
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+
464489 // Queue preserves pauses that arrive before the previous approval has
465490 // returned to the caller, which can happen with concurrent tool calls.
466491 const pauseQueue = yield* Queue.unbounded<InternalPausedExecution<E>>();
packages/core/execution/src/promise.ts+5−2
@@ -41,7 +41,10 @@ export type ExecutionEngine = { · 40 unmodified lines
4141 code: string,
4242 options: { readonly onElicitation: ElicitationHandler },
4343 ) => Promise<ExecuteResult>;
44− readonly executeWithPause: (code: string) => Promise<ExecutionResult>;
44+ readonly executeWithPause: (
45+ code: string,
46+ options?: { readonly autoApprove?: boolean },
47+ ) => Promise<ExecutionResult>;
4548 readonly resume: (
4649 executionId: string,
4750 response: ResumeResponse,
@@ -149,7 +152,7 @@ export const toPromiseExecutionEngine = <E extends Cause.YieldableError>( · 101 unmodified lines
149152 Effect.tryPromise(() => options.onElicitation(ctx)).pipe(Effect.orDie),
150153 }),
151154 ),
152− executeWithPause: (code) => Effect.runPromise(engine.executeWithPause(code)),
155+ executeWithPause: (code, options) => Effect.runPromise(engine.executeWithPause(code, options)),
153156 resume: (executionId, response) => Effect.runPromise(engine.resume(executionId, response)),
154157 getPausedExecution: (executionId) => Effect.runPromise(engine.getPausedExecution(executionId)),
155158 getDescription: () => Effect.runPromise(engine.getDescription),
packages/core/execution/src/tool-invoker.test.ts+20−0
@@ -1346,6 +1346,26 @@ describe("pause/resume with multiple elicitations", () => { · 1345 unmodified lines
13461346 { timeout: 10000 },
13471347 );
13481348
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+
13491369 // live clock: the sandbox timeout is a real timer, so the test must
13501370 // actually wait for it rather than suspend on the virtual TestClock.
13511371 it.live(
packages/plugins/google/src/react/AddGoogleSource.tsx+2−4
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
99 } from "@executor-js/react/plugins/integration-identity";
1010 import { Button } from "@executor-js/react/components/button";
1111 import { FloatActions } from "@executor-js/react/components/float-actions";
12−import { Spinner } from "@executor-js/react/components/spinner";
1312 import {
1413 addIntegrationErrorMessage,
1514 FormErrorAlert,
@@ -170,9 +169,8 @@ export default function AddGoogleSource(props: { · 154 unmodified lines
170169 <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
171170 Cancel
172171 </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
176174 </Button>
177175 </FloatActions>
178176 </div>
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−4
@@ -16,7 +16,6 @@ import { · 15 unmodified lines
1616 type AuthMethodSeed,
1717 } from "@executor-js/react/components/auth-method-list-editor";
1818 import { FloatActions } from "@executor-js/react/components/float-actions";
19−import { Spinner } from "@executor-js/react/components/spinner";
2019 import {
2120 addIntegrationErrorMessage,
2221 FormErrorAlert,
@@ -156,9 +155,8 @@ export default function AddGraphqlSource(props: { · 133 unmodified lines
156155 <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
157156 Cancel
158157 </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
162160 </Button>
163161 </FloatActions>
164162 </div>
packages/plugins/mcp/src/react/AddMcpSource.tsx+5−17
@@ -17,7 +17,6 @@ import { · 16 unmodified lines
1717 } from "@executor-js/react/components/card-stack";
1818 import { FloatActions } from "@executor-js/react/components/float-actions";
1919 import { Input } from "@executor-js/react/components/input";
20−import { Spinner } from "@executor-js/react/components/spinner";
2120 import { TagInput } from "@executor-js/react/components/tag-input";
2221 import {
2322 integrationDisplayNameFromStdio,
@@ -473,14 +472,8 @@ export default function AddMcpSource(props: { · 449 unmodified lines
473472 Cancel
474473 </Button>
475474 {(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
484477 </Button>
485478 )}
486479 </FloatActions>
@@ -546,15 +539,10 @@ export default function AddMcpSource(props: { · 59 unmodified lines
546539 <Button
547540 type="button"
548541 onClick={handleAddStdio}
549− disabled={!stdioCommand.trim() || stdioAdding || stdioSlugExists}
542+ disabled={!stdioCommand.trim() || stdioSlugExists}
543+ loading={stdioAdding}
550544 >
551− {stdioAdding ? (
552− <>
553− <Spinner className="size-3.5" /> Adding…
554− </>
555− ) : (
556− "Add source"
557− )}
545+ Add source
558546 </Button>
559547 </FloatActions>
560548 </>
packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx+2−4
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
99 } from "@executor-js/react/plugins/integration-identity";
1010 import { Button } from "@executor-js/react/components/button";
1111 import { FloatActions } from "@executor-js/react/components/float-actions";
12−import { Spinner } from "@executor-js/react/components/spinner";
1312 import {
1413 addIntegrationErrorMessage,
1514 FormErrorAlert,
@@ -147,9 +146,8 @@ export default function AddMicrosoftSource(props: { · 131 unmodified lines
147146 <Button variant="ghost" onClick={() => props.onCancel()} disabled={adding}>
148147 Cancel
149148 </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
153151 </Button>
154152 </FloatActions>
155153 </div>
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+3−4
@@ -21,7 +21,7 @@ import { CardStack, CardStackContent } from "@executor-js/react/components/card- · 20 unmodified lines
2121 import { FieldLabel } from "@executor-js/react/components/field";
2222 import { FloatActions } from "@executor-js/react/components/float-actions";
2323 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";
2525 import {
2626 addIntegrationErrorMessage,
2727 errorMessageFromExit,
@@ -376,9 +376,8 @@ export default function AddOpenApiSource(props: { · 348 unmodified lines
376376 Cancel
377377 </Button>
378378 {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
382381 </Button>
383382 )}
384383 </FloatActions>
packages/react/src/components/accounts-section.tsx+31−30
@@ -356,6 +356,23 @@ export function AccountsSection(props: { · 355 unmodified lines
356356
357357 const loading = !AsyncResult.isSuccess(orgConnections) && !AsyncResult.isSuccess(userConnections);
358358
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+
359376 const modalState = reconnectHandoff ?? accountHandoff;
360377 const modal = (
361378 <AddAccountModal
@@ -379,24 +396,17 @@ export function AccountsSection(props: { · 17 unmodified lines
379396 <h3 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
380397 Connections
381398 </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}
400410 </div>
401411
402412 {loading ? (
@@ -404,7 +414,7 @@ export function AccountsSection(props: { · 1 unmodified line
404414 <div className="size-1.5 animate-pulse rounded-full bg-muted-foreground/30" />
405415 <p className="text-sm text-muted-foreground">Loading accounts…</p>
406416 </div>
407− ) : totalCount === 0 ? (
417+ ) : showEmptyState ? (
408418 <div className="rounded-lg border border-dashed border-border/60 px-6 py-8 text-center">
409419 <p className="text-sm font-medium text-foreground">No connections yet</p>
410420 <p className="mt-1 text-sm text-muted-foreground">
@@ -414,19 +424,10 @@ export function AccountsSection(props: { · 3 unmodified lines
414424 type="button"
415425 className="mt-4"
416426 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}
427428 disabled={!canAddConnection}
428429 >
429− Add a connection
430+ Add connection
430431 </Button>
431432 </div>
432433 ) : (
packages/react/src/components/button.tsx+26−2
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"; · 2 unmodified lines
33 import { Slot } from "radix-ui";
44
55 import { cn } from "../lib/utils";
6+import { Spinner } from "./spinner";
67
78 const buttonVariants = cva(
89 "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
4142 variant = "default",
4243 size = "default",
4344 asChild = false,
45+ loading = false,
46+ disabled,
47+ children,
4448 ...props
4549 }: React.ComponentProps<"button"> &
4650 VariantProps<typeof buttonVariants> & {
4751 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;
4856 }) {
4957 const Comp = asChild ? Slot.Root : "button";
58+ const showLoading = loading && !asChild;
5059
5160 return (
5261 <Comp
5362 data-slot="button"
5463 data-variant={variant}
5564 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}
5768 {...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>
5983 );
6084 }
6185
packages/react/src/components/float-actions.tsx+12−1
@@ -26,7 +26,18 @@ function FloatActions({ className, children }: { className?: string; children: R · 25 unmodified lines
2626 }
2727
2828 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+ >
3041 <div className="flex items-center justify-end gap-3 px-4 py-3">{children}</div>
3142 </CardStack>
3243 );
packages/react/src/components/tool-run-panel.tsx+3−1
@@ -215,8 +215,10 @@ export function ToolRunPanel(props: { · 214 unmodified lines
215215
216216 // Read-only invoke: pass the validated args through as JSON. Empty
217217 // `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.
218220 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: [] });
220222 setRunning(false);
221223
222224 if (Exit.isFailure(exit)) {