birdclaw 0.8.2 → 0.8.3

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 (89) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/package.json +2 -1
  3. package/src/cli/command-context.ts +17 -0
  4. package/src/cli/register-analysis.ts +500 -0
  5. package/src/cli/register-compose.ts +40 -0
  6. package/src/cli/register-graph.ts +132 -0
  7. package/src/cli/register-inbox.ts +41 -0
  8. package/src/cli/register-storage.ts +106 -0
  9. package/src/cli.ts +30 -750
  10. package/src/components/AccountSwitcher.tsx +7 -15
  11. package/src/components/AvatarChip.tsx +1 -1
  12. package/src/components/AvatarPreload.ts +149 -0
  13. package/src/components/MarkdownCitations.tsx +680 -0
  14. package/src/components/MarkdownViewer.tsx +8 -674
  15. package/src/components/ProfileAnalysisClient.ts +191 -0
  16. package/src/components/ProfileAnalysisStream.tsx +16 -185
  17. package/src/components/ProfilePreview.tsx +2 -0
  18. package/src/components/links-controller.ts +162 -0
  19. package/src/components/links-model.ts +198 -0
  20. package/src/components/network-map-controller.ts +84 -0
  21. package/src/components/network-map-model.ts +255 -0
  22. package/src/components/useTimelineRouteData.ts +105 -235
  23. package/src/lib/analysis-runtime.ts +238 -0
  24. package/src/lib/api-client.ts +16 -215
  25. package/src/lib/api-contracts.ts +328 -0
  26. package/src/lib/archive-import-plan.ts +102 -0
  27. package/src/lib/archive-import.ts +170 -239
  28. package/src/lib/authored-live.ts +75 -120
  29. package/src/lib/backup.ts +335 -424
  30. package/src/lib/blocks-write.ts +30 -26
  31. package/src/lib/blocks.ts +18 -20
  32. package/src/lib/database-metrics.ts +88 -0
  33. package/src/lib/database-migrations.ts +34 -0
  34. package/src/lib/database-schema.ts +312 -0
  35. package/src/lib/database-writer.ts +69 -0
  36. package/src/lib/db.ts +84 -330
  37. package/src/lib/dm-read-model.ts +533 -0
  38. package/src/lib/dms-live.ts +34 -97
  39. package/src/lib/follow-graph.ts +17 -27
  40. package/src/lib/import-repository.ts +138 -0
  41. package/src/lib/inbox.ts +2 -1
  42. package/src/lib/live-sync-engine.ts +209 -0
  43. package/src/lib/live-transport-gateway.ts +128 -0
  44. package/src/lib/mention-threads-live.ts +90 -177
  45. package/src/lib/mentions-export.ts +1 -1
  46. package/src/lib/mentions-live.ts +57 -181
  47. package/src/lib/moderation-target.ts +15 -4
  48. package/src/lib/moderation-write.ts +1 -1
  49. package/src/lib/mutes-write.ts +30 -26
  50. package/src/lib/openai-response-runtime.ts +251 -0
  51. package/src/lib/paginated-sync.ts +93 -0
  52. package/src/lib/period-digest.ts +116 -304
  53. package/src/lib/profile-analysis.ts +36 -110
  54. package/src/lib/queries.ts +6 -2381
  55. package/src/lib/query-actions.ts +437 -0
  56. package/src/lib/query-client.tsx +47 -0
  57. package/src/lib/query-read-model-shared.ts +52 -0
  58. package/src/lib/query-read-models.ts +5 -0
  59. package/src/lib/query-resource.ts +41 -0
  60. package/src/lib/query-status.ts +164 -0
  61. package/src/lib/research.ts +1 -1
  62. package/src/lib/runtime-services.ts +20 -0
  63. package/src/lib/search-discussion.ts +75 -279
  64. package/src/lib/server-runtime-services.ts +30 -0
  65. package/src/lib/sqlite.ts +48 -12
  66. package/src/lib/streaming-ingestion.ts +240 -0
  67. package/src/lib/sync-cache.ts +6 -1
  68. package/src/lib/sync-plan.ts +175 -0
  69. package/src/lib/timeline-collections-live.ts +83 -257
  70. package/src/lib/timeline-live.ts +86 -236
  71. package/src/lib/timeline-read-model.ts +1191 -0
  72. package/src/lib/tweet-repository.ts +156 -0
  73. package/src/lib/tweet-search-live.ts +63 -167
  74. package/src/lib/web-sync.ts +67 -50
  75. package/src/lib/whois.ts +2 -1
  76. package/src/routes/__root.tsx +11 -8
  77. package/src/routes/api/action.tsx +1 -1
  78. package/src/routes/api/conversation.tsx +1 -1
  79. package/src/routes/api/query.tsx +32 -26
  80. package/src/routes/api/status.tsx +6 -4
  81. package/src/routes/api/sync.tsx +5 -2
  82. package/src/routes/blocks.tsx +97 -131
  83. package/src/routes/data-sources.tsx +17 -25
  84. package/src/routes/dms.tsx +167 -184
  85. package/src/routes/inbox.tsx +63 -57
  86. package/src/routes/links.tsx +31 -394
  87. package/src/routes/network-map.tsx +41 -344
  88. package/src/routes/rate-limits.tsx +17 -21
  89. package/src/lib/client-cache.ts +0 -109
package/src/lib/whois.ts CHANGED
@@ -9,7 +9,8 @@ import { fetchProfileBioEntities } from "./profile-bio-entities";
9
9
  import { fetchProfileSnapshots } from "./profile-history";
10
10
  import { resolveProfilesForIdsEffect } from "./profile-resolver";
11
11
  import type { ProfileResolveResult } from "./profile-resolver";
12
- import { listDmConversations, listTimelineItems } from "./queries";
12
+ import { listDmConversations } from "./dm-read-model";
13
+ import { listTimelineItems } from "./timeline-read-model";
13
14
  import { expandUrlsFromTextsEffect } from "./url-expansion";
14
15
  import type {
15
16
  DmConversationItem,
@@ -6,6 +6,7 @@ import {
6
6
  } from "@tanstack/react-router";
7
7
  import type { ReactNode } from "react";
8
8
  import { AppNav } from "#/components/AppNav";
9
+ import { BirdclawQueryProvider } from "#/lib/query-client";
9
10
  import { ThemeProvider, themeScript } from "#/lib/theme";
10
11
  import {
11
12
  bodyClass,
@@ -64,14 +65,16 @@ function RootDocument({ children }: { children: ReactNode }) {
64
65
  <script suppressHydrationWarning>{themeScript}</script>
65
66
  </head>
66
67
  <body className={bodyClass}>
67
- <ThemeProvider>
68
- <div className={wideMode ? siteShellDmClass : siteShellClass}>
69
- <AppNav compact={wideMode} />
70
- <main className={wideMode ? mainColumnDmClass : mainColumnClass}>
71
- {children}
72
- </main>
73
- </div>
74
- </ThemeProvider>
68
+ <BirdclawQueryProvider>
69
+ <ThemeProvider>
70
+ <div className={wideMode ? siteShellDmClass : siteShellClass}>
71
+ <AppNav compact={wideMode} />
72
+ <main className={wideMode ? mainColumnDmClass : mainColumnClass}>
73
+ {children}
74
+ </main>
75
+ </div>
76
+ </ThemeProvider>
77
+ </BirdclawQueryProvider>
75
78
  <Scripts />
76
79
  </body>
77
80
  </html>
@@ -18,7 +18,7 @@ import {
18
18
  createDmReplyEffect,
19
19
  createPostEffect,
20
20
  createTweetReplyEffect,
21
- } from "#/lib/queries";
21
+ } from "#/lib/query-actions";
22
22
  import type { ActionsTransport } from "#/lib/config";
23
23
  import type { InboxKind } from "#/lib/types";
24
24
 
@@ -5,7 +5,7 @@ import {
5
5
  runRouteEffect,
6
6
  sensitiveRequestErrorResponse,
7
7
  } from "#/lib/http-effect";
8
- import { getTweetConversation } from "#/lib/queries";
8
+ import { getTweetConversation } from "#/lib/timeline-read-model";
9
9
 
10
10
  function json(data: unknown, status = 200) {
11
11
  return new Response(JSON.stringify(data), {
@@ -1,5 +1,6 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
+ import { queryResponseSchema } from "#/lib/api-contracts";
3
4
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
5
  import {
5
6
  jsonResponse,
@@ -7,7 +8,7 @@ import {
7
8
  runRouteEffect,
8
9
  sensitiveRequestErrorResponse,
9
10
  } from "#/lib/http-effect";
10
- import { queryResource } from "#/lib/queries";
11
+ import { queryResource } from "#/lib/query-resource";
11
12
  import type {
12
13
  DmQuery,
13
14
  ReplyFilter,
@@ -78,35 +79,40 @@ export const Route = createFileRoute("/api/query")({
78
79
 
79
80
  if (resource === "dms") {
80
81
  return jsonResponse(
81
- queryResource("dms", {
82
- ...baseFilters,
83
- participant: url.searchParams.get("participant") ?? undefined,
84
- minFollowers: parseOptionalNumber(
85
- url.searchParams.get("minFollowers"),
86
- ),
87
- maxFollowers: parseOptionalNumber(
88
- url.searchParams.get("maxFollowers"),
89
- ),
90
- minInfluenceScore: parseOptionalNumber(
91
- url.searchParams.get("minInfluenceScore"),
92
- ),
93
- maxInfluenceScore: parseOptionalNumber(
94
- url.searchParams.get("maxInfluenceScore"),
95
- ),
96
- sort: parseDmSort(url.searchParams.get("sort")),
97
- inbox: parseDmInbox(url.searchParams.get("inbox")),
98
- conversationId:
99
- url.searchParams.get("conversationId") ?? undefined,
100
- }),
82
+ queryResponseSchema.parse(
83
+ queryResource("dms", {
84
+ ...baseFilters,
85
+ participant:
86
+ url.searchParams.get("participant") ?? undefined,
87
+ minFollowers: parseOptionalNumber(
88
+ url.searchParams.get("minFollowers"),
89
+ ),
90
+ maxFollowers: parseOptionalNumber(
91
+ url.searchParams.get("maxFollowers"),
92
+ ),
93
+ minInfluenceScore: parseOptionalNumber(
94
+ url.searchParams.get("minInfluenceScore"),
95
+ ),
96
+ maxInfluenceScore: parseOptionalNumber(
97
+ url.searchParams.get("maxInfluenceScore"),
98
+ ),
99
+ sort: parseDmSort(url.searchParams.get("sort")),
100
+ inbox: parseDmInbox(url.searchParams.get("inbox")),
101
+ conversationId:
102
+ url.searchParams.get("conversationId") ?? undefined,
103
+ }),
104
+ ),
101
105
  );
102
106
  }
103
107
 
104
108
  return jsonResponse(
105
- queryResource(resource, {
106
- ...baseFilters,
107
- resource,
108
- untilId: url.searchParams.get("untilId") ?? undefined,
109
- }),
109
+ queryResponseSchema.parse(
110
+ queryResource(resource, {
111
+ ...baseFilters,
112
+ resource,
113
+ untilId: url.searchParams.get("untilId") ?? undefined,
114
+ }),
115
+ ),
110
116
  );
111
117
  }),
112
118
  ),
@@ -1,12 +1,13 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
+ import { queryEnvelopeSchema } from "#/lib/api-contracts";
3
4
  import { maybeAutoUpdateBackupEffect } from "#/lib/backup";
4
5
  import {
5
6
  jsonResponse,
6
7
  runRouteEffect,
7
8
  sensitiveRequestErrorResponse,
8
9
  } from "#/lib/http-effect";
9
- import { getQueryEnvelopeEffect } from "#/lib/queries";
10
+ import { getQueryEnvelopeEffect } from "#/lib/query-status";
10
11
 
11
12
  export const Route = createFileRoute("/api/status")({
12
13
  server: {
@@ -18,9 +19,10 @@ export const Route = createFileRoute("/api/status")({
18
19
  if (denied) return denied;
19
20
 
20
21
  yield* maybeAutoUpdateBackupEffect();
21
- return jsonResponse(
22
- yield* getQueryEnvelopeEffect({ includeArchives: false }),
23
- );
22
+ const envelope = yield* getQueryEnvelopeEffect({
23
+ includeArchives: false,
24
+ });
25
+ return jsonResponse(queryEnvelopeSchema.parse(envelope));
24
26
  }),
25
27
  ),
26
28
  },
@@ -1,5 +1,6 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Effect } from "effect";
3
+ import { webSyncJobSchema } from "#/lib/api-contracts";
3
4
  import {
4
5
  jsonResponse,
5
6
  requestJsonEffect,
@@ -72,7 +73,7 @@ export const Route = createFileRoute("/api/sync")({
72
73
  );
73
74
  }
74
75
 
75
- return jsonResponse(job);
76
+ return jsonResponse(webSyncJobSchema.parse(job));
76
77
  },
77
78
  POST: ({ request }) =>
78
79
  runRouteEffect(
@@ -97,7 +98,9 @@ export const Route = createFileRoute("/api/sync")({
97
98
  parseAccountId(body.accountId),
98
99
  parseSyncOptions(kind, body),
99
100
  );
100
- return jsonResponse(job, { status: job.inProgress ? 202 : 200 });
101
+ return jsonResponse(webSyncJobSchema.parse(job), {
102
+ status: job.inProgress ? 202 : 200,
103
+ });
101
104
  }),
102
105
  ),
103
106
  },
@@ -1,13 +1,16 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ keepPreviousData,
4
+ useQuery,
5
+ useQueryClient,
6
+ } from "@tanstack/react-query";
2
7
  import { useEffect, useMemo, useState } from "react";
3
8
  import { AvatarChip } from "#/components/AvatarChip";
9
+ import { useDebouncedValue } from "#/components/useDebouncedValue";
10
+ import { fetchQueryEnvelope, postAction } from "#/lib/api-client";
4
11
  import { formatCompactNumber } from "#/lib/present";
5
- import type {
6
- BlockItem,
7
- BlockListResponse,
8
- BlockSearchItem,
9
- QueryEnvelope,
10
- } from "#/lib/types";
12
+ import { queryKeys } from "#/lib/query-client";
13
+ import type { BlockListResponse } from "#/lib/types";
11
14
  import {
12
15
  blockRowBodyClass,
13
16
  blockRowClass,
@@ -35,128 +38,96 @@ export const Route = createFileRoute("/blocks")({
35
38
  });
36
39
 
37
40
  function BlocksRoute() {
38
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
41
+ const queryClient = useQueryClient();
39
42
  const [accountId, setAccountId] = useState<string>("acct_primary");
40
43
  const [search, setSearch] = useState("");
41
- const [items, setItems] = useState<BlockItem[]>([]);
42
- const [matches, setMatches] = useState<BlockSearchItem[]>([]);
43
- const [refreshTick, setRefreshTick] = useState(0);
44
44
  const [isSubmitting, setIsSubmitting] = useState(false);
45
- const [isSyncing, setIsSyncing] = useState(false);
46
45
  const [message, setMessage] = useState("");
47
- const [error, setError] = useState("");
46
+ const [actionError, setActionError] = useState("");
47
+ const statusQuery = useQuery({
48
+ queryKey: queryKeys.status,
49
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
50
+ });
51
+ const meta = statusQuery.data ?? null;
52
+ const debouncedSearch = useDebouncedValue(search, 180);
48
53
  const hasAccountId = accountId.trim().length > 0;
49
54
  const isReady = Boolean(meta);
50
-
51
- useEffect(() => {
52
- const controller = new AbortController();
53
-
54
- fetch("/api/status", { signal: controller.signal })
55
- .then((response) => response.json())
56
- .then((data: QueryEnvelope) => {
57
- setMeta(data);
58
- setAccountId(data.accounts[0]?.id ?? "acct_primary");
59
- setError("");
60
- })
61
- .catch((error: unknown) => {
62
- if (error instanceof DOMException && error.name === "AbortError") {
63
- return;
64
- }
65
- setError(
66
- error instanceof Error
67
- ? error.message
68
- : "Unable to load blocklist status",
69
- );
55
+ const blocksQueryKey = [
56
+ ...queryKeys.blocks,
57
+ { accountId, search: debouncedSearch },
58
+ ] as const;
59
+ const blocksQuery = useQuery({
60
+ queryKey: blocksQueryKey,
61
+ enabled: hasAccountId,
62
+ queryFn: async ({ signal }) => {
63
+ const params = new URLSearchParams({
64
+ account: accountId,
65
+ limit: "12",
70
66
  });
71
-
72
- return () => {
73
- controller.abort();
74
- };
75
- }, []);
76
-
77
- useEffect(() => {
78
- const controller = new AbortController();
79
- const params = new URLSearchParams({
80
- account: accountId,
81
- limit: "12",
82
- refresh: String(refreshTick),
83
- });
84
- if (search.trim()) {
85
- params.set("search", search.trim());
86
- }
87
-
88
- fetch(`/api/blocks?${params.toString()}`, { signal: controller.signal })
89
- .then((response) => response.json())
90
- .then((data: BlockListResponse) => {
91
- setItems(data.items);
92
- setMatches(data.matches);
93
- setError("");
94
- })
95
- .catch((error: unknown) => {
96
- if (error instanceof DOMException && error.name === "AbortError") {
97
- return;
98
- }
99
- setError(
100
- error instanceof Error ? error.message : "Unable to load blocklist",
101
- );
67
+ if (debouncedSearch.trim()) {
68
+ params.set("search", debouncedSearch.trim());
69
+ }
70
+ const response = await fetch(`/api/blocks?${params.toString()}`, {
71
+ signal,
102
72
  });
103
-
104
- return () => {
105
- controller.abort();
106
- };
107
- }, [accountId, refreshTick, search]);
108
-
109
- useEffect(() => {
110
- if (!hasAccountId) {
111
- return;
112
- }
113
-
114
- const controller = new AbortController();
115
- setIsSyncing(true);
116
-
117
- fetch("/api/action", {
118
- method: "POST",
119
- headers: { "content-type": "application/json" },
120
- body: JSON.stringify({
73
+ if (!response.ok) {
74
+ throw new Error(
75
+ `Blocklist request failed (${String(response.status)})`,
76
+ );
77
+ }
78
+ return (await response.json()) as BlockListResponse;
79
+ },
80
+ placeholderData: keepPreviousData,
81
+ staleTime: 5 * 60_000,
82
+ });
83
+ const items = blocksQuery.data?.items ?? [];
84
+ const matches = blocksQuery.data?.matches ?? [];
85
+ const blockSyncQuery = useQuery({
86
+ queryKey: [...queryKeys.blockSync, accountId],
87
+ enabled: hasAccountId,
88
+ retry: false,
89
+ queryFn: async () => {
90
+ const data = (await postAction({
121
91
  kind: "syncBlocks",
122
92
  accountId,
123
- }),
124
- signal: controller.signal,
125
- })
126
- .then((response) => response.json())
127
- .then(
128
- (data: {
129
- ok?: boolean;
130
- synced?: boolean;
131
- syncedCount?: number;
132
- transport?: { ok?: boolean; output?: string };
133
- }) => {
134
- if (data.ok === false) {
135
- setError(data.transport?.output ?? "Block sync failed");
136
- return;
137
- }
138
- setRefreshTick((value) => value + 1);
139
- if (data.transport?.output?.includes("disabled")) {
140
- return;
141
- }
142
- setMessage(
143
- data.transport?.output ??
144
- `Synced ${String(data.syncedCount ?? 0)} remote blocks`,
145
- );
146
- },
147
- )
148
- .catch((error: unknown) => {
149
- if (error instanceof DOMException && error.name === "AbortError") {
150
- return;
151
- }
152
- setError(error instanceof Error ? error.message : "Block sync failed");
153
- })
154
- .finally(() => setIsSyncing(false));
93
+ })) as {
94
+ ok?: boolean;
95
+ syncedCount?: number;
96
+ transport?: { ok?: boolean; output?: string };
97
+ };
98
+ if (data.ok === false || data.transport?.ok === false) {
99
+ throw new Error(data.transport?.output ?? "Block sync failed");
100
+ }
101
+ await queryClient.invalidateQueries({ queryKey: queryKeys.blocks });
102
+ return data;
103
+ },
104
+ staleTime: 5 * 60_000,
105
+ });
106
+ const isSyncing = blockSyncQuery.isFetching;
107
+ const queryError =
108
+ statusQuery.error ?? blocksQuery.error ?? blockSyncQuery.error ?? null;
109
+ const error =
110
+ actionError ||
111
+ (queryError instanceof Error
112
+ ? queryError.message
113
+ : queryError
114
+ ? "Unable to load blocklist"
115
+ : "");
155
116
 
156
- return () => {
157
- controller.abort();
158
- };
159
- }, [accountId, hasAccountId]);
117
+ useEffect(() => {
118
+ if (!meta?.accounts.length) return;
119
+ if (meta.accounts.some((account) => account.id === accountId)) return;
120
+ setAccountId(meta.accounts[0]?.id ?? "acct_primary");
121
+ }, [accountId, meta]);
122
+
123
+ useEffect(() => {
124
+ const data = blockSyncQuery.data;
125
+ if (!data || data.transport?.output?.includes("disabled")) return;
126
+ setMessage(
127
+ data.transport?.output ??
128
+ `Synced ${String(data.syncedCount ?? 0)} remote blocks`,
129
+ );
130
+ }, [blockSyncQuery.data]);
160
131
 
161
132
  const subtitle = useMemo(() => {
162
133
  if (!meta) {
@@ -177,26 +148,21 @@ function BlocksRoute() {
177
148
  if (!normalized) return;
178
149
 
179
150
  setIsSubmitting(true);
180
- setError("");
151
+ setActionError("");
181
152
  setMessage("");
182
153
 
183
154
  try {
184
- const response = await fetch("/api/action", {
185
- method: "POST",
186
- headers: { "content-type": "application/json" },
187
- body: JSON.stringify({
188
- kind,
189
- accountId,
190
- query: normalized,
191
- }),
192
- });
193
- const data = (await response.json()) as {
155
+ const data = (await postAction({
156
+ kind,
157
+ accountId,
158
+ query: normalized,
159
+ })) as {
194
160
  ok?: boolean;
195
161
  profile?: { handle?: string };
196
162
  transport?: { ok?: boolean; output?: string };
197
163
  };
198
164
  if (data.ok === false || data.transport?.ok === false) {
199
- setError(data.transport?.output ?? "Blocklist action failed");
165
+ setActionError(data.transport?.output ?? "Blocklist action failed");
200
166
  return;
201
167
  }
202
168
 
@@ -205,9 +171,9 @@ function BlocksRoute() {
205
171
  data.profile?.handle ?? normalized.replace(/^@/, "")
206
172
  } · ${data.transport?.output ?? "local"}`,
207
173
  );
208
- setRefreshTick((value) => value + 1);
174
+ await queryClient.invalidateQueries({ queryKey: queryKeys.blocks });
209
175
  } catch (submitError) {
210
- setError(
176
+ setActionError(
211
177
  submitError instanceof Error
212
178
  ? submitError.message
213
179
  : "Blocklist action failed",
@@ -1,4 +1,5 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { useQuery } from "@tanstack/react-query";
2
3
  import {
3
4
  AlertTriangle,
4
5
  CheckCircle2,
@@ -8,7 +9,8 @@ import {
8
9
  TerminalSquare,
9
10
  XCircle,
10
11
  } from "lucide-react";
11
- import { useCallback, useEffect, useMemo, useState } from "react";
12
+ import { useMemo } from "react";
13
+ import { queryKeys } from "#/lib/query-client";
12
14
  import type {
13
15
  LiveDataSourceCapability,
14
16
  LiveDataSourceKind,
@@ -169,11 +171,14 @@ function CapabilityRow({
169
171
  }
170
172
 
171
173
  function DataSourcesRoute() {
172
- const [snapshot, setSnapshot] = useState<LiveDataSourcesResponse | null>(
173
- null,
174
- );
175
- const [error, setError] = useState<string | null>(null);
176
- const [loading, setLoading] = useState(true);
174
+ const dataSourcesQuery = useQuery({
175
+ queryKey: queryKeys.dataSources,
176
+ queryFn: fetchDataSources,
177
+ staleTime: 5 * 60_000,
178
+ });
179
+ const snapshot = dataSourcesQuery.data ?? null;
180
+ const loading = dataSourcesQuery.isFetching;
181
+ const error = dataSourcesQuery.error;
177
182
  const sourcesByKind = useMemo(
178
183
  () =>
179
184
  new Map(
@@ -182,23 +187,6 @@ function DataSourcesRoute() {
182
187
  [snapshot],
183
188
  );
184
189
 
185
- const load = useCallback(() => {
186
- setLoading(true);
187
- setError(null);
188
- fetchDataSources()
189
- .then(setSnapshot)
190
- .catch((cause: unknown) => {
191
- setError(
192
- cause instanceof Error ? cause.message : "Data sources unavailable",
193
- );
194
- })
195
- .finally(() => setLoading(false));
196
- }, []);
197
-
198
- useEffect(() => {
199
- load();
200
- }, [load]);
201
-
202
190
  return (
203
191
  <section className="flex min-h-screen flex-col">
204
192
  <header className={pageHeaderClass}>
@@ -213,7 +201,7 @@ function DataSourcesRoute() {
213
201
  <button
214
202
  className={secondaryButtonClass}
215
203
  type="button"
216
- onClick={load}
204
+ onClick={() => void dataSourcesQuery.refetch()}
217
205
  disabled={loading}
218
206
  >
219
207
  <RefreshCw className={cx("size-4", loading && "animate-spin")} />
@@ -222,7 +210,11 @@ function DataSourcesRoute() {
222
210
  </div>
223
211
  </div>
224
212
  </header>
225
- {error ? <div className={errorCopyClass}>{error}</div> : null}
213
+ {error ? (
214
+ <div className={errorCopyClass}>
215
+ {error instanceof Error ? error.message : "Data sources unavailable"}
216
+ </div>
217
+ ) : null}
226
218
  {snapshot ? (
227
219
  <>
228
220
  <div className="grid border-t border-[var(--line)]">