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,901 @@
1
+ 'use client'
2
+
3
+ import { useEffect, useRef, useState } from 'react'
4
+ import { ChevronRight, RotateCcw, Trash2, Upload, X } from 'lucide-react'
5
+ import type { Agent } from '@/lib/types'
6
+ import { useSettings } from '@/app/settings-provider'
7
+ import { AgentAvatar } from '@/components/AgentAvatar'
8
+ import { OnboardingWizard } from '@/components/OnboardingWizard'
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Accent color presets
12
+ // ---------------------------------------------------------------------------
13
+
14
+ const ACCENT_PRESETS = [
15
+ { label: 'Gold', value: '#F5C518' },
16
+ { label: 'Blue', value: '#3B82F6' },
17
+ { label: 'Green', value: '#22C55E' },
18
+ { label: 'Red', value: '#EF4444' },
19
+ { label: 'Orange', value: '#F97316' },
20
+ { label: 'Purple', value: '#A855F7' },
21
+ { label: 'Pink', value: '#EC4899' },
22
+ { label: 'Teal', value: '#14B8A6' },
23
+ { label: 'Cyan', value: '#06B6D4' },
24
+ { label: 'Indigo', value: '#6366F1' },
25
+ { label: 'Rose', value: '#F43F5E' },
26
+ { label: 'Lime', value: '#84CC16' },
27
+ ]
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Image resize helper (Canvas API → 200px max dimension, base64 JPEG)
31
+ // ---------------------------------------------------------------------------
32
+
33
+ function resizeImage(file: File, maxSize: number): Promise<string> {
34
+ return new Promise((resolve, reject) => {
35
+ const img = new Image()
36
+ const reader = new FileReader()
37
+ reader.onload = () => {
38
+ img.onload = () => {
39
+ const scale = Math.min(maxSize / img.width, maxSize / img.height, 1)
40
+ const w = Math.round(img.width * scale)
41
+ const h = Math.round(img.height * scale)
42
+ const canvas = document.createElement('canvas')
43
+ canvas.width = w
44
+ canvas.height = h
45
+ const ctx = canvas.getContext('2d')!
46
+ ctx.drawImage(img, 0, 0, w, h)
47
+ resolve(canvas.toDataURL('image/jpeg', 0.85))
48
+ }
49
+ img.onerror = reject
50
+ img.src = reader.result as string
51
+ }
52
+ reader.onerror = reject
53
+ reader.readAsDataURL(file)
54
+ })
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // Settings page
59
+ // ---------------------------------------------------------------------------
60
+
61
+ export default function SettingsPage() {
62
+ const {
63
+ settings,
64
+ setAccentColor,
65
+ setPortalName,
66
+ setPortalSubtitle,
67
+ setPortalEmoji,
68
+ setPortalIcon,
69
+ setIconBgHidden,
70
+ setEmojiOnly,
71
+ setAgentOverride,
72
+ clearAgentOverride,
73
+ resetAll,
74
+ } = useSettings()
75
+
76
+ const [wizardOpen, setWizardOpen] = useState(false)
77
+ const [agents, setAgents] = useState<Agent[]>([])
78
+ const [expandedAgent, setExpandedAgent] = useState<string | null>(null)
79
+ const [nameValue, setNameValue] = useState(settings.portalName ?? '')
80
+ const [subtitleValue, setSubtitleValue] = useState(settings.portalSubtitle ?? '')
81
+ const [emojiValue, setEmojiValue] = useState(settings.portalEmoji ?? '')
82
+ const fileInputRef = useRef<HTMLInputElement>(null)
83
+ const portalIconInputRef = useRef<HTMLInputElement>(null)
84
+
85
+ // Sync local input values when settings change externally (e.g., reset)
86
+ useEffect(() => {
87
+ setNameValue(settings.portalName ?? '')
88
+ setSubtitleValue(settings.portalSubtitle ?? '')
89
+ setEmojiValue(settings.portalEmoji ?? '')
90
+ }, [settings.portalName, settings.portalSubtitle, settings.portalEmoji])
91
+
92
+ // Fetch agents
93
+ useEffect(() => {
94
+ fetch('/api/agents')
95
+ .then((r) => {
96
+ if (!r.ok) throw new Error(`HTTP ${r.status}`)
97
+ return r.json()
98
+ })
99
+ .then((data: unknown) => {
100
+ if (Array.isArray(data)) setAgents(data as Agent[])
101
+ })
102
+ .catch(() => setAgents([]))
103
+ }, [])
104
+
105
+ async function handleIconUpload(file: File) {
106
+ try {
107
+ const dataUrl = await resizeImage(file, 200)
108
+ setPortalIcon(dataUrl)
109
+ } catch {
110
+ // silently fail — user can retry
111
+ }
112
+ }
113
+
114
+ async function handleImageUpload(agentId: string, file: File) {
115
+ try {
116
+ const dataUrl = await resizeImage(file, 200)
117
+ setAgentOverride(agentId, { profileImage: dataUrl })
118
+ } catch {
119
+ // silently fail — user can retry
120
+ }
121
+ }
122
+
123
+ return (
124
+ <div
125
+ className="h-full overflow-y-auto"
126
+ style={{ background: 'var(--bg)' }}
127
+ >
128
+ <div
129
+ style={{
130
+ maxWidth: 600,
131
+ margin: '0 auto',
132
+ padding: 'var(--space-6) var(--space-4) var(--space-12)',
133
+ }}
134
+ >
135
+ {/* Page header */}
136
+ <h1
137
+ style={{
138
+ fontSize: 'var(--text-title1)',
139
+ fontWeight: 'var(--weight-bold)',
140
+ letterSpacing: 'var(--tracking-tight)',
141
+ color: 'var(--text-primary)',
142
+ margin: '0 0 var(--space-8)',
143
+ }}
144
+ >
145
+ Settings
146
+ </h1>
147
+
148
+ {/* ── Section 1: Accent Color ── */}
149
+ <section style={{ marginBottom: 'var(--space-8)' }}>
150
+ <div
151
+ style={{
152
+ fontSize: 'var(--text-caption1)',
153
+ fontWeight: 'var(--weight-semibold)',
154
+ letterSpacing: 'var(--tracking-wide)',
155
+ textTransform: 'uppercase',
156
+ color: 'var(--text-tertiary)',
157
+ padding: '0 var(--space-4) var(--space-2)',
158
+ }}
159
+ >
160
+ Accent Color
161
+ </div>
162
+ <div
163
+ style={{
164
+ background: 'var(--material-regular)',
165
+ borderRadius: 'var(--radius-md)',
166
+ border: '1px solid var(--separator)',
167
+ padding: 'var(--space-4)',
168
+ }}
169
+ >
170
+ {/* Preset swatches */}
171
+ <div
172
+ style={{
173
+ display: 'flex',
174
+ flexWrap: 'wrap',
175
+ gap: 'var(--space-2)',
176
+ marginBottom: 'var(--space-3)',
177
+ }}
178
+ >
179
+ {ACCENT_PRESETS.map((preset) => {
180
+ const isActive = settings.accentColor === preset.value
181
+ return (
182
+ <button
183
+ key={preset.value}
184
+ onClick={() => setAccentColor(preset.value)}
185
+ aria-label={preset.label}
186
+ title={preset.label}
187
+ style={{
188
+ width: 32,
189
+ height: 32,
190
+ borderRadius: '50%',
191
+ background: preset.value,
192
+ border: isActive ? '2px solid var(--text-primary)' : '2px solid transparent',
193
+ outline: isActive ? `2px solid ${preset.value}` : 'none',
194
+ outlineOffset: 2,
195
+ cursor: 'pointer',
196
+ transition: 'all 100ms var(--ease-smooth)',
197
+ }}
198
+ />
199
+ )
200
+ })}
201
+ </div>
202
+
203
+ {/* Custom color picker + Reset */}
204
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
205
+ <label
206
+ style={{
207
+ display: 'flex',
208
+ alignItems: 'center',
209
+ gap: 'var(--space-2)',
210
+ fontSize: 'var(--text-footnote)',
211
+ color: 'var(--text-secondary)',
212
+ cursor: 'pointer',
213
+ }}
214
+ >
215
+ Custom:
216
+ <input
217
+ type="color"
218
+ value={settings.accentColor ?? '#F5C518'}
219
+ onChange={(e) => setAccentColor(e.target.value)}
220
+ style={{
221
+ width: 28,
222
+ height: 28,
223
+ border: 'none',
224
+ borderRadius: '50%',
225
+ cursor: 'pointer',
226
+ background: 'none',
227
+ padding: 0,
228
+ }}
229
+ />
230
+ </label>
231
+ {settings.accentColor && (
232
+ <button
233
+ onClick={() => setAccentColor(null)}
234
+ style={{
235
+ fontSize: 'var(--text-footnote)',
236
+ color: 'var(--system-blue)',
237
+ background: 'none',
238
+ border: 'none',
239
+ cursor: 'pointer',
240
+ padding: 0,
241
+ display: 'inline-flex',
242
+ alignItems: 'center',
243
+ gap: 4,
244
+ }}
245
+ >
246
+ <RotateCcw size={12} />
247
+ Reset to Default
248
+ </button>
249
+ )}
250
+ </div>
251
+ </div>
252
+ </section>
253
+
254
+ {/* ── Section 2: Branding ── */}
255
+ <section style={{ marginBottom: 'var(--space-8)' }}>
256
+ <div
257
+ style={{
258
+ fontSize: 'var(--text-caption1)',
259
+ fontWeight: 'var(--weight-semibold)',
260
+ letterSpacing: 'var(--tracking-wide)',
261
+ textTransform: 'uppercase',
262
+ color: 'var(--text-tertiary)',
263
+ padding: '0 var(--space-4) var(--space-2)',
264
+ }}
265
+ >
266
+ Branding
267
+ </div>
268
+ <div
269
+ style={{
270
+ background: 'var(--material-regular)',
271
+ borderRadius: 'var(--radius-md)',
272
+ border: '1px solid var(--separator)',
273
+ overflow: 'hidden',
274
+ }}
275
+ >
276
+ {/* Name field */}
277
+ <div style={{ padding: 'var(--space-3) var(--space-4)' }}>
278
+ <label
279
+ style={{
280
+ display: 'block',
281
+ fontSize: 'var(--text-caption1)',
282
+ color: 'var(--text-tertiary)',
283
+ marginBottom: 'var(--space-1)',
284
+ }}
285
+ >
286
+ Name
287
+ </label>
288
+ <input
289
+ type="text"
290
+ className="apple-input"
291
+ placeholder="ClawPort"
292
+ value={nameValue}
293
+ onChange={(e) => setNameValue(e.target.value)}
294
+ onBlur={() => setPortalName(nameValue || null)}
295
+ style={{
296
+ width: '100%',
297
+ background: 'var(--bg-secondary)',
298
+ border: '1px solid var(--separator)',
299
+ }}
300
+ />
301
+ </div>
302
+
303
+ <div style={{ borderTop: '1px solid var(--separator)' }} />
304
+
305
+ {/* Subtitle field */}
306
+ <div style={{ padding: 'var(--space-3) var(--space-4)' }}>
307
+ <label
308
+ style={{
309
+ display: 'block',
310
+ fontSize: 'var(--text-caption1)',
311
+ color: 'var(--text-tertiary)',
312
+ marginBottom: 'var(--space-1)',
313
+ }}
314
+ >
315
+ Subtitle
316
+ </label>
317
+ <input
318
+ type="text"
319
+ className="apple-input"
320
+ placeholder="Command Centre"
321
+ value={subtitleValue}
322
+ onChange={(e) => setSubtitleValue(e.target.value)}
323
+ onBlur={() => setPortalSubtitle(subtitleValue || null)}
324
+ style={{
325
+ width: '100%',
326
+ background: 'var(--bg-secondary)',
327
+ border: '1px solid var(--separator)',
328
+ }}
329
+ />
330
+ </div>
331
+
332
+ <div style={{ borderTop: '1px solid var(--separator)' }} />
333
+
334
+ {/* Logo / Icon — emoji or image */}
335
+ <div style={{ padding: 'var(--space-3) var(--space-4)' }}>
336
+ <label
337
+ style={{
338
+ display: 'block',
339
+ fontSize: 'var(--text-caption1)',
340
+ color: 'var(--text-tertiary)',
341
+ marginBottom: 'var(--space-2)',
342
+ }}
343
+ >
344
+ Logo / Icon
345
+ </label>
346
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
347
+ {/* Live preview */}
348
+ {settings.portalIcon ? (
349
+ <img
350
+ src={settings.portalIcon}
351
+ alt="Portal icon"
352
+ style={{
353
+ width: 36,
354
+ height: 36,
355
+ borderRadius: 10,
356
+ objectFit: 'cover',
357
+ boxShadow: 'var(--shadow-card)',
358
+ flexShrink: 0,
359
+ }}
360
+ />
361
+ ) : (
362
+ <div
363
+ style={{
364
+ width: 36,
365
+ height: 36,
366
+ borderRadius: 10,
367
+ background: settings.iconBgHidden
368
+ ? 'transparent'
369
+ : settings.accentColor
370
+ ? `linear-gradient(135deg, ${settings.accentColor}, ${settings.accentColor}dd)`
371
+ : 'linear-gradient(135deg, #f5c518, #e8b800)',
372
+ boxShadow: settings.iconBgHidden ? 'none' : 'var(--shadow-card)',
373
+ display: 'flex',
374
+ alignItems: 'center',
375
+ justifyContent: 'center',
376
+ fontSize: settings.iconBgHidden ? 28 : 18,
377
+ flexShrink: 0,
378
+ }}
379
+ >
380
+ {settings.portalEmoji ?? '\ud83e\udd9e'}
381
+ </div>
382
+ )}
383
+
384
+ {/* Emoji input */}
385
+ <input
386
+ type="text"
387
+ className="apple-input"
388
+ placeholder={'\ud83e\udd9e'}
389
+ value={emojiValue}
390
+ onChange={(e) => setEmojiValue(e.target.value)}
391
+ onBlur={() => setPortalEmoji(emojiValue || null)}
392
+ style={{
393
+ width: 60,
394
+ textAlign: 'center',
395
+ fontSize: 'var(--text-title2)',
396
+ padding: '6px 8px',
397
+ background: 'var(--bg-secondary)',
398
+ border: '1px solid var(--separator)',
399
+ }}
400
+ />
401
+
402
+ {/* Upload image button */}
403
+ <button
404
+ onClick={() => portalIconInputRef.current?.click()}
405
+ style={{
406
+ display: 'flex',
407
+ alignItems: 'center',
408
+ gap: 'var(--space-2)',
409
+ padding: 'var(--space-2) var(--space-3)',
410
+ borderRadius: 'var(--radius-sm)',
411
+ background: 'var(--fill-tertiary)',
412
+ color: 'var(--text-secondary)',
413
+ border: 'none',
414
+ cursor: 'pointer',
415
+ fontSize: 'var(--text-footnote)',
416
+ flexShrink: 0,
417
+ }}
418
+ >
419
+ <Upload size={14} />
420
+ Upload Image
421
+ </button>
422
+ <input
423
+ ref={portalIconInputRef}
424
+ type="file"
425
+ accept="image/*"
426
+ style={{ display: 'none' }}
427
+ onChange={(e) => {
428
+ const file = e.target.files?.[0]
429
+ if (file) handleIconUpload(file)
430
+ e.target.value = ''
431
+ }}
432
+ />
433
+
434
+ {/* Clear overrides */}
435
+ {(settings.portalIcon || settings.portalEmoji) && (
436
+ <button
437
+ onClick={() => {
438
+ setPortalIcon(null)
439
+ setPortalEmoji(null)
440
+ }}
441
+ aria-label="Reset icon"
442
+ style={{
443
+ width: 24,
444
+ height: 24,
445
+ borderRadius: '50%',
446
+ background: 'var(--fill-tertiary)',
447
+ color: 'var(--text-tertiary)',
448
+ border: 'none',
449
+ cursor: 'pointer',
450
+ display: 'flex',
451
+ alignItems: 'center',
452
+ justifyContent: 'center',
453
+ flexShrink: 0,
454
+ }}
455
+ >
456
+ <X size={12} />
457
+ </button>
458
+ )}
459
+ </div>
460
+
461
+ {/* Hide background toggle — only relevant when no uploaded image */}
462
+ {!settings.portalIcon && (
463
+ <div
464
+ style={{
465
+ display: 'flex',
466
+ alignItems: 'center',
467
+ justifyContent: 'space-between',
468
+ marginTop: 'var(--space-3)',
469
+ paddingTop: 'var(--space-3)',
470
+ borderTop: '1px solid var(--separator)',
471
+ }}
472
+ >
473
+ <span
474
+ style={{
475
+ fontSize: 'var(--text-footnote)',
476
+ color: 'var(--text-secondary)',
477
+ }}
478
+ >
479
+ Hide background
480
+ </span>
481
+ <button
482
+ role="switch"
483
+ aria-checked={settings.iconBgHidden}
484
+ onClick={() => setIconBgHidden(!settings.iconBgHidden)}
485
+ className="focus-ring"
486
+ style={{
487
+ width: 51,
488
+ height: 31,
489
+ borderRadius: 16,
490
+ background: settings.iconBgHidden ? 'var(--system-green)' : 'var(--fill-primary)',
491
+ border: 'none',
492
+ cursor: 'pointer',
493
+ position: 'relative',
494
+ flexShrink: 0,
495
+ transition: 'background 200ms var(--ease-smooth)',
496
+ }}
497
+ >
498
+ <span
499
+ style={{
500
+ position: 'absolute',
501
+ top: 2,
502
+ left: settings.iconBgHidden ? 22 : 2,
503
+ width: 27,
504
+ height: 27,
505
+ borderRadius: '50%',
506
+ background: '#fff',
507
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
508
+ transition: 'left 200ms var(--ease-spring)',
509
+ }}
510
+ />
511
+ </button>
512
+ </div>
513
+ )}
514
+ </div>
515
+
516
+ <div style={{ borderTop: '1px solid var(--separator)' }} />
517
+
518
+ {/* Emoji-only avatar toggle */}
519
+ <div
520
+ style={{
521
+ padding: 'var(--space-3) var(--space-4)',
522
+ display: 'flex',
523
+ alignItems: 'center',
524
+ justifyContent: 'space-between',
525
+ gap: 'var(--space-3)',
526
+ }}
527
+ >
528
+ <div>
529
+ <div
530
+ style={{
531
+ fontSize: 'var(--text-body)',
532
+ fontWeight: 'var(--weight-medium)',
533
+ color: 'var(--text-primary)',
534
+ }}
535
+ >
536
+ Emoji Only Avatars
537
+ </div>
538
+ <div
539
+ style={{
540
+ fontSize: 'var(--text-caption1)',
541
+ color: 'var(--text-tertiary)',
542
+ marginTop: 1,
543
+ }}
544
+ >
545
+ Show emoji without colored background
546
+ </div>
547
+ </div>
548
+ <button
549
+ role="switch"
550
+ aria-checked={settings.emojiOnly}
551
+ onClick={() => setEmojiOnly(!settings.emojiOnly)}
552
+ className="focus-ring"
553
+ style={{
554
+ width: 51,
555
+ height: 31,
556
+ borderRadius: 16,
557
+ background: settings.emojiOnly ? 'var(--system-green)' : 'var(--fill-primary)',
558
+ border: 'none',
559
+ cursor: 'pointer',
560
+ position: 'relative',
561
+ flexShrink: 0,
562
+ transition: 'background 200ms var(--ease-smooth)',
563
+ }}
564
+ >
565
+ <span
566
+ style={{
567
+ position: 'absolute',
568
+ top: 2,
569
+ left: settings.emojiOnly ? 22 : 2,
570
+ width: 27,
571
+ height: 27,
572
+ borderRadius: '50%',
573
+ background: '#fff',
574
+ boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
575
+ transition: 'left 200ms var(--ease-spring)',
576
+ }}
577
+ />
578
+ </button>
579
+ </div>
580
+ </div>
581
+ </section>
582
+
583
+ {/* ── Section 3: Agent Customization ── */}
584
+ <section style={{ marginBottom: 'var(--space-8)' }}>
585
+ <div
586
+ style={{
587
+ fontSize: 'var(--text-caption1)',
588
+ fontWeight: 'var(--weight-semibold)',
589
+ letterSpacing: 'var(--tracking-wide)',
590
+ textTransform: 'uppercase',
591
+ color: 'var(--text-tertiary)',
592
+ padding: '0 var(--space-4) var(--space-2)',
593
+ }}
594
+ >
595
+ Agent Customization
596
+ </div>
597
+ <div
598
+ style={{
599
+ background: 'var(--material-regular)',
600
+ borderRadius: 'var(--radius-md)',
601
+ border: '1px solid var(--separator)',
602
+ overflow: 'hidden',
603
+ }}
604
+ >
605
+ {agents.map((agent, idx) => {
606
+ const isExpanded = expandedAgent === agent.id
607
+ const override = settings.agentOverrides[agent.id]
608
+ const hasOverride = override && (override.emoji || override.profileImage)
609
+
610
+ return (
611
+ <div key={agent.id}>
612
+ {idx > 0 && (
613
+ <div style={{ borderTop: '1px solid var(--separator)' }} />
614
+ )}
615
+
616
+ {/* Agent row — tap to expand */}
617
+ <button
618
+ onClick={() => setExpandedAgent(isExpanded ? null : agent.id)}
619
+ style={{
620
+ display: 'flex',
621
+ alignItems: 'center',
622
+ gap: 'var(--space-3)',
623
+ padding: 'var(--space-3) var(--space-4)',
624
+ width: '100%',
625
+ background: 'none',
626
+ border: 'none',
627
+ cursor: 'pointer',
628
+ textAlign: 'left',
629
+ }}
630
+ >
631
+ <AgentAvatar agent={agent} size={32} borderRadius={9} />
632
+ <div style={{ flex: 1, minWidth: 0 }}>
633
+ <div
634
+ style={{
635
+ fontSize: 'var(--text-body)',
636
+ fontWeight: 'var(--weight-medium)',
637
+ color: 'var(--text-primary)',
638
+ whiteSpace: 'nowrap',
639
+ overflow: 'hidden',
640
+ textOverflow: 'ellipsis',
641
+ }}
642
+ >
643
+ {agent.name}
644
+ </div>
645
+ <div
646
+ style={{
647
+ fontSize: 'var(--text-caption1)',
648
+ color: 'var(--text-tertiary)',
649
+ whiteSpace: 'nowrap',
650
+ overflow: 'hidden',
651
+ textOverflow: 'ellipsis',
652
+ }}
653
+ >
654
+ {agent.title}
655
+ </div>
656
+ </div>
657
+ {hasOverride && (
658
+ <span
659
+ style={{
660
+ width: 6,
661
+ height: 6,
662
+ borderRadius: '50%',
663
+ background: 'var(--accent)',
664
+ flexShrink: 0,
665
+ }}
666
+ />
667
+ )}
668
+ <ChevronRight
669
+ size={16}
670
+ style={{
671
+ color: 'var(--text-quaternary)',
672
+ flexShrink: 0,
673
+ transform: isExpanded ? 'rotate(90deg)' : 'none',
674
+ transition: 'transform 150ms var(--ease-smooth)',
675
+ }}
676
+ />
677
+ </button>
678
+
679
+ {/* Expanded inline editor */}
680
+ {isExpanded && (
681
+ <div
682
+ style={{
683
+ padding: '0 var(--space-4) var(--space-4)',
684
+ display: 'flex',
685
+ flexDirection: 'column',
686
+ gap: 'var(--space-3)',
687
+ }}
688
+ >
689
+ {/* Emoji override */}
690
+ <div>
691
+ <label
692
+ style={{
693
+ display: 'block',
694
+ fontSize: 'var(--text-caption1)',
695
+ color: 'var(--text-tertiary)',
696
+ marginBottom: 'var(--space-1)',
697
+ }}
698
+ >
699
+ Custom Emoji
700
+ </label>
701
+ <input
702
+ type="text"
703
+ className="apple-input"
704
+ placeholder={agent.emoji}
705
+ value={override?.emoji ?? ''}
706
+ onChange={(e) => {
707
+ const val = e.target.value
708
+ setAgentOverride(agent.id, {
709
+ emoji: val || undefined,
710
+ })
711
+ }}
712
+ style={{
713
+ width: 80,
714
+ fontSize: 'var(--text-title2)',
715
+ textAlign: 'center',
716
+ padding: '6px 8px',
717
+ background: 'var(--bg-secondary)',
718
+ border: '1px solid var(--separator)',
719
+ }}
720
+ />
721
+ </div>
722
+
723
+ {/* Profile image upload */}
724
+ <div>
725
+ <label
726
+ style={{
727
+ display: 'block',
728
+ fontSize: 'var(--text-caption1)',
729
+ color: 'var(--text-tertiary)',
730
+ marginBottom: 'var(--space-1)',
731
+ }}
732
+ >
733
+ Profile Image
734
+ </label>
735
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
736
+ <button
737
+ onClick={() => {
738
+ fileInputRef.current?.click()
739
+ }}
740
+ style={{
741
+ display: 'flex',
742
+ alignItems: 'center',
743
+ gap: 'var(--space-2)',
744
+ padding: 'var(--space-2) var(--space-3)',
745
+ borderRadius: 'var(--radius-sm)',
746
+ background: 'var(--fill-tertiary)',
747
+ color: 'var(--text-secondary)',
748
+ border: 'none',
749
+ cursor: 'pointer',
750
+ fontSize: 'var(--text-footnote)',
751
+ }}
752
+ >
753
+ <Upload size={14} />
754
+ Upload
755
+ </button>
756
+ <input
757
+ ref={fileInputRef}
758
+ type="file"
759
+ accept="image/*"
760
+ style={{ display: 'none' }}
761
+ onChange={(e) => {
762
+ const file = e.target.files?.[0]
763
+ if (file) handleImageUpload(agent.id, file)
764
+ e.target.value = ''
765
+ }}
766
+ />
767
+ {override?.profileImage && (
768
+ <div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
769
+ <img
770
+ src={override.profileImage}
771
+ alt="Preview"
772
+ style={{
773
+ width: 32,
774
+ height: 32,
775
+ borderRadius: 8,
776
+ objectFit: 'cover',
777
+ }}
778
+ />
779
+ <button
780
+ onClick={() => setAgentOverride(agent.id, { profileImage: undefined })}
781
+ aria-label="Remove image"
782
+ style={{
783
+ width: 20,
784
+ height: 20,
785
+ borderRadius: '50%',
786
+ background: 'var(--fill-tertiary)',
787
+ color: 'var(--text-tertiary)',
788
+ border: 'none',
789
+ cursor: 'pointer',
790
+ display: 'flex',
791
+ alignItems: 'center',
792
+ justifyContent: 'center',
793
+ }}
794
+ >
795
+ <X size={12} />
796
+ </button>
797
+ </div>
798
+ )}
799
+ </div>
800
+ </div>
801
+
802
+ {/* Reset button */}
803
+ {hasOverride && (
804
+ <button
805
+ onClick={() => clearAgentOverride(agent.id)}
806
+ style={{
807
+ display: 'flex',
808
+ alignItems: 'center',
809
+ gap: 'var(--space-2)',
810
+ padding: 'var(--space-2) var(--space-3)',
811
+ borderRadius: 'var(--radius-sm)',
812
+ background: 'none',
813
+ color: 'var(--system-red)',
814
+ border: 'none',
815
+ cursor: 'pointer',
816
+ fontSize: 'var(--text-footnote)',
817
+ alignSelf: 'flex-start',
818
+ }}
819
+ >
820
+ <RotateCcw size={14} />
821
+ Reset to Default
822
+ </button>
823
+ )}
824
+ </div>
825
+ )}
826
+ </div>
827
+ )
828
+ })}
829
+ </div>
830
+ </section>
831
+
832
+ {/* ── Section 4: Reset All ── */}
833
+ <section>
834
+ <div
835
+ style={{
836
+ background: 'var(--material-regular)',
837
+ borderRadius: 'var(--radius-md)',
838
+ border: '1px solid var(--separator)',
839
+ padding: 'var(--space-4)',
840
+ display: 'flex',
841
+ alignItems: 'center',
842
+ justifyContent: 'center',
843
+ gap: 'var(--space-3)',
844
+ }}
845
+ >
846
+ <button
847
+ onClick={() => setWizardOpen(true)}
848
+ className="btn-scale"
849
+ style={{
850
+ padding: 'var(--space-2) var(--space-6)',
851
+ borderRadius: 'var(--radius-md)',
852
+ background: 'var(--accent)',
853
+ color: 'var(--accent-contrast)',
854
+ border: 'none',
855
+ cursor: 'pointer',
856
+ fontSize: 'var(--text-body)',
857
+ fontWeight: 'var(--weight-semibold)',
858
+ transition: 'all 150ms var(--ease-spring)',
859
+ display: 'inline-flex',
860
+ alignItems: 'center',
861
+ gap: 'var(--space-2)',
862
+ }}
863
+ >
864
+ <RotateCcw size={16} />
865
+ Re-run Setup
866
+ </button>
867
+ <button
868
+ onClick={() => {
869
+ if (window.confirm('Reset all settings to defaults?')) {
870
+ resetAll()
871
+ }
872
+ }}
873
+ className="btn-scale"
874
+ style={{
875
+ padding: 'var(--space-2) var(--space-6)',
876
+ borderRadius: 'var(--radius-md)',
877
+ background: 'var(--system-red)',
878
+ color: '#fff',
879
+ border: 'none',
880
+ cursor: 'pointer',
881
+ fontSize: 'var(--text-body)',
882
+ fontWeight: 'var(--weight-semibold)',
883
+ transition: 'all 150ms var(--ease-spring)',
884
+ display: 'inline-flex',
885
+ alignItems: 'center',
886
+ gap: 'var(--space-2)',
887
+ }}
888
+ >
889
+ <Trash2 size={16} />
890
+ Reset All Settings
891
+ </button>
892
+ </div>
893
+ </section>
894
+
895
+ {wizardOpen && (
896
+ <OnboardingWizard forceOpen onClose={() => setWizardOpen(false)} />
897
+ )}
898
+ </div>
899
+ </div>
900
+ )
901
+ }