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).
7import { expect } from "@effect/vitest";
8import { Effect } from "effect";
9
10import { scenario } from "../src/scenario";
11import { 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).
15const 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
24scenario(
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.
18import { randomUUID } from "node:crypto";
19
20import { expect } from "@effect/vitest";
21import { Effect } from "effect";
22import { composePluginApi } from "@executor-js/api/server";
23
24import { scenario } from "../src/scenario";
25import { Api, Target } from "../src/services";
26
27const 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. */
31const createPolicyCode = (pattern: string) => `
32return await tools.executor.coreTools.policies.create({
33 owner: "user",
34 pattern: ${JSON.stringify(pattern)},
35 action: "block",
36});
37`;
38
39scenario(
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
9 9
10const ExecuteRequest = Schema.Struct({10const 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
14const CompletedResult = Schema.Struct({18const 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
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);
packages/core/execution/src/engine.ts+27−2
@@ -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).
58const acceptAllHandler: ElicitationHandler = () => Effect.succeed({ action: "accept" });
59
56// ---------------------------------------------------------------------------60// ---------------------------------------------------------------------------
57// Result formatting61// 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, or390 * 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 the468 * 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 has489 // 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>>();
packages/core/execution/src/promise.ts+5−2
@@ -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),
packages/core/execution/src/tool-invoker.test.ts+20−0
@@ -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 must1369 // 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(
packages/plugins/google/src/react/AddGoogleSource.tsx+2−4
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
9} from "@executor-js/react/plugins/integration-identity";9} from "@executor-js/react/plugins/integration-identity";
10import { Button } from "@executor-js/react/components/button";10import { Button } from "@executor-js/react/components/button";
11import { FloatActions } from "@executor-js/react/components/float-actions";11import { FloatActions } from "@executor-js/react/components/float-actions";
12import { Spinner } from "@executor-js/react/components/spinner";
13import {12import {
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 Cancel170 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>
packages/plugins/graphql/src/react/AddGraphqlSource.tsx+2−4
@@ -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";
18import { FloatActions } from "@executor-js/react/components/float-actions";18import { FloatActions } from "@executor-js/react/components/float-actions";
19import { Spinner } from "@executor-js/react/components/spinner";
20import {19import {
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 Cancel156 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>
packages/plugins/mcp/src/react/AddMcpSource.tsx+5−17
@@ -17,7 +17,6 @@ import { · 16 unmodified lines
17} from "@executor-js/react/components/card-stack";17} from "@executor-js/react/components/card-stack";
18import { FloatActions } from "@executor-js/react/components/float-actions";18import { FloatActions } from "@executor-js/react/components/float-actions";
19import { Input } from "@executor-js/react/components/input";19import { Input } from "@executor-js/react/components/input";
20import { Spinner } from "@executor-js/react/components/spinner";
21import { TagInput } from "@executor-js/react/components/tag-input";20import { TagInput } from "@executor-js/react/components/tag-input";
22import {21import {
23 integrationDisplayNameFromStdio,22 integrationDisplayNameFromStdio,
@@ -473,14 +472,8 @@ export default function AddMcpSource(props: { · 449 unmodified lines
473 Cancel472 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 <Button539 <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 </>
packages/plugins/microsoft/src/react/AddMicrosoftSource.tsx+2−4
@@ -9,7 +9,6 @@ import { · 8 unmodified lines
9} from "@executor-js/react/plugins/integration-identity";9} from "@executor-js/react/plugins/integration-identity";
10import { Button } from "@executor-js/react/components/button";10import { Button } from "@executor-js/react/components/button";
11import { FloatActions } from "@executor-js/react/components/float-actions";11import { FloatActions } from "@executor-js/react/components/float-actions";
12import { Spinner } from "@executor-js/react/components/spinner";
13import {12import {
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 Cancel147 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>
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
21import { FieldLabel } from "@executor-js/react/components/field";21import { FieldLabel } from "@executor-js/react/components/field";
22import { FloatActions } from "@executor-js/react/components/float-actions";22import { FloatActions } from "@executor-js/react/components/float-actions";
23import { Textarea } from "@executor-js/react/components/textarea";23import { Textarea } from "@executor-js/react/components/textarea";
24import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";24import { IOSSpinner } from "@executor-js/react/components/spinner";
25import {25import {
26 addIntegrationErrorMessage,26 addIntegrationErrorMessage,
27 errorMessageFromExit,27 errorMessageFromExit,
@@ -376,9 +376,8 @@ export default function AddOpenApiSource(props: { · 348 unmodified lines
376 Cancel376 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>
packages/react/src/components/accounts-section.tsx+31−30
@@ -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 <AddAccountModal378 <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 Connections397 Connections
381 </h3>398 </h3>
382 <Button399 {!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 connection430 Add connection
430 </Button>431 </Button>
431 </div>432 </div>
432 ) : (433 ) : (
packages/react/src/components/button.tsx+26−2
@@ -3,6 +3,7 @@ import { cva, type VariantProps } from "class-variance-authority"; · 2 unmodified lines
3import { Slot } from "radix-ui";3import { Slot } from "radix-ui";
4 4
5import { cn } from "../lib/utils";5import { cn } from "../lib/utils";
6import { Spinner } from "./spinner";
6 7
7const buttonVariants = cva(8const 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 ...props48 ...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 <Comp61 <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
packages/react/src/components/float-actions.tsx+12−1
@@ -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 );
packages/react/src/components/tool-run-panel.tsx+3−1
@@ -215,8 +215,10 @@ export function ToolRunPanel(props: { · 214 unmodified lines
215 215
216 // Read-only invoke: pass the validated args through as JSON. Empty216 // 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)) {