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,23 +1,41 @@
1
1
  import { Link, useRouterState } from "@tanstack/react-router";
2
2
  import {
3
- brandMarkClass,
3
+ Bell,
4
+ Bookmark,
5
+ Heart,
6
+ Home,
7
+ Inbox,
8
+ Link as LinkIcon,
9
+ Mail,
10
+ ShieldOff,
11
+ } from "lucide-react";
12
+ import {
4
13
  cx,
5
- eyebrowClass,
6
- navClass,
7
14
  navLinkActiveClass,
8
15
  navLinkClass,
9
- navLinksClass,
16
+ navLinkIconClass,
17
+ navLinkLabelClass,
18
+ sidebarBrandClass,
19
+ sidebarBrandCopyClass,
20
+ sidebarBrandMarkClass,
21
+ sidebarBrandTaglineClass,
22
+ sidebarBrandTitleClass,
23
+ sidebarFooterClass,
24
+ sidebarNavClass,
25
+ sidebarShellClass,
10
26
  } from "#/lib/ui";
27
+ import { BirdclawMark } from "./BrandMark";
11
28
  import { ThemeSlider } from "./ThemeSlider";
12
29
 
13
30
  const links = [
14
- { to: "/inbox", label: "Inbox" },
15
- { to: "/", label: "Home" },
16
- { to: "/mentions", label: "Mentions" },
17
- { to: "/likes", label: "Likes" },
18
- { to: "/bookmarks", label: "Bookmarks" },
19
- { to: "/dms", label: "DMs" },
20
- { to: "/blocks", label: "Blocks" },
31
+ { to: "/inbox", label: "Inbox", icon: Inbox },
32
+ { to: "/", label: "Home", icon: Home },
33
+ { to: "/mentions", label: "Mentions", icon: Bell },
34
+ { to: "/likes", label: "Likes", icon: Heart },
35
+ { to: "/bookmarks", label: "Bookmarks", icon: Bookmark },
36
+ { to: "/links", label: "Links", icon: LinkIcon },
37
+ { to: "/dms", label: "DMs", icon: Mail },
38
+ { to: "/blocks", label: "Blocks", icon: ShieldOff },
21
39
  ] as const;
22
40
 
23
41
  export function AppNav() {
@@ -26,26 +44,45 @@ export function AppNav() {
26
44
  });
27
45
 
28
46
  return (
29
- <nav className={navClass}>
30
- <div>
31
- <p className={eyebrowClass}>birdclaw</p>
32
- <h1 className={brandMarkClass}>Quiet signal for Twitter.</h1>
47
+ <aside className={sidebarShellClass}>
48
+ <div className="flex flex-col">
49
+ <Link to="/" className={sidebarBrandClass}>
50
+ <span className={sidebarBrandMarkClass}>
51
+ <BirdclawMark className="size-10" />
52
+ </span>
53
+ <span className={sidebarBrandCopyClass}>
54
+ <span className={sidebarBrandTitleClass}>birdclaw</span>
55
+ <span className={sidebarBrandTaglineClass}>
56
+ Fast search for your Twitter archive.
57
+ </span>
58
+ </span>
59
+ </Link>
60
+ <nav className={sidebarNavClass} aria-label="Primary">
61
+ {links.map((link) => {
62
+ const active = pathname === link.to;
63
+ const Icon = link.icon;
64
+ return (
65
+ <Link
66
+ key={link.to}
67
+ to={link.to}
68
+ aria-label={link.label}
69
+ className={cx(navLinkClass, active && navLinkActiveClass)}
70
+ >
71
+ <Icon
72
+ className={navLinkIconClass}
73
+ size={22}
74
+ strokeWidth={active ? 2.4 : 1.8}
75
+ aria-hidden="true"
76
+ />
77
+ <span className={navLinkLabelClass}>{link.label}</span>
78
+ </Link>
79
+ );
80
+ })}
81
+ </nav>
33
82
  </div>
34
- <div className={navLinksClass}>
35
- {links.map((link) => {
36
- const active = pathname === link.to;
37
- return (
38
- <Link
39
- key={link.to}
40
- to={link.to}
41
- className={cx(navLinkClass, active && navLinkActiveClass)}
42
- >
43
- {link.label}
44
- </Link>
45
- );
46
- })}
83
+ <div className={sidebarFooterClass}>
47
84
  <ThemeSlider />
48
85
  </div>
49
- </nav>
86
+ </aside>
50
87
  );
51
88
  }
@@ -1,6 +1,11 @@
1
1
  import { useState } from "react";
2
2
  import { getInitials } from "#/lib/present";
3
- import { avatarChipClass, avatarChipLargeClass, cx } from "#/lib/ui";
3
+ import {
4
+ avatarChipClass,
5
+ avatarChipLargeClass,
6
+ avatarChipSmallClass,
7
+ cx,
8
+ } from "#/lib/ui";
4
9
 
5
10
  export function AvatarChip({
6
11
  profileId,
@@ -13,7 +18,7 @@ export function AvatarChip({
13
18
  avatarUrl?: string;
14
19
  name: string;
15
20
  hue: number;
16
- size?: "default" | "large";
21
+ size?: "default" | "large" | "small";
17
22
  }) {
18
23
  const avatarSrc =
19
24
  profileId && avatarUrl
@@ -26,10 +31,10 @@ export function AvatarChip({
26
31
  <span
27
32
  className={cx(
28
33
  avatarChipClass,
29
- "avatar-chip",
30
- size === "large" && cx(avatarChipLargeClass, "avatar-chip-large"),
34
+ size === "large" && avatarChipLargeClass,
35
+ size === "small" && avatarChipSmallClass,
31
36
  )}
32
- style={{ backgroundColor: `hsl(${hue} 72% 50%)` }}
37
+ style={{ backgroundColor: `hsl(${String(hue)} 72% 50%)` }}
33
38
  >
34
39
  {showImage ? (
35
40
  <img
@@ -0,0 +1,67 @@
1
+ import { cx, emptyStateClass } from "#/lib/ui";
2
+
3
+ export function BirdclawMark({
4
+ animated = false,
5
+ className,
6
+ }: {
7
+ animated?: boolean;
8
+ className?: string;
9
+ }) {
10
+ return (
11
+ <span
12
+ aria-hidden="true"
13
+ className={cx(
14
+ "birdclaw-mark relative inline-grid shrink-0 place-items-center",
15
+ animated && "birdclaw-mark-animated",
16
+ className,
17
+ )}
18
+ >
19
+ <img
20
+ alt=""
21
+ className="size-full object-contain drop-shadow-[0_10px_22px_var(--brand-shadow)]"
22
+ draggable={false}
23
+ src="/birdclaw-mark.png"
24
+ />
25
+ </span>
26
+ );
27
+ }
28
+
29
+ export function BirdclawLoading({
30
+ label,
31
+ detail,
32
+ }: {
33
+ label: string;
34
+ detail?: string;
35
+ }) {
36
+ return (
37
+ <div className={cx(emptyStateClass, "birdclaw-state")}>
38
+ <BirdclawMark animated className="size-16" />
39
+ <div className="mt-3 text-[14px] font-semibold text-[var(--ink)]">
40
+ {label}
41
+ </div>
42
+ {detail ? (
43
+ <div className="mt-1 text-[13px] text-[var(--ink-soft)]">{detail}</div>
44
+ ) : null}
45
+ </div>
46
+ );
47
+ }
48
+
49
+ export function BirdclawEmpty({
50
+ label,
51
+ detail,
52
+ }: {
53
+ label: string;
54
+ detail?: string;
55
+ }) {
56
+ return (
57
+ <div className={cx(emptyStateClass, "birdclaw-state")}>
58
+ <BirdclawMark className="size-12 opacity-75" />
59
+ <div className="mt-3 text-[14px] font-semibold text-[var(--ink)]">
60
+ {label}
61
+ </div>
62
+ {detail ? (
63
+ <div className="mt-1 text-[13px] text-[var(--ink-soft)]">{detail}</div>
64
+ ) : null}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,126 @@
1
+ import { MessageCircle } from "lucide-react";
2
+ import { formatShortTimestamp } from "#/lib/present";
3
+ import type { EmbeddedTweet } from "#/lib/types";
4
+ import {
5
+ cx,
6
+ feedActionIconClass,
7
+ feedRowHandleClass,
8
+ feedRowNameClass,
9
+ feedRowTimestampClass,
10
+ } from "#/lib/ui";
11
+ import { AvatarChip } from "./AvatarChip";
12
+ import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
13
+ import { ProfilePreview } from "./ProfilePreview";
14
+ import { TweetMediaGrid } from "./TweetMediaGrid";
15
+ import { TweetRichText } from "./TweetRichText";
16
+
17
+ export function ConversationThread({
18
+ anchorId,
19
+ error,
20
+ items,
21
+ loading,
22
+ }: {
23
+ anchorId: string;
24
+ error?: string | null;
25
+ items: EmbeddedTweet[];
26
+ loading: boolean;
27
+ }) {
28
+ if (loading) {
29
+ return (
30
+ <section className="mt-3 rounded-2xl border border-[var(--line)] bg-[var(--bg-card)]">
31
+ <BirdclawLoading
32
+ detail="Finding archived replies around this post"
33
+ label="Loading conversation"
34
+ />
35
+ </section>
36
+ );
37
+ }
38
+
39
+ if (error) {
40
+ return (
41
+ <section className="mt-3 rounded-2xl border border-[var(--alert)] bg-[var(--alert-soft)] px-4 py-3 text-[14px] text-[var(--alert)]">
42
+ {error}
43
+ </section>
44
+ );
45
+ }
46
+
47
+ if (items.length <= 1) {
48
+ return (
49
+ <section className="mt-3 rounded-2xl border border-[var(--line)]">
50
+ <BirdclawEmpty
51
+ detail="This post has no other archived replies locally."
52
+ label="No thread context yet"
53
+ />
54
+ </section>
55
+ );
56
+ }
57
+
58
+ return (
59
+ <section
60
+ aria-label="Conversation"
61
+ className="mt-3 overflow-hidden rounded-2xl border border-[var(--line)] bg-[var(--bg-card)]"
62
+ >
63
+ <div className="flex items-center gap-2 border-b border-[var(--line)] px-4 py-2.5 text-[13px] font-bold text-[var(--ink)]">
64
+ <MessageCircle className={feedActionIconClass} strokeWidth={1.8} />
65
+ <span>{items.length} tweets in conversation</span>
66
+ </div>
67
+ <div className="flex flex-col">
68
+ {items.map((tweet, index) => {
69
+ const isAnchor = tweet.id === anchorId;
70
+ return (
71
+ <div
72
+ className={cx(
73
+ "flex gap-3 px-4 py-3",
74
+ index > 0 && "border-t border-[var(--line)]",
75
+ isAnchor && "bg-[var(--accent-soft)]",
76
+ )}
77
+ key={tweet.id}
78
+ >
79
+ <div className="flex flex-col items-center">
80
+ <AvatarChip
81
+ avatarUrl={tweet.author.avatarUrl}
82
+ hue={tweet.author.avatarHue}
83
+ name={tweet.author.displayName}
84
+ profileId={tweet.author.id}
85
+ size="small"
86
+ />
87
+ {index < items.length - 1 ? (
88
+ <span className="mt-2 w-px flex-1 bg-[var(--line)]" />
89
+ ) : null}
90
+ </div>
91
+ <div className="min-w-0 flex-1">
92
+ <header className="flex min-w-0 items-center gap-1.5 text-[14px]">
93
+ <ProfilePreview profile={tweet.author}>
94
+ <span className="flex min-w-0 items-center gap-1.5">
95
+ <span className={feedRowNameClass}>
96
+ {tweet.author.displayName}
97
+ </span>
98
+ <span className={feedRowHandleClass}>
99
+ @{tweet.author.handle}
100
+ </span>
101
+ </span>
102
+ </ProfilePreview>
103
+ <span className="text-[var(--ink-soft)]">·</span>
104
+ <span className={feedRowTimestampClass}>
105
+ {formatShortTimestamp(tweet.createdAt)}
106
+ </span>
107
+ {isAnchor ? (
108
+ <span className="ml-auto rounded-full bg-[var(--accent)] px-2 py-0.5 text-[11px] font-bold text-white">
109
+ selected
110
+ </span>
111
+ ) : null}
112
+ </header>
113
+ <TweetRichText
114
+ className="mt-1 whitespace-pre-wrap break-words text-[14px] leading-[1.45] text-[var(--ink)] [overflow-wrap:anywhere]"
115
+ entities={tweet.entities}
116
+ text={tweet.text}
117
+ />
118
+ <TweetMediaGrid items={tweet.media} />
119
+ </div>
120
+ </div>
121
+ );
122
+ })}
123
+ </div>
124
+ </section>
125
+ );
126
+ }
@@ -1,67 +1,63 @@
1
+ import { CheckCircle2, Circle } from "lucide-react";
1
2
  import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
2
3
  import type { DmConversationItem, DmMessageItem } from "#/lib/types";
3
4
  import {
4
- actionButtonClass,
5
5
  composerBarClass,
6
6
  composerInputClass,
7
- composerShellClass,
8
- contextBioClass,
9
- contextHandleClass,
10
7
  contextStatRowClass,
11
- contextStatsClass,
12
8
  contextStatTermClass,
13
9
  contextStatValueClass,
14
10
  cx,
15
- dmGridClass,
11
+ dmComposerShellClass,
12
+ dmListBodyClass,
16
13
  dmListClass,
17
- dmListCopyClass,
14
+ dmListHandleClass,
15
+ dmListHeaderClass,
18
16
  dmListItemActiveClass,
19
17
  dmListItemClass,
20
- dmPreviewTextClass,
21
- emptyStateClass,
22
- eyebrowClass,
23
- identityRowClass,
24
- messageBubbleClass,
25
- messageBubbleOutboundClass,
26
- messageMetaClass,
27
- messageRowClass,
28
- messageRowOutboundClass,
29
- messageStackClass,
30
- metaStackClass,
18
+ dmListNameClass,
19
+ dmListPreviewClass,
20
+ dmListTimestampClass,
21
+ dmMessageBubbleClass,
22
+ dmMessageBubbleOutboundClass,
23
+ dmMessageMetaClass,
24
+ dmMessageRowClass,
25
+ dmMessageRowOutboundClass,
26
+ dmMessagesClass,
27
+ dmShellClass,
28
+ dmThreadClass,
29
+ dmThreadHeaderClass,
30
+ dmThreadNameClass,
31
+ dmThreadSubtitleClass,
32
+ dmThreadTitleClass,
31
33
  pillAlertClass,
32
34
  pillClass,
33
35
  pillSoftClass,
34
- threadBioClass,
35
- threadDetailColumnClass,
36
- threadDetailHeaderClass,
37
- threadHeaderClass,
38
- threadShellClass,
39
- threadSubtitleClass,
40
- threadTitleClass,
36
+ primaryButtonClass,
41
37
  timestampClass,
42
38
  } from "#/lib/ui";
43
39
  import { AvatarChip } from "./AvatarChip";
40
+ import { BirdclawEmpty } from "./BrandMark";
44
41
 
45
42
  function MessageBubble({ message }: { message: DmMessageItem }) {
43
+ const outbound = message.direction === "outbound";
46
44
  return (
47
45
  <div
48
- className={cx(
49
- messageRowClass,
50
- message.direction === "outbound" && messageRowOutboundClass,
51
- )}
46
+ className={cx(dmMessageRowClass, outbound && dmMessageRowOutboundClass)}
52
47
  >
53
- <div className={messageMetaClass}>
54
- <span>{message.sender.displayName}</span>
55
- <span>{formatShortTimestamp(message.createdAt)}</span>
56
- </div>
57
48
  <div
58
49
  className={cx(
59
- messageBubbleClass,
60
- message.direction === "outbound" && messageBubbleOutboundClass,
50
+ dmMessageBubbleClass,
51
+ outbound && dmMessageBubbleOutboundClass,
61
52
  )}
62
53
  >
63
54
  {message.text}
64
55
  </div>
56
+ <div className={dmMessageMetaClass}>
57
+ <span>{message.sender.displayName}</span>
58
+ <span>·</span>
59
+ <span>{formatShortTimestamp(message.createdAt)}</span>
60
+ </div>
65
61
  </div>
66
62
  );
67
63
  }
@@ -85,12 +81,18 @@ export function DmWorkspace({
85
81
  }) {
86
82
  const participant = selectedConversation?.participant ?? null;
87
83
  const subtitle = selectedConversation
88
- ? `${selectedConversation.needsReply ? "Reply owed" : "Thread clear"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
84
+ ? `${selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
89
85
  : "No conversation selected";
90
86
 
91
87
  return (
92
- <section className={dmGridClass}>
88
+ <section className={dmShellClass}>
93
89
  <aside className={dmListClass}>
90
+ {conversations.length === 0 ? (
91
+ <BirdclawEmpty
92
+ detail="Sync DMs to populate this lane."
93
+ label="No conversations"
94
+ />
95
+ ) : null}
94
96
  {conversations.map((conversation) => {
95
97
  const active = conversation.id === selectedConversation?.id;
96
98
  return (
@@ -106,70 +108,85 @@ export function DmWorkspace({
106
108
  name={conversation.participant.displayName}
107
109
  profileId={conversation.participant.id}
108
110
  />
109
- <div className={dmListCopyClass}>
110
- <div className={identityRowClass}>
111
- <strong>{conversation.participant.displayName}</strong>
112
- <span>@{conversation.participant.handle}</span>
111
+ <div className={dmListBodyClass}>
112
+ <div className={dmListHeaderClass}>
113
+ <div className="flex min-w-0 items-center gap-1.5">
114
+ <span className={dmListNameClass}>
115
+ {conversation.participant.displayName}
116
+ </span>
117
+ <span className={dmListHandleClass}>
118
+ @{conversation.participant.handle}
119
+ </span>
120
+ </div>
121
+ <span className={dmListTimestampClass}>
122
+ {formatShortTimestamp(conversation.lastMessageAt)}
123
+ </span>
113
124
  </div>
114
- <p className={dmPreviewTextClass}>
125
+ <p className={dmListPreviewClass}>
115
126
  {conversation.lastMessagePreview}
116
127
  </p>
117
- </div>
118
- <div className={metaStackClass}>
119
- <span
120
- className={cx(
121
- pillClass,
122
- conversation.needsReply ? pillAlertClass : pillSoftClass,
123
- )}
124
- >
125
- {conversation.needsReply ? "needs reply" : "clear"}
126
- </span>
127
- <span className={cx(pillClass, pillSoftClass)}>
128
- {conversation.influenceScore} · {conversation.influenceLabel}
129
- </span>
130
- <span className={timestampClass}>
131
- {formatShortTimestamp(conversation.lastMessageAt)}
132
- </span>
128
+ <div className="mt-1 flex items-center gap-1.5">
129
+ <span
130
+ className={cx(
131
+ pillClass,
132
+ conversation.needsReply ? pillAlertClass : pillSoftClass,
133
+ )}
134
+ aria-label={
135
+ conversation.needsReply ? "Reply open" : "We replied"
136
+ }
137
+ >
138
+ {conversation.needsReply ? (
139
+ <Circle className="size-3" strokeWidth={2.2} />
140
+ ) : (
141
+ <CheckCircle2 className="size-3.5" strokeWidth={2} />
142
+ )}
143
+ {conversation.needsReply ? "open" : "replied"}
144
+ </span>
145
+ <span className={cx(pillClass, pillSoftClass)}>
146
+ {conversation.influenceScore} ·{" "}
147
+ {conversation.influenceLabel}
148
+ </span>
149
+ </div>
133
150
  </div>
134
151
  </button>
135
152
  );
136
153
  })}
137
154
  </aside>
138
155
 
139
- <div className={threadShellClass}>
156
+ <div className={dmThreadClass}>
140
157
  {selectedConversation ? (
141
158
  <>
142
- <header className={threadHeaderClass}>
143
- <div>
144
- <p className={eyebrowClass}>direct messages</p>
145
- <h2 className={threadTitleClass}>
146
- {selectedConversation.participant.displayName}
147
- </h2>
148
- <p className={threadSubtitleClass}>{subtitle}</p>
149
- <p className={threadBioClass}>
150
- {selectedConversation.lastMessagePreview}
151
- </p>
152
- </div>
153
- <div className={threadDetailColumnClass}>
154
- <div className={threadDetailHeaderClass}>
155
- <AvatarChip
156
- avatarUrl={participant?.avatarUrl}
157
- hue={participant?.avatarHue ?? 18}
158
- name={participant?.displayName ?? "Unknown"}
159
- profileId={participant?.id ?? undefined}
160
- size="large"
161
- />
162
- <div>
163
- <strong>{participant?.displayName}</strong>
164
- <p className={cx("context-handle", contextHandleClass)}>
165
- @{participant?.handle}
166
- </p>
159
+ <header className={dmThreadHeaderClass}>
160
+ <div className={dmThreadTitleClass}>
161
+ <AvatarChip
162
+ avatarUrl={participant?.avatarUrl}
163
+ hue={participant?.avatarHue ?? 18}
164
+ name={participant?.displayName ?? "Unknown"}
165
+ profileId={participant?.id ?? undefined}
166
+ />
167
+ <div className="min-w-0">
168
+ <div className={dmThreadNameClass}>
169
+ {selectedConversation.participant.displayName}
167
170
  </div>
171
+ <div className={dmThreadSubtitleClass}>{subtitle}</div>
168
172
  </div>
169
- <p className={cx("context-bio", contextBioClass)}>
170
- {participant?.bio}
171
- </p>
172
- <dl className={contextStatsClass}>
173
+ </div>
174
+ <button
175
+ className={primaryButtonClass}
176
+ onClick={() => onReplySend(selectedConversation.id)}
177
+ type="button"
178
+ >
179
+ Reply
180
+ </button>
181
+ </header>
182
+ {participant?.bio || participant?.followersCount ? (
183
+ <div className="border-b border-[var(--line)] px-4 py-3">
184
+ {participant?.bio ? (
185
+ <p className="text-[14px] leading-[1.4] text-[var(--ink)]">
186
+ {participant.bio}
187
+ </p>
188
+ ) : null}
189
+ <dl className="mt-2 grid grid-cols-2 gap-1 text-[13px]">
173
190
  <div className={contextStatRowClass}>
174
191
  <dt className={contextStatTermClass}>Followers</dt>
175
192
  <dd className={contextStatValueClass}>
@@ -187,8 +204,8 @@ export function DmWorkspace({
187
204
  <dt className={contextStatTermClass}>Reply state</dt>
188
205
  <dd className={contextStatValueClass}>
189
206
  {selectedConversation.needsReply
190
- ? "Needs reply"
191
- : "Replied"}
207
+ ? "Reply open"
208
+ : "We replied"}
192
209
  </dd>
193
210
  </div>
194
211
  <div className={contextStatRowClass}>
@@ -198,36 +215,29 @@ export function DmWorkspace({
198
215
  </dd>
199
216
  </div>
200
217
  </dl>
201
- <button
202
- className={actionButtonClass}
203
- onClick={() => onReplySend(selectedConversation.id)}
204
- type="button"
205
- >
206
- Reply
207
- </button>
208
218
  </div>
209
- </header>
210
- <div className={messageStackClass}>
219
+ ) : null}
220
+ <div className={dmMessagesClass}>
211
221
  {selectedMessages.map((message) => (
212
222
  <MessageBubble key={message.id} message={message} />
213
223
  ))}
214
224
  </div>
215
- <div className={composerShellClass}>
225
+ <div className={dmComposerShellClass}>
216
226
  <textarea
217
227
  className={composerInputClass}
218
228
  onChange={(event) => onReplyDraftChange(event.target.value)}
219
229
  placeholder={`Reply to @${selectedConversation.participant.handle}`}
220
- rows={4}
230
+ rows={3}
221
231
  value={replyDraft}
222
232
  />
223
233
  <div className={composerBarClass}>
224
234
  <span className={timestampClass}>
225
235
  {selectedConversation.needsReply
226
- ? "Reply still owed"
227
- : "Thread clear"}
236
+ ? "Reply open"
237
+ : "We replied"}
228
238
  </span>
229
239
  <button
230
- className={actionButtonClass}
240
+ className={primaryButtonClass}
231
241
  disabled={!replyDraft.trim()}
232
242
  onClick={() => onReplySend(selectedConversation.id)}
233
243
  type="button"
@@ -238,7 +248,10 @@ export function DmWorkspace({
238
248
  </div>
239
249
  </>
240
250
  ) : (
241
- <div className={emptyStateClass}>No DM selected.</div>
251
+ <BirdclawEmpty
252
+ detail="Pick a conversation to read the thread."
253
+ label="No DM selected"
254
+ />
242
255
  )}
243
256
  </div>
244
257
  </section>