clawport-ui 0.1.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 (132) hide show
  1. package/.env.example +35 -0
  2. package/BRANDING.md +131 -0
  3. package/CLAUDE.md +252 -0
  4. package/README.md +262 -0
  5. package/SETUP.md +337 -0
  6. package/app/agents/[id]/page.tsx +727 -0
  7. package/app/api/agents/route.ts +12 -0
  8. package/app/api/chat/[id]/route.ts +139 -0
  9. package/app/api/cron-runs/route.ts +13 -0
  10. package/app/api/crons/route.ts +12 -0
  11. package/app/api/kanban/chat/[id]/route.ts +119 -0
  12. package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
  13. package/app/api/memory/route.ts +12 -0
  14. package/app/api/transcribe/route.ts +37 -0
  15. package/app/api/tts/route.ts +42 -0
  16. package/app/chat/[id]/page.tsx +10 -0
  17. package/app/chat/page.tsx +200 -0
  18. package/app/crons/page.tsx +870 -0
  19. package/app/docs/page.tsx +399 -0
  20. package/app/favicon.ico +0 -0
  21. package/app/globals.css +692 -0
  22. package/app/kanban/page.tsx +327 -0
  23. package/app/layout.tsx +45 -0
  24. package/app/memory/page.tsx +685 -0
  25. package/app/page.tsx +817 -0
  26. package/app/providers.tsx +37 -0
  27. package/app/settings/page.tsx +901 -0
  28. package/app/settings-provider.tsx +209 -0
  29. package/components/AgentAvatar.tsx +54 -0
  30. package/components/AgentNode.tsx +122 -0
  31. package/components/Breadcrumbs.tsx +126 -0
  32. package/components/DynamicFavicon.tsx +62 -0
  33. package/components/ErrorState.tsx +97 -0
  34. package/components/FeedView.tsx +494 -0
  35. package/components/GlobalSearch.tsx +571 -0
  36. package/components/GridView.tsx +532 -0
  37. package/components/ManorMap.tsx +157 -0
  38. package/components/MobileSidebar.tsx +251 -0
  39. package/components/NavLinks.tsx +271 -0
  40. package/components/OnboardingWizard.tsx +1067 -0
  41. package/components/Sidebar.tsx +115 -0
  42. package/components/ThemeToggle.tsx +108 -0
  43. package/components/chat/AgentList.tsx +537 -0
  44. package/components/chat/ConversationView.tsx +1047 -0
  45. package/components/chat/FileAttachment.tsx +140 -0
  46. package/components/chat/MediaPreview.tsx +111 -0
  47. package/components/chat/VoiceMessage.tsx +139 -0
  48. package/components/crons/PipelineGraph.tsx +327 -0
  49. package/components/crons/WeeklySchedule.tsx +630 -0
  50. package/components/docs/AgentsSection.tsx +209 -0
  51. package/components/docs/ApiReferenceSection.tsx +256 -0
  52. package/components/docs/ArchitectureSection.tsx +221 -0
  53. package/components/docs/ComponentsSection.tsx +253 -0
  54. package/components/docs/CronSystemSection.tsx +235 -0
  55. package/components/docs/DocSection.tsx +346 -0
  56. package/components/docs/GettingStartedSection.tsx +169 -0
  57. package/components/docs/ThemingSection.tsx +257 -0
  58. package/components/docs/TroubleshootingSection.tsx +200 -0
  59. package/components/kanban/AgentPicker.tsx +321 -0
  60. package/components/kanban/CreateTicketModal.tsx +333 -0
  61. package/components/kanban/KanbanBoard.tsx +70 -0
  62. package/components/kanban/KanbanColumn.tsx +166 -0
  63. package/components/kanban/TicketCard.tsx +245 -0
  64. package/components/kanban/TicketDetailPanel.tsx +850 -0
  65. package/components/ui/badge.tsx +48 -0
  66. package/components/ui/button.tsx +64 -0
  67. package/components/ui/card.tsx +92 -0
  68. package/components/ui/dialog.tsx +158 -0
  69. package/components/ui/scroll-area.tsx +58 -0
  70. package/components/ui/separator.tsx +28 -0
  71. package/components/ui/skeleton.tsx +27 -0
  72. package/components/ui/tabs.tsx +91 -0
  73. package/components/ui/tooltip.tsx +57 -0
  74. package/components.json +23 -0
  75. package/docs/API.md +648 -0
  76. package/docs/COMPONENTS.md +1059 -0
  77. package/docs/THEMING.md +795 -0
  78. package/lib/agents-registry.ts +35 -0
  79. package/lib/agents.json +282 -0
  80. package/lib/agents.test.ts +367 -0
  81. package/lib/agents.ts +32 -0
  82. package/lib/anthropic.test.ts +422 -0
  83. package/lib/anthropic.ts +220 -0
  84. package/lib/api-error.ts +16 -0
  85. package/lib/audio-recorder.test.ts +72 -0
  86. package/lib/audio-recorder.ts +169 -0
  87. package/lib/conversations.test.ts +331 -0
  88. package/lib/conversations.ts +117 -0
  89. package/lib/cron-pipelines.test.ts +69 -0
  90. package/lib/cron-pipelines.ts +58 -0
  91. package/lib/cron-runs.test.ts +118 -0
  92. package/lib/cron-runs.ts +67 -0
  93. package/lib/cron-utils.test.ts +222 -0
  94. package/lib/cron-utils.ts +160 -0
  95. package/lib/crons.test.ts +502 -0
  96. package/lib/crons.ts +114 -0
  97. package/lib/env.test.ts +44 -0
  98. package/lib/env.ts +14 -0
  99. package/lib/kanban/automation.test.ts +245 -0
  100. package/lib/kanban/automation.ts +143 -0
  101. package/lib/kanban/chat-store.test.ts +149 -0
  102. package/lib/kanban/chat-store.ts +81 -0
  103. package/lib/kanban/store.test.ts +238 -0
  104. package/lib/kanban/store.ts +98 -0
  105. package/lib/kanban/types.ts +50 -0
  106. package/lib/kanban/useAgentWork.ts +78 -0
  107. package/lib/memory.ts +45 -0
  108. package/lib/multimodal.test.ts +219 -0
  109. package/lib/multimodal.ts +68 -0
  110. package/lib/pipeline.integration.test.ts +343 -0
  111. package/lib/sanitize.ts +194 -0
  112. package/lib/settings.test.ts +137 -0
  113. package/lib/settings.ts +94 -0
  114. package/lib/styles.ts +24 -0
  115. package/lib/themes.ts +9 -0
  116. package/lib/transcribe.test.ts +141 -0
  117. package/lib/transcribe.ts +111 -0
  118. package/lib/types.ts +66 -0
  119. package/lib/utils.ts +6 -0
  120. package/lib/validation.test.ts +132 -0
  121. package/lib/validation.ts +80 -0
  122. package/next.config.ts +7 -0
  123. package/package.json +56 -0
  124. package/postcss.config.mjs +7 -0
  125. package/public/file.svg +1 -0
  126. package/public/globe.svg +1 -0
  127. package/public/next.svg +1 -0
  128. package/public/vercel.svg +1 -0
  129. package/public/window.svg +1 -0
  130. package/scripts/setup.mjs +215 -0
  131. package/tsconfig.json +34 -0
  132. package/vitest.config.ts +17 -0
@@ -0,0 +1,537 @@
1
+ 'use client'
2
+ import { useState } from 'react'
3
+ import type { Agent } from '@/lib/types'
4
+ import type { ConversationStore } from '@/lib/conversations'
5
+ import { Skeleton } from '@/components/ui/skeleton'
6
+ import { AgentAvatar } from '@/components/AgentAvatar'
7
+
8
+ interface AgentListProps {
9
+ agents: Agent[]
10
+ conversations: ConversationStore
11
+ activeId: string | null
12
+ onSelect: (agent: Agent) => void
13
+ loading?: boolean
14
+ }
15
+
16
+ export function AgentList({ agents, conversations, activeId, onSelect, loading }: AgentListProps) {
17
+ const [search, setSearch] = useState('')
18
+
19
+ const filtered = search.trim()
20
+ ? agents.filter(a => {
21
+ const q = search.toLowerCase()
22
+ return a.name.toLowerCase().includes(q) || a.title.toLowerCase().includes(q)
23
+ })
24
+ : agents
25
+
26
+ const sorted = [...filtered].sort((a, b) => {
27
+ const ca = conversations[a.id]
28
+ const cb = conversations[b.id]
29
+ if (ca && cb) return cb.lastActivity - ca.lastActivity
30
+ if (ca) return -1
31
+ if (cb) return 1
32
+ return a.name.localeCompare(b.name)
33
+ })
34
+
35
+ return (
36
+ <div
37
+ className="hidden md:flex md:flex-col"
38
+ style={{
39
+ width: 300,
40
+ flexShrink: 0,
41
+ background: 'var(--sidebar-bg)',
42
+ backdropFilter: 'var(--sidebar-backdrop)',
43
+ WebkitBackdropFilter: 'var(--sidebar-backdrop)',
44
+ borderRight: '1px solid var(--separator)',
45
+ height: '100%',
46
+ }}
47
+ >
48
+ {/* Header */}
49
+ <div style={{
50
+ padding: 'var(--space-4) var(--space-4) var(--space-3)',
51
+ borderBottom: '1px solid var(--separator)',
52
+ background: 'var(--material-thick)',
53
+ backdropFilter: 'blur(20px)',
54
+ WebkitBackdropFilter: 'blur(20px)',
55
+ flexShrink: 0,
56
+ }}>
57
+ <h2 style={{
58
+ fontSize: 'var(--text-title2)',
59
+ fontWeight: 'var(--weight-bold)',
60
+ letterSpacing: '-0.5px',
61
+ color: 'var(--text-primary)',
62
+ margin: 0,
63
+ }}>
64
+ Messages
65
+ </h2>
66
+
67
+ {/* Search */}
68
+ <div style={{
69
+ marginTop: 'var(--space-3)',
70
+ background: 'var(--fill-tertiary)',
71
+ borderRadius: 'var(--radius-md)',
72
+ padding: '7px var(--space-3)',
73
+ display: 'flex',
74
+ alignItems: 'center',
75
+ gap: 'var(--space-2)',
76
+ }}>
77
+ <svg
78
+ width="14" height="14" viewBox="0 0 24 24" fill="none"
79
+ stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
80
+ style={{ flexShrink: 0 }}
81
+ aria-hidden="true"
82
+ >
83
+ <circle cx="11" cy="11" r="8" />
84
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
85
+ </svg>
86
+ <input
87
+ type="text"
88
+ value={search}
89
+ onChange={e => setSearch(e.target.value)}
90
+ placeholder="Search agents..."
91
+ aria-label="Search agents"
92
+ className="focus-ring"
93
+ style={{
94
+ flex: 1,
95
+ fontSize: 'var(--text-footnote)',
96
+ color: 'var(--text-primary)',
97
+ background: 'transparent',
98
+ border: 'none',
99
+ outline: 'none',
100
+ padding: 0,
101
+ margin: 0,
102
+ lineHeight: 1.4,
103
+ }}
104
+ />
105
+ {search.trim() && (
106
+ <button
107
+ className="btn-ghost focus-ring"
108
+ onClick={() => setSearch('')}
109
+ aria-label="Clear search"
110
+ style={{
111
+ padding: 2,
112
+ borderRadius: '50%',
113
+ display: 'flex',
114
+ alignItems: 'center',
115
+ justifyContent: 'center',
116
+ flexShrink: 0,
117
+ }}
118
+ >
119
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
120
+ <line x1="18" y1="6" x2="6" y2="18" />
121
+ <line x1="6" y1="6" x2="18" y2="18" />
122
+ </svg>
123
+ </button>
124
+ )}
125
+ </div>
126
+ </div>
127
+
128
+ {/* Agent list */}
129
+ <div style={{ flex: 1, overflowY: 'auto', padding: 'var(--space-1) 0' }} role="listbox" aria-label="Agent list">
130
+ {loading ? (
131
+ <div style={{ padding: 'var(--space-1) 0' }} role="status" aria-label="Loading agents">
132
+ {[1, 2, 3, 4].map((i) => (
133
+ <div key={i} style={{
134
+ display: 'flex',
135
+ alignItems: 'center',
136
+ gap: 'var(--space-3)',
137
+ padding: 'var(--space-3) var(--space-4)',
138
+ }}>
139
+ <Skeleton className="rounded-full" style={{ width: 40, height: 40, flexShrink: 0 }} />
140
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
141
+ <Skeleton style={{ width: '55%', height: 14 }} />
142
+ <Skeleton style={{ width: '80%', height: 11 }} />
143
+ </div>
144
+ </div>
145
+ ))}
146
+ </div>
147
+ ) : sorted.length === 0 && search.trim() ? (
148
+ <div style={{
149
+ display: 'flex',
150
+ flexDirection: 'column',
151
+ alignItems: 'center',
152
+ justifyContent: 'center',
153
+ padding: 'var(--space-8) var(--space-4)',
154
+ textAlign: 'center',
155
+ }}>
156
+ <div style={{
157
+ fontSize: 'var(--text-footnote)',
158
+ color: 'var(--text-tertiary)',
159
+ lineHeight: 'var(--leading-relaxed)',
160
+ }}>
161
+ No agents match &lsquo;{search.trim()}&rsquo;
162
+ </div>
163
+ </div>
164
+ ) : (
165
+ sorted.map(agent => {
166
+ const conv = conversations[agent.id]
167
+ const lastMsg = conv?.messages[conv.messages.length - 1]
168
+ const unread = conv?.unread || 0
169
+ const isActive = agent.id === activeId
170
+
171
+ const preview = lastMsg
172
+ ? (lastMsg.role === 'user' ? 'You: ' : '') +
173
+ lastMsg.content.replace(/[#*`]/g, '').slice(0, 50) +
174
+ (lastMsg.content.length > 50 ? '\u2026' : '')
175
+ : agent.description?.slice(0, 50) || 'Start a conversation'
176
+
177
+ const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : ''
178
+
179
+ return (
180
+ <button
181
+ key={agent.id}
182
+ onClick={() => onSelect(agent)}
183
+ role="option"
184
+ aria-selected={isActive}
185
+ className="hover-bg focus-ring"
186
+ style={{
187
+ width: '100%',
188
+ display: 'flex',
189
+ alignItems: 'center',
190
+ gap: 'var(--space-3)',
191
+ padding: 'var(--space-3) var(--space-4)',
192
+ background: isActive ? 'var(--fill-secondary)' : 'transparent',
193
+ border: 'none',
194
+ cursor: 'pointer',
195
+ textAlign: 'left',
196
+ }}
197
+ >
198
+ {/* Avatar */}
199
+ <div style={{ position: 'relative', flexShrink: 0 }}>
200
+ <AgentAvatar agent={agent} size={40} borderRadius={20} />
201
+ {/* Online dot */}
202
+ <div style={{
203
+ position: 'absolute',
204
+ bottom: 0,
205
+ right: 0,
206
+ width: 8,
207
+ height: 8,
208
+ borderRadius: '50%',
209
+ background: 'var(--system-green)',
210
+ border: '1.5px solid var(--bg)',
211
+ }} />
212
+ </div>
213
+
214
+ {/* Text content */}
215
+ <div style={{ flex: 1, minWidth: 0 }}>
216
+ <div style={{
217
+ display: 'flex',
218
+ justifyContent: 'space-between',
219
+ alignItems: 'baseline',
220
+ marginBottom: 2,
221
+ }}>
222
+ <span style={{
223
+ fontSize: 'var(--text-footnote)',
224
+ fontWeight: unread > 0 ? 'var(--weight-bold)' : 'var(--weight-semibold)',
225
+ color: 'var(--text-primary)',
226
+ letterSpacing: '-0.2px',
227
+ overflow: 'hidden',
228
+ textOverflow: 'ellipsis',
229
+ whiteSpace: 'nowrap',
230
+ maxWidth: 140,
231
+ }}>
232
+ {agent.name}
233
+ </span>
234
+ <span style={{
235
+ fontSize: 'var(--text-caption2)',
236
+ color: unread > 0 ? 'var(--accent)' : 'var(--text-tertiary)',
237
+ flexShrink: 0,
238
+ marginLeft: 'var(--space-1)',
239
+ }}>
240
+ {timeLabel}
241
+ </span>
242
+ </div>
243
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
244
+ <span style={{
245
+ fontSize: 'var(--text-caption1)',
246
+ color: unread > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)',
247
+ fontWeight: unread > 0 ? 'var(--weight-medium)' : 'var(--weight-regular)',
248
+ overflow: 'hidden',
249
+ textOverflow: 'ellipsis',
250
+ whiteSpace: 'nowrap',
251
+ flex: 1,
252
+ minWidth: 0,
253
+ }}>
254
+ {preview}
255
+ </span>
256
+ {unread > 0 && (
257
+ <div style={{
258
+ flexShrink: 0,
259
+ marginLeft: 'var(--space-2)',
260
+ background: 'var(--accent)',
261
+ color: 'var(--accent-contrast)',
262
+ borderRadius: 10,
263
+ minWidth: 20,
264
+ height: 20,
265
+ padding: '0 6px',
266
+ display: 'flex',
267
+ alignItems: 'center',
268
+ justifyContent: 'center',
269
+ fontSize: 'var(--text-caption2)',
270
+ fontWeight: 'var(--weight-bold)',
271
+ }}>
272
+ {unread > 9 ? '9+' : unread}
273
+ </div>
274
+ )}
275
+ </div>
276
+ </div>
277
+ </button>
278
+ )
279
+ })
280
+ )}
281
+ </div>
282
+ </div>
283
+ )
284
+ }
285
+
286
+ /* Mobile agent list — shown full width on small screens */
287
+ export function AgentListMobile({
288
+ agents,
289
+ conversations,
290
+ onSelect,
291
+ loading,
292
+ }: Omit<AgentListProps, 'activeId'>) {
293
+ const [search, setSearch] = useState('')
294
+
295
+ const filtered = search.trim()
296
+ ? agents.filter(a => {
297
+ const q = search.toLowerCase()
298
+ return a.name.toLowerCase().includes(q) || a.title.toLowerCase().includes(q)
299
+ })
300
+ : agents
301
+
302
+ const sorted = [...filtered].sort((a, b) => {
303
+ const ca = conversations[a.id]
304
+ const cb = conversations[b.id]
305
+ if (ca && cb) return cb.lastActivity - ca.lastActivity
306
+ if (ca) return -1
307
+ if (cb) return 1
308
+ return a.name.localeCompare(b.name)
309
+ })
310
+
311
+ return (
312
+ <div style={{
313
+ display: 'flex',
314
+ flexDirection: 'column',
315
+ height: '100%',
316
+ background: 'var(--bg)',
317
+ }}>
318
+ {/* Header */}
319
+ <div style={{
320
+ padding: 'var(--space-4) var(--space-4) var(--space-3)',
321
+ borderBottom: '1px solid var(--separator)',
322
+ background: 'var(--material-thick)',
323
+ backdropFilter: 'blur(20px)',
324
+ WebkitBackdropFilter: 'blur(20px)',
325
+ flexShrink: 0,
326
+ }}>
327
+ <h2 style={{
328
+ fontSize: 'var(--text-title1)',
329
+ fontWeight: 'var(--weight-bold)',
330
+ letterSpacing: '-0.5px',
331
+ color: 'var(--text-primary)',
332
+ margin: 0,
333
+ }}>
334
+ Messages
335
+ </h2>
336
+
337
+ {/* Search */}
338
+ <div style={{
339
+ marginTop: 'var(--space-3)',
340
+ background: 'var(--fill-tertiary)',
341
+ borderRadius: 'var(--radius-md)',
342
+ padding: '10px var(--space-3)',
343
+ display: 'flex',
344
+ alignItems: 'center',
345
+ gap: 'var(--space-2)',
346
+ }}>
347
+ <svg
348
+ width="16" height="16" viewBox="0 0 24 24" fill="none"
349
+ stroke="var(--text-tertiary)" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
350
+ style={{ flexShrink: 0 }}
351
+ aria-hidden="true"
352
+ >
353
+ <circle cx="11" cy="11" r="8" />
354
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
355
+ </svg>
356
+ <input
357
+ type="text"
358
+ value={search}
359
+ onChange={e => setSearch(e.target.value)}
360
+ placeholder="Search agents..."
361
+ aria-label="Search agents"
362
+ className="focus-ring"
363
+ style={{
364
+ flex: 1,
365
+ fontSize: 'var(--text-subheadline)',
366
+ color: 'var(--text-primary)',
367
+ background: 'transparent',
368
+ border: 'none',
369
+ outline: 'none',
370
+ padding: 0,
371
+ margin: 0,
372
+ lineHeight: 1.4,
373
+ }}
374
+ />
375
+ </div>
376
+ </div>
377
+
378
+ {/* Agent list */}
379
+ <div style={{ flex: 1, overflowY: 'auto', padding: 'var(--space-1) 0' }} role="listbox" aria-label="Agent list">
380
+ {loading ? (
381
+ <div style={{ padding: 'var(--space-1) 0' }} role="status" aria-label="Loading agents">
382
+ {[1, 2, 3, 4].map((i) => (
383
+ <div key={i} style={{
384
+ display: 'flex',
385
+ alignItems: 'center',
386
+ gap: 'var(--space-3)',
387
+ padding: 'var(--space-3) var(--space-4)',
388
+ }}>
389
+ <Skeleton className="rounded-full" style={{ width: 44, height: 44, flexShrink: 0 }} />
390
+ <div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
391
+ <Skeleton style={{ width: '55%', height: 15 }} />
392
+ <Skeleton style={{ width: '80%', height: 12 }} />
393
+ </div>
394
+ </div>
395
+ ))}
396
+ </div>
397
+ ) : sorted.length === 0 && search.trim() ? (
398
+ <div style={{
399
+ display: 'flex',
400
+ flexDirection: 'column',
401
+ alignItems: 'center',
402
+ justifyContent: 'center',
403
+ padding: 'var(--space-8) var(--space-4)',
404
+ textAlign: 'center',
405
+ }}>
406
+ <div style={{
407
+ fontSize: 'var(--text-subheadline)',
408
+ color: 'var(--text-tertiary)',
409
+ lineHeight: 'var(--leading-relaxed)',
410
+ }}>
411
+ No agents match &lsquo;{search.trim()}&rsquo;
412
+ </div>
413
+ </div>
414
+ ) : (
415
+ sorted.map(agent => {
416
+ const conv = conversations[agent.id]
417
+ const lastMsg = conv?.messages[conv.messages.length - 1]
418
+ const unread = conv?.unread || 0
419
+
420
+ const preview = lastMsg
421
+ ? (lastMsg.role === 'user' ? 'You: ' : '') +
422
+ lastMsg.content.replace(/[#*`]/g, '').slice(0, 60) +
423
+ (lastMsg.content.length > 60 ? '\u2026' : '')
424
+ : agent.description?.slice(0, 60) || 'Start a conversation'
425
+
426
+ const timeLabel = lastMsg ? formatTime(lastMsg.timestamp) : ''
427
+
428
+ return (
429
+ <button
430
+ key={agent.id}
431
+ onClick={() => onSelect(agent)}
432
+ role="option"
433
+ aria-selected={false}
434
+ className="hover-bg focus-ring"
435
+ style={{
436
+ width: '100%',
437
+ display: 'flex',
438
+ alignItems: 'center',
439
+ gap: 'var(--space-3)',
440
+ padding: 'var(--space-3) var(--space-4)',
441
+ background: 'transparent',
442
+ border: 'none',
443
+ cursor: 'pointer',
444
+ textAlign: 'left',
445
+ }}
446
+ >
447
+ {/* Avatar */}
448
+ <div style={{ position: 'relative', flexShrink: 0 }}>
449
+ <AgentAvatar agent={agent} size={44} borderRadius={22} />
450
+ <div style={{
451
+ position: 'absolute',
452
+ bottom: 0,
453
+ right: 0,
454
+ width: 10,
455
+ height: 10,
456
+ borderRadius: '50%',
457
+ background: 'var(--system-green)',
458
+ border: '2px solid var(--bg)',
459
+ }} />
460
+ </div>
461
+
462
+ {/* Text content */}
463
+ <div style={{ flex: 1, minWidth: 0 }}>
464
+ <div style={{
465
+ display: 'flex',
466
+ justifyContent: 'space-between',
467
+ alignItems: 'baseline',
468
+ marginBottom: 2,
469
+ }}>
470
+ <span style={{
471
+ fontSize: 'var(--text-subheadline)',
472
+ fontWeight: unread > 0 ? 'var(--weight-bold)' : 'var(--weight-semibold)',
473
+ color: 'var(--text-primary)',
474
+ letterSpacing: '-0.2px',
475
+ }}>
476
+ {agent.name}
477
+ </span>
478
+ <span style={{
479
+ fontSize: 'var(--text-caption1)',
480
+ color: unread > 0 ? 'var(--accent)' : 'var(--text-tertiary)',
481
+ flexShrink: 0,
482
+ marginLeft: 'var(--space-1)',
483
+ }}>
484
+ {timeLabel}
485
+ </span>
486
+ </div>
487
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
488
+ <span style={{
489
+ fontSize: 'var(--text-footnote)',
490
+ color: unread > 0 ? 'var(--text-secondary)' : 'var(--text-tertiary)',
491
+ fontWeight: unread > 0 ? 'var(--weight-medium)' : 'var(--weight-regular)',
492
+ overflow: 'hidden',
493
+ textOverflow: 'ellipsis',
494
+ whiteSpace: 'nowrap',
495
+ flex: 1,
496
+ minWidth: 0,
497
+ }}>
498
+ {preview}
499
+ </span>
500
+ {unread > 0 && (
501
+ <div style={{
502
+ flexShrink: 0,
503
+ marginLeft: 'var(--space-2)',
504
+ background: 'var(--accent)',
505
+ color: 'var(--accent-contrast)',
506
+ borderRadius: 10,
507
+ minWidth: 20,
508
+ height: 20,
509
+ padding: '0 6px',
510
+ display: 'flex',
511
+ alignItems: 'center',
512
+ justifyContent: 'center',
513
+ fontSize: 'var(--text-caption2)',
514
+ fontWeight: 'var(--weight-bold)',
515
+ }}>
516
+ {unread > 9 ? '9+' : unread}
517
+ </div>
518
+ )}
519
+ </div>
520
+ </div>
521
+ </button>
522
+ )
523
+ })
524
+ )}
525
+ </div>
526
+ </div>
527
+ )
528
+ }
529
+
530
+ function formatTime(ts: number): string {
531
+ const now = Date.now()
532
+ const diff = now - ts
533
+ if (diff < 60000) return 'now'
534
+ if (diff < 3600000) return `${Math.floor(diff / 60000)}m`
535
+ if (diff < 86400000) return new Date(ts).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true })
536
+ return new Date(ts).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
537
+ }