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,321 @@
1
+ 'use client'
2
+
3
+ import { useState, useRef, useEffect, useCallback } from 'react'
4
+ import type { Agent } from '@/lib/types'
5
+ import { AgentAvatar } from '@/components/AgentAvatar'
6
+
7
+ interface AgentPickerProps {
8
+ agents: Agent[]
9
+ value: string // agent id or ''
10
+ onChange: (agentId: string) => void
11
+ }
12
+
13
+ export function AgentPicker({ agents, value, onChange }: AgentPickerProps) {
14
+ const [open, setOpen] = useState(false)
15
+ const [search, setSearch] = useState('')
16
+ const [highlightIdx, setHighlightIdx] = useState(0)
17
+ const containerRef = useRef<HTMLDivElement>(null)
18
+ const searchRef = useRef<HTMLInputElement>(null)
19
+ const listRef = useRef<HTMLDivElement>(null)
20
+
21
+ const selected = agents.find(a => a.id === value) ?? null
22
+
23
+ // Filter agents by search
24
+ const filtered = search.trim()
25
+ ? agents.filter(a => {
26
+ const q = search.toLowerCase()
27
+ return (
28
+ a.name.toLowerCase().includes(q) ||
29
+ a.id.toLowerCase().includes(q) ||
30
+ a.title.toLowerCase().includes(q) ||
31
+ a.description.toLowerCase().includes(q)
32
+ )
33
+ })
34
+ : agents
35
+
36
+ // Include "Unassigned" option at the top
37
+ const hasUnassigned = !search.trim() || 'unassigned'.includes(search.toLowerCase())
38
+
39
+ // Reset highlight when filter changes
40
+ useEffect(() => {
41
+ setHighlightIdx(0)
42
+ }, [search])
43
+
44
+ // Focus search when opening
45
+ useEffect(() => {
46
+ if (open) {
47
+ setTimeout(() => searchRef.current?.focus(), 0)
48
+ } else {
49
+ setSearch('')
50
+ }
51
+ }, [open])
52
+
53
+ // Close on outside click
54
+ useEffect(() => {
55
+ if (!open) return
56
+ function handleClick(e: MouseEvent) {
57
+ if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
58
+ setOpen(false)
59
+ }
60
+ }
61
+ document.addEventListener('mousedown', handleClick)
62
+ return () => document.removeEventListener('mousedown', handleClick)
63
+ }, [open])
64
+
65
+ // Scroll highlighted item into view
66
+ useEffect(() => {
67
+ if (!open || !listRef.current) return
68
+ const items = listRef.current.querySelectorAll('[data-agent-option]')
69
+ const item = items[highlightIdx]
70
+ if (item) item.scrollIntoView({ block: 'nearest' })
71
+ }, [highlightIdx, open])
72
+
73
+ const totalOptions = (hasUnassigned ? 1 : 0) + filtered.length
74
+
75
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
76
+ if (!open) {
77
+ if (e.key === 'Enter' || e.key === ' ' || e.key === 'ArrowDown') {
78
+ e.preventDefault()
79
+ setOpen(true)
80
+ }
81
+ return
82
+ }
83
+
84
+ if (e.key === 'Escape') {
85
+ e.preventDefault()
86
+ setOpen(false)
87
+ return
88
+ }
89
+
90
+ if (e.key === 'ArrowDown') {
91
+ e.preventDefault()
92
+ setHighlightIdx(i => Math.min(i + 1, totalOptions - 1))
93
+ } else if (e.key === 'ArrowUp') {
94
+ e.preventDefault()
95
+ setHighlightIdx(i => Math.max(i - 1, 0))
96
+ } else if (e.key === 'Enter') {
97
+ e.preventDefault()
98
+ // Select the highlighted option
99
+ if (hasUnassigned && highlightIdx === 0) {
100
+ onChange('')
101
+ } else {
102
+ const agentIdx = hasUnassigned ? highlightIdx - 1 : highlightIdx
103
+ if (filtered[agentIdx]) {
104
+ onChange(filtered[agentIdx].id)
105
+ }
106
+ }
107
+ setOpen(false)
108
+ }
109
+ }, [open, highlightIdx, totalOptions, hasUnassigned, filtered, onChange])
110
+
111
+ function selectAgent(agentId: string) {
112
+ onChange(agentId)
113
+ setOpen(false)
114
+ }
115
+
116
+ return (
117
+ <div ref={containerRef} style={{ position: 'relative' }} onKeyDown={handleKeyDown}>
118
+ {/* Trigger button */}
119
+ <button
120
+ type="button"
121
+ className="apple-input focus-ring"
122
+ onClick={() => setOpen(!open)}
123
+ aria-haspopup="listbox"
124
+ aria-expanded={open}
125
+ style={{
126
+ width: '100%',
127
+ display: 'flex',
128
+ alignItems: 'center',
129
+ gap: 'var(--space-2)',
130
+ padding: '8px 12px',
131
+ fontSize: 'var(--text-body)',
132
+ color: selected ? 'var(--text-primary)' : 'var(--text-tertiary)',
133
+ cursor: 'pointer',
134
+ textAlign: 'left',
135
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E")`,
136
+ backgroundRepeat: 'no-repeat',
137
+ backgroundPosition: 'right 12px center',
138
+ paddingRight: 36,
139
+ minHeight: 40,
140
+ }}
141
+ >
142
+ {selected ? (
143
+ <>
144
+ <AgentAvatar agent={selected} size={22} borderRadius={6} />
145
+ <span style={{ fontWeight: 'var(--weight-medium)' }}>{selected.name}</span>
146
+ <span style={{ fontSize: 'var(--text-caption1)', color: 'var(--text-tertiary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 }}>
147
+ {selected.title}
148
+ </span>
149
+ </>
150
+ ) : (
151
+ <span>Unassigned</span>
152
+ )}
153
+ </button>
154
+
155
+ {/* Dropdown */}
156
+ {open && (
157
+ <div
158
+ style={{
159
+ position: 'absolute',
160
+ top: '100%',
161
+ left: 0,
162
+ right: 0,
163
+ marginTop: 4,
164
+ zIndex: 50,
165
+ background: 'var(--material-regular)',
166
+ border: '1px solid var(--separator)',
167
+ borderRadius: 'var(--radius-md)',
168
+ boxShadow: '0 8px 32px rgba(0,0,0,0.3)',
169
+ overflow: 'hidden',
170
+ }}
171
+ >
172
+ {/* Search */}
173
+ <div style={{ padding: '8px 8px 4px' }}>
174
+ <input
175
+ ref={searchRef}
176
+ type="text"
177
+ placeholder="Search agents..."
178
+ value={search}
179
+ onChange={e => setSearch(e.target.value)}
180
+ className="focus-ring"
181
+ style={{
182
+ width: '100%',
183
+ padding: '6px 10px',
184
+ fontSize: 'var(--text-footnote)',
185
+ border: '1px solid var(--separator)',
186
+ borderRadius: 'var(--radius-sm)',
187
+ background: 'var(--fill-tertiary)',
188
+ color: 'var(--text-primary)',
189
+ outline: 'none',
190
+ }}
191
+ />
192
+ </div>
193
+
194
+ {/* Options list */}
195
+ <div
196
+ ref={listRef}
197
+ role="listbox"
198
+ style={{
199
+ maxHeight: 280,
200
+ overflowY: 'auto',
201
+ padding: '4px',
202
+ }}
203
+ >
204
+ {/* Unassigned option */}
205
+ {hasUnassigned && (
206
+ <div
207
+ data-agent-option
208
+ role="option"
209
+ aria-selected={value === ''}
210
+ onClick={() => selectAgent('')}
211
+ style={{
212
+ display: 'flex',
213
+ alignItems: 'center',
214
+ gap: 'var(--space-2)',
215
+ padding: '8px 10px',
216
+ borderRadius: 'var(--radius-sm)',
217
+ cursor: 'pointer',
218
+ background: highlightIdx === 0 ? 'var(--fill-secondary)' : 'transparent',
219
+ transition: 'background 100ms',
220
+ }}
221
+ onMouseEnter={() => setHighlightIdx(0)}
222
+ >
223
+ <div style={{
224
+ width: 32,
225
+ height: 32,
226
+ borderRadius: 8,
227
+ background: 'var(--fill-tertiary)',
228
+ display: 'flex',
229
+ alignItems: 'center',
230
+ justifyContent: 'center',
231
+ fontSize: 14,
232
+ color: 'var(--text-tertiary)',
233
+ flexShrink: 0,
234
+ }}>
235
+
236
+ </div>
237
+ <div>
238
+ <div style={{ fontSize: 'var(--text-footnote)', fontWeight: 'var(--weight-medium)', color: 'var(--text-secondary)' }}>
239
+ Unassigned
240
+ </div>
241
+ <div style={{ fontSize: 'var(--text-caption2)', color: 'var(--text-tertiary)' }}>
242
+ No agent assigned
243
+ </div>
244
+ </div>
245
+ {value === '' && (
246
+ <span style={{ marginLeft: 'auto', color: 'var(--accent)', fontSize: 13, flexShrink: 0 }}>&#10003;</span>
247
+ )}
248
+ </div>
249
+ )}
250
+
251
+ {/* Agent options */}
252
+ {filtered.map((agent, i) => {
253
+ const optionIdx = hasUnassigned ? i + 1 : i
254
+ const isHighlighted = highlightIdx === optionIdx
255
+ const isSelected = value === agent.id
256
+
257
+ return (
258
+ <div
259
+ key={agent.id}
260
+ data-agent-option
261
+ role="option"
262
+ aria-selected={isSelected}
263
+ onClick={() => selectAgent(agent.id)}
264
+ onMouseEnter={() => setHighlightIdx(optionIdx)}
265
+ style={{
266
+ display: 'flex',
267
+ alignItems: 'center',
268
+ gap: 'var(--space-2)',
269
+ padding: '8px 10px',
270
+ borderRadius: 'var(--radius-sm)',
271
+ cursor: 'pointer',
272
+ background: isHighlighted ? 'var(--fill-secondary)' : 'transparent',
273
+ transition: 'background 100ms',
274
+ }}
275
+ >
276
+ <AgentAvatar agent={agent} size={32} borderRadius={8} />
277
+ <div style={{ flex: 1, minWidth: 0 }}>
278
+ <div style={{
279
+ fontSize: 'var(--text-footnote)',
280
+ fontWeight: 'var(--weight-semibold)',
281
+ color: 'var(--text-primary)',
282
+ overflow: 'hidden',
283
+ textOverflow: 'ellipsis',
284
+ whiteSpace: 'nowrap',
285
+ }}>
286
+ {agent.name}
287
+ </div>
288
+ <div style={{
289
+ fontSize: 'var(--text-caption2)',
290
+ color: 'var(--text-tertiary)',
291
+ overflow: 'hidden',
292
+ textOverflow: 'ellipsis',
293
+ whiteSpace: 'nowrap',
294
+ }}>
295
+ {agent.title}
296
+ </div>
297
+ </div>
298
+ {isSelected && (
299
+ <span style={{ color: 'var(--accent)', fontSize: 13, flexShrink: 0 }}>&#10003;</span>
300
+ )}
301
+ </div>
302
+ )
303
+ })}
304
+
305
+ {/* No results */}
306
+ {filtered.length === 0 && !hasUnassigned && (
307
+ <div style={{
308
+ padding: 'var(--space-4)',
309
+ textAlign: 'center',
310
+ fontSize: 'var(--text-footnote)',
311
+ color: 'var(--text-tertiary)',
312
+ }}>
313
+ No agents match &ldquo;{search}&rdquo;
314
+ </div>
315
+ )}
316
+ </div>
317
+ </div>
318
+ )}
319
+ </div>
320
+ )
321
+ }
@@ -0,0 +1,333 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback } from 'react'
4
+ import { Plus } from 'lucide-react'
5
+ import type { Agent } from '@/lib/types'
6
+ import type { TicketPriority, TeamRole } from '@/lib/kanban/types'
7
+ import { PRIORITY_COLORS, ROLE_LABELS } from '@/lib/kanban/types'
8
+ import { AgentPicker } from '@/components/kanban/AgentPicker'
9
+ import {
10
+ Dialog,
11
+ DialogContent,
12
+ DialogHeader,
13
+ DialogTitle,
14
+ DialogDescription,
15
+ } from '@/components/ui/dialog'
16
+
17
+ interface CreateTicketModalProps {
18
+ open: boolean
19
+ onOpenChange: (open: boolean) => void
20
+ agents: Agent[]
21
+ onSubmit: (ticket: {
22
+ title: string
23
+ description: string
24
+ priority: TicketPriority
25
+ assigneeId: string | null
26
+ assigneeRole: TeamRole | null
27
+ }) => void
28
+ }
29
+
30
+ const PRIORITIES: TicketPriority[] = ['low', 'medium', 'high']
31
+ const PRIORITY_LABELS: Record<TicketPriority, string> = {
32
+ low: 'Low',
33
+ medium: 'Medium',
34
+ high: 'High',
35
+ }
36
+
37
+ const ROLES: TeamRole[] = ['lead-dev', 'ux-ui', 'qa']
38
+
39
+ const initialState = {
40
+ title: '',
41
+ description: '',
42
+ priority: 'medium' as TicketPriority,
43
+ assigneeId: '' as string,
44
+ assigneeRole: null as TeamRole | null,
45
+ }
46
+
47
+ export function CreateTicketModal({
48
+ open,
49
+ onOpenChange,
50
+ agents,
51
+ onSubmit,
52
+ }: CreateTicketModalProps) {
53
+ const [form, setForm] = useState(initialState)
54
+
55
+ const resetForm = useCallback(() => {
56
+ setForm(initialState)
57
+ }, [])
58
+
59
+ function handleOpenChange(next: boolean) {
60
+ if (!next) resetForm()
61
+ onOpenChange(next)
62
+ }
63
+
64
+ function handleSubmit(e: React.FormEvent) {
65
+ e.preventDefault()
66
+ if (!form.title.trim()) return
67
+
68
+ onSubmit({
69
+ title: form.title.trim(),
70
+ description: form.description.trim(),
71
+ priority: form.priority,
72
+ assigneeId: form.assigneeId || null,
73
+ assigneeRole: form.assigneeId ? form.assigneeRole : null,
74
+ })
75
+
76
+ resetForm()
77
+ onOpenChange(false)
78
+ }
79
+
80
+ const selectedAgent = agents.find((a) => a.id === form.assigneeId) ?? null
81
+
82
+ return (
83
+ <Dialog open={open} onOpenChange={handleOpenChange}>
84
+ <DialogContent
85
+ showCloseButton
86
+ style={{
87
+ background: 'var(--bg)',
88
+ border: '1px solid var(--separator)',
89
+ borderRadius: 'var(--radius-lg)',
90
+ boxShadow: 'var(--shadow-card)',
91
+ maxWidth: 480,
92
+ }}
93
+ >
94
+ <DialogHeader>
95
+ <DialogTitle
96
+ style={{
97
+ fontSize: 'var(--text-title3)',
98
+ fontWeight: 'var(--weight-bold)',
99
+ color: 'var(--text-primary)',
100
+ }}
101
+ >
102
+ Create Ticket
103
+ </DialogTitle>
104
+ <DialogDescription
105
+ style={{
106
+ fontSize: 'var(--text-caption1)',
107
+ color: 'var(--text-tertiary)',
108
+ }}
109
+ >
110
+ Add a new ticket to the backlog.
111
+ </DialogDescription>
112
+ </DialogHeader>
113
+
114
+ <form
115
+ onSubmit={handleSubmit}
116
+ style={{
117
+ display: 'flex',
118
+ flexDirection: 'column',
119
+ gap: 'var(--space-4)',
120
+ }}
121
+ >
122
+ {/* Title */}
123
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
124
+ <label
125
+ htmlFor="ticket-title"
126
+ style={{
127
+ fontSize: 'var(--text-caption1)',
128
+ fontWeight: 'var(--weight-medium)',
129
+ color: 'var(--text-secondary)',
130
+ }}
131
+ >
132
+ Title
133
+ </label>
134
+ <input
135
+ id="ticket-title"
136
+ type="text"
137
+ className="apple-input focus-ring"
138
+ placeholder="What needs to be done?"
139
+ value={form.title}
140
+ onChange={(e) => setForm((f) => ({ ...f, title: e.target.value }))}
141
+ required
142
+ autoFocus
143
+ style={{
144
+ fontSize: 'var(--text-body)',
145
+ color: 'var(--text-primary)',
146
+ }}
147
+ />
148
+ </div>
149
+
150
+ {/* Description */}
151
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
152
+ <label
153
+ htmlFor="ticket-description"
154
+ style={{
155
+ fontSize: 'var(--text-caption1)',
156
+ fontWeight: 'var(--weight-medium)',
157
+ color: 'var(--text-secondary)',
158
+ }}
159
+ >
160
+ Description
161
+ </label>
162
+ <textarea
163
+ id="ticket-description"
164
+ className="apple-input focus-ring"
165
+ placeholder="Add details..."
166
+ rows={3}
167
+ value={form.description}
168
+ onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
169
+ style={{
170
+ fontSize: 'var(--text-body)',
171
+ color: 'var(--text-primary)',
172
+ resize: 'vertical',
173
+ minHeight: 72,
174
+ }}
175
+ />
176
+ </div>
177
+
178
+ {/* Priority */}
179
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
180
+ <span
181
+ style={{
182
+ fontSize: 'var(--text-caption1)',
183
+ fontWeight: 'var(--weight-medium)',
184
+ color: 'var(--text-secondary)',
185
+ }}
186
+ >
187
+ Priority
188
+ </span>
189
+ <div style={{ display: 'flex', gap: 'var(--space-2)' }}>
190
+ {PRIORITIES.map((p) => {
191
+ const isSelected = form.priority === p
192
+ return (
193
+ <button
194
+ key={p}
195
+ type="button"
196
+ className="focus-ring"
197
+ onClick={() => setForm((f) => ({ ...f, priority: p }))}
198
+ style={{
199
+ flex: 1,
200
+ display: 'flex',
201
+ alignItems: 'center',
202
+ justifyContent: 'center',
203
+ gap: 'var(--space-1)',
204
+ padding: 'var(--space-2) var(--space-3)',
205
+ borderRadius: 'var(--radius-md)',
206
+ border: isSelected
207
+ ? `2px solid ${PRIORITY_COLORS[p]}`
208
+ : '2px solid var(--separator)',
209
+ background: isSelected ? 'var(--fill-tertiary)' : 'transparent',
210
+ cursor: 'pointer',
211
+ fontSize: 'var(--text-caption1)',
212
+ fontWeight: 'var(--weight-medium)',
213
+ color: isSelected ? 'var(--text-primary)' : 'var(--text-tertiary)',
214
+ transition: 'all 150ms var(--ease-smooth)',
215
+ }}
216
+ >
217
+ <span
218
+ style={{
219
+ width: 8,
220
+ height: 8,
221
+ borderRadius: '50%',
222
+ background: PRIORITY_COLORS[p],
223
+ flexShrink: 0,
224
+ }}
225
+ />
226
+ {PRIORITY_LABELS[p]}
227
+ </button>
228
+ )
229
+ })}
230
+ </div>
231
+ </div>
232
+
233
+ {/* Assignee */}
234
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
235
+ <label
236
+ style={{
237
+ fontSize: 'var(--text-caption1)',
238
+ fontWeight: 'var(--weight-medium)',
239
+ color: 'var(--text-secondary)',
240
+ }}
241
+ >
242
+ Assignee
243
+ </label>
244
+ <AgentPicker
245
+ agents={agents}
246
+ value={form.assigneeId}
247
+ onChange={(agentId) =>
248
+ setForm((f) => ({
249
+ ...f,
250
+ assigneeId: agentId,
251
+ assigneeRole: agentId ? f.assigneeRole : null,
252
+ }))
253
+ }
254
+ />
255
+ </div>
256
+
257
+ {/* Role (only shown when assignee is selected) */}
258
+ {form.assigneeId && (
259
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
260
+ <span
261
+ style={{
262
+ fontSize: 'var(--text-caption1)',
263
+ fontWeight: 'var(--weight-medium)',
264
+ color: 'var(--text-secondary)',
265
+ }}
266
+ >
267
+ Role
268
+ </span>
269
+ <div style={{ display: 'flex', gap: 'var(--space-2)' }}>
270
+ {ROLES.map((r) => {
271
+ const isSelected = form.assigneeRole === r
272
+ return (
273
+ <button
274
+ key={r}
275
+ type="button"
276
+ className="focus-ring"
277
+ onClick={() =>
278
+ setForm((f) => ({
279
+ ...f,
280
+ assigneeRole: f.assigneeRole === r ? null : r,
281
+ }))
282
+ }
283
+ style={{
284
+ flex: 1,
285
+ padding: 'var(--space-2) var(--space-3)',
286
+ borderRadius: 'var(--radius-md)',
287
+ border: isSelected
288
+ ? '2px solid var(--accent)'
289
+ : '2px solid var(--separator)',
290
+ background: isSelected ? 'var(--accent-fill)' : 'transparent',
291
+ cursor: 'pointer',
292
+ fontSize: 'var(--text-caption2)',
293
+ fontWeight: 'var(--weight-medium)',
294
+ color: isSelected ? 'var(--text-primary)' : 'var(--text-tertiary)',
295
+ transition: 'all 150ms var(--ease-smooth)',
296
+ textAlign: 'center',
297
+ }}
298
+ >
299
+ {ROLE_LABELS[r]}
300
+ </button>
301
+ )
302
+ })}
303
+ </div>
304
+ </div>
305
+ )}
306
+
307
+ {/* Submit */}
308
+ <button
309
+ type="submit"
310
+ className="btn-primary focus-ring"
311
+ disabled={!form.title.trim()}
312
+ style={{
313
+ borderRadius: 'var(--radius-md)',
314
+ padding: '12px 20px',
315
+ width: '100%',
316
+ fontSize: 'var(--text-body)',
317
+ fontWeight: 'var(--weight-semibold)',
318
+ border: 'none',
319
+ display: 'flex',
320
+ alignItems: 'center',
321
+ justifyContent: 'center',
322
+ gap: 'var(--space-2)',
323
+ marginTop: 'var(--space-2)',
324
+ }}
325
+ >
326
+ <Plus size={16} />
327
+ Create Ticket
328
+ </button>
329
+ </form>
330
+ </DialogContent>
331
+ </Dialog>
332
+ )
333
+ }