birdclaw 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +25 -0
  3. package/package.json +6 -1
  4. package/src/cli.ts +438 -26
  5. package/src/components/AppNav.tsx +10 -0
  6. package/src/components/MarkdownViewer.tsx +438 -72
  7. package/src/components/ProfileAnalysisStream.tsx +428 -0
  8. package/src/components/ProfilePreview.tsx +120 -9
  9. package/src/components/SavedTimelineView.tsx +30 -8
  10. package/src/components/SyncNowButton.tsx +5 -2
  11. package/src/components/TimelineCard.tsx +20 -4
  12. package/src/components/TimelineRouteFrame.tsx +16 -0
  13. package/src/components/TweetRichText.tsx +36 -12
  14. package/src/components/useTimelineRouteData.ts +74 -6
  15. package/src/lib/account-sync-job.ts +15 -3
  16. package/src/lib/archive-finder.ts +1 -1
  17. package/src/lib/archive-import.ts +245 -7
  18. package/src/lib/authored-live.ts +1 -0
  19. package/src/lib/avatar-cache.ts +50 -0
  20. package/src/lib/backup.ts +4 -3
  21. package/src/lib/bird.ts +33 -0
  22. package/src/lib/config.ts +35 -2
  23. package/src/lib/data-sources.ts +219 -0
  24. package/src/lib/db.ts +62 -1
  25. package/src/lib/geocoding.ts +296 -0
  26. package/src/lib/location.ts +137 -0
  27. package/src/lib/mention-threads-live.ts +94 -1
  28. package/src/lib/mentions-live.ts +187 -40
  29. package/src/lib/network-map.ts +382 -0
  30. package/src/lib/period-digest.ts +377 -13
  31. package/src/lib/profile-analysis.ts +1272 -0
  32. package/src/lib/profile-bio-entities.ts +1 -1
  33. package/src/lib/queries.ts +14 -4
  34. package/src/lib/search-discussion.ts +1016 -0
  35. package/src/lib/timeline-live.ts +272 -19
  36. package/src/lib/tweet-account-edges.ts +2 -0
  37. package/src/lib/tweet-render.ts +141 -1
  38. package/src/lib/tweet-search-live.ts +565 -0
  39. package/src/lib/types.ts +37 -2
  40. package/src/lib/ui.ts +1 -1
  41. package/src/lib/web-sync.ts +7 -2
  42. package/src/lib/xurl-rate-limits.ts +267 -0
  43. package/src/lib/xurl.ts +551 -41
  44. package/src/routeTree.gen.ts +231 -0
  45. package/src/routes/__root.tsx +5 -6
  46. package/src/routes/api/data-sources.tsx +24 -0
  47. package/src/routes/api/network-map.tsx +55 -0
  48. package/src/routes/api/period-digest.tsx +11 -1
  49. package/src/routes/api/profile-analysis.tsx +152 -0
  50. package/src/routes/api/query.tsx +1 -0
  51. package/src/routes/api/search-discussion.tsx +169 -0
  52. package/src/routes/api/xurl-rate-limits.tsx +24 -0
  53. package/src/routes/data-sources.tsx +255 -0
  54. package/src/routes/discuss.tsx +419 -0
  55. package/src/routes/network-map.tsx +1035 -0
  56. package/src/routes/profile-analyze.tsx +112 -0
  57. package/src/routes/profiles.$handle.tsx +228 -0
  58. package/src/routes/rate-limits.tsx +309 -0
  59. package/src/routes/today.tsx +22 -8
  60. package/src/styles.css +22 -0
@@ -0,0 +1,152 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import { runEffectBackground } from "#/lib/effect-runtime";
5
+ import {
6
+ parseBoundedInteger,
7
+ runRouteEffect,
8
+ sensitiveRequestErrorResponse,
9
+ } from "#/lib/http-effect";
10
+ import {
11
+ streamProfileAnalysisEffect,
12
+ type ProfileAnalysisOptions,
13
+ type ProfileAnalysisStreamEvent,
14
+ } from "#/lib/profile-analysis";
15
+
16
+ const encoder = new TextEncoder();
17
+
18
+ function parseBoolean(value: string | null) {
19
+ return value === "true" || value === "1" || value === "yes";
20
+ }
21
+
22
+ function parseOptions(url: URL): ProfileAnalysisOptions {
23
+ const conversationDelayMs = parseBoundedInteger(
24
+ url.searchParams.get("conversationDelayMs"),
25
+ { min: 0, max: 60_000 },
26
+ );
27
+ const rateLimitRetryMs = parseBoundedInteger(
28
+ url.searchParams.get("rateLimitRetryMs"),
29
+ { min: 0, max: 900_000 },
30
+ );
31
+ const rateLimitMaxRetries = parseBoundedInteger(
32
+ url.searchParams.get("rateLimitRetries"),
33
+ { min: 0, max: 10 },
34
+ );
35
+ return {
36
+ handle: url.searchParams.get("handle") ?? "",
37
+ account: url.searchParams.get("account") ?? undefined,
38
+ refresh: parseBoolean(url.searchParams.get("refresh")),
39
+ model: url.searchParams.get("model") === "gpt-5.5" ? "gpt-5.5" : undefined,
40
+ maxTweets: parseBoundedInteger(url.searchParams.get("maxTweets"), {
41
+ max: 20_000,
42
+ }),
43
+ maxPages: parseBoundedInteger(url.searchParams.get("maxPages"), {
44
+ max: 500,
45
+ }),
46
+ maxConversations: parseBoundedInteger(
47
+ url.searchParams.get("maxConversations"),
48
+ { max: 500 },
49
+ ),
50
+ maxConversationPages: parseBoundedInteger(
51
+ url.searchParams.get("maxConversationPages"),
52
+ { max: 50 },
53
+ ),
54
+ ...(conversationDelayMs !== undefined ? { conversationDelayMs } : {}),
55
+ ...(rateLimitRetryMs !== undefined ? { rateLimitRetryMs } : {}),
56
+ ...(rateLimitMaxRetries !== undefined ? { rateLimitMaxRetries } : {}),
57
+ };
58
+ }
59
+
60
+ function encodeEvent(event: ProfileAnalysisStreamEvent) {
61
+ return encoder.encode(`${JSON.stringify(event)}\n`);
62
+ }
63
+
64
+ export const Route = createFileRoute("/api/profile-analysis")({
65
+ server: {
66
+ handlers: {
67
+ GET: ({ request }) =>
68
+ runRouteEffect(
69
+ Effect.sync(() => {
70
+ const denied = sensitiveRequestErrorResponse(request);
71
+ if (denied) return denied;
72
+
73
+ const url = new URL(request.url);
74
+ const options = parseOptions(url);
75
+ let abortAnalysis: (() => void) | undefined;
76
+
77
+ return new Response(
78
+ new ReadableStream({
79
+ cancel() {
80
+ abortAnalysis?.();
81
+ },
82
+ start(controller) {
83
+ const abortController = new AbortController();
84
+ let closed = false;
85
+ const close = () => {
86
+ closed = true;
87
+ abortController.abort();
88
+ };
89
+ const closeController = () => {
90
+ request.signal.removeEventListener("abort", onAbort);
91
+ if (!closed) {
92
+ closed = true;
93
+ controller.close();
94
+ }
95
+ };
96
+ const onAbort = () => close();
97
+ request.signal.addEventListener("abort", onAbort, {
98
+ once: true,
99
+ });
100
+ abortAnalysis = close;
101
+ const enqueue = (event: ProfileAnalysisStreamEvent) => {
102
+ if (closed) return;
103
+ try {
104
+ controller.enqueue(encodeEvent(event));
105
+ } catch {
106
+ close();
107
+ }
108
+ };
109
+ enqueue({
110
+ type: "status",
111
+ label: "Starting profile analysis",
112
+ });
113
+
114
+ runEffectBackground(
115
+ Effect.gen(function* () {
116
+ yield* maybeAutoUpdateBackupEffect();
117
+ return yield* streamProfileAnalysisEffect(
118
+ {
119
+ ...options,
120
+ signal: abortController.signal,
121
+ },
122
+ { onEvent: enqueue },
123
+ );
124
+ }),
125
+ {
126
+ onSuccess: closeController,
127
+ onFailure: (error) => {
128
+ enqueue({
129
+ type: "error",
130
+ error:
131
+ error instanceof Error
132
+ ? error.message
133
+ : "Profile analysis failed",
134
+ });
135
+ closeController();
136
+ },
137
+ },
138
+ );
139
+ },
140
+ }),
141
+ {
142
+ headers: {
143
+ "cache-control": "no-store",
144
+ "content-type": "application/x-ndjson; charset=utf-8",
145
+ },
146
+ },
147
+ );
148
+ }),
149
+ ),
150
+ },
151
+ },
152
+ });
@@ -105,6 +105,7 @@ export const Route = createFileRoute("/api/query")({
105
105
  queryResource(resource, {
106
106
  ...baseFilters,
107
107
  resource,
108
+ untilId: url.searchParams.get("untilId") ?? undefined,
108
109
  }),
109
110
  );
110
111
  }),
@@ -0,0 +1,169 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
+ import { runEffectBackground } from "#/lib/effect-runtime";
5
+ import {
6
+ parseBoundedInteger,
7
+ runRouteEffect,
8
+ sensitiveRequestErrorResponse,
9
+ } from "#/lib/http-effect";
10
+ import {
11
+ streamSearchDiscussionEffect,
12
+ type SearchDiscussionOptions,
13
+ type SearchDiscussionSource,
14
+ type SearchDiscussionStreamEvent,
15
+ } from "#/lib/search-discussion";
16
+ import type { TweetSearchMode } from "#/lib/tweet-search-live";
17
+
18
+ const encoder = new TextEncoder();
19
+ const MAX_DISCUSSION_SEARCH_LIMIT = 20_000;
20
+ const MAX_DISCUSSION_SEARCH_PAGES = 200;
21
+
22
+ function parseBoolean(value: string | null) {
23
+ return value === "true" || value === "1" || value === "yes";
24
+ }
25
+
26
+ function parseSource(value: string | null): SearchDiscussionSource {
27
+ if (
28
+ value === "all" ||
29
+ value === "home" ||
30
+ value === "mentions" ||
31
+ value === "authored" ||
32
+ value === "search" ||
33
+ value === "likes" ||
34
+ value === "bookmarks"
35
+ ) {
36
+ return value;
37
+ }
38
+ return "search";
39
+ }
40
+
41
+ function parseMode(value: string | null): TweetSearchMode {
42
+ if (
43
+ value === "auto" ||
44
+ value === "bird" ||
45
+ value === "xurl" ||
46
+ value === "local"
47
+ ) {
48
+ return value;
49
+ }
50
+ return "auto";
51
+ }
52
+
53
+ function parseOptions(url: URL): SearchDiscussionOptions {
54
+ return {
55
+ query: url.searchParams.get("query") ?? "",
56
+ account: url.searchParams.get("account") ?? undefined,
57
+ source: parseSource(url.searchParams.get("source")),
58
+ mode: parseMode(url.searchParams.get("mode")),
59
+ includeDms: parseBoolean(url.searchParams.get("includeDms")),
60
+ since: url.searchParams.get("since") ?? undefined,
61
+ until: url.searchParams.get("until") ?? undefined,
62
+ question: url.searchParams.get("question") ?? undefined,
63
+ originalsOnly: parseBoolean(url.searchParams.get("originalsOnly")),
64
+ hideLowQuality: parseBoolean(url.searchParams.get("hideLowQuality")),
65
+ refresh: parseBoolean(url.searchParams.get("refresh")),
66
+ model: url.searchParams.get("model") === "gpt-5.5" ? "gpt-5.5" : undefined,
67
+ limit: parseBoundedInteger(url.searchParams.get("limit"), {
68
+ max: MAX_DISCUSSION_SEARCH_LIMIT,
69
+ }),
70
+ maxPages: parseBoundedInteger(url.searchParams.get("maxPages"), {
71
+ max: MAX_DISCUSSION_SEARCH_PAGES,
72
+ }),
73
+ };
74
+ }
75
+
76
+ function encodeEvent(event: SearchDiscussionStreamEvent) {
77
+ return encoder.encode(`${JSON.stringify(event)}\n`);
78
+ }
79
+
80
+ export const Route = createFileRoute("/api/search-discussion")({
81
+ server: {
82
+ handlers: {
83
+ GET: ({ request }) =>
84
+ runRouteEffect(
85
+ Effect.sync(() => {
86
+ const denied = sensitiveRequestErrorResponse(request);
87
+ if (denied) return denied;
88
+
89
+ const url = new URL(request.url);
90
+ const options = parseOptions(url);
91
+ let abortDiscussion: (() => void) | undefined;
92
+
93
+ return new Response(
94
+ new ReadableStream({
95
+ cancel() {
96
+ abortDiscussion?.();
97
+ },
98
+ start(controller) {
99
+ const abortController = new AbortController();
100
+ let closed = false;
101
+ const close = () => {
102
+ closed = true;
103
+ abortController.abort();
104
+ };
105
+ const closeController = () => {
106
+ request.signal.removeEventListener("abort", onAbort);
107
+ if (!closed) {
108
+ closed = true;
109
+ controller.close();
110
+ }
111
+ };
112
+ const onAbort = () => close();
113
+ request.signal.addEventListener("abort", onAbort, {
114
+ once: true,
115
+ });
116
+ abortDiscussion = close;
117
+ const enqueue = (event: SearchDiscussionStreamEvent) => {
118
+ if (closed) return;
119
+ try {
120
+ controller.enqueue(encodeEvent(event));
121
+ } catch {
122
+ close();
123
+ }
124
+ };
125
+
126
+ runEffectBackground(
127
+ maybeAutoUpdateBackupEffect().pipe(
128
+ Effect.flatMap(() => {
129
+ if (closed || abortController.signal.aborted) {
130
+ return Effect.succeed(undefined);
131
+ }
132
+ return streamSearchDiscussionEffect(
133
+ {
134
+ ...options,
135
+ signal: abortController.signal,
136
+ prefetchAvatars: true,
137
+ },
138
+ { onEvent: enqueue },
139
+ );
140
+ }),
141
+ ),
142
+ {
143
+ onSuccess: closeController,
144
+ onFailure: (error) => {
145
+ enqueue({
146
+ type: "error",
147
+ error:
148
+ error instanceof Error
149
+ ? error.message
150
+ : "Discussion failed",
151
+ });
152
+ closeController();
153
+ },
154
+ },
155
+ );
156
+ },
157
+ }),
158
+ {
159
+ headers: {
160
+ "cache-control": "no-store",
161
+ "content-type": "application/x-ndjson; charset=utf-8",
162
+ },
163
+ },
164
+ );
165
+ }),
166
+ ),
167
+ },
168
+ },
169
+ });
@@ -0,0 +1,24 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import {
4
+ jsonResponse,
5
+ runRouteEffect,
6
+ sensitiveRequestErrorResponse,
7
+ } from "#/lib/http-effect";
8
+ import { getXurlRateLimitSnapshot } from "#/lib/xurl-rate-limits";
9
+
10
+ export const Route = createFileRoute("/api/xurl-rate-limits")({
11
+ server: {
12
+ handlers: {
13
+ GET: ({ request }) =>
14
+ runRouteEffect(
15
+ Effect.sync(() => {
16
+ const denied = sensitiveRequestErrorResponse(request);
17
+ if (denied) return denied;
18
+
19
+ return jsonResponse(getXurlRateLimitSnapshot());
20
+ }),
21
+ ),
22
+ },
23
+ },
24
+ });
@@ -0,0 +1,255 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ AlertTriangle,
4
+ CheckCircle2,
5
+ Database,
6
+ RefreshCw,
7
+ Route as RouteIcon,
8
+ TerminalSquare,
9
+ XCircle,
10
+ } from "lucide-react";
11
+ import { useCallback, useEffect, useMemo, useState } from "react";
12
+ import type {
13
+ LiveDataSourceCapability,
14
+ LiveDataSourceKind,
15
+ LiveDataSourcesResponse,
16
+ LiveDataSourceStatus,
17
+ } from "#/lib/types";
18
+ import {
19
+ cx,
20
+ errorCopyClass,
21
+ pageHeaderActionsClass,
22
+ pageHeaderClass,
23
+ pageHeaderRowClass,
24
+ pageSubtitleClass,
25
+ pageTitleClass,
26
+ secondaryButtonClass,
27
+ statusCopyClass,
28
+ } from "#/lib/ui";
29
+
30
+ export const Route = createFileRoute("/data-sources")({
31
+ component: DataSourcesRoute,
32
+ });
33
+
34
+ async function fetchDataSources() {
35
+ const response = await fetch("/api/data-sources");
36
+ if (!response.ok) {
37
+ throw new Error(`Data source status failed (${String(response.status)})`);
38
+ }
39
+ return (await response.json()) as LiveDataSourcesResponse;
40
+ }
41
+
42
+ function sourceIcon(source: LiveDataSourceKind) {
43
+ if (source === "birdclaw") return Database;
44
+ if (source === "bird") return TerminalSquare;
45
+ return RouteIcon;
46
+ }
47
+
48
+ function statusTone(status: LiveDataSourceStatus["status"]) {
49
+ if (status === "ok") {
50
+ return "border-[color:color-mix(in_srgb,#22c55e_45%,var(--line))] bg-[color:color-mix(in_srgb,#22c55e_10%,var(--bg))] text-[color:color-mix(in_srgb,#22c55e_82%,var(--ink))]";
51
+ }
52
+ if (status === "warning") {
53
+ return "border-[color:color-mix(in_srgb,#f59e0b_50%,var(--line))] bg-[color:color-mix(in_srgb,#f59e0b_12%,var(--bg))] text-[color:color-mix(in_srgb,#f59e0b_82%,var(--ink))]";
54
+ }
55
+ return "border-[color:color-mix(in_srgb,var(--alert)_55%,var(--line))] bg-[var(--alert-soft)] text-[var(--alert)]";
56
+ }
57
+
58
+ function statusIcon(status: LiveDataSourceStatus["status"]) {
59
+ if (status === "ok") return CheckCircle2;
60
+ if (status === "warning") return AlertTriangle;
61
+ return XCircle;
62
+ }
63
+
64
+ function sourceLabel(source: LiveDataSourceKind) {
65
+ if (source === "birdclaw") return "local";
66
+ return source;
67
+ }
68
+
69
+ function SourceCard({ source }: { source: LiveDataSourceStatus }) {
70
+ const Icon = sourceIcon(source.source);
71
+ const StatusIcon = statusIcon(source.status);
72
+ return (
73
+ <section className="min-w-0 border-b border-[var(--line)] px-4 py-4">
74
+ <div className="flex items-start justify-between gap-3">
75
+ <div className="flex min-w-0 items-center gap-3">
76
+ <div className="grid size-10 shrink-0 place-items-center rounded-md border border-[var(--line)] bg-[var(--bg-active)] text-[var(--ink)]">
77
+ <Icon className="size-5" strokeWidth={1.9} />
78
+ </div>
79
+ <div className="min-w-0">
80
+ <h2 className="truncate text-[16px] font-bold text-[var(--ink)]">
81
+ {source.label}
82
+ </h2>
83
+ <p className="truncate text-[13px] text-[var(--ink-soft)]">
84
+ {source.installed === false ? "not installed" : source.detail}
85
+ </p>
86
+ </div>
87
+ </div>
88
+ <span
89
+ className={cx(
90
+ "inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-[12px] font-bold",
91
+ statusTone(source.status),
92
+ )}
93
+ >
94
+ <StatusIcon className="size-3.5" strokeWidth={2} />
95
+ {source.works ? "works" : source.status}
96
+ </span>
97
+ </div>
98
+ <div className="mt-4 flex flex-wrap gap-2">
99
+ {source.accounts.length > 0 ? (
100
+ source.accounts.map((account, index) => (
101
+ <span
102
+ key={`${account.app ?? ""}:${account.username ?? account.handle ?? account.id ?? String(index)}`}
103
+ className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[var(--line)] bg-[var(--bg)] px-2.5 py-1 text-[12px] font-semibold text-[var(--ink-soft)]"
104
+ >
105
+ {account.app ? (
106
+ <span className="text-[var(--ink-faint)]">{account.app}</span>
107
+ ) : null}
108
+ <span className="truncate">
109
+ {account.handle ??
110
+ (account.username ? `@${account.username}` : account.id)}
111
+ </span>
112
+ {account.isDefault ? (
113
+ <span className="text-[var(--accent)]">default</span>
114
+ ) : null}
115
+ </span>
116
+ ))
117
+ ) : (
118
+ <span className="text-[13px] text-[var(--ink-soft)]">
119
+ no authenticated account detected
120
+ </span>
121
+ )}
122
+ </div>
123
+ </section>
124
+ );
125
+ }
126
+
127
+ function CapabilityRow({
128
+ capability,
129
+ sourcesByKind,
130
+ }: {
131
+ capability: LiveDataSourceCapability;
132
+ sourcesByKind: Map<LiveDataSourceKind, LiveDataSourceStatus>;
133
+ }) {
134
+ const chain = [capability.primary, ...capability.fallbacks];
135
+ return (
136
+ <div className="grid gap-3 border-b border-[var(--line)] px-4 py-4 min-[840px]:grid-cols-[220px_minmax(0,1fr)]">
137
+ <div className="min-w-0">
138
+ <div className="font-bold text-[var(--ink)]">{capability.label}</div>
139
+ {capability.notes ? (
140
+ <div className="mt-1 text-[12px] text-[var(--ink-soft)]">
141
+ {capability.notes}
142
+ </div>
143
+ ) : null}
144
+ </div>
145
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
146
+ {chain.map((source, index) => {
147
+ const status = sourcesByKind.get(source);
148
+ const ok = Boolean(status?.works);
149
+ return (
150
+ <div
151
+ key={`${capability.key}:${source}:${String(index)}`}
152
+ className={cx(
153
+ "inline-flex items-center gap-2 rounded-full border px-3 py-1 text-[13px] font-semibold",
154
+ ok
155
+ ? "border-[color:color-mix(in_srgb,#22c55e_38%,var(--line))] text-[var(--ink)]"
156
+ : "border-[var(--line)] text-[var(--ink-soft)]",
157
+ )}
158
+ >
159
+ <span className="text-[12px] text-[var(--ink-faint)]">
160
+ {index === 0 ? "primary" : `fallback ${String(index)}`}
161
+ </span>
162
+ <span>{sourceLabel(source)}</span>
163
+ </div>
164
+ );
165
+ })}
166
+ </div>
167
+ </div>
168
+ );
169
+ }
170
+
171
+ function DataSourcesRoute() {
172
+ const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(null);
173
+ const [error, setError] = useState<string | null>(null);
174
+ const [loading, setLoading] = useState(true);
175
+ const sourcesByKind = useMemo(
176
+ () =>
177
+ new Map(
178
+ (snapshot?.sources ?? []).map((source) => [source.source, source]),
179
+ ),
180
+ [snapshot],
181
+ );
182
+
183
+ const load = useCallback(() => {
184
+ setLoading(true);
185
+ setError(null);
186
+ fetchDataSources()
187
+ .then(setSnapshot)
188
+ .catch((cause: unknown) => {
189
+ setError(
190
+ cause instanceof Error ? cause.message : "Data sources unavailable",
191
+ );
192
+ })
193
+ .finally(() => setLoading(false));
194
+ }, []);
195
+
196
+ useEffect(() => {
197
+ load();
198
+ }, [load]);
199
+
200
+ return (
201
+ <section className="flex min-h-screen flex-col">
202
+ <header className={pageHeaderClass}>
203
+ <div className={pageHeaderRowClass}>
204
+ <div>
205
+ <h1 className={pageTitleClass}>Data Sources</h1>
206
+ <p className={pageSubtitleClass}>
207
+ Live auth, local archive, and automatic fallback order
208
+ </p>
209
+ </div>
210
+ <div className={pageHeaderActionsClass}>
211
+ <button
212
+ className={secondaryButtonClass}
213
+ type="button"
214
+ onClick={load}
215
+ disabled={loading}
216
+ >
217
+ <RefreshCw className={cx("size-4", loading && "animate-spin")} />
218
+ Refresh
219
+ </button>
220
+ </div>
221
+ </div>
222
+ </header>
223
+ {error ? <div className={errorCopyClass}>{error}</div> : null}
224
+ {snapshot ? (
225
+ <>
226
+ <div className="grid border-t border-[var(--line)]">
227
+ {snapshot.sources.map((source) => (
228
+ <SourceCard key={source.source} source={source} />
229
+ ))}
230
+ </div>
231
+ <section className="min-h-0 flex-1">
232
+ <header className="border-b border-[var(--line)] px-4 py-3">
233
+ <h2 className="text-[16px] font-bold text-[var(--ink)]">
234
+ Fallbacks
235
+ </h2>
236
+ <p className="text-[13px] text-[var(--ink-soft)]">
237
+ Calls try the next source when the current source cannot serve
238
+ the request.
239
+ </p>
240
+ </header>
241
+ {snapshot.capabilities.map((capability) => (
242
+ <CapabilityRow
243
+ key={capability.key}
244
+ capability={capability}
245
+ sourcesByKind={sourcesByKind}
246
+ />
247
+ ))}
248
+ </section>
249
+ </>
250
+ ) : loading ? (
251
+ <div className={statusCopyClass}>Checking data sources...</div>
252
+ ) : null}
253
+ </section>
254
+ );
255
+ }