birdclaw 0.4.1 → 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 (85) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +113 -7
  3. package/bin/birdclaw.mjs +50 -11
  4. package/package.json +30 -28
  5. package/playwright.config.ts +1 -0
  6. package/public/birdclaw-mark.png +0 -0
  7. package/public/favicon.ico +0 -0
  8. package/public/logo192.png +0 -0
  9. package/public/logo512.png +0 -0
  10. package/public/manifest.json +2 -2
  11. package/scripts/browser-perf.mjs +399 -0
  12. package/scripts/build-docs-site.mjs +940 -0
  13. package/scripts/docs-site-assets.mjs +311 -0
  14. package/scripts/run-vitest.mjs +21 -0
  15. package/scripts/sanitize-node-options.mjs +23 -0
  16. package/scripts/start-test-server.mjs +29 -0
  17. package/src/cli.ts +496 -19
  18. package/src/components/AppNav.tsx +66 -29
  19. package/src/components/AvatarChip.tsx +10 -5
  20. package/src/components/BrandMark.tsx +67 -0
  21. package/src/components/ConversationThread.tsx +126 -0
  22. package/src/components/DmWorkspace.tsx +118 -105
  23. package/src/components/EmbeddedTweetCard.tsx +20 -14
  24. package/src/components/FeedState.tsx +147 -0
  25. package/src/components/InboxCard.tsx +104 -90
  26. package/src/components/LinkPreviewCard.tsx +270 -0
  27. package/src/components/ProfilePreview.tsx +8 -3
  28. package/src/components/SavedTimelineView.tsx +89 -71
  29. package/src/components/SyncNowButton.tsx +105 -0
  30. package/src/components/ThemeSlider.tsx +10 -59
  31. package/src/components/TimelineCard.tsx +326 -86
  32. package/src/components/TimelineRouteFrame.tsx +156 -0
  33. package/src/components/TweetMediaGrid.tsx +120 -23
  34. package/src/components/TweetRichText.tsx +19 -4
  35. package/src/components/useTimelineRouteData.ts +137 -0
  36. package/src/lib/api-client.ts +229 -0
  37. package/src/lib/archive-finder.ts +24 -20
  38. package/src/lib/archive-import.ts +1582 -67
  39. package/src/lib/authored-live.ts +1074 -0
  40. package/src/lib/backup.ts +316 -14
  41. package/src/lib/bird-actions.ts +1 -10
  42. package/src/lib/bird-command.ts +57 -0
  43. package/src/lib/bird.ts +89 -5
  44. package/src/lib/config.ts +1 -1
  45. package/src/lib/conversation-surface.ts +174 -0
  46. package/src/lib/db.ts +193 -4
  47. package/src/lib/follow-graph.ts +1053 -0
  48. package/src/lib/link-index.ts +11 -98
  49. package/src/lib/link-insights.ts +834 -0
  50. package/src/lib/link-preview-metadata.ts +334 -0
  51. package/src/lib/media-fetch.ts +787 -0
  52. package/src/lib/media-includes.ts +165 -0
  53. package/src/lib/mention-threads-live.ts +535 -43
  54. package/src/lib/mentions-live.ts +623 -19
  55. package/src/lib/moderation-target.ts +6 -0
  56. package/src/lib/profile-hydration.ts +1 -1
  57. package/src/lib/profile-resolver.ts +115 -1
  58. package/src/lib/queries.ts +326 -35
  59. package/src/lib/seed.ts +127 -8
  60. package/src/lib/timeline-collections-live.ts +145 -22
  61. package/src/lib/timeline-live.ts +10 -15
  62. package/src/lib/tweet-account-edges.ts +9 -2
  63. package/src/lib/tweet-render.ts +97 -0
  64. package/src/lib/types.ts +185 -2
  65. package/src/lib/ui.ts +383 -147
  66. package/src/lib/url-expansion-store.ts +110 -0
  67. package/src/lib/url-expansion.ts +20 -3
  68. package/src/lib/web-sync.ts +443 -0
  69. package/src/lib/x-profile.ts +7 -2
  70. package/src/lib/xurl.ts +296 -12
  71. package/src/routeTree.gen.ts +126 -0
  72. package/src/routes/__root.tsx +7 -3
  73. package/src/routes/api/conversation.tsx +34 -0
  74. package/src/routes/api/link-insights.tsx +79 -0
  75. package/src/routes/api/link-preview.tsx +43 -0
  76. package/src/routes/api/profile-hydrate.tsx +51 -0
  77. package/src/routes/api/sync.tsx +59 -0
  78. package/src/routes/blocks.tsx +111 -86
  79. package/src/routes/dms.tsx +172 -87
  80. package/src/routes/inbox.tsx +98 -86
  81. package/src/routes/index.tsx +22 -115
  82. package/src/routes/links.tsx +928 -0
  83. package/src/routes/mentions.tsx +22 -115
  84. package/src/styles.css +169 -43
  85. package/vite.config.ts +8 -0
@@ -1,4 +1,5 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
+ import { Sparkles } from "lucide-react";
2
3
  import { useEffect, useMemo, useState } from "react";
3
4
  import { InboxCard } from "#/components/InboxCard";
4
5
  import type {
@@ -8,21 +9,19 @@ import type {
8
9
  QueryEnvelope,
9
10
  } from "#/lib/types";
10
11
  import {
11
- actionButtonClass,
12
12
  cx,
13
- eyebrowClass,
14
- feedPageClass,
15
- heroControlsClass,
16
- heroCopyClass,
17
- heroShellClass,
18
- heroTitleClass,
19
- inboxLaneClass,
20
- navLinkActiveClass,
21
- navLinkClass,
22
- pageWrapClass,
23
- segmentActiveClass,
24
- segmentClass,
25
- segmentedClass,
13
+ emptyStateClass,
14
+ feedClass,
15
+ pageHeaderClass,
16
+ pageHeaderRowClass,
17
+ pageSubtitleClass,
18
+ pageTitleClass,
19
+ primaryButtonClass,
20
+ secondaryButtonClass,
21
+ tabButtonActiveClass,
22
+ tabButtonClass,
23
+ tabButtonIndicatorClass,
24
+ tabStripClass,
26
25
  textFieldClass,
27
26
  textFieldShortClass,
28
27
  timestampClass,
@@ -32,6 +31,12 @@ export const Route = createFileRoute("/inbox")({
32
31
  component: InboxRoute,
33
32
  });
34
33
 
34
+ const TABS: Array<{ value: InboxKind; label: string }> = [
35
+ { value: "mixed", label: "Mixed" },
36
+ { value: "mentions", label: "Mentions" },
37
+ { value: "dms", label: "DMs" },
38
+ ];
39
+
35
40
  function InboxRoute() {
36
41
  const [meta, setMeta] = useState<QueryEnvelope | null>(null);
37
42
  const [items, setItems] = useState<InboxItem[]>([]);
@@ -70,7 +75,7 @@ function InboxRoute() {
70
75
 
71
76
  const subtitle = useMemo(() => {
72
77
  if (!meta || !stats) return "Ranking unreplied mentions and DMs...";
73
- return `${stats.total} items in queue · ${stats.openai} OpenAI scored · ${meta.transport.statusText}`;
78
+ return `${String(stats.total)} in queue · ${String(stats.openai)} OpenAI scored · ${meta.transport.statusText}`;
74
79
  }, [meta, stats]);
75
80
 
76
81
  async function scoreNow() {
@@ -122,80 +127,87 @@ function InboxRoute() {
122
127
  }
123
128
 
124
129
  return (
125
- <main className={pageWrapClass}>
126
- <div className={feedPageClass}>
127
- <section className={heroShellClass}>
128
- <div>
129
- <p className={eyebrowClass}>inbox</p>
130
- <h2 className={heroTitleClass}>AI triage for mentions and DMs.</h2>
131
- <p className={heroCopyClass}>{subtitle}</p>
132
- </div>
133
- <div className={heroControlsClass}>
134
- <div className={segmentedClass}>
135
- {(["mixed", "mentions", "dms"] as const).map((value) => (
136
- <button
137
- key={value}
138
- className={cx(
139
- segmentClass,
140
- value === kind && segmentActiveClass,
141
- )}
142
- onClick={() => setKind(value)}
143
- type="button"
144
- >
145
- {value}
146
- </button>
147
- ))}
148
- </div>
149
- <input
150
- className={cx(textFieldClass, textFieldShortClass)}
151
- inputMode="numeric"
152
- onChange={(event) => setMinScore(event.target.value)}
153
- placeholder="Min AI score"
154
- value={minScore}
155
- />
156
- <button
157
- className={cx(navLinkClass, hideLowSignal && navLinkActiveClass)}
158
- onClick={() => setHideLowSignal((value) => !value)}
159
- type="button"
160
- >
161
- {hideLowSignal ? "Hide low-signal" : "Show all"}
162
- </button>
163
- <button
164
- className={actionButtonClass}
165
- disabled={isScoring}
166
- onClick={() => void scoreNow()}
167
- type="button"
168
- >
169
- {isScoring ? "Scoring..." : "Score with OpenAI"}
170
- </button>
130
+ <>
131
+ <header className={pageHeaderClass}>
132
+ <div className={pageHeaderRowClass}>
133
+ <div className="flex min-w-0 flex-col">
134
+ <h1 className={pageTitleClass}>Inbox</h1>
135
+ <p className={pageSubtitleClass}>{subtitle}</p>
171
136
  </div>
172
- </section>
173
-
174
- <section className={inboxLaneClass}>
175
- {items.map((item) => (
176
- <InboxCard
177
- key={item.id}
178
- isReplying={activeReplyId === item.id}
179
- item={item}
180
- onReplyChange={setReplyDraft}
181
- onReplySend={() => void sendReply(item)}
182
- onReplyToggle={() => {
183
- if (activeReplyId === item.id) {
184
- setActiveReplyId(null);
185
- setReplyDraft("");
186
- return;
187
- }
188
- setActiveReplyId(item.id);
137
+ <button
138
+ className={primaryButtonClass}
139
+ disabled={isScoring}
140
+ onClick={() => void scoreNow()}
141
+ type="button"
142
+ >
143
+ <Sparkles className="size-4" strokeWidth={2.2} />
144
+ {isScoring ? "Scoring..." : "Score with OpenAI"}
145
+ </button>
146
+ </div>
147
+ <div className="flex flex-wrap items-center gap-2 px-4 pb-3">
148
+ <input
149
+ className={cx(textFieldClass, textFieldShortClass)}
150
+ inputMode="numeric"
151
+ onChange={(event) => setMinScore(event.target.value)}
152
+ placeholder="Min AI score"
153
+ value={minScore}
154
+ />
155
+ <button
156
+ className={secondaryButtonClass}
157
+ onClick={() => setHideLowSignal((value) => !value)}
158
+ type="button"
159
+ aria-pressed={hideLowSignal}
160
+ >
161
+ {hideLowSignal ? "Hide low-signal" : "Show all"}
162
+ </button>
163
+ </div>
164
+ <div className={tabStripClass}>
165
+ {TABS.map((tab) => {
166
+ const active = kind === tab.value;
167
+ return (
168
+ <button
169
+ key={tab.value}
170
+ type="button"
171
+ aria-pressed={active}
172
+ className={cx(tabButtonClass, active && tabButtonActiveClass)}
173
+ onClick={() => setKind(tab.value)}
174
+ >
175
+ <span className="relative inline-flex flex-col items-center justify-center py-1">
176
+ {tab.value}
177
+ {active ? <span className={tabButtonIndicatorClass} /> : null}
178
+ </span>
179
+ </button>
180
+ );
181
+ })}
182
+ </div>
183
+ </header>
184
+ <section className={feedClass}>
185
+ {items.length === 0 ? (
186
+ <div className={emptyStateClass}>Inbox clear.</div>
187
+ ) : null}
188
+ {items.map((item) => (
189
+ <InboxCard
190
+ key={item.id}
191
+ isReplying={activeReplyId === item.id}
192
+ item={item}
193
+ onReplyChange={setReplyDraft}
194
+ onReplySend={() => void sendReply(item)}
195
+ onReplyToggle={() => {
196
+ if (activeReplyId === item.id) {
197
+ setActiveReplyId(null);
189
198
  setReplyDraft("");
190
- }}
191
- replyDraft={activeReplyId === item.id ? replyDraft : ""}
192
- />
193
- ))}
194
- </section>
195
- </div>
199
+ return;
200
+ }
201
+ setActiveReplyId(item.id);
202
+ setReplyDraft("");
203
+ }}
204
+ replyDraft={activeReplyId === item.id ? replyDraft : ""}
205
+ />
206
+ ))}
207
+ </section>
196
208
  {isSendingReply ? (
197
- <p className={timestampClass}>Sending reply...</p>
209
+ <p className={cx(timestampClass, "px-4 py-2")}>Sending reply...</p>
198
210
  ) : null}
199
- </main>
211
+ </>
200
212
  );
201
213
  }
@@ -1,125 +1,32 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
- import { useEffect, useMemo, useState } from "react";
3
- import { TimelineCard } from "#/components/TimelineCard";
4
- import type {
5
- QueryEnvelope,
6
- QueryResponse,
7
- ReplyFilter,
8
- TimelineItem,
9
- } from "#/lib/types";
10
- import {
11
- cx,
12
- eyebrowClass,
13
- feedPageClass,
14
- heroControlsClass,
15
- heroCopyClass,
16
- heroShellClass,
17
- heroTitleClass,
18
- pageWrapClass,
19
- segmentActiveClass,
20
- segmentClass,
21
- segmentedClass,
22
- textFieldClass,
23
- textFieldWideClass,
24
- timelineLaneClass,
25
- } from "#/lib/ui";
2
+ import { TimelineRouteFrame } from "#/components/TimelineRouteFrame";
3
+ import type { QueryEnvelope } from "#/lib/types";
26
4
 
27
5
  export const Route = createFileRoute("/")({
28
6
  component: HomeRoute,
29
7
  });
30
8
 
31
- function HomeRoute() {
32
- const [meta, setMeta] = useState<QueryEnvelope | null>(null);
33
- const [items, setItems] = useState<TimelineItem[]>([]);
34
- const [replyFilter, setReplyFilter] = useState<ReplyFilter>("all");
35
- const [search, setSearch] = useState("");
36
- const [refreshTick, setRefreshTick] = useState(0);
37
-
38
- useEffect(() => {
39
- fetch("/api/status")
40
- .then((response) => response.json())
41
- .then((data: QueryEnvelope) => setMeta(data));
42
- }, []);
43
-
44
- useEffect(() => {
45
- const url = new URL("/api/query", window.location.origin);
46
- url.searchParams.set("resource", "home");
47
- url.searchParams.set("replyFilter", replyFilter);
48
- url.searchParams.set("refresh", String(refreshTick));
49
- if (search.trim()) {
50
- url.searchParams.set("search", search.trim());
51
- }
52
-
53
- fetch(url)
54
- .then((response) => response.json())
55
- .then((data: QueryResponse) => setItems(data.items as TimelineItem[]));
56
- }, [refreshTick, replyFilter, search]);
57
-
58
- const subtitle = useMemo(() => {
59
- if (!meta) return "Loading local context...";
60
- return `${meta.stats.home} home items · ${meta.stats.needsReply} waiting on action · ${meta.transport.statusText}`;
61
- }, [meta]);
62
-
63
- async function replyToTweet(tweetId: string) {
64
- const text = window.prompt("Reply text");
65
- if (!text?.trim()) return;
66
-
67
- await fetch("/api/action", {
68
- method: "POST",
69
- headers: { "content-type": "application/json" },
70
- body: JSON.stringify({
71
- kind: "replyTweet",
72
- accountId: "acct_primary",
73
- tweetId,
74
- text,
75
- }),
76
- });
77
-
78
- setRefreshTick((value) => value + 1);
79
- }
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
+ }
80
13
 
14
+ function HomeRoute() {
81
15
  return (
82
- <main className={pageWrapClass}>
83
- <div className={feedPageClass}>
84
- <section className={heroShellClass}>
85
- <div>
86
- <p className={eyebrowClass}>home timeline</p>
87
- <h2 className={heroTitleClass}>
88
- Read first. Act only where signal survives.
89
- </h2>
90
- <p className={heroCopyClass}>{subtitle}</p>
91
- </div>
92
- <div className={heroControlsClass}>
93
- <input
94
- className={cx(textFieldClass, textFieldWideClass)}
95
- onChange={(event) => setSearch(event.target.value)}
96
- placeholder="Search local timeline"
97
- value={search}
98
- />
99
- <div className={segmentedClass}>
100
- {(["all", "replied", "unreplied"] as const).map((value) => (
101
- <button
102
- key={value}
103
- className={cx(
104
- segmentClass,
105
- value === replyFilter && segmentActiveClass,
106
- )}
107
- onClick={() => setReplyFilter(value)}
108
- type="button"
109
- >
110
- {value}
111
- </button>
112
- ))}
113
- </div>
114
- </div>
115
- </section>
116
-
117
- <section className={timelineLaneClass}>
118
- {items.map((item) => (
119
- <TimelineCard key={item.id} item={item} onReply={replyToTweet} />
120
- ))}
121
- </section>
122
- </div>
123
- </main>
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
+ />
124
31
  );
125
32
  }