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,850 @@
1
+ 'use client'
2
+
3
+ import React, { useEffect, useRef, useState, useCallback } from 'react'
4
+ import { Maximize2, Minimize2 } from 'lucide-react'
5
+ import type { Agent } from '@/lib/types'
6
+ import type { KanbanTicket, TicketStatus, TicketPriority } from '@/lib/kanban/types'
7
+ import { PRIORITY_COLORS, ROLE_LABELS, COLUMNS } from '@/lib/kanban/types'
8
+ import { AgentAvatar } from '@/components/AgentAvatar'
9
+
10
+ /* ── Chat message type (local to kanban) ─────────────── */
11
+
12
+ interface ChatMessage {
13
+ id: string
14
+ role: 'user' | 'assistant'
15
+ content: string
16
+ timestamp: number
17
+ isStreaming?: boolean
18
+ }
19
+
20
+ /* ── Simple markdown formatting (matches ConversationView pattern) ── */
21
+
22
+ function formatInline(text: string): React.ReactNode {
23
+ // Handle **bold**, `code`, and plain text segments
24
+ const parts: React.ReactNode[] = []
25
+ const re = /(\*\*(.+?)\*\*|`([^`]+)`)/g
26
+ let last = 0
27
+ let match: RegExpExecArray | null
28
+
29
+ while ((match = re.exec(text)) !== null) {
30
+ if (match.index > last) parts.push(text.slice(last, match.index))
31
+ if (match[2]) {
32
+ parts.push(<strong key={match.index} style={{ fontWeight: 600, color: 'var(--text-primary)' }}>{match[2]}</strong>)
33
+ } else if (match[3]) {
34
+ parts.push(
35
+ <code key={match.index} style={{
36
+ background: 'var(--code-bg)',
37
+ border: '1px solid var(--code-border)',
38
+ borderRadius: 4,
39
+ padding: '1px 5px',
40
+ fontSize: '0.9em',
41
+ fontFamily: 'var(--font-mono)',
42
+ color: 'var(--code-text)',
43
+ }}>{match[3]}</code>
44
+ )
45
+ }
46
+ last = match.index + match[0].length
47
+ }
48
+ if (last < text.length) parts.push(text.slice(last))
49
+ return parts.length === 1 ? parts[0] : <>{parts}</>
50
+ }
51
+
52
+ function formatContent(content: string): React.ReactNode {
53
+ if (!content) return null
54
+ const lines = content.split('\n')
55
+ const result: React.ReactNode[] = []
56
+ let inCode = false
57
+ const codeBlock: string[] = []
58
+
59
+ for (let i = 0; i < lines.length; i++) {
60
+ const line = lines[i]
61
+
62
+ // Code fence toggle
63
+ if (line.startsWith('```')) {
64
+ if (inCode) {
65
+ result.push(
66
+ <pre key={`code-${i}`} style={{
67
+ background: 'var(--code-bg)',
68
+ border: '1px solid var(--code-border)',
69
+ borderRadius: 'var(--radius-sm)',
70
+ padding: 'var(--space-2) var(--space-3)',
71
+ fontSize: 'var(--text-caption1)',
72
+ fontFamily: 'var(--font-mono)',
73
+ color: 'var(--code-text)',
74
+ overflowX: 'auto',
75
+ margin: '4px 0',
76
+ whiteSpace: 'pre-wrap',
77
+ wordBreak: 'break-word',
78
+ }}>
79
+ {codeBlock.join('\n')}
80
+ </pre>
81
+ )
82
+ codeBlock.length = 0
83
+ }
84
+ inCode = !inCode
85
+ continue
86
+ }
87
+
88
+ if (inCode) {
89
+ codeBlock.push(line)
90
+ continue
91
+ }
92
+
93
+ if (line.trim() === '') {
94
+ result.push(<div key={`space-${i}`} style={{ height: 4 }} />)
95
+ continue
96
+ }
97
+
98
+ // Headings
99
+ const headingMatch = line.match(/^(#{1,3})\s+(.+)/)
100
+ if (headingMatch) {
101
+ const level = headingMatch[1].length
102
+ result.push(
103
+ <div key={i} style={{
104
+ fontSize: level === 1 ? 'var(--text-subheadline)' : 'var(--text-footnote)',
105
+ fontWeight: 600,
106
+ color: 'var(--text-primary)',
107
+ marginTop: 6,
108
+ marginBottom: 2,
109
+ }}>
110
+ {formatInline(headingMatch[2])}
111
+ </div>
112
+ )
113
+ continue
114
+ }
115
+
116
+ // Bullet points
117
+ if (line.match(/^[-*] /)) {
118
+ result.push(
119
+ <div key={i} style={{ display: 'flex', gap: 'var(--space-1)', marginBottom: 1 }}>
120
+ <span style={{ color: 'var(--accent)', flexShrink: 0 }}>&bull;</span>
121
+ <span>{formatInline(line.slice(2))}</span>
122
+ </div>
123
+ )
124
+ continue
125
+ }
126
+
127
+ // Numbered lists
128
+ const numMatch = line.match(/^(\d+)[.)]\s+(.+)/)
129
+ if (numMatch) {
130
+ result.push(
131
+ <div key={i} style={{ display: 'flex', gap: 'var(--space-1)', marginBottom: 1 }}>
132
+ <span style={{ color: 'var(--text-tertiary)', flexShrink: 0, minWidth: 16, textAlign: 'right' }}>{numMatch[1]}.</span>
133
+ <span>{formatInline(numMatch[2])}</span>
134
+ </div>
135
+ )
136
+ continue
137
+ }
138
+
139
+ result.push(<div key={i} style={{ marginBottom: 1 }}>{formatInline(line)}</div>)
140
+ }
141
+ return <>{result}</>
142
+ }
143
+
144
+ /* ── Priority badge ──────────────────────────────────── */
145
+
146
+ function PriorityBadge({ priority }: { priority: TicketPriority }) {
147
+ return (
148
+ <span style={{
149
+ display: 'inline-flex',
150
+ alignItems: 'center',
151
+ gap: 'var(--space-1)',
152
+ fontSize: 'var(--text-caption2)',
153
+ fontWeight: 600,
154
+ color: PRIORITY_COLORS[priority],
155
+ textTransform: 'uppercase',
156
+ letterSpacing: '0.5px',
157
+ }}>
158
+ <span style={{
159
+ width: 6,
160
+ height: 6,
161
+ borderRadius: '50%',
162
+ background: PRIORITY_COLORS[priority],
163
+ }} />
164
+ {priority}
165
+ </span>
166
+ )
167
+ }
168
+
169
+ /* ── Status badge ────────────────────────────────────── */
170
+
171
+ function StatusBadge({ status }: { status: TicketStatus }) {
172
+ const label = COLUMNS.find(c => c.id === status)?.title ?? status
173
+ return (
174
+ <span style={{
175
+ fontSize: 'var(--text-caption2)',
176
+ fontWeight: 600,
177
+ color: 'var(--text-secondary)',
178
+ background: 'var(--fill-tertiary)',
179
+ padding: '2px var(--space-2)',
180
+ borderRadius: 'var(--radius-sm)',
181
+ textTransform: 'uppercase',
182
+ letterSpacing: '0.3px',
183
+ }}>
184
+ {label}
185
+ </span>
186
+ )
187
+ }
188
+
189
+ /* ── Main component ──────────────────────────────────── */
190
+
191
+ interface TicketDetailPanelProps {
192
+ ticket: KanbanTicket
193
+ agent: Agent | null
194
+ onClose: () => void
195
+ onStatusChange: (status: TicketStatus) => void
196
+ onDelete: () => void
197
+ onRetryWork?: () => void
198
+ }
199
+
200
+ export function TicketDetailPanel({
201
+ ticket,
202
+ agent,
203
+ onClose,
204
+ onStatusChange,
205
+ onDelete,
206
+ onRetryWork,
207
+ }: TicketDetailPanelProps) {
208
+ const [messages, setMessages] = useState<ChatMessage[]>([])
209
+ const [input, setInput] = useState('')
210
+ const [isStreaming, setIsStreaming] = useState(false)
211
+ const [expanded, setExpanded] = useState(false)
212
+ const messagesEndRef = useRef<HTMLDivElement>(null)
213
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
214
+ const closeRef = useRef<HTMLButtonElement>(null)
215
+
216
+ // Load messages from API on mount / ticket change
217
+ useEffect(() => {
218
+ let cancelled = false
219
+ fetch(`/api/kanban/chat-history/${ticket.id}`)
220
+ .then(res => res.ok ? res.json() : [])
221
+ .then((msgs: ChatMessage[]) => { if (!cancelled) setMessages(msgs) })
222
+ .catch(() => { if (!cancelled) setMessages([]) })
223
+ return () => { cancelled = true }
224
+ }, [ticket.id])
225
+
226
+ // Auto-scroll to bottom
227
+ useEffect(() => {
228
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' })
229
+ }, [messages])
230
+
231
+ // Escape key to close
232
+ useEffect(() => {
233
+ function handleKeyDown(e: KeyboardEvent) {
234
+ if (e.key === 'Escape') onClose()
235
+ }
236
+ window.addEventListener('keydown', handleKeyDown)
237
+ return () => window.removeEventListener('keydown', handleKeyDown)
238
+ }, [onClose])
239
+
240
+ // Focus close button on mount
241
+ useEffect(() => {
242
+ closeRef.current?.focus()
243
+ }, [])
244
+
245
+ /* ── Send message + stream response ─────────────── */
246
+
247
+ const sendMessage = useCallback(async () => {
248
+ const text = input.trim()
249
+ if (!text || isStreaming || !agent) return
250
+
251
+ const userMsg: ChatMessage = {
252
+ id: crypto.randomUUID(),
253
+ role: 'user',
254
+ content: text,
255
+ timestamp: Date.now(),
256
+ }
257
+
258
+ const assistantMsgId = crypto.randomUUID()
259
+ const assistantMsg: ChatMessage = {
260
+ id: assistantMsgId,
261
+ role: 'assistant',
262
+ content: '',
263
+ timestamp: Date.now(),
264
+ isStreaming: true,
265
+ }
266
+
267
+ setMessages(prev => [...prev, userMsg, assistantMsg])
268
+ setInput('')
269
+ setIsStreaming(true)
270
+
271
+ // Build API messages (just role + content)
272
+ const allMessages = [...messages, userMsg]
273
+ const apiMessages = allMessages.map(m => ({ role: m.role, content: m.content }))
274
+
275
+ try {
276
+ const res = await fetch(`/api/kanban/chat/${agent.id}`, {
277
+ method: 'POST',
278
+ headers: { 'Content-Type': 'application/json' },
279
+ body: JSON.stringify({
280
+ messages: apiMessages,
281
+ ticket: {
282
+ title: ticket.title,
283
+ description: ticket.description,
284
+ status: ticket.status,
285
+ priority: ticket.priority,
286
+ assigneeRole: ticket.assigneeRole,
287
+ workResult: ticket.workResult,
288
+ },
289
+ }),
290
+ })
291
+
292
+ if (!res.ok || !res.body) throw new Error('Stream failed')
293
+
294
+ const reader = res.body.getReader()
295
+ const decoder = new TextDecoder()
296
+ let buffer = ''
297
+ let fullContent = ''
298
+
299
+ while (true) {
300
+ const { done, value } = await reader.read()
301
+ if (done) break
302
+ buffer += decoder.decode(value, { stream: true })
303
+ const lines = buffer.split('\n')
304
+ buffer = lines.pop() || ''
305
+ for (const line of lines) {
306
+ if (line.startsWith('data: ') && line !== 'data: [DONE]') {
307
+ try {
308
+ const chunk = JSON.parse(line.slice(6))
309
+ if (chunk.content) {
310
+ fullContent += chunk.content
311
+ const captured = fullContent
312
+ setMessages(prev =>
313
+ prev.map(m => m.id === assistantMsgId
314
+ ? { ...m, content: captured, isStreaming: true }
315
+ : m
316
+ )
317
+ )
318
+ }
319
+ } catch { /* skip malformed chunks */ }
320
+ }
321
+ }
322
+ }
323
+
324
+ const finalContent = fullContent
325
+ setMessages(prev =>
326
+ prev.map(m => m.id === assistantMsgId
327
+ ? { ...m, content: finalContent, isStreaming: false }
328
+ : m
329
+ )
330
+ )
331
+
332
+ // Persist user + assistant messages to filesystem
333
+ const completedAssistant = { ...assistantMsg, content: finalContent, isStreaming: undefined, timestamp: Date.now() }
334
+ fetch(`/api/kanban/chat-history/${ticket.id}`, {
335
+ method: 'POST',
336
+ headers: { 'Content-Type': 'application/json' },
337
+ body: JSON.stringify({ messages: [userMsg, completedAssistant] }),
338
+ }).catch(() => { /* persist best-effort */ })
339
+ } catch {
340
+ const errorContent = 'Error getting response. Check API connection.'
341
+ setMessages(prev =>
342
+ prev.map(m => m.id === assistantMsgId
343
+ ? { ...m, content: errorContent, isStreaming: false }
344
+ : m
345
+ )
346
+ )
347
+
348
+ // Persist user message + error response
349
+ const errorAssistant = { ...assistantMsg, content: errorContent, isStreaming: undefined, timestamp: Date.now() }
350
+ fetch(`/api/kanban/chat-history/${ticket.id}`, {
351
+ method: 'POST',
352
+ headers: { 'Content-Type': 'application/json' },
353
+ body: JSON.stringify({ messages: [userMsg, errorAssistant] }),
354
+ }).catch(() => { /* persist best-effort */ })
355
+ } finally {
356
+ setIsStreaming(false)
357
+ textareaRef.current?.focus()
358
+ }
359
+ }, [input, isStreaming, agent, messages, ticket])
360
+
361
+ function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
362
+ if (e.key === 'Enter' && !e.shiftKey) {
363
+ e.preventDefault()
364
+ sendMessage()
365
+ }
366
+ }
367
+
368
+ function handleDelete() {
369
+ if (window.confirm(`Delete ticket "${ticket.title}"? This cannot be undone.`)) {
370
+ onDelete()
371
+ }
372
+ }
373
+
374
+ const accentColor = agent?.color || 'var(--accent)'
375
+
376
+ return (
377
+ <div
378
+ className="fixed inset-0 z-40 md:absolute md:inset-y-0 md:right-0 md:left-auto md:z-30 panel-slide-in"
379
+ >
380
+ <div
381
+ className="h-full flex flex-col ml-auto"
382
+ style={{
383
+ width: '100%',
384
+ maxWidth: expanded ? 680 : 420,
385
+ flexShrink: 0,
386
+ transition: 'max-width 200ms var(--ease-smooth)',
387
+ background: 'var(--material-regular)',
388
+ backdropFilter: 'var(--sidebar-backdrop)',
389
+ WebkitBackdropFilter: 'var(--sidebar-backdrop)',
390
+ boxShadow: '-4px 0 24px rgba(0,0,0,0.25)',
391
+ display: 'flex',
392
+ flexDirection: 'column',
393
+ }}
394
+ >
395
+ {/* Color strip */}
396
+ <div style={{ height: 3, background: accentColor, flexShrink: 0 }} />
397
+
398
+ {/* Scrollable top section */}
399
+ <div style={{ flex: '0 0 auto', overflowY: 'auto', maxHeight: ticket.workResult ? '55%' : '45%' }}>
400
+ {/* Panel controls */}
401
+ <div style={{
402
+ padding: 'var(--space-4) var(--space-5) 0',
403
+ display: 'flex',
404
+ justifyContent: 'flex-end',
405
+ gap: 'var(--space-2)',
406
+ }}>
407
+ <button
408
+ onClick={() => setExpanded(e => !e)}
409
+ className="focus-ring"
410
+ aria-label={expanded ? 'Collapse panel' : 'Expand panel'}
411
+ style={{
412
+ width: 28,
413
+ height: 28,
414
+ borderRadius: '50%',
415
+ display: 'flex',
416
+ alignItems: 'center',
417
+ justifyContent: 'center',
418
+ background: 'var(--fill-secondary)',
419
+ color: 'var(--text-secondary)',
420
+ border: 'none',
421
+ cursor: 'pointer',
422
+ transition: 'all 150ms var(--ease-spring)',
423
+ }}
424
+ >
425
+ {expanded ? <Minimize2 size={13} /> : <Maximize2 size={13} />}
426
+ </button>
427
+ <button
428
+ ref={closeRef}
429
+ onClick={onClose}
430
+ className="focus-ring"
431
+ aria-label="Close detail panel"
432
+ style={{
433
+ width: 28,
434
+ height: 28,
435
+ borderRadius: '50%',
436
+ display: 'flex',
437
+ alignItems: 'center',
438
+ justifyContent: 'center',
439
+ background: 'var(--fill-secondary)',
440
+ color: 'var(--text-secondary)',
441
+ border: 'none',
442
+ cursor: 'pointer',
443
+ fontSize: 'var(--text-footnote)',
444
+ transition: 'all 150ms var(--ease-spring)',
445
+ }}
446
+ >
447
+ &#x2715;
448
+ </button>
449
+ </div>
450
+
451
+ {/* Title + meta */}
452
+ <div style={{ padding: 'var(--space-2) var(--space-5) var(--space-4)' }}>
453
+ <h2 style={{
454
+ fontSize: 'var(--text-title3)',
455
+ fontWeight: 700,
456
+ letterSpacing: '-0.3px',
457
+ color: 'var(--text-primary)',
458
+ margin: 0,
459
+ lineHeight: 1.25,
460
+ }}>
461
+ {ticket.title}
462
+ </h2>
463
+
464
+ <div style={{
465
+ display: 'flex',
466
+ alignItems: 'center',
467
+ gap: 'var(--space-3)',
468
+ marginTop: 'var(--space-2)',
469
+ }}>
470
+ <StatusBadge status={ticket.status} />
471
+ <PriorityBadge priority={ticket.priority} />
472
+ </div>
473
+
474
+ {/* Assignee */}
475
+ {agent ? (
476
+ <div style={{
477
+ display: 'flex',
478
+ alignItems: 'center',
479
+ gap: 'var(--space-2)',
480
+ marginTop: 'var(--space-3)',
481
+ fontSize: 'var(--text-footnote)',
482
+ color: 'var(--text-secondary)',
483
+ }}>
484
+ <AgentAvatar agent={agent} size={24} borderRadius={7} />
485
+ <span>{agent.name}</span>
486
+ {ticket.assigneeRole && (
487
+ <span style={{ color: 'var(--text-tertiary)' }}>
488
+ ({ROLE_LABELS[ticket.assigneeRole]})
489
+ </span>
490
+ )}
491
+ </div>
492
+ ) : (
493
+ <div style={{
494
+ marginTop: 'var(--space-3)',
495
+ fontSize: 'var(--text-footnote)',
496
+ color: 'var(--text-tertiary)',
497
+ fontStyle: 'italic',
498
+ }}>
499
+ Unassigned
500
+ </div>
501
+ )}
502
+ </div>
503
+
504
+ {/* Status controls */}
505
+ <div style={{
506
+ padding: '0 var(--space-5) var(--space-4)',
507
+ }}>
508
+ <div style={{
509
+ fontSize: 'var(--text-caption1)',
510
+ fontWeight: 600,
511
+ color: 'var(--text-tertiary)',
512
+ textTransform: 'uppercase',
513
+ letterSpacing: '0.5px',
514
+ marginBottom: 'var(--space-2)',
515
+ }}>
516
+ Move to
517
+ </div>
518
+ <div style={{ display: 'flex', gap: 'var(--space-1)', flexWrap: 'wrap' }}>
519
+ {COLUMNS.map(col => {
520
+ const isCurrent = col.id === ticket.status
521
+ return (
522
+ <button
523
+ key={col.id}
524
+ onClick={() => { if (!isCurrent) onStatusChange(col.id) }}
525
+ disabled={isCurrent}
526
+ className="focus-ring"
527
+ style={{
528
+ fontSize: 'var(--text-caption2)',
529
+ fontWeight: 600,
530
+ padding: '3px var(--space-2)',
531
+ borderRadius: 'var(--radius-sm)',
532
+ border: 'none',
533
+ cursor: isCurrent ? 'default' : 'pointer',
534
+ background: isCurrent ? accentColor : 'var(--fill-tertiary)',
535
+ color: isCurrent ? '#fff' : 'var(--text-secondary)',
536
+ opacity: isCurrent ? 1 : 0.8,
537
+ transition: 'all 120ms ease',
538
+ }}
539
+ >
540
+ {col.title}
541
+ </button>
542
+ )
543
+ })}
544
+ </div>
545
+ </div>
546
+
547
+ {/* Description */}
548
+ {ticket.description && (
549
+ <div style={{ padding: '0 var(--space-5) var(--space-4)' }}>
550
+ <div style={{
551
+ height: 1,
552
+ background: 'var(--separator)',
553
+ marginBottom: 'var(--space-3)',
554
+ }} />
555
+ <div style={{
556
+ fontSize: 'var(--text-caption1)',
557
+ fontWeight: 600,
558
+ color: 'var(--text-tertiary)',
559
+ textTransform: 'uppercase',
560
+ letterSpacing: '0.5px',
561
+ marginBottom: 'var(--space-2)',
562
+ }}>
563
+ Description
564
+ </div>
565
+ <div style={{
566
+ fontSize: 'var(--text-footnote)',
567
+ color: 'var(--text-secondary)',
568
+ lineHeight: 1.5,
569
+ whiteSpace: 'pre-wrap',
570
+ }}>
571
+ {ticket.description}
572
+ </div>
573
+ </div>
574
+ )}
575
+
576
+ {/* Agent work result */}
577
+ {ticket.workResult && (
578
+ <div style={{ padding: '0 var(--space-5) var(--space-4)' }}>
579
+ <div style={{
580
+ height: 1,
581
+ background: 'var(--separator)',
582
+ marginBottom: 'var(--space-3)',
583
+ }} />
584
+ <div style={{
585
+ fontSize: 'var(--text-caption1)',
586
+ fontWeight: 600,
587
+ color: 'var(--text-tertiary)',
588
+ textTransform: 'uppercase',
589
+ letterSpacing: '0.5px',
590
+ marginBottom: 'var(--space-2)',
591
+ }}>
592
+ Agent Work
593
+ </div>
594
+ <div style={{
595
+ fontSize: 'var(--text-footnote)',
596
+ color: 'var(--text-primary)',
597
+ lineHeight: 1.5,
598
+ borderLeft: `2px solid ${accentColor}`,
599
+ paddingLeft: 'var(--space-3)',
600
+ }}>
601
+ {formatContent(ticket.workResult)}
602
+ </div>
603
+ </div>
604
+ )}
605
+
606
+ {/* Work failed banner */}
607
+ {ticket.workState === 'failed' && (
608
+ <div style={{
609
+ padding: '0 var(--space-5) var(--space-4)',
610
+ }}>
611
+ <div style={{
612
+ padding: 'var(--space-3)',
613
+ borderRadius: 'var(--radius-md)',
614
+ border: '1px solid var(--system-red)',
615
+ background: 'color-mix(in srgb, var(--system-red) 8%, transparent)',
616
+ display: 'flex',
617
+ flexDirection: 'column',
618
+ gap: 'var(--space-2)',
619
+ }}>
620
+ <div style={{
621
+ fontSize: 'var(--text-footnote)',
622
+ fontWeight: 600,
623
+ color: 'var(--system-red)',
624
+ }}>
625
+ Agent work failed
626
+ </div>
627
+ {ticket.workError && (
628
+ <div style={{
629
+ fontSize: 'var(--text-caption2)',
630
+ color: 'var(--text-secondary)',
631
+ }}>
632
+ {ticket.workError}
633
+ </div>
634
+ )}
635
+ {onRetryWork && (
636
+ <button
637
+ onClick={onRetryWork}
638
+ className="focus-ring"
639
+ style={{
640
+ alignSelf: 'flex-start',
641
+ fontSize: 'var(--text-caption2)',
642
+ fontWeight: 600,
643
+ padding: '3px var(--space-3)',
644
+ borderRadius: 'var(--radius-sm)',
645
+ border: '1px solid var(--system-red)',
646
+ background: 'transparent',
647
+ color: 'var(--system-red)',
648
+ cursor: 'pointer',
649
+ }}
650
+ >
651
+ Retry
652
+ </button>
653
+ )}
654
+ </div>
655
+ </div>
656
+ )}
657
+ </div>
658
+
659
+ {/* Separator */}
660
+ <div style={{
661
+ height: 1,
662
+ background: 'var(--separator)',
663
+ flexShrink: 0,
664
+ margin: '0 var(--space-5)',
665
+ }} />
666
+
667
+ {/* Chat section (bottom half) */}
668
+ <div style={{
669
+ flex: 1,
670
+ display: 'flex',
671
+ flexDirection: 'column',
672
+ minHeight: 0,
673
+ padding: 'var(--space-3) var(--space-5) 0',
674
+ }}>
675
+ <div style={{
676
+ fontSize: 'var(--text-caption1)',
677
+ fontWeight: 600,
678
+ color: 'var(--text-tertiary)',
679
+ textTransform: 'uppercase',
680
+ letterSpacing: '0.5px',
681
+ marginBottom: 'var(--space-2)',
682
+ flexShrink: 0,
683
+ }}>
684
+ Agent Chat
685
+ </div>
686
+
687
+ {!agent ? (
688
+ <div style={{
689
+ flex: 1,
690
+ display: 'flex',
691
+ alignItems: 'center',
692
+ justifyContent: 'center',
693
+ color: 'var(--text-tertiary)',
694
+ fontSize: 'var(--text-footnote)',
695
+ fontStyle: 'italic',
696
+ }}>
697
+ No agent assigned
698
+ </div>
699
+ ) : (
700
+ <>
701
+ {/* Messages */}
702
+ <div style={{
703
+ flex: 1,
704
+ overflowY: 'auto',
705
+ display: 'flex',
706
+ flexDirection: 'column',
707
+ gap: 'var(--space-2)',
708
+ paddingBottom: 'var(--space-2)',
709
+ minHeight: 0,
710
+ }}>
711
+ {messages.length === 0 && (
712
+ <div style={{
713
+ color: 'var(--text-tertiary)',
714
+ fontSize: 'var(--text-caption1)',
715
+ textAlign: 'center',
716
+ padding: 'var(--space-6) 0',
717
+ fontStyle: 'italic',
718
+ }}>
719
+ Ask {agent.name} about this ticket...
720
+ </div>
721
+ )}
722
+
723
+ {messages.map(msg => (
724
+ <div
725
+ key={msg.id}
726
+ style={{
727
+ display: 'flex',
728
+ flexDirection: 'column',
729
+ alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
730
+ }}
731
+ >
732
+ <div style={{
733
+ maxWidth: '85%',
734
+ padding: 'var(--space-2) var(--space-3)',
735
+ borderRadius: 'var(--radius-md)',
736
+ fontSize: 'var(--text-footnote)',
737
+ lineHeight: 1.45,
738
+ background: msg.role === 'user' ? accentColor : 'var(--fill-tertiary)',
739
+ color: msg.role === 'user' ? '#fff' : 'var(--text-primary)',
740
+ }}>
741
+ {formatContent(msg.content)}
742
+ {msg.isStreaming && !msg.content && (
743
+ <span style={{ opacity: 0.5 }}>Thinking...</span>
744
+ )}
745
+ {msg.isStreaming && msg.content && (
746
+ <span style={{
747
+ display: 'inline-block',
748
+ width: 4,
749
+ height: 14,
750
+ background: msg.role === 'user' ? '#fff' : 'var(--text-primary)',
751
+ marginLeft: 2,
752
+ opacity: 0.6,
753
+ animation: 'blink 1s infinite',
754
+ verticalAlign: 'text-bottom',
755
+ }} />
756
+ )}
757
+ </div>
758
+ </div>
759
+ ))}
760
+ <div ref={messagesEndRef} />
761
+ </div>
762
+
763
+ {/* Input */}
764
+ <div style={{
765
+ flexShrink: 0,
766
+ padding: 'var(--space-2) 0 var(--space-3)',
767
+ display: 'flex',
768
+ gap: 'var(--space-2)',
769
+ alignItems: 'flex-end',
770
+ }}>
771
+ <textarea
772
+ ref={textareaRef}
773
+ value={input}
774
+ onChange={e => setInput(e.target.value)}
775
+ onKeyDown={handleKeyDown}
776
+ placeholder={`Message ${agent.name}...`}
777
+ rows={1}
778
+ disabled={isStreaming}
779
+ style={{
780
+ flex: 1,
781
+ resize: 'none',
782
+ border: '1px solid var(--separator)',
783
+ borderRadius: 'var(--radius-md)',
784
+ background: 'var(--fill-tertiary)',
785
+ color: 'var(--text-primary)',
786
+ padding: 'var(--space-2) var(--space-3)',
787
+ fontSize: 'var(--text-footnote)',
788
+ fontFamily: 'inherit',
789
+ outline: 'none',
790
+ lineHeight: 1.4,
791
+ maxHeight: 80,
792
+ }}
793
+ />
794
+ <button
795
+ onClick={sendMessage}
796
+ disabled={!input.trim() || isStreaming}
797
+ className="focus-ring"
798
+ aria-label="Send message"
799
+ style={{
800
+ width: 32,
801
+ height: 32,
802
+ borderRadius: 'var(--radius-md)',
803
+ border: 'none',
804
+ cursor: !input.trim() || isStreaming ? 'default' : 'pointer',
805
+ background: !input.trim() || isStreaming ? 'var(--fill-tertiary)' : accentColor,
806
+ color: !input.trim() || isStreaming ? 'var(--text-tertiary)' : '#fff',
807
+ display: 'flex',
808
+ alignItems: 'center',
809
+ justifyContent: 'center',
810
+ fontSize: 16,
811
+ flexShrink: 0,
812
+ transition: 'all 120ms ease',
813
+ }}
814
+ >
815
+ &#x2191;
816
+ </button>
817
+ </div>
818
+ </>
819
+ )}
820
+ </div>
821
+
822
+ {/* Delete button */}
823
+ <div style={{
824
+ flexShrink: 0,
825
+ padding: 'var(--space-2) var(--space-5) var(--space-4)',
826
+ borderTop: '1px solid var(--separator)',
827
+ }}>
828
+ <button
829
+ onClick={handleDelete}
830
+ className="focus-ring"
831
+ style={{
832
+ width: '100%',
833
+ padding: 'var(--space-2) var(--space-3)',
834
+ borderRadius: 'var(--radius-md)',
835
+ border: '1px solid var(--system-red)',
836
+ background: 'transparent',
837
+ color: 'var(--system-red)',
838
+ fontSize: 'var(--text-footnote)',
839
+ fontWeight: 600,
840
+ cursor: 'pointer',
841
+ transition: 'all 120ms ease',
842
+ }}
843
+ >
844
+ Delete Ticket
845
+ </button>
846
+ </div>
847
+ </div>
848
+ </div>
849
+ )
850
+ }