birdclaw 0.5.0 → 0.5.1

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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +5 -0
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +7 -6
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +399 -0
  7. package/scripts/build-docs-site.mjs +940 -0
  8. package/scripts/docs-site-assets.mjs +311 -0
  9. package/scripts/run-vitest.mjs +21 -0
  10. package/scripts/sanitize-node-options.mjs +23 -0
  11. package/scripts/start-test-server.mjs +29 -0
  12. package/src/cli.ts +46 -1
  13. package/src/components/AppNav.tsx +3 -3
  14. package/src/components/BrandMark.tsx +67 -0
  15. package/src/components/ConversationThread.tsx +11 -10
  16. package/src/components/DmWorkspace.tsx +24 -9
  17. package/src/components/FeedState.tsx +147 -0
  18. package/src/components/InboxCard.tsx +14 -2
  19. package/src/components/SavedTimelineView.tsx +64 -56
  20. package/src/components/SyncNowButton.tsx +105 -0
  21. package/src/components/ThemeSlider.tsx +10 -59
  22. package/src/components/TimelineCard.tsx +157 -61
  23. package/src/components/TimelineRouteFrame.tsx +156 -0
  24. package/src/components/TweetMediaGrid.tsx +120 -23
  25. package/src/components/TweetRichText.tsx +13 -2
  26. package/src/components/useTimelineRouteData.ts +137 -0
  27. package/src/lib/api-client.ts +229 -0
  28. package/src/lib/archive-finder.ts +24 -20
  29. package/src/lib/archive-import.ts +18 -3
  30. package/src/lib/conversation-surface.ts +174 -0
  31. package/src/lib/db.ts +2 -0
  32. package/src/lib/queries.ts +93 -28
  33. package/src/lib/ui.ts +11 -3
  34. package/src/lib/web-sync.ts +443 -0
  35. package/src/routeTree.gen.ts +21 -0
  36. package/src/routes/api/sync.tsx +59 -0
  37. package/src/routes/dms.tsx +100 -27
  38. package/src/routes/index.tsx +21 -127
  39. package/src/routes/links.tsx +50 -5
  40. package/src/routes/mentions.tsx +21 -127
  41. package/src/styles.css +74 -11
  42. package/vite.config.ts +8 -0
@@ -17,6 +17,7 @@ import { Route as DmsRouteImport } from './routes/dms'
17
17
  import { Route as BookmarksRouteImport } from './routes/bookmarks'
18
18
  import { Route as BlocksRouteImport } from './routes/blocks'
19
19
  import { Route as IndexRouteImport } from './routes/index'
20
+ import { Route as ApiSyncRouteImport } from './routes/api/sync'
20
21
  import { Route as ApiStatusRouteImport } from './routes/api/status'
21
22
  import { Route as ApiQueryRouteImport } from './routes/api/query'
22
23
  import { Route as ApiProfileHydrateRouteImport } from './routes/api/profile-hydrate'
@@ -68,6 +69,11 @@ const IndexRoute = IndexRouteImport.update({
68
69
  path: '/',
69
70
  getParentRoute: () => rootRouteImport,
70
71
  } as any)
72
+ const ApiSyncRoute = ApiSyncRouteImport.update({
73
+ id: '/api/sync',
74
+ path: '/api/sync',
75
+ getParentRoute: () => rootRouteImport,
76
+ } as any)
71
77
  const ApiStatusRoute = ApiStatusRouteImport.update({
72
78
  id: '/api/status',
73
79
  path: '/api/status',
@@ -138,6 +144,7 @@ export interface FileRoutesByFullPath {
138
144
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
139
145
  '/api/query': typeof ApiQueryRoute
140
146
  '/api/status': typeof ApiStatusRoute
147
+ '/api/sync': typeof ApiSyncRoute
141
148
  }
142
149
  export interface FileRoutesByTo {
143
150
  '/': typeof IndexRoute
@@ -158,6 +165,7 @@ export interface FileRoutesByTo {
158
165
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
159
166
  '/api/query': typeof ApiQueryRoute
160
167
  '/api/status': typeof ApiStatusRoute
168
+ '/api/sync': typeof ApiSyncRoute
161
169
  }
162
170
  export interface FileRoutesById {
163
171
  __root__: typeof rootRouteImport
@@ -179,6 +187,7 @@ export interface FileRoutesById {
179
187
  '/api/profile-hydrate': typeof ApiProfileHydrateRoute
180
188
  '/api/query': typeof ApiQueryRoute
181
189
  '/api/status': typeof ApiStatusRoute
190
+ '/api/sync': typeof ApiSyncRoute
182
191
  }
183
192
  export interface FileRouteTypes {
184
193
  fileRoutesByFullPath: FileRoutesByFullPath
@@ -201,6 +210,7 @@ export interface FileRouteTypes {
201
210
  | '/api/profile-hydrate'
202
211
  | '/api/query'
203
212
  | '/api/status'
213
+ | '/api/sync'
204
214
  fileRoutesByTo: FileRoutesByTo
205
215
  to:
206
216
  | '/'
@@ -221,6 +231,7 @@ export interface FileRouteTypes {
221
231
  | '/api/profile-hydrate'
222
232
  | '/api/query'
223
233
  | '/api/status'
234
+ | '/api/sync'
224
235
  id:
225
236
  | '__root__'
226
237
  | '/'
@@ -241,6 +252,7 @@ export interface FileRouteTypes {
241
252
  | '/api/profile-hydrate'
242
253
  | '/api/query'
243
254
  | '/api/status'
255
+ | '/api/sync'
244
256
  fileRoutesById: FileRoutesById
245
257
  }
246
258
  export interface RootRouteChildren {
@@ -262,6 +274,7 @@ export interface RootRouteChildren {
262
274
  ApiProfileHydrateRoute: typeof ApiProfileHydrateRoute
263
275
  ApiQueryRoute: typeof ApiQueryRoute
264
276
  ApiStatusRoute: typeof ApiStatusRoute
277
+ ApiSyncRoute: typeof ApiSyncRoute
265
278
  }
266
279
 
267
280
  declare module '@tanstack/react-router' {
@@ -322,6 +335,13 @@ declare module '@tanstack/react-router' {
322
335
  preLoaderRoute: typeof IndexRouteImport
323
336
  parentRoute: typeof rootRouteImport
324
337
  }
338
+ '/api/sync': {
339
+ id: '/api/sync'
340
+ path: '/api/sync'
341
+ fullPath: '/api/sync'
342
+ preLoaderRoute: typeof ApiSyncRouteImport
343
+ parentRoute: typeof rootRouteImport
344
+ }
325
345
  '/api/status': {
326
346
  id: '/api/status'
327
347
  path: '/api/status'
@@ -414,6 +434,7 @@ const rootRouteChildren: RootRouteChildren = {
414
434
  ApiProfileHydrateRoute: ApiProfileHydrateRoute,
415
435
  ApiQueryRoute: ApiQueryRoute,
416
436
  ApiStatusRoute: ApiStatusRoute,
437
+ ApiSyncRoute: ApiSyncRoute,
417
438
  }
418
439
  export const routeTree = rootRouteImport
419
440
  ._addFileChildren(rootRouteChildren)
@@ -0,0 +1,59 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { getWebSyncJob, parseWebSyncKind, startWebSync } from "#/lib/web-sync";
3
+
4
+ function json(data: unknown, init?: ResponseInit) {
5
+ return new Response(JSON.stringify(data), {
6
+ ...init,
7
+ headers: {
8
+ "content-type": "application/json",
9
+ ...init?.headers,
10
+ },
11
+ });
12
+ }
13
+
14
+ function parseAccountId(value: unknown) {
15
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
16
+ }
17
+
18
+ export const Route = createFileRoute("/api/sync")({
19
+ server: {
20
+ handlers: {
21
+ GET: ({ request }) => {
22
+ const url = new URL(request.url);
23
+ const id = url.searchParams.get("id");
24
+ if (!id) {
25
+ return json(
26
+ { ok: false, message: "Missing sync job id" },
27
+ { status: 400 },
28
+ );
29
+ }
30
+
31
+ const job = getWebSyncJob(id);
32
+ if (!job) {
33
+ return json(
34
+ { ok: false, message: "Sync job not found" },
35
+ { status: 404 },
36
+ );
37
+ }
38
+
39
+ return json(job);
40
+ },
41
+ POST: async ({ request }) => {
42
+ const body = (await request.json().catch(() => ({}))) as Record<
43
+ string,
44
+ unknown
45
+ >;
46
+ const kind = parseWebSyncKind(body.kind);
47
+ if (!kind) {
48
+ return json(
49
+ { ok: false, message: "Unknown sync kind" },
50
+ { status: 400 },
51
+ );
52
+ }
53
+
54
+ const job = startWebSync(kind, parseAccountId(body.accountId));
55
+ return json(job, { status: job.inProgress ? 202 : 200 });
56
+ },
57
+ },
58
+ },
59
+ });
@@ -2,11 +2,17 @@ import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Search } from "lucide-react";
3
3
  import { useEffect, useMemo, useState } from "react";
4
4
  import { DmWorkspace } from "#/components/DmWorkspace";
5
+ import { FeedEmpty, FeedError, FeedLoading } from "#/components/FeedState";
6
+ import { SyncNowButton } from "#/components/SyncNowButton";
7
+ import {
8
+ fetchQueryEnvelope,
9
+ fetchQueryResponse,
10
+ postAction,
11
+ } from "#/lib/api-client";
5
12
  import type {
6
13
  DmConversationItem,
7
14
  DmMessageItem,
8
15
  QueryEnvelope,
9
- QueryResponse,
10
16
  ReplyFilter,
11
17
  } from "#/lib/types";
12
18
  import {
@@ -43,6 +49,9 @@ function DmsRoute() {
43
49
  const [meta, setMeta] = useState<QueryEnvelope | null>(null);
44
50
  const [items, setItems] = useState<DmConversationItem[]>([]);
45
51
  const [messages, setMessages] = useState<DmMessageItem[]>([]);
52
+ const [loadedConversationId, setLoadedConversationId] = useState<
53
+ string | undefined
54
+ >();
46
55
  const [selectedConversationId, setSelectedConversationId] = useState<
47
56
  string | undefined
48
57
  >();
@@ -53,15 +62,20 @@ function DmsRoute() {
53
62
  const [search, setSearch] = useState("");
54
63
  const [replyDraft, setReplyDraft] = useState("");
55
64
  const [refreshTick, setRefreshTick] = useState(0);
65
+ const [loading, setLoading] = useState(true);
66
+ const [error, setError] = useState<string | null>(null);
67
+
68
+ async function loadStatus() {
69
+ setMeta(await fetchQueryEnvelope());
70
+ }
56
71
 
57
72
  useEffect(() => {
58
- fetch("/api/status")
59
- .then((response) => response.json())
60
- .then((data: QueryEnvelope) => setMeta(data));
73
+ void loadStatus();
61
74
  }, []);
62
75
 
63
76
  useEffect(() => {
64
77
  const controller = new AbortController();
78
+ let active = true;
65
79
  const url = new URL("/api/query", window.location.origin);
66
80
  url.searchParams.set("resource", "dms");
67
81
  url.searchParams.set("replyFilter", replyFilter);
@@ -76,12 +90,15 @@ function DmsRoute() {
76
90
  url.searchParams.set("search", search.trim());
77
91
  }
78
92
 
79
- fetch(url, { signal: controller.signal })
80
- .then((response) => response.json())
81
- .then((data: QueryResponse) => {
93
+ setError(null);
94
+ setLoading(true);
95
+ fetchQueryResponse(url, { signal: controller.signal })
96
+ .then((data) => {
97
+ if (!active) return;
82
98
  const conversations = data.items as DmConversationItem[];
83
99
  const nextSelected =
84
100
  data.selectedConversation?.conversation.id ?? conversations[0]?.id;
101
+ setLoadedConversationId(data.selectedConversation?.conversation.id);
85
102
  setItems(conversations);
86
103
  setSelectedConversationId((current) => {
87
104
  if (!current) return nextSelected;
@@ -93,14 +110,31 @@ function DmsRoute() {
93
110
  });
94
111
  setMessages(data.selectedConversation?.messages ?? []);
95
112
  })
96
- .catch((error: unknown) => {
97
- if (error instanceof DOMException && error.name === "AbortError") {
113
+ .catch((fetchError: unknown) => {
114
+ if (
115
+ fetchError instanceof DOMException &&
116
+ fetchError.name === "AbortError"
117
+ ) {
98
118
  return;
99
119
  }
100
- throw error;
120
+ if (!active) return;
121
+ setError(
122
+ fetchError instanceof Error
123
+ ? fetchError.message
124
+ : "Messages unavailable",
125
+ );
126
+ setItems([]);
127
+ setMessages([]);
128
+ setLoadedConversationId(undefined);
129
+ })
130
+ .finally(() => {
131
+ if (active) {
132
+ setLoading(false);
133
+ }
101
134
  });
102
135
 
103
136
  return () => {
137
+ active = false;
104
138
  controller.abort();
105
139
  };
106
140
  }, [
@@ -115,6 +149,13 @@ function DmsRoute() {
115
149
 
116
150
  const selectedConversation =
117
151
  items.find((item) => item.id === selectedConversationId) ?? null;
152
+ const switchingConversation =
153
+ loading &&
154
+ Boolean(
155
+ selectedConversationId &&
156
+ loadedConversationId &&
157
+ selectedConversationId !== loadedConversationId,
158
+ );
118
159
 
119
160
  const subtitle = useMemo(() => {
120
161
  if (!meta) return "Loading direct messages...";
@@ -170,14 +211,10 @@ function DmsRoute() {
170
211
  );
171
212
 
172
213
  try {
173
- await fetch("/api/action", {
174
- method: "POST",
175
- headers: { "content-type": "application/json" },
176
- body: JSON.stringify({
177
- kind: "replyDm",
178
- conversationId,
179
- text,
180
- }),
214
+ await postAction({
215
+ kind: "replyDm",
216
+ conversationId,
217
+ text,
181
218
  });
182
219
 
183
220
  setSelectedConversationId(conversationId);
@@ -190,6 +227,11 @@ function DmsRoute() {
190
227
  }
191
228
  }
192
229
 
230
+ function refreshLocalView() {
231
+ setRefreshTick((value) => value + 1);
232
+ void loadStatus();
233
+ }
234
+
193
235
  return (
194
236
  <>
195
237
  <header className={pageHeaderClass}>
@@ -198,6 +240,11 @@ function DmsRoute() {
198
240
  <h1 className={pageTitleClass}>Messages</h1>
199
241
  <p className={pageSubtitleClass}>{subtitle}</p>
200
242
  </div>
243
+ <SyncNowButton
244
+ kind="dms"
245
+ label="Sync DMs"
246
+ onSynced={refreshLocalView}
247
+ />
201
248
  </div>
202
249
  <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
203
250
  <label className={cx(searchFieldShellClass, "flex-1 min-w-[200px]")}>
@@ -260,15 +307,41 @@ function DmsRoute() {
260
307
  </div>
261
308
  </header>
262
309
 
263
- <DmWorkspace
264
- conversations={items}
265
- onReplyDraftChange={setReplyDraft}
266
- onReplySend={replyToConversation}
267
- onSelectConversation={setSelectedConversationId}
268
- replyDraft={replyDraft}
269
- selectedConversation={selectedConversation}
270
- selectedMessages={messages}
271
- />
310
+ {loading && (items.length === 0 || switchingConversation) ? (
311
+ <FeedLoading
312
+ detail="Reading local conversations and reply state"
313
+ label="Loading messages"
314
+ />
315
+ ) : error ? (
316
+ <FeedError
317
+ action={
318
+ <button
319
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
320
+ onClick={() => setRefreshTick((value) => value + 1)}
321
+ type="button"
322
+ >
323
+ Retry
324
+ </button>
325
+ }
326
+ message={error}
327
+ title="Could not load messages"
328
+ />
329
+ ) : items.length === 0 ? (
330
+ <FeedEmpty
331
+ detail="Sync DMs or broaden the filters to find a conversation."
332
+ label="No conversations in this view"
333
+ />
334
+ ) : (
335
+ <DmWorkspace
336
+ conversations={items}
337
+ onReplyDraftChange={setReplyDraft}
338
+ onReplySend={replyToConversation}
339
+ onSelectConversation={setSelectedConversationId}
340
+ replyDraft={replyDraft}
341
+ selectedConversation={selectedConversation}
342
+ selectedMessages={messages}
343
+ />
344
+ )}
272
345
  </>
273
346
  );
274
347
  }
@@ -1,138 +1,32 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { Search } from "lucide-react";
3
- import { useEffect, useMemo, useState } from "react";
4
- import { TimelineCard } from "#/components/TimelineCard";
5
- import type {
6
- QueryEnvelope,
7
- QueryResponse,
8
- ReplyFilter,
9
- TimelineItem,
10
- } from "#/lib/types";
11
- import {
12
- cx,
13
- emptyStateClass,
14
- feedClass,
15
- pageHeaderClass,
16
- pageHeaderRowClass,
17
- pageSubtitleClass,
18
- pageTitleClass,
19
- searchFieldIconClass,
20
- searchFieldInputClass,
21
- searchFieldShellClass,
22
- tabButtonActiveClass,
23
- tabButtonClass,
24
- tabButtonIndicatorClass,
25
- tabStripClass,
26
- } from "#/lib/ui";
2
+ import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
3
+ import type { QueryEnvelope } from "#/lib/types";
27
4
 
28
5
  export const Route = createFileRoute("/")({
29
6
  component: HomeRoute,
30
7
  });
31
8
 
32
- const TABS: Array<{ value: ReplyFilter; label: string }> = [
33
- { value: "all", label: "All" },
34
- { value: "unreplied", label: "Unreplied" },
35
- { value: "replied", label: "Replied" },
36
- ];
9
+ function homeSubtitle(meta: QueryEnvelope | null) {
10
+ if (!meta) return "Loading local context...";
11
+ return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
12
+ }
37
13
 
38
14
  function HomeRoute() {
39
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
40
- const [items, setItems] = useState<TimelineItem[]>([]);
41
- const [replyFilter, setReplyFilter] = useState<ReplyFilter>("all");
42
- const [search, setSearch] = useState("");
43
- const [refreshTick, setRefreshTick] = useState(0);
44
-
45
- useEffect(() => {
46
- fetch("/api/status")
47
- .then((response) => response.json())
48
- .then((data: QueryEnvelope) => setMeta(data));
49
- }, []);
50
-
51
- useEffect(() => {
52
- const url = new URL("/api/query", window.location.origin);
53
- url.searchParams.set("resource", "home");
54
- url.searchParams.set("replyFilter", replyFilter);
55
- url.searchParams.set("refresh", String(refreshTick));
56
- if (search.trim()) {
57
- url.searchParams.set("search", search.trim());
58
- }
59
-
60
- fetch(url)
61
- .then((response) => response.json())
62
- .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
63
- }, [refreshTick, replyFilter, search]);
64
-
65
- const subtitle = useMemo(() => {
66
- if (!meta) return "Loading local context...";
67
- return `${String(meta.stats.home)} items · ${String(meta.stats.needsReply)} waiting · ${meta.transport.statusText}`;
68
- }, [meta]);
69
-
70
- async function replyToTweet(tweetId: string) {
71
- const text = window.prompt("Reply text");
72
- if (!text?.trim()) return;
73
-
74
- await fetch("/api/action", {
75
- method: "POST",
76
- headers: { "content-type": "application/json" },
77
- body: JSON.stringify({
78
- kind: "replyTweet",
79
- accountId: "acct_primary",
80
- tweetId,
81
- text,
82
- }),
83
- });
84
-
85
- setRefreshTick((value) => value + 1);
86
- }
87
-
88
15
  return (
89
- <>
90
- <header className={pageHeaderClass}>
91
- <div className={pageHeaderRowClass}>
92
- <div className="flex min-w-0 flex-col">
93
- <h1 className={pageTitleClass}>Home</h1>
94
- <p className={pageSubtitleClass}>{subtitle}</p>
95
- </div>
96
- </div>
97
- <div className="px-4 pb-3">
98
- <label className={searchFieldShellClass}>
99
- <Search className={searchFieldIconClass} strokeWidth={2} />
100
- <input
101
- className={searchFieldInputClass}
102
- onChange={(event) => setSearch(event.target.value)}
103
- placeholder="Search local timeline"
104
- value={search}
105
- />
106
- </label>
107
- </div>
108
- <div className={tabStripClass}>
109
- {TABS.map((tab) => {
110
- const active = replyFilter === tab.value;
111
- return (
112
- <button
113
- key={tab.value}
114
- type="button"
115
- aria-pressed={active}
116
- className={cx(tabButtonClass, active && tabButtonActiveClass)}
117
- onClick={() => setReplyFilter(tab.value)}
118
- >
119
- <span className="relative inline-flex flex-col items-center justify-center py-1">
120
- {tab.value}
121
- {active ? <span className={tabButtonIndicatorClass} /> : null}
122
- </span>
123
- </button>
124
- );
125
- })}
126
- </div>
127
- </header>
128
- <section className={feedClass}>
129
- {items.length === 0 ? (
130
- <div className={emptyStateClass}>No posts to show.</div>
131
- ) : null}
132
- {items.map((item) => (
133
- <TimelineCard key={item.id} item={item} onReply={replyToTweet} />
134
- ))}
135
- </section>
136
- </>
16
+ <TimelineRouteFrame
17
+ emptyDetail="Try a different filter or sync the timeline again."
18
+ emptyLabel="No posts in this view"
19
+ errorFallback="Timeline unavailable"
20
+ errorTitle="Could not load posts"
21
+ initialReplyFilter="all"
22
+ loadingDetail="Reading the local timeline store"
23
+ loadingLabel="Loading posts"
24
+ resource="home"
25
+ searchPlaceholder="Search local timeline"
26
+ subtitle={homeSubtitle}
27
+ syncKind="timeline"
28
+ syncLabel="Sync timeline"
29
+ title="Home"
30
+ />
137
31
  );
138
32
  }
@@ -11,6 +11,12 @@ import {
11
11
  } from "lucide-react";
12
12
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
13
  import { AvatarChip } from "#/components/AvatarChip";
14
+ import {
15
+ FeedEmpty,
16
+ FeedError,
17
+ FeedLoading,
18
+ LinkSkeletonRows,
19
+ } from "#/components/FeedState";
14
20
  import { ProfilePreview } from "#/components/ProfilePreview";
15
21
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
16
22
  import type {
@@ -26,7 +32,6 @@ import type {
26
32
  } from "#/lib/types";
27
33
  import {
28
34
  cx,
29
- emptyStateClass,
30
35
  pageHeaderClass,
31
36
  pageHeaderRowClass,
32
37
  pageSubtitleClass,
@@ -649,6 +654,7 @@ function LinksRoute() {
649
654
  const cacheRef = useRef(new Map<string, LinkInsightResponse>());
650
655
  const inFlightRef = useRef(new Set<string>());
651
656
  const mountedRef = useRef(true);
657
+ const [errorByKey, setErrorByKey] = useState<Record<string, string>>({});
652
658
  const [, bumpCacheVersion] = useState(0);
653
659
  const currentCacheKey = insightCacheKey(
654
660
  kind,
@@ -658,6 +664,8 @@ function LinksRoute() {
658
664
  refreshTick,
659
665
  );
660
666
  const data = cacheRef.current.get(currentCacheKey) ?? null;
667
+ const error = errorByKey[currentCacheKey] ?? null;
668
+ const loading = !data && !error;
661
669
 
662
670
  useEffect(() => {
663
671
  return () => {
@@ -672,6 +680,11 @@ function LinksRoute() {
672
680
  return;
673
681
  }
674
682
  inFlightRef.current.add(key);
683
+ setErrorByKey((current) => {
684
+ const rest = { ...current };
685
+ delete rest[key];
686
+ return rest;
687
+ });
675
688
  fetch(linkInsightsUrl(fetchKind, range, sort, source, refreshTick))
676
689
  .then((response) => response.json())
677
690
  .then((response: LinkInsightResponse) => {
@@ -684,7 +697,17 @@ function LinksRoute() {
684
697
  if (error instanceof DOMException && error.name === "AbortError") {
685
698
  return;
686
699
  }
700
+ if (!mountedRef.current) {
701
+ return;
702
+ }
687
703
  console.warn("Link insights failed", error);
704
+ setErrorByKey((current) => ({
705
+ ...current,
706
+ [key]:
707
+ error instanceof Error
708
+ ? error.message
709
+ : "Link insights unavailable",
710
+ }));
688
711
  })
689
712
  .finally(() => {
690
713
  inFlightRef.current.delete(key);
@@ -865,10 +888,32 @@ function LinksRoute() {
865
888
  </header>
866
889
 
867
890
  <section className="flex flex-col">
868
- {items.length === 0 ? (
869
- <div className={emptyStateClass}>
870
- {data ? "No links in this window." : "Loading links..."}
871
- </div>
891
+ {loading ? (
892
+ <FeedLoading
893
+ detail="Ranking URLs and collecting local discussion"
894
+ label="Loading links"
895
+ >
896
+ <LinkSkeletonRows />
897
+ </FeedLoading>
898
+ ) : error ? (
899
+ <FeedError
900
+ action={
901
+ <button
902
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
903
+ onClick={() => setRefreshTick((value) => value + 1)}
904
+ type="button"
905
+ >
906
+ Retry
907
+ </button>
908
+ }
909
+ message={error}
910
+ title="Could not load links"
911
+ />
912
+ ) : items.length === 0 ? (
913
+ <FeedEmpty
914
+ detail="Try a different range, source, or search term."
915
+ label="No links in this window"
916
+ />
872
917
  ) : null}
873
918
  {items.map((item, index) => (
874
919
  <LinkInsightRow