birdclaw 0.5.0 → 0.6.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 (105) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/README.md +55 -5
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +9 -7
  5. package/public/birdclaw-mark.png +0 -0
  6. package/scripts/browser-perf.mjs +400 -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 +42 -0
  12. package/src/cli.ts +422 -14
  13. package/src/components/AccountSwitcher.tsx +186 -0
  14. package/src/components/AppNav.tsx +29 -9
  15. package/src/components/AvatarChip.tsx +9 -3
  16. package/src/components/BrandMark.tsx +67 -0
  17. package/src/components/ConversationThread.tsx +11 -10
  18. package/src/components/DmWorkspace.tsx +39 -14
  19. package/src/components/FeedState.tsx +147 -0
  20. package/src/components/InboxCard.tsx +14 -2
  21. package/src/components/LinkPreviewCard.tsx +40 -18
  22. package/src/components/MarkdownViewer.tsx +452 -0
  23. package/src/components/SavedTimelineView.tsx +64 -56
  24. package/src/components/SyncNowButton.tsx +137 -0
  25. package/src/components/ThemeSlider.tsx +49 -93
  26. package/src/components/TimelineCard.tsx +364 -136
  27. package/src/components/TimelineRouteFrame.tsx +170 -0
  28. package/src/components/TweetMediaGrid.tsx +170 -24
  29. package/src/components/TweetRichText.tsx +28 -13
  30. package/src/components/account-selection.ts +64 -0
  31. package/src/components/useTimelineRouteData.ts +153 -0
  32. package/src/lib/account-sync-job.ts +654 -0
  33. package/src/lib/actions-transport.ts +216 -146
  34. package/src/lib/api-client.ts +304 -0
  35. package/src/lib/archive-finder.ts +72 -53
  36. package/src/lib/archive-import.ts +1377 -1298
  37. package/src/lib/authored-live.ts +261 -204
  38. package/src/lib/avatar-cache.ts +159 -44
  39. package/src/lib/backup.ts +1532 -951
  40. package/src/lib/bird-actions.ts +139 -57
  41. package/src/lib/bird-command.ts +101 -28
  42. package/src/lib/bird.ts +549 -194
  43. package/src/lib/blocklist.ts +40 -23
  44. package/src/lib/blocks-write.ts +129 -80
  45. package/src/lib/blocks.ts +165 -97
  46. package/src/lib/bookmark-sync-job.ts +250 -160
  47. package/src/lib/conversation-surface.ts +205 -0
  48. package/src/lib/db.ts +35 -3
  49. package/src/lib/dms-live.ts +720 -66
  50. package/src/lib/effect-runtime.ts +45 -0
  51. package/src/lib/follow-graph.ts +224 -180
  52. package/src/lib/http-effect.ts +222 -0
  53. package/src/lib/inbox.ts +74 -43
  54. package/src/lib/link-index.ts +88 -76
  55. package/src/lib/link-insights.ts +24 -0
  56. package/src/lib/link-preview-metadata.ts +472 -52
  57. package/src/lib/media-fetch.ts +286 -213
  58. package/src/lib/mention-threads-live.ts +352 -288
  59. package/src/lib/mentions-live.ts +390 -342
  60. package/src/lib/moderation-target.ts +102 -65
  61. package/src/lib/moderation-write.ts +77 -18
  62. package/src/lib/mutes-write.ts +129 -80
  63. package/src/lib/mutes.ts +8 -1
  64. package/src/lib/openai.ts +84 -53
  65. package/src/lib/period-digest.ts +953 -0
  66. package/src/lib/profile-affiliation-hydration.ts +93 -54
  67. package/src/lib/profile-hydration.ts +124 -72
  68. package/src/lib/profile-replies.ts +60 -43
  69. package/src/lib/profile-resolver.ts +402 -294
  70. package/src/lib/queries.ts +1024 -189
  71. package/src/lib/research.ts +165 -120
  72. package/src/lib/sqlite.ts +1 -0
  73. package/src/lib/timeline-collections-live.ts +204 -167
  74. package/src/lib/timeline-live.ts +60 -39
  75. package/src/lib/tweet-lookup.ts +30 -19
  76. package/src/lib/types.ts +38 -1
  77. package/src/lib/ui.ts +41 -10
  78. package/src/lib/url-expansion.ts +226 -55
  79. package/src/lib/url-safety.ts +220 -0
  80. package/src/lib/web-sync.ts +511 -0
  81. package/src/lib/whois.ts +166 -147
  82. package/src/lib/x-web.ts +102 -71
  83. package/src/lib/xurl.ts +681 -411
  84. package/src/routeTree.gen.ts +63 -0
  85. package/src/routes/__root.tsx +25 -5
  86. package/src/routes/api/action.tsx +127 -78
  87. package/src/routes/api/avatar.tsx +39 -30
  88. package/src/routes/api/blocks.tsx +26 -23
  89. package/src/routes/api/conversation.tsx +25 -14
  90. package/src/routes/api/inbox.tsx +27 -21
  91. package/src/routes/api/link-insights.tsx +31 -25
  92. package/src/routes/api/link-preview.tsx +25 -21
  93. package/src/routes/api/period-digest.tsx +123 -0
  94. package/src/routes/api/profile-hydrate.tsx +31 -28
  95. package/src/routes/api/query.tsx +79 -55
  96. package/src/routes/api/status.tsx +18 -10
  97. package/src/routes/api/sync.tsx +105 -0
  98. package/src/routes/dms.tsx +195 -55
  99. package/src/routes/inbox.tsx +32 -19
  100. package/src/routes/index.tsx +21 -127
  101. package/src/routes/links.tsx +50 -5
  102. package/src/routes/mentions.tsx +21 -127
  103. package/src/routes/today.tsx +441 -0
  104. package/src/styles.css +74 -11
  105. package/vite.config.ts +8 -0
@@ -0,0 +1,105 @@
1
+ import { createFileRoute } from "@tanstack/react-router";
2
+ import { Effect } from "effect";
3
+ import {
4
+ jsonResponse,
5
+ requestJsonEffect,
6
+ runRouteEffect,
7
+ sensitiveRequestErrorResponse,
8
+ } from "#/lib/http-effect";
9
+ import {
10
+ getWebSyncJob,
11
+ parseWebSyncKind,
12
+ startWebSync,
13
+ type WebSyncDmInbox,
14
+ type WebSyncOptions,
15
+ } from "#/lib/web-sync";
16
+
17
+ function parseAccountId(value: unknown) {
18
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
19
+ }
20
+
21
+ function parseDmInbox(value: unknown): WebSyncDmInbox | undefined {
22
+ return value === "all" || value === "accepted" || value === "requests"
23
+ ? value
24
+ : undefined;
25
+ }
26
+
27
+ function parsePositiveInteger(value: unknown, max: number) {
28
+ const parsed =
29
+ typeof value === "number"
30
+ ? value
31
+ : typeof value === "string"
32
+ ? Number(value)
33
+ : Number.NaN;
34
+ if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
35
+ return Math.min(parsed, max);
36
+ }
37
+
38
+ function parseSyncOptions(kind: string, body: Record<string, unknown>) {
39
+ if (kind !== "dms") return {};
40
+ const options: WebSyncOptions = {};
41
+ const inbox = parseDmInbox(body.inbox);
42
+ const limit = parsePositiveInteger(body.limit, 1000);
43
+ const maxPages = parsePositiveInteger(body.maxPages, 250);
44
+ if (inbox) options.inbox = inbox;
45
+ if (limit) options.limit = limit;
46
+ if (maxPages) options.maxPages = maxPages;
47
+ if (typeof body.allPages === "boolean") options.allPages = body.allPages;
48
+ return options;
49
+ }
50
+
51
+ export const Route = createFileRoute("/api/sync")({
52
+ server: {
53
+ handlers: {
54
+ GET: ({ request }) => {
55
+ const denied = sensitiveRequestErrorResponse(request);
56
+ if (denied) return denied;
57
+
58
+ const url = new URL(request.url);
59
+ const id = url.searchParams.get("id");
60
+ if (!id) {
61
+ return jsonResponse(
62
+ { ok: false, message: "Missing sync job id" },
63
+ { status: 400 },
64
+ );
65
+ }
66
+
67
+ const job = getWebSyncJob(id);
68
+ if (!job) {
69
+ return jsonResponse(
70
+ { ok: false, message: "Sync job not found" },
71
+ { status: 404 },
72
+ );
73
+ }
74
+
75
+ return jsonResponse(job);
76
+ },
77
+ POST: ({ request }) =>
78
+ runRouteEffect(
79
+ Effect.gen(function* () {
80
+ const denied = sensitiveRequestErrorResponse(request);
81
+ if (denied) return denied;
82
+
83
+ const body = yield* requestJsonEffect<Record<string, unknown>>(
84
+ request,
85
+ {},
86
+ );
87
+ const kind = parseWebSyncKind(body.kind);
88
+ if (!kind) {
89
+ return jsonResponse(
90
+ { ok: false, message: "Unknown sync kind" },
91
+ { status: 400 },
92
+ );
93
+ }
94
+
95
+ const job = startWebSync(
96
+ kind,
97
+ parseAccountId(body.accountId),
98
+ parseSyncOptions(kind, body),
99
+ );
100
+ return jsonResponse(job, { status: job.inProgress ? 202 : 200 });
101
+ }),
102
+ ),
103
+ },
104
+ },
105
+ });
@@ -2,11 +2,18 @@ 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 { useSelectedAccountId } from "#/components/account-selection";
8
+ import {
9
+ fetchQueryEnvelope,
10
+ fetchQueryResponse,
11
+ postAction,
12
+ } from "#/lib/api-client";
5
13
  import type {
6
14
  DmConversationItem,
7
15
  DmMessageItem,
8
16
  QueryEnvelope,
9
- QueryResponse,
10
17
  ReplyFilter,
11
18
  } from "#/lib/types";
12
19
  import {
@@ -25,8 +32,7 @@ import {
25
32
  tabButtonClass,
26
33
  tabButtonIndicatorClass,
27
34
  tabStripClass,
28
- textFieldClass,
29
- textFieldShortClass,
35
+ timestampClass,
30
36
  } from "#/lib/ui";
31
37
 
32
38
  export const Route = createFileRoute("/dms")({
@@ -39,36 +45,71 @@ const TABS: Array<{ value: ReplyFilter; label: string }> = [
39
45
  { value: "replied", label: "Replied" },
40
46
  ];
41
47
 
48
+ const SORTS: Array<{ value: "recent" | "followers"; label: string }> = [
49
+ { value: "recent", label: "Newest" },
50
+ { value: "followers", label: "Followers" },
51
+ ];
52
+
53
+ type DmInboxFilter = "all" | "accepted" | "requests";
54
+
55
+ const INBOX_FILTERS: Array<{ value: DmInboxFilter; label: string }> = [
56
+ { value: "all", label: "All" },
57
+ { value: "accepted", label: "Accepted" },
58
+ { value: "requests", label: "Requests" },
59
+ ];
60
+
61
+ const filterNumberFieldClass =
62
+ "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
+
42
64
  function DmsRoute() {
43
65
  const [meta, setMeta] = useState<QueryEnvelope | null>(null);
44
66
  const [items, setItems] = useState<DmConversationItem[]>([]);
45
67
  const [messages, setMessages] = useState<DmMessageItem[]>([]);
68
+ const [loadedConversationId, setLoadedConversationId] = useState<
69
+ string | undefined
70
+ >();
46
71
  const [selectedConversationId, setSelectedConversationId] = useState<
47
72
  string | undefined
48
73
  >();
74
+ const [inboxFilter, setInboxFilter] = useState<DmInboxFilter>("all");
49
75
  const [replyFilter, setReplyFilter] = useState<ReplyFilter>("unreplied");
50
- const [minFollowers, setMinFollowers] = useState("0");
51
- const [minInfluenceScore, setMinInfluenceScore] = useState("0");
52
- const [sort, setSort] = useState<"recent" | "influence">("recent");
76
+ const [minFollowers, setMinFollowers] = useState("");
77
+ const [minInfluenceScore, setMinInfluenceScore] = useState("");
78
+ const [sort, setSort] = useState<"recent" | "followers">("recent");
53
79
  const [search, setSearch] = useState("");
54
80
  const [replyDraft, setReplyDraft] = useState("");
55
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);
85
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
86
+
87
+ async function loadStatus() {
88
+ setMeta(await fetchQueryEnvelope());
89
+ }
56
90
 
57
91
  useEffect(() => {
58
- fetch("/api/status")
59
- .then((response) => response.json())
60
- .then((data: QueryEnvelope) => setMeta(data));
92
+ void loadStatus();
61
93
  }, []);
62
94
 
63
95
  useEffect(() => {
64
96
  const controller = new AbortController();
97
+ let active = true;
65
98
  const url = new URL("/api/query", window.location.origin);
66
99
  url.searchParams.set("resource", "dms");
100
+ url.searchParams.set("inbox", inboxFilter);
67
101
  url.searchParams.set("replyFilter", replyFilter);
68
- url.searchParams.set("minFollowers", minFollowers);
69
- url.searchParams.set("minInfluenceScore", minInfluenceScore);
70
102
  url.searchParams.set("refresh", String(refreshTick));
71
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
+ }
72
113
  if (selectedConversationId) {
73
114
  url.searchParams.set("conversationId", selectedConversationId);
74
115
  }
@@ -76,12 +117,15 @@ function DmsRoute() {
76
117
  url.searchParams.set("search", search.trim());
77
118
  }
78
119
 
79
- fetch(url, { signal: controller.signal })
80
- .then((response) => response.json())
81
- .then((data: QueryResponse) => {
120
+ setError(null);
121
+ setLoading(true);
122
+ fetchQueryResponse(url, { signal: controller.signal })
123
+ .then((data) => {
124
+ if (!active) return;
82
125
  const conversations = data.items as DmConversationItem[];
83
126
  const nextSelected =
84
127
  data.selectedConversation?.conversation.id ?? conversations[0]?.id;
128
+ setLoadedConversationId(data.selectedConversation?.conversation.id);
85
129
  setItems(conversations);
86
130
  setSelectedConversationId((current) => {
87
131
  if (!current) return nextSelected;
@@ -93,28 +137,54 @@ function DmsRoute() {
93
137
  });
94
138
  setMessages(data.selectedConversation?.messages ?? []);
95
139
  })
96
- .catch((error: unknown) => {
97
- if (error instanceof DOMException && error.name === "AbortError") {
140
+ .catch((fetchError: unknown) => {
141
+ if (
142
+ fetchError instanceof DOMException &&
143
+ fetchError.name === "AbortError"
144
+ ) {
98
145
  return;
99
146
  }
100
- throw error;
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
+ }
101
161
  });
102
162
 
103
163
  return () => {
164
+ active = false;
104
165
  controller.abort();
105
166
  };
106
167
  }, [
107
168
  minFollowers,
108
169
  minInfluenceScore,
170
+ inboxFilter,
109
171
  refreshTick,
110
172
  replyFilter,
111
173
  search,
112
174
  selectedConversationId,
175
+ selectedAccountId,
113
176
  sort,
114
177
  ]);
115
178
 
116
179
  const selectedConversation =
117
180
  items.find((item) => item.id === selectedConversationId) ?? null;
181
+ const switchingConversation =
182
+ loading &&
183
+ Boolean(
184
+ selectedConversationId &&
185
+ loadedConversationId &&
186
+ selectedConversationId !== loadedConversationId,
187
+ );
118
188
 
119
189
  const subtitle = useMemo(() => {
120
190
  if (!meta) return "Loading direct messages...";
@@ -153,6 +223,7 @@ function DmsRoute() {
153
223
  const previousMessages = messages;
154
224
  const previousItems = items;
155
225
 
226
+ setReplyError(null);
156
227
  setReplyDraft("");
157
228
  setMessages((current) => [...current, optimisticMessage]);
158
229
  setItems((current) =>
@@ -170,14 +241,10 @@ function DmsRoute() {
170
241
  );
171
242
 
172
243
  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
- }),
244
+ await postAction({
245
+ kind: "replyDm",
246
+ conversationId,
247
+ text,
181
248
  });
182
249
 
183
250
  setSelectedConversationId(conversationId);
@@ -186,10 +253,15 @@ function DmsRoute() {
186
253
  setReplyDraft(text);
187
254
  setMessages(previousMessages);
188
255
  setItems(previousItems);
189
- throw error;
256
+ setReplyError(error instanceof Error ? error.message : "Reply failed");
190
257
  }
191
258
  }
192
259
 
260
+ function refreshLocalView() {
261
+ setRefreshTick((value) => value + 1);
262
+ void loadStatus();
263
+ }
264
+
193
265
  return (
194
266
  <>
195
267
  <header className={pageHeaderClass}>
@@ -198,8 +270,35 @@ function DmsRoute() {
198
270
  <h1 className={pageTitleClass}>Messages</h1>
199
271
  <p className={pageSubtitleClass}>{subtitle}</p>
200
272
  </div>
273
+ <SyncNowButton
274
+ accounts={meta?.accounts}
275
+ kind="dms"
276
+ label="Sync DMs"
277
+ onSynced={refreshLocalView}
278
+ syncOptions={{
279
+ inbox: inboxFilter,
280
+ limit: inboxFilter === "requests" ? 200 : 50,
281
+ maxPages: inboxFilter === "requests" ? 3 : 1,
282
+ }}
283
+ />
201
284
  </div>
202
285
  <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
286
+ <div className={segmentedClass} aria-label="DM inbox">
287
+ {INBOX_FILTERS.map((filter) => (
288
+ <button
289
+ key={filter.value}
290
+ aria-pressed={inboxFilter === filter.value}
291
+ className={cx(
292
+ segmentClass,
293
+ inboxFilter === filter.value && segmentActiveClass,
294
+ )}
295
+ onClick={() => setInboxFilter(filter.value)}
296
+ type="button"
297
+ >
298
+ {filter.label}
299
+ </button>
300
+ ))}
301
+ </div>
203
302
  <label className={cx(searchFieldShellClass, "flex-1 min-w-[200px]")}>
204
303
  <Search className={searchFieldIconClass} strokeWidth={2} />
205
304
  <input
@@ -209,37 +308,47 @@ function DmsRoute() {
209
308
  value={search}
210
309
  />
211
310
  </label>
212
- <input
213
- className={cx(textFieldClass, textFieldShortClass)}
214
- inputMode="numeric"
215
- onChange={(event) => setMinFollowers(event.target.value)}
216
- placeholder="Min followers"
217
- value={minFollowers}
218
- />
219
- <input
220
- className={cx(textFieldClass, textFieldShortClass)}
221
- inputMode="numeric"
222
- onChange={(event) => setMinInfluenceScore(event.target.value)}
223
- placeholder="Min score"
224
- value={minInfluenceScore}
225
- />
311
+ <label className={cx(filterNumberFieldClass, "w-[156px]")}>
312
+ <span className="shrink-0 text-[12px] font-semibold text-[var(--ink-soft)]">
313
+ Followers
314
+ </span>
315
+ <input
316
+ className="min-w-0 flex-1 border-0 bg-transparent text-right text-[14px] text-[var(--ink)] outline-none placeholder:text-[var(--ink-soft)]"
317
+ inputMode="numeric"
318
+ onChange={(event) => setMinFollowers(event.target.value)}
319
+ placeholder="Any"
320
+ value={minFollowers}
321
+ />
322
+ </label>
323
+ <label className={cx(filterNumberFieldClass, "w-[132px]")}>
324
+ <span className="shrink-0 text-[12px] font-semibold text-[var(--ink-soft)]">
325
+ Score
326
+ </span>
327
+ <input
328
+ className="min-w-0 flex-1 border-0 bg-transparent text-right text-[14px] text-[var(--ink)] outline-none placeholder:text-[var(--ink-soft)]"
329
+ inputMode="numeric"
330
+ onChange={(event) => setMinInfluenceScore(event.target.value)}
331
+ placeholder="Any"
332
+ value={minInfluenceScore}
333
+ />
334
+ </label>
226
335
  <div className={segmentedClass}>
227
- {(["recent", "influence"] as const).map((value) => (
336
+ {SORTS.map((option) => (
228
337
  <button
229
- key={value}
338
+ key={option.value}
230
339
  className={cx(
231
340
  segmentClass,
232
- value === sort && segmentActiveClass,
341
+ option.value === sort && segmentActiveClass,
233
342
  )}
234
- onClick={() => setSort(value)}
343
+ onClick={() => setSort(option.value)}
235
344
  type="button"
236
345
  >
237
- {value}
346
+ {option.label}
238
347
  </button>
239
348
  ))}
240
349
  </div>
241
350
  </div>
242
- <div className={tabStripClass}>
351
+ <div className={tabStripClass} aria-label="DM reply filter">
243
352
  {TABS.map((tab) => {
244
353
  const active = replyFilter === tab.value;
245
354
  return (
@@ -259,16 +368,47 @@ function DmsRoute() {
259
368
  })}
260
369
  </div>
261
370
  </header>
371
+ {replyError ? (
372
+ <p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
373
+ {replyError}
374
+ </p>
375
+ ) : null}
262
376
 
263
- <DmWorkspace
264
- conversations={items}
265
- onReplyDraftChange={setReplyDraft}
266
- onReplySend={replyToConversation}
267
- onSelectConversation={setSelectedConversationId}
268
- replyDraft={replyDraft}
269
- selectedConversation={selectedConversation}
270
- selectedMessages={messages}
271
- />
377
+ {loading && (items.length === 0 || switchingConversation) ? (
378
+ <FeedLoading
379
+ detail="Reading local conversations and reply state"
380
+ label="Loading messages"
381
+ />
382
+ ) : error ? (
383
+ <FeedError
384
+ action={
385
+ <button
386
+ className="rounded-full bg-[var(--accent)] px-4 py-1.5 text-[14px] font-bold text-white"
387
+ onClick={() => setRefreshTick((value) => value + 1)}
388
+ type="button"
389
+ >
390
+ Retry
391
+ </button>
392
+ }
393
+ message={error}
394
+ title="Could not load messages"
395
+ />
396
+ ) : items.length === 0 ? (
397
+ <FeedEmpty
398
+ detail="Sync DMs or broaden the filters to find a conversation."
399
+ label="No conversations in this view"
400
+ />
401
+ ) : (
402
+ <DmWorkspace
403
+ conversations={items}
404
+ onReplyDraftChange={setReplyDraft}
405
+ onReplySend={replyToConversation}
406
+ onSelectConversation={setSelectedConversationId}
407
+ replyDraft={replyDraft}
408
+ selectedConversation={selectedConversation}
409
+ selectedMessages={messages}
410
+ />
411
+ )}
272
412
  </>
273
413
  );
274
414
  }
@@ -1,7 +1,9 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { Sparkles } from "lucide-react";
3
3
  import { useEffect, useMemo, useState } from "react";
4
+ import { useSelectedAccountId } from "#/components/account-selection";
4
5
  import { InboxCard } from "#/components/InboxCard";
6
+ import { postAction } from "#/lib/api-client";
5
7
  import type {
6
8
  InboxItem,
7
9
  InboxKind,
@@ -48,7 +50,9 @@ function InboxRoute() {
48
50
  const [activeReplyId, setActiveReplyId] = useState<string | null>(null);
49
51
  const [replyDraft, setReplyDraft] = useState("");
50
52
  const [isSendingReply, setIsSendingReply] = useState(false);
53
+ const [replyError, setReplyError] = useState<string | null>(null);
51
54
  const [stats, setStats] = useState<InboxResponse["stats"] | null>(null);
55
+ const selectedAccountId = useSelectedAccountId(meta?.accounts);
52
56
 
53
57
  useEffect(() => {
54
58
  fetch("/api/status")
@@ -61,6 +65,9 @@ function InboxRoute() {
61
65
  url.searchParams.set("kind", kind);
62
66
  url.searchParams.set("minScore", minScore);
63
67
  url.searchParams.set("refresh", String(refreshTick));
68
+ if (selectedAccountId) {
69
+ url.searchParams.set("account", selectedAccountId);
70
+ }
64
71
  if (hideLowSignal) {
65
72
  url.searchParams.set("hideLowSignal", "1");
66
73
  }
@@ -71,7 +78,7 @@ function InboxRoute() {
71
78
  setItems(data.items);
72
79
  setStats(data.stats);
73
80
  });
74
- }, [hideLowSignal, kind, minScore, refreshTick]);
81
+ }, [hideLowSignal, kind, minScore, refreshTick, selectedAccountId]);
75
82
 
76
83
  const subtitle = useMemo(() => {
77
84
  if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
@@ -87,6 +94,7 @@ function InboxRoute() {
87
94
  body: JSON.stringify({
88
95
  kind: "scoreInbox",
89
96
  scoreKind: kind,
97
+ account: selectedAccountId,
90
98
  limit: 8,
91
99
  }),
92
100
  });
@@ -99,28 +107,27 @@ function InboxRoute() {
99
107
  async function sendReply(item: InboxItem) {
100
108
  if (!replyDraft.trim()) return;
101
109
  setIsSendingReply(true);
110
+ setReplyError(null);
102
111
  try {
103
- await fetch("/api/action", {
104
- method: "POST",
105
- headers: { "content-type": "application/json" },
106
- body: JSON.stringify(
107
- item.entityKind === "dm"
108
- ? {
109
- kind: "replyDm",
110
- conversationId: item.entityId,
111
- text: replyDraft,
112
- }
113
- : {
114
- kind: "replyTweet",
115
- accountId: item.accountId,
116
- tweetId: item.entityId,
117
- text: replyDraft,
118
- },
119
- ),
120
- });
112
+ await postAction(
113
+ item.entityKind === "dm"
114
+ ? {
115
+ kind: "replyDm",
116
+ conversationId: item.entityId,
117
+ text: replyDraft,
118
+ }
119
+ : {
120
+ kind: "replyTweet",
121
+ accountId: item.accountId,
122
+ tweetId: item.entityId,
123
+ text: replyDraft,
124
+ },
125
+ );
121
126
  setReplyDraft("");
122
127
  setActiveReplyId(null);
123
128
  setRefreshTick((value) => value + 1);
129
+ } catch (error) {
130
+ setReplyError(error instanceof Error ? error.message : "Reply failed");
124
131
  } finally {
125
132
  setIsSendingReply(false);
126
133
  }
@@ -181,6 +188,11 @@ function InboxRoute() {
181
188
  })}
182
189
  </div>
183
190
  </header>
191
+ {replyError ? (
192
+ <p className={cx(timestampClass, "px-4 py-2 text-red-500")}>
193
+ {replyError}
194
+ </p>
195
+ ) : null}
184
196
  <section className={feedClass}>
185
197
  {items.length === 0 ? (
186
198
  <div className={emptyStateClass}>Inbox clear.</div>
@@ -193,6 +205,7 @@ function InboxRoute() {
193
205
  onReplyChange={setReplyDraft}
194
206
  onReplySend={() => void sendReply(item)}
195
207
  onReplyToggle={() => {
208
+ setReplyError(null);
196
209
  if (activeReplyId === item.id) {
197
210
  setActiveReplyId(null);
198
211
  setReplyDraft("");