birdclaw 0.8.1 → 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 (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/package.json +2 -4
  3. package/scripts/browser-perf.mjs +27 -0
  4. package/src/cli/command-context.ts +17 -0
  5. package/src/cli/register-analysis.ts +500 -0
  6. package/src/cli/register-compose.ts +40 -0
  7. package/src/cli/register-graph.ts +132 -0
  8. package/src/cli/register-inbox.ts +41 -0
  9. package/src/cli/register-storage.ts +106 -0
  10. package/src/cli.ts +30 -750
  11. package/src/components/AccountSwitcher.tsx +7 -15
  12. package/src/components/AvatarChip.tsx +1 -1
  13. package/src/components/AvatarPreload.ts +149 -0
  14. package/src/components/ConversationThread.tsx +4 -0
  15. package/src/components/EmbeddedTweetCard.tsx +4 -0
  16. package/src/components/FloatingPreview.tsx +382 -0
  17. package/src/components/MarkdownCitations.tsx +680 -0
  18. package/src/components/MarkdownViewer.tsx +7 -715
  19. package/src/components/ProfileAnalysisClient.ts +191 -0
  20. package/src/components/ProfileAnalysisStream.tsx +16 -185
  21. package/src/components/ProfilePreview.tsx +41 -81
  22. package/src/components/TimelineCard.tsx +18 -2
  23. package/src/components/TweetArticleCard.tsx +66 -0
  24. package/src/components/TweetRichText.tsx +33 -2
  25. package/src/components/links-controller.ts +162 -0
  26. package/src/components/links-model.ts +198 -0
  27. package/src/components/network-map-controller.ts +84 -0
  28. package/src/components/network-map-model.ts +255 -0
  29. package/src/components/useDebouncedValue.ts +12 -0
  30. package/src/components/useTimelineRouteData.ts +142 -170
  31. package/src/lib/analysis-runtime.ts +238 -0
  32. package/src/lib/api-client.ts +16 -100
  33. package/src/lib/api-contracts.ts +328 -0
  34. package/src/lib/archive-finder.ts +38 -0
  35. package/src/lib/archive-import-plan.ts +102 -0
  36. package/src/lib/archive-import.ts +170 -239
  37. package/src/lib/authored-live.ts +77 -182
  38. package/src/lib/backup.ts +335 -424
  39. package/src/lib/bird.ts +32 -1
  40. package/src/lib/blocks-write.ts +30 -26
  41. package/src/lib/blocks.ts +18 -20
  42. package/src/lib/database-metrics.ts +88 -0
  43. package/src/lib/database-migrations.ts +34 -0
  44. package/src/lib/database-schema.ts +312 -0
  45. package/src/lib/database-writer.ts +69 -0
  46. package/src/lib/db.ts +141 -334
  47. package/src/lib/dm-read-model.ts +533 -0
  48. package/src/lib/dms-live.ts +34 -97
  49. package/src/lib/follow-graph.ts +17 -27
  50. package/src/lib/import-repository.ts +138 -0
  51. package/src/lib/inbox.ts +2 -1
  52. package/src/lib/live-sync-engine.ts +209 -0
  53. package/src/lib/live-transport-gateway.ts +128 -0
  54. package/src/lib/mention-threads-live.ts +90 -176
  55. package/src/lib/mentions-export.ts +1 -1
  56. package/src/lib/mentions-live.ts +57 -225
  57. package/src/lib/moderation-target.ts +15 -4
  58. package/src/lib/moderation-write.ts +1 -1
  59. package/src/lib/mutes-write.ts +30 -26
  60. package/src/lib/openai-response-runtime.ts +251 -0
  61. package/src/lib/paginated-sync.ts +93 -0
  62. package/src/lib/period-digest.ts +116 -304
  63. package/src/lib/profile-analysis.ts +37 -111
  64. package/src/lib/queries.ts +6 -2380
  65. package/src/lib/query-actions.ts +437 -0
  66. package/src/lib/query-client.tsx +47 -0
  67. package/src/lib/query-read-model-shared.ts +52 -0
  68. package/src/lib/query-read-models.ts +5 -0
  69. package/src/lib/query-resource.ts +41 -0
  70. package/src/lib/query-status.ts +164 -0
  71. package/src/lib/research.ts +1 -1
  72. package/src/lib/runtime-services.ts +20 -0
  73. package/src/lib/search-discussion.ts +75 -279
  74. package/src/lib/server-runtime-services.ts +30 -0
  75. package/src/lib/sqlite.ts +53 -14
  76. package/src/lib/streaming-ingestion.ts +240 -0
  77. package/src/lib/sync-cache.ts +6 -1
  78. package/src/lib/sync-plan.ts +175 -0
  79. package/src/lib/timeline-collections-live.ts +83 -256
  80. package/src/lib/timeline-live.ts +86 -235
  81. package/src/lib/timeline-read-model.ts +1191 -0
  82. package/src/lib/tweet-render.ts +78 -0
  83. package/src/lib/tweet-repository.ts +156 -0
  84. package/src/lib/tweet-search-live.ts +63 -166
  85. package/src/lib/types.ts +8 -0
  86. package/src/lib/ui.ts +1 -1
  87. package/src/lib/web-sync.ts +67 -50
  88. package/src/lib/whois.ts +2 -1
  89. package/src/router.tsx +1 -1
  90. package/src/routes/__root.tsx +11 -21
  91. package/src/routes/api/action.tsx +1 -1
  92. package/src/routes/api/conversation.tsx +1 -1
  93. package/src/routes/api/query.tsx +32 -26
  94. package/src/routes/api/status.tsx +6 -2
  95. package/src/routes/api/sync.tsx +5 -2
  96. package/src/routes/blocks.tsx +97 -131
  97. package/src/routes/data-sources.tsx +17 -25
  98. package/src/routes/dms.tsx +168 -167
  99. package/src/routes/inbox.tsx +63 -57
  100. package/src/routes/links.tsx +31 -329
  101. package/src/routes/network-map.tsx +41 -344
  102. package/src/routes/rate-limits.tsx +17 -21
  103. package/vite.config.ts +0 -2
@@ -1,4 +1,10 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ keepPreviousData,
4
+ useMutation,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
2
8
  import { Search } from "lucide-react";
3
9
  import { useEffect, useMemo, useState } from "react";
4
10
  import { DmWorkspace } from "#/components/DmWorkspace";
@@ -10,12 +16,14 @@ import {
10
16
  fetchQueryResponse,
11
17
  postAction,
12
18
  } from "#/lib/api-client";
19
+ import { queryKeys } from "#/lib/query-client";
13
20
  import type {
14
21
  DmConversationItem,
15
22
  DmMessageItem,
16
- QueryEnvelope,
23
+ QueryResponse,
17
24
  ReplyFilter,
18
25
  } from "#/lib/types";
26
+ import { useDebouncedValue } from "#/components/useDebouncedValue";
19
27
  import {
20
28
  cx,
21
29
  pageHeaderClass,
@@ -62,12 +70,7 @@ const filterNumberFieldClass =
62
70
  "flex h-[46px] shrink-0 items-center gap-2 rounded-md border border-[var(--line)] bg-[var(--bg)] px-3 py-0 text-[14px] text-[var(--ink)] outline-none transition-colors duration-150 focus-within:border-[var(--accent)] focus-within:shadow-[0_0_0_1px_var(--accent)]";
63
71
 
64
72
  function DmsRoute() {
65
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
66
- const [items, setItems] = useState<DmConversationItem[]>([]);
67
- const [messages, setMessages] = useState<DmMessageItem[]>([]);
68
- const [loadedConversationId, setLoadedConversationId] = useState<
69
- string | undefined
70
- >();
73
+ const queryClient = useQueryClient();
71
74
  const [selectedConversationId, setSelectedConversationId] = useState<
72
75
  string | undefined
73
76
  >();
@@ -78,189 +81,187 @@ function DmsRoute() {
78
81
  const [sort, setSort] = useState<"recent" | "followers">("recent");
79
82
  const [search, setSearch] = useState("");
80
83
  const [replyDraft, setReplyDraft] = useState("");
81
- const [refreshTick, setRefreshTick] = useState(0);
82
- const [loading, setLoading] = useState(true);
83
- const [error, setError] = useState<string | null>(null);
84
- const [replyError, setReplyError] = useState<string | null>(null);
84
+ const statusQuery = useQuery({
85
+ queryKey: queryKeys.status,
86
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
87
+ });
88
+ const meta = statusQuery.data ?? null;
85
89
  const selectedAccountId = useSelectedAccountId(meta?.accounts);
86
-
87
- async function loadStatus() {
88
- setMeta(await fetchQueryEnvelope());
89
- }
90
+ const debouncedSearch = useDebouncedValue(search, 180);
91
+ const dmsQueryKey = [
92
+ ...queryKeys.dms,
93
+ {
94
+ inboxFilter,
95
+ replyFilter,
96
+ minFollowers,
97
+ minInfluenceScore,
98
+ sort,
99
+ search: debouncedSearch,
100
+ selectedAccountId: selectedAccountId ?? null,
101
+ selectedConversationId: selectedConversationId ?? null,
102
+ },
103
+ ] as const;
104
+ const dmsQuery = useQuery({
105
+ queryKey: dmsQueryKey,
106
+ queryFn: ({ signal }) => {
107
+ const url = new URL("/api/query", window.location.origin);
108
+ url.searchParams.set("resource", "dms");
109
+ url.searchParams.set("inbox", inboxFilter);
110
+ url.searchParams.set("replyFilter", replyFilter);
111
+ url.searchParams.set("sort", sort);
112
+ if (minFollowers.trim()) {
113
+ url.searchParams.set("minFollowers", minFollowers.trim());
114
+ }
115
+ if (minInfluenceScore.trim()) {
116
+ url.searchParams.set("minInfluenceScore", minInfluenceScore.trim());
117
+ }
118
+ if (selectedAccountId && inboxFilter !== "requests") {
119
+ url.searchParams.set("account", selectedAccountId);
120
+ }
121
+ if (selectedConversationId) {
122
+ url.searchParams.set("conversationId", selectedConversationId);
123
+ }
124
+ if (debouncedSearch.trim()) {
125
+ url.searchParams.set("search", debouncedSearch.trim());
126
+ }
127
+ return fetchQueryResponse(url, { signal });
128
+ },
129
+ placeholderData: keepPreviousData,
130
+ staleTime: 5 * 60_000,
131
+ });
132
+ const items = (dmsQuery.data?.items ?? []) as DmConversationItem[];
133
+ const messages = dmsQuery.data?.selectedConversation?.messages ?? [];
134
+ const loadedConversationId =
135
+ dmsQuery.data?.selectedConversation?.conversation.id;
90
136
 
91
137
  useEffect(() => {
92
- void loadStatus();
93
- }, []);
94
-
95
- useEffect(() => {
96
- const controller = new AbortController();
97
- let active = true;
98
- const url = new URL("/api/query", window.location.origin);
99
- url.searchParams.set("resource", "dms");
100
- url.searchParams.set("inbox", inboxFilter);
101
- url.searchParams.set("replyFilter", replyFilter);
102
- url.searchParams.set("refresh", String(refreshTick));
103
- url.searchParams.set("sort", sort);
104
- if (minFollowers.trim()) {
105
- url.searchParams.set("minFollowers", minFollowers.trim());
106
- }
107
- if (minInfluenceScore.trim()) {
108
- url.searchParams.set("minInfluenceScore", minInfluenceScore.trim());
109
- }
110
- if (selectedAccountId && inboxFilter !== "requests") {
111
- url.searchParams.set("account", selectedAccountId);
112
- }
113
- if (selectedConversationId) {
114
- url.searchParams.set("conversationId", selectedConversationId);
115
- }
116
- if (search.trim()) {
117
- url.searchParams.set("search", search.trim());
118
- }
119
-
120
- setError(null);
121
- setLoading(true);
122
- fetchQueryResponse(url, { signal: controller.signal })
123
- .then((data) => {
124
- if (!active) return;
125
- const conversations = data.items as DmConversationItem[];
126
- const nextSelected =
127
- data.selectedConversation?.conversation.id ?? conversations[0]?.id;
128
- setLoadedConversationId(data.selectedConversation?.conversation.id);
129
- setItems(conversations);
130
- setSelectedConversationId((current) => {
131
- if (!current) return nextSelected;
132
- return conversations.some(
133
- (conversation) => conversation.id === current,
134
- )
135
- ? current
136
- : nextSelected;
137
- });
138
- setMessages(data.selectedConversation?.messages ?? []);
139
- })
140
- .catch((fetchError: unknown) => {
141
- if (
142
- fetchError instanceof DOMException &&
143
- fetchError.name === "AbortError"
144
- ) {
145
- return;
146
- }
147
- if (!active) return;
148
- setError(
149
- fetchError instanceof Error
150
- ? fetchError.message
151
- : "Messages unavailable",
152
- );
153
- setItems([]);
154
- setMessages([]);
155
- setLoadedConversationId(undefined);
156
- })
157
- .finally(() => {
158
- if (active) {
159
- setLoading(false);
160
- }
161
- });
162
-
163
- return () => {
164
- active = false;
165
- controller.abort();
166
- };
167
- }, [
168
- minFollowers,
169
- minInfluenceScore,
170
- inboxFilter,
171
- refreshTick,
172
- replyFilter,
173
- search,
174
- selectedConversationId,
175
- selectedAccountId,
176
- sort,
177
- ]);
138
+ if (!dmsQuery.data) return;
139
+ const nextSelected = loadedConversationId ?? items[0]?.id;
140
+ setSelectedConversationId((current) => {
141
+ if (!current) return nextSelected;
142
+ return items.some((conversation) => conversation.id === current)
143
+ ? current
144
+ : nextSelected;
145
+ });
146
+ }, [dmsQuery.data, items, loadedConversationId]);
178
147
 
179
148
  const selectedConversation =
180
149
  items.find((item) => item.id === selectedConversationId) ?? null;
181
- const switchingConversation =
182
- loading &&
183
- Boolean(
184
- selectedConversationId &&
185
- loadedConversationId &&
186
- selectedConversationId !== loadedConversationId,
187
- );
150
+ const switchingConversation = Boolean(
151
+ !dmsQuery.isError &&
152
+ selectedConversationId &&
153
+ loadedConversationId &&
154
+ selectedConversationId !== loadedConversationId,
155
+ );
188
156
 
189
157
  const subtitle = useMemo(() => {
190
158
  if (!meta) return "Loading direct messages...";
191
159
  return `${String(meta.stats.dms)} conversations cached locally`;
192
160
  }, [meta]);
193
-
194
- async function replyToConversation(conversationId: string) {
195
- const text = replyDraft.trim();
196
- if (!text || !selectedConversation) return;
197
-
198
- const now = new Date().toISOString();
199
- const accountRecord = meta?.accounts.find(
200
- (account) => account.id === selectedConversation.accountId,
201
- );
202
- const senderHandle = (
203
- accountRecord?.handle ?? selectedConversation.accountHandle
204
- ).replace(/^@/, "");
205
- const optimisticMessage: DmMessageItem = {
206
- id: `optimistic-${now}`,
161
+ const replyMutation = useMutation({
162
+ mutationFn: ({
207
163
  conversationId,
208
164
  text,
209
- createdAt: now,
210
- direction: "outbound",
211
- isReplied: true,
212
- mediaCount: 0,
213
- sender: {
214
- id: `local-${selectedConversation.accountId}`,
215
- handle: senderHandle,
216
- displayName: accountRecord?.name ?? senderHandle,
217
- bio: "",
218
- followersCount: 0,
219
- avatarHue: 18,
165
+ }: {
166
+ conversationId: string;
167
+ text: string;
168
+ }) => postAction({ kind: "replyDm", conversationId, text }),
169
+ onMutate: async ({ conversationId, text }) => {
170
+ await queryClient.cancelQueries({ queryKey: dmsQueryKey });
171
+ const previous = queryClient.getQueryData<QueryResponse>(dmsQueryKey);
172
+ if (!previous || !selectedConversation) return { previous };
173
+ const now = new Date().toISOString();
174
+ const accountRecord = meta?.accounts.find(
175
+ (account) => account.id === selectedConversation.accountId,
176
+ );
177
+ const senderHandle = (
178
+ accountRecord?.handle ?? selectedConversation.accountHandle
179
+ ).replace(/^@/, "");
180
+ const optimisticMessage: DmMessageItem = {
181
+ id: `optimistic-${now}`,
182
+ conversationId,
183
+ text,
220
184
  createdAt: now,
221
- },
222
- };
223
- const previousMessages = messages;
224
- const previousItems = items;
225
-
226
- setReplyError(null);
227
- setReplyDraft("");
228
- setMessages((current) => [...current, optimisticMessage]);
229
- setItems((current) =>
230
- current.map((item) =>
231
- item.id === conversationId
185
+ direction: "outbound",
186
+ isReplied: true,
187
+ mediaCount: 0,
188
+ sender: {
189
+ id: `local-${selectedConversation.accountId}`,
190
+ handle: senderHandle,
191
+ displayName: accountRecord?.name ?? senderHandle,
192
+ bio: "",
193
+ followersCount: 0,
194
+ avatarHue: 18,
195
+ createdAt: now,
196
+ },
197
+ };
198
+ const previousItems = previous.items as DmConversationItem[];
199
+ queryClient.setQueryData<QueryResponse>(dmsQueryKey, {
200
+ ...previous,
201
+ items: previousItems.map((item) =>
202
+ item.id === conversationId
203
+ ? {
204
+ ...item,
205
+ lastMessageAt: now,
206
+ lastMessagePreview: text,
207
+ needsReply: false,
208
+ unreadCount: 0,
209
+ }
210
+ : item,
211
+ ),
212
+ selectedConversation: previous.selectedConversation
232
213
  ? {
233
- ...item,
234
- lastMessageAt: now,
235
- lastMessagePreview: text,
236
- needsReply: false,
237
- unreadCount: 0,
214
+ ...previous.selectedConversation,
215
+ messages: [
216
+ ...previous.selectedConversation.messages,
217
+ optimisticMessage,
218
+ ],
238
219
  }
239
- : item,
240
- ),
241
- );
242
-
243
- try {
244
- await postAction({
245
- kind: "replyDm",
246
- conversationId,
247
- text,
220
+ : previous.selectedConversation,
248
221
  });
222
+ return { previous };
223
+ },
224
+ onError: (_error, _variables, context) => {
225
+ if (context?.previous) {
226
+ queryClient.setQueryData(dmsQueryKey, context.previous);
227
+ }
228
+ },
229
+ onSettled: () =>
230
+ Promise.all([
231
+ queryClient.invalidateQueries({ queryKey: queryKeys.dms }),
232
+ queryClient.invalidateQueries({ queryKey: queryKeys.status }),
233
+ ]),
234
+ });
249
235
 
236
+ async function replyToConversation(conversationId: string) {
237
+ const text = replyDraft.trim();
238
+ if (!text || !selectedConversation) return;
239
+ setReplyDraft("");
240
+ try {
241
+ await replyMutation.mutateAsync({ conversationId, text });
250
242
  setSelectedConversationId(conversationId);
251
- setRefreshTick((value) => value + 1);
252
- } catch (error) {
243
+ } catch {
253
244
  setReplyDraft(text);
254
- setMessages(previousMessages);
255
- setItems(previousItems);
256
- setReplyError(error instanceof Error ? error.message : "Reply failed");
257
245
  }
258
246
  }
259
247
 
260
248
  function refreshLocalView() {
261
- setRefreshTick((value) => value + 1);
262
- void loadStatus();
249
+ void Promise.all([
250
+ queryClient.invalidateQueries({ queryKey: queryKeys.dms }),
251
+ queryClient.invalidateQueries({ queryKey: queryKeys.status }),
252
+ ]);
263
253
  }
254
+ const loading = dmsQuery.isPending;
255
+ const error = dmsQuery.error
256
+ ? dmsQuery.error instanceof Error
257
+ ? dmsQuery.error.message
258
+ : "Messages unavailable"
259
+ : null;
260
+ const replyError = replyMutation.error
261
+ ? replyMutation.error instanceof Error
262
+ ? replyMutation.error.message
263
+ : "Reply failed"
264
+ : null;
264
265
 
265
266
  return (
266
267
  <>
@@ -374,7 +375,7 @@ function DmsRoute() {
374
375
  </p>
375
376
  ) : null}
376
377
 
377
- {loading && (items.length === 0 || switchingConversation) ? (
378
+ {(loading && items.length === 0) || switchingConversation ? (
378
379
  <FeedLoading
379
380
  detail="Reading local conversations and reply state"
380
381
  label="Loading messages"
@@ -384,7 +385,7 @@ function DmsRoute() {
384
385
  action={
385
386
  <button
386
387
  className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
387
- onClick={() => setRefreshTick((value) => value + 1)}
388
+ onClick={() => void dmsQuery.refetch()}
388
389
  type="button"
389
390
  >
390
391
  Retry
@@ -1,15 +1,17 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import {
3
+ keepPreviousData,
4
+ useMutation,
5
+ useQuery,
6
+ useQueryClient,
7
+ } from "@tanstack/react-query";
2
8
  import { Sparkles } from "lucide-react";
3
- import { useEffect, useMemo, useState } from "react";
9
+ import { useMemo, useState } from "react";
4
10
  import { useSelectedAccountId } from "#/components/account-selection";
5
11
  import { InboxCard } from "#/components/InboxCard";
6
- import { postAction } from "#/lib/api-client";
7
- import type {
8
- InboxItem,
9
- InboxKind,
10
- InboxResponse,
11
- QueryEnvelope,
12
- } from "#/lib/types";
12
+ import { fetchQueryEnvelope, postAction } from "#/lib/api-client";
13
+ import { queryKeys } from "#/lib/query-client";
14
+ import type { InboxItem, InboxKind, InboxResponse } from "#/lib/types";
13
15
  import {
14
16
  cx,
15
17
  emptyStateClass,
@@ -40,45 +42,64 @@ const TABS: Array<{ value: InboxKind; label: string }> = [
40
42
  ];
41
43
 
42
44
  function InboxRoute() {
43
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
44
- const [items, setItems] = useState<InboxItem[]>([]);
45
+ const queryClient = useQueryClient();
45
46
  const [kind, setKind] = useState<InboxKind>("mixed");
46
47
  const [minScore, setMinScore] = useState("40");
47
48
  const [hideLowSignal, setHideLowSignal] = useState(true);
48
- const [refreshTick, setRefreshTick] = useState(0);
49
- const [isScoring, setIsScoring] = useState(false);
50
49
  const [activeReplyId, setActiveReplyId] = useState<string | null>(null);
51
50
  const [replyDraft, setReplyDraft] = useState("");
52
51
  const [isSendingReply, setIsSendingReply] = useState(false);
53
52
  const [replyError, setReplyError] = useState<string | null>(null);
54
- const [stats, setStats] = useState<InboxResponse["stats"] | null>(null);
53
+ const statusQuery = useQuery({
54
+ queryKey: queryKeys.status,
55
+ queryFn: ({ signal }) => fetchQueryEnvelope({ signal }),
56
+ });
57
+ const meta = statusQuery.data ?? null;
55
58
  const selectedAccountId = useSelectedAccountId(meta?.accounts);
59
+ const inboxQueryKey = [
60
+ ...queryKeys.inbox,
61
+ {
62
+ hideLowSignal,
63
+ kind,
64
+ minScore,
65
+ selectedAccountId: selectedAccountId ?? null,
66
+ },
67
+ ] as const;
68
+ const inboxQuery = useQuery({
69
+ queryKey: inboxQueryKey,
70
+ queryFn: async ({ signal }) => {
71
+ const url = new URL("/api/inbox", window.location.origin);
72
+ url.searchParams.set("kind", kind);
73
+ url.searchParams.set("minScore", minScore);
74
+ if (selectedAccountId) {
75
+ url.searchParams.set("account", selectedAccountId);
76
+ }
77
+ if (hideLowSignal) {
78
+ url.searchParams.set("hideLowSignal", "1");
79
+ }
56
80
 
57
- useEffect(() => {
58
- fetch("/api/status")
59
- .then((response) => response.json())
60
- .then((data: QueryEnvelope) => setMeta(data));
61
- }, []);
62
-
63
- useEffect(() => {
64
- const url = new URL("/api/inbox", window.location.origin);
65
- url.searchParams.set("kind", kind);
66
- url.searchParams.set("minScore", minScore);
67
- url.searchParams.set("refresh", String(refreshTick));
68
- if (selectedAccountId) {
69
- url.searchParams.set("account", selectedAccountId);
70
- }
71
- if (hideLowSignal) {
72
- url.searchParams.set("hideLowSignal", "1");
73
- }
74
-
75
- fetch(url)
76
- .then((response) => response.json())
77
- .then((data: InboxResponse) => {
78
- setItems(data.items);
79
- setStats(data.stats);
80
- });
81
- }, [hideLowSignal, kind, minScore, refreshTick, selectedAccountId]);
81
+ const response = await fetch(url, { signal });
82
+ if (!response.ok) {
83
+ throw new Error(`Inbox request failed (${String(response.status)})`);
84
+ }
85
+ return (await response.json()) as InboxResponse;
86
+ },
87
+ placeholderData: keepPreviousData,
88
+ staleTime: 5 * 60_000,
89
+ });
90
+ const items = inboxQuery.data?.items ?? [];
91
+ const stats = inboxQuery.data?.stats ?? null;
92
+ const scoreMutation = useMutation({
93
+ mutationFn: () =>
94
+ postAction({
95
+ kind: "scoreInbox",
96
+ scoreKind: kind,
97
+ account: selectedAccountId,
98
+ limit: 8,
99
+ }),
100
+ onSuccess: () =>
101
+ queryClient.invalidateQueries({ queryKey: queryKeys.inbox }),
102
+ });
82
103
 
83
104
  const subtitle = useMemo(() => {
84
105
  if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
@@ -86,22 +107,7 @@ function InboxRoute() {
86
107
  }, [meta, stats]);
87
108
 
88
109
  async function scoreNow() {
89
- setIsScoring(true);
90
- try {
91
- await fetch("/api/action", {
92
- method: "POST",
93
- headers: { "content-type": "application/json" },
94
- body: JSON.stringify({
95
- kind: "scoreInbox",
96
- scoreKind: kind,
97
- account: selectedAccountId,
98
- limit: 8,
99
- }),
100
- });
101
- setRefreshTick((value) => value + 1);
102
- } finally {
103
- setIsScoring(false);
104
- }
110
+ await scoreMutation.mutateAsync();
105
111
  }
106
112
 
107
113
  async function sendReply(item: InboxItem) {
@@ -125,7 +131,7 @@ function InboxRoute() {
125
131
  );
126
132
  setReplyDraft("");
127
133
  setActiveReplyId(null);
128
- setRefreshTick((value) => value + 1);
134
+ await queryClient.invalidateQueries({ queryKey: queryKeys.inbox });
129
135
  } catch (error) {
130
136
  setReplyError(error instanceof Error ? error.message : "Reply failed");
131
137
  } finally {
@@ -143,12 +149,12 @@ function InboxRoute() {
143
149
  </div>
144
150
  <button
145
151
  className={primaryButtonClass}
146
- disabled={isScoring}
152
+ disabled={scoreMutation.isPending}
147
153
  onClick={() => void scoreNow()}
148
154
  type="button"
149
155
  >
150
156
  <Sparkles className="size-4" strokeWidth={2.2} />
151
- {isScoring ? "Scoring..." : "Score with OpenAI"}
157
+ {scoreMutation.isPending ? "Scoring..." : "Score with OpenAI"}
152
158
  </button>
153
159
  </div>
154
160
  <div className="flex flex-wrap items-center gap-2 px-4 pb-3">