Skip to content

Commit bc961e2

Browse files
authored
Add retryable loading and error states (#1262)
1 parent 4460596 commit bc961e2

10 files changed

Lines changed: 547 additions & 443 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Button } from "./button";
2+
import { cn } from "../lib/utils";
3+
4+
export function ErrorState(props: {
5+
readonly message: string;
6+
readonly onRetry: () => void;
7+
readonly className?: string;
8+
}) {
9+
return (
10+
<div
11+
className={cn(
12+
"flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-3",
13+
props.className,
14+
)}
15+
>
16+
<p className="text-sm text-destructive">{props.message}</p>
17+
<Button type="button" variant="outline" size="sm" onClick={props.onRetry}>
18+
Retry
19+
</Button>
20+
</div>
21+
);
22+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import * as Cause from "effect/Cause";
3+
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4+
5+
import { isAsyncResultLoading } from "./async-result";
6+
7+
describe("isAsyncResultLoading", () => {
8+
it("treats initial and waiting async results as loading", () => {
9+
expect(isAsyncResultLoading(AsyncResult.initial())).toBe(true);
10+
expect(isAsyncResultLoading(AsyncResult.initial(true))).toBe(true);
11+
expect(isAsyncResultLoading(AsyncResult.success(["cached"], { waiting: true }))).toBe(true);
12+
});
13+
14+
it("does not treat settled success or failure as loading", () => {
15+
expect(isAsyncResultLoading(AsyncResult.success(["ready"]))).toBe(false);
16+
expect(isAsyncResultLoading(AsyncResult.failure(Cause.fail("boom")))).toBe(false);
17+
});
18+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
2+
3+
export function isAsyncResultLoading<A, E>(result: AsyncResult.AsyncResult<A, E>): boolean {
4+
return AsyncResult.isInitial(result) || AsyncResult.isWaiting(result);
5+
}

packages/react/src/pages/api-keys.tsx

Lines changed: 63 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useState } from "react";
22
import { Exit } from "effect";
33
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4-
import { useAtomSet, useAtomValue } from "@effect/atom-react";
4+
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
55
import { toast } from "sonner";
66
import { apiKeyWriteKeys } from "../api/reactivity-keys";
77
import { trackEvent } from "../api/analytics";
@@ -21,6 +21,8 @@ import {
2121
import { Input } from "../components/input";
2222
import { Label } from "../components/label";
2323
import { useExecutorDocumentTitle } from "../lib/document-title";
24+
import { ErrorState } from "../components/error-state";
25+
import { isAsyncResultLoading } from "../lib/async-result";
2426

2527
// ---------------------------------------------------------------------------
2628
// Shared API-keys page. Reads/writes the provider-neutral `/account/api-keys`
@@ -61,6 +63,7 @@ const defaultApiKeyName = (): string =>
6163
export function ApiKeysPage() {
6264
useExecutorDocumentTitle("API keys");
6365
const result = useAtomValue(apiKeysAtom);
66+
const refreshApiKeys = useAtomRefresh(apiKeysAtom);
6467
const doCreate = useAtomSet(createApiKey, { mode: "promiseExit" });
6568
const doRevoke = useAtomSet(revokeApiKey, { mode: "promiseExit" });
6669
const [createOpen, setCreateOpen] = useState(false);
@@ -134,65 +137,69 @@ export function ApiKeysPage() {
134137
</p>
135138
</PageHeader>
136139

137-
{AsyncResult.match(result, {
138-
onInitial: () => (
139-
<div className="rounded-md border border-border bg-card p-6 text-sm text-muted-foreground">
140-
Loading API keys...
141-
</div>
142-
),
143-
onFailure: () => (
144-
<div className="rounded-md border border-border bg-card p-6 text-sm text-destructive">
145-
Failed to load API keys
146-
</div>
147-
),
148-
onSuccess: ({ value }) =>
149-
value.apiKeys.length === 0 ? (
150-
<div className="rounded-md border border-dashed border-border bg-card p-8">
151-
<h2 className="text-base font-semibold text-foreground">No API keys</h2>
152-
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
153-
Create a key and send it in the Authorization Bearer header.
154-
</p>
140+
{isAsyncResultLoading(result) ? (
141+
<div className="rounded-md border border-border bg-card p-6 text-sm text-muted-foreground">
142+
Loading API keys...
143+
</div>
144+
) : (
145+
AsyncResult.match(result, {
146+
onInitial: () => (
147+
<div className="rounded-md border border-border bg-card p-6 text-sm text-muted-foreground">
148+
Loading API keys...
155149
</div>
156-
) : (
157-
<div className="overflow-hidden rounded-md border border-border bg-card">
158-
<div className="grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wider text-muted-foreground md:grid-cols-[1.4fr_1fr_1fr_auto]">
159-
<span>Name</span>
160-
<span className="hidden md:block">Created</span>
161-
<span className="hidden md:block">Last used</span>
162-
<span className="text-right">Actions</span>
150+
),
151+
onFailure: () => (
152+
<ErrorState message="Failed to load API keys" onRetry={refreshApiKeys} />
153+
),
154+
onSuccess: ({ value }) =>
155+
value.apiKeys.length === 0 ? (
156+
<div className="rounded-md border border-dashed border-border bg-card p-8">
157+
<h2 className="text-base font-semibold text-foreground">No API keys</h2>
158+
<p className="mt-2 max-w-xl text-sm leading-6 text-muted-foreground">
159+
Create a key and send it in the Authorization Bearer header.
160+
</p>
163161
</div>
164-
{value.apiKeys.map((key: ApiKeySummary) => (
165-
<div
166-
key={key.id}
167-
className="grid grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 last:border-b-0 md:grid-cols-[1.4fr_1fr_1fr_auto]"
168-
>
169-
<div className="min-w-0">
170-
<p className="truncate text-sm font-medium text-foreground">{key.name}</p>
171-
<p className="mt-1 font-mono text-xs text-muted-foreground">
172-
{key.obfuscatedValue}
162+
) : (
163+
<div className="overflow-hidden rounded-md border border-border bg-card">
164+
<div className="grid grid-cols-[1fr_auto] gap-4 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wider text-muted-foreground md:grid-cols-[1.4fr_1fr_1fr_auto]">
165+
<span>Name</span>
166+
<span className="hidden md:block">Created</span>
167+
<span className="hidden md:block">Last used</span>
168+
<span className="text-right">Actions</span>
169+
</div>
170+
{value.apiKeys.map((key: ApiKeySummary) => (
171+
<div
172+
key={key.id}
173+
className="grid grid-cols-[1fr_auto] items-center gap-4 border-b border-border px-4 py-4 last:border-b-0 md:grid-cols-[1.4fr_1fr_1fr_auto]"
174+
>
175+
<div className="min-w-0">
176+
<p className="truncate text-sm font-medium text-foreground">{key.name}</p>
177+
<p className="mt-1 font-mono text-xs text-muted-foreground">
178+
{key.obfuscatedValue}
179+
</p>
180+
</div>
181+
<p className="hidden text-sm text-muted-foreground md:block">
182+
{formatDate(key.createdAt)}
173183
</p>
184+
<p className="hidden text-sm text-muted-foreground md:block">
185+
{formatDate(key.lastUsedAt)}
186+
</p>
187+
<Button
188+
variant="ghost"
189+
size="icon-sm"
190+
onClick={() => handleRevoke(key)}
191+
disabled={revokingId === key.id}
192+
title={`Revoke ${key.name}`}
193+
className="text-muted-foreground hover:text-destructive"
194+
>
195+
<span aria-hidden="true">×</span>
196+
</Button>
174197
</div>
175-
<p className="hidden text-sm text-muted-foreground md:block">
176-
{formatDate(key.createdAt)}
177-
</p>
178-
<p className="hidden text-sm text-muted-foreground md:block">
179-
{formatDate(key.lastUsedAt)}
180-
</p>
181-
<Button
182-
variant="ghost"
183-
size="icon-sm"
184-
onClick={() => handleRevoke(key)}
185-
disabled={revokingId === key.id}
186-
title={`Revoke ${key.name}`}
187-
className="text-muted-foreground hover:text-destructive"
188-
>
189-
<span aria-hidden="true">×</span>
190-
</Button>
191-
</div>
192-
))}
193-
</div>
194-
),
195-
})}
198+
))}
199+
</div>
200+
),
201+
})
202+
)}
196203

197204
<Dialog open={createOpen} onOpenChange={closeCreate}>
198205
<DialogContent className="sm:max-w-[480px]">

packages/react/src/pages/integration-detail.tsx

Lines changed: 49 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ import { useIntegrationPlugins, type IntegrationAccountHandoff } from "@executor
3434
import { Button } from "../components/button";
3535
import { Skeleton } from "../components/skeleton";
3636
import { useExecutorDocumentTitle } from "../lib/document-title";
37+
import { ErrorState } from "../components/error-state";
38+
import { isAsyncResultLoading } from "../lib/async-result";
3739

3840
// v2: the route's `namespace` param is the integration slug. Tools belong to
3941
// the integration's per-owner connections; a tool's policy id is
@@ -138,7 +140,6 @@ export function IntegrationDetailPage(props: { namespace: string }) {
138140
...(oauthClient !== undefined ? { oauthClient } : {}),
139141
};
140142
}, [locationSearch]);
141-
142143
useEffect(() => {
143144
if (accountHandoff && !isBuiltInIntegration) {
144145
setActiveTab("accounts");
@@ -446,52 +447,58 @@ export function IntegrationDetailPage(props: { namespace: string }) {
446447
value="tools"
447448
className="flex min-h-0 flex-col overflow-hidden data-[state=inactive]:hidden"
448449
>
449-
{AsyncResult.match(tools, {
450-
onInitial: () => <IntegrationDetailSkeleton />,
451-
onFailure: () => (
452-
<div className="p-6 text-sm text-destructive">Failed to load tools</div>
453-
),
454-
onSuccess: () => (
455-
<div className="flex min-h-0 flex-1 overflow-hidden">
456-
{/* Left: tool tree */}
457-
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 lg:w-80 xl:w-[22rem]">
458-
<ToolTree
459-
tools={integrationTools}
460-
selectedToolId={selectedToolId}
461-
onSelect={setSelectedToolId}
462-
onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
463-
onClearPolicy={(pattern) => void policyActions.clear(pattern)}
464-
policies={sortedPolicies}
465-
groupByConnection={!isBuiltInIntegration}
466-
/>
450+
{isAsyncResultLoading(tools) ? (
451+
<IntegrationDetailSkeleton />
452+
) : (
453+
AsyncResult.match(tools, {
454+
onInitial: () => <IntegrationDetailSkeleton />,
455+
onFailure: () => (
456+
<div className="p-6">
457+
<ErrorState message="Failed to load tools" onRetry={refreshTools} />
467458
</div>
468-
469-
{/* Right: tool detail with Schema · TypeScript · Run tabs */}
470-
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
471-
{selectedTool && selectedAddress && selectedBareName ? (
472-
<ToolDetail
473-
address={selectedAddress}
474-
toolName={selectedTool.name}
475-
staticTool={selection?.static}
476-
policy={selectedTool.policy}
459+
),
460+
onSuccess: () => (
461+
<div className="flex min-h-0 flex-1 overflow-hidden">
462+
{/* Left: tool tree */}
463+
<div className="flex w-72 shrink-0 flex-col border-r border-border/60 lg:w-80 xl:w-[22rem]">
464+
<ToolTree
465+
tools={integrationTools}
466+
selectedToolId={selectedToolId}
467+
onSelect={setSelectedToolId}
477468
onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
478469
onClearPolicy={(pattern) => void policyActions.clear(pattern)}
479-
{...(!selection?.static && selectedBareName
480-
? {
481-
integration: slug,
482-
runToolName: selectedBareName,
483-
connections: integrationConnections,
484-
initialConnectionName: selection?.connection ?? null,
485-
}
486-
: {})}
470+
policies={sortedPolicies}
471+
groupByConnection={!isBuiltInIntegration}
487472
/>
488-
) : (
489-
<ToolDetailEmpty hasTools={integrationTools.length > 0} />
490-
)}
473+
</div>
474+
475+
{/* Right: tool detail with Schema · TypeScript · Run tabs */}
476+
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
477+
{selectedTool && selectedAddress && selectedBareName ? (
478+
<ToolDetail
479+
address={selectedAddress}
480+
toolName={selectedTool.name}
481+
staticTool={selection?.static}
482+
policy={selectedTool.policy}
483+
onSetPolicy={(pattern, action) => void policyActions.set(pattern, action)}
484+
onClearPolicy={(pattern) => void policyActions.clear(pattern)}
485+
{...(!selection?.static && selectedBareName
486+
? {
487+
integration: slug,
488+
runToolName: selectedBareName,
489+
connections: integrationConnections,
490+
initialConnectionName: selection?.connection ?? null,
491+
}
492+
: {})}
493+
/>
494+
) : (
495+
<ToolDetailEmpty hasTools={integrationTools.length > 0} />
496+
)}
497+
</div>
491498
</div>
492-
</div>
493-
),
494-
})}
499+
),
500+
})
501+
)}
495502
</TabsContent>
496503
</Tabs>
497504

packages/react/src/pages/integrations.tsx

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Suspense, useCallback, useMemo, useState } from "react";
22
import { Link, useNavigate } from "@tanstack/react-router";
3-
import { useAtomSet, useAtomValue } from "@effect/atom-react";
3+
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
44
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
55
import * as Exit from "effect/Exit";
66
import { PlusIcon } from "lucide-react";
@@ -41,6 +41,8 @@ import {
4141
import { IntegrationIconWithAccount } from "../components/integration-icon-with-account";
4242
import { Skeleton } from "../components/skeleton";
4343
import { useExecutorDocumentTitle } from "../lib/document-title";
44+
import { ErrorState } from "../components/error-state";
45+
import { isAsyncResultLoading } from "../lib/async-result";
4446

4547
const KIND_TO_PLUGIN_KEY: Record<string, string> = {
4648
openapi: "openapi",
@@ -67,6 +69,7 @@ const bestDetection = (
6769
export function IntegrationsPage() {
6870
useExecutorDocumentTitle("Integrations");
6971
const integrations = useAtomValue(integrationsOptimisticAtom);
72+
const refreshIntegrations = useAtomRefresh(integrationsOptimisticAtom);
7073
const [connectOpen, setConnectOpen] = useState(false);
7174

7275
return (
@@ -95,28 +98,34 @@ export function IntegrationsPage() {
9598

9699
<div className="mb-8 border-t border-border/50" />
97100

98-
{AsyncResult.match(integrations, {
99-
onInitial: () => <IntegrationsGridSkeleton />,
100-
onFailure: () => <p className="text-sm text-destructive">Failed to load integrations</p>,
101-
onSuccess: ({ value }) => {
102-
if (value.length === 0) {
101+
{isAsyncResultLoading(integrations) ? (
102+
<IntegrationsGridSkeleton />
103+
) : (
104+
AsyncResult.match(integrations, {
105+
onInitial: () => <IntegrationsGridSkeleton />,
106+
onFailure: () => (
107+
<ErrorState message="Failed to load integrations" onRetry={refreshIntegrations} />
108+
),
109+
onSuccess: ({ value }) => {
110+
if (value.length === 0) {
111+
return (
112+
<EmptyIntegrations
113+
onConnect={() => {
114+
setConnectOpen(true);
115+
trackEvent("integration_connect_dialog_opened");
116+
}}
117+
/>
118+
);
119+
}
120+
103121
return (
104-
<EmptyIntegrations
105-
onConnect={() => {
106-
setConnectOpen(true);
107-
trackEvent("integration_connect_dialog_opened");
108-
}}
109-
/>
122+
<div className="mb-8 space-y-3">
123+
<IntegrationGrid integrations={value} />
124+
</div>
110125
);
111-
}
112-
113-
return (
114-
<div className="mb-8 space-y-3">
115-
<IntegrationGrid integrations={value} />
116-
</div>
117-
);
118-
},
119-
})}
126+
},
127+
})
128+
)}
120129

121130
<ConnectDialog open={connectOpen} onOpenChange={setConnectOpen} />
122131
</PageContainer>

0 commit comments

Comments
 (0)