kanban-lite 1.0.4

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 (99) hide show
  1. package/.editorconfig +9 -0
  2. package/.github/workflows/ci.yml +59 -0
  3. package/.github/workflows/release.yml +75 -0
  4. package/.prettierignore +6 -0
  5. package/.prettierrc.yaml +4 -0
  6. package/.vscode/extensions.json +3 -0
  7. package/.vscode/launch.json +17 -0
  8. package/.vscode/settings.json +21 -0
  9. package/.vscode/tasks.json +22 -0
  10. package/.vscodeignore +11 -0
  11. package/CHANGELOG.md +184 -0
  12. package/CLAUDE.md +58 -0
  13. package/CONTRIBUTING.md +114 -0
  14. package/LICENSE +22 -0
  15. package/README.md +482 -0
  16. package/SKILL.md +237 -0
  17. package/dist/cli.js +8716 -0
  18. package/dist/extension.js +8463 -0
  19. package/dist/mcp-server.js +1327 -0
  20. package/dist/standalone-webview/icons-Dx9MGYqN.js +180 -0
  21. package/dist/standalone-webview/icons-Dx9MGYqN.js.map +1 -0
  22. package/dist/standalone-webview/index.js +85 -0
  23. package/dist/standalone-webview/index.js.map +1 -0
  24. package/dist/standalone-webview/react-vendor-DkYdDBET.js +25 -0
  25. package/dist/standalone-webview/react-vendor-DkYdDBET.js.map +1 -0
  26. package/dist/standalone-webview/style.css +1 -0
  27. package/dist/standalone.js +7513 -0
  28. package/dist/webview/icons-Dx9MGYqN.js +180 -0
  29. package/dist/webview/icons-Dx9MGYqN.js.map +1 -0
  30. package/dist/webview/index.js +85 -0
  31. package/dist/webview/index.js.map +1 -0
  32. package/dist/webview/react-vendor-DkYdDBET.js +25 -0
  33. package/dist/webview/react-vendor-DkYdDBET.js.map +1 -0
  34. package/dist/webview/style.css +1 -0
  35. package/docs/images/board-overview.png +0 -0
  36. package/docs/images/editor-view.png +0 -0
  37. package/docs/plans/2026-02-20-kanban-json-config-design.md +74 -0
  38. package/docs/plans/2026-02-20-kanban-json-config.md +690 -0
  39. package/eslint.config.mjs +31 -0
  40. package/package.json +161 -0
  41. package/postcss.config.js +6 -0
  42. package/resources/icon-light.png +0 -0
  43. package/resources/icon-light.svg +105 -0
  44. package/resources/icon.png +0 -0
  45. package/resources/icon.svg +105 -0
  46. package/resources/kanban-dark.svg +21 -0
  47. package/resources/kanban-light.svg +21 -0
  48. package/resources/kanban.svg +21 -0
  49. package/src/cli/index.ts +846 -0
  50. package/src/extension/FeatureHeaderProvider.ts +370 -0
  51. package/src/extension/KanbanPanel.ts +973 -0
  52. package/src/extension/SidebarViewProvider.ts +507 -0
  53. package/src/extension/featureFileUtils.ts +82 -0
  54. package/src/extension/index.ts +234 -0
  55. package/src/mcp-server/index.ts +632 -0
  56. package/src/sdk/KanbanSDK.ts +349 -0
  57. package/src/sdk/__tests__/KanbanSDK.test.ts +468 -0
  58. package/src/sdk/__tests__/parser.test.ts +170 -0
  59. package/src/sdk/fileUtils.ts +76 -0
  60. package/src/sdk/index.ts +6 -0
  61. package/src/sdk/parser.ts +70 -0
  62. package/src/sdk/types.ts +15 -0
  63. package/src/shared/config.ts +113 -0
  64. package/src/shared/editorTypes.ts +14 -0
  65. package/src/shared/types.ts +120 -0
  66. package/src/standalone/__tests__/server.integration.test.ts +1916 -0
  67. package/src/standalone/__tests__/webhooks.test.ts +357 -0
  68. package/src/standalone/fileUtils.ts +70 -0
  69. package/src/standalone/index.ts +71 -0
  70. package/src/standalone/server.ts +1046 -0
  71. package/src/standalone/webhooks.ts +135 -0
  72. package/src/webview/App.tsx +469 -0
  73. package/src/webview/assets/main.css +329 -0
  74. package/src/webview/assets/standalone-theme.css +130 -0
  75. package/src/webview/components/ColumnDialog.tsx +119 -0
  76. package/src/webview/components/CreateFeatureDialog.tsx +524 -0
  77. package/src/webview/components/DatePicker.tsx +185 -0
  78. package/src/webview/components/FeatureCard.tsx +186 -0
  79. package/src/webview/components/FeatureEditor.tsx +623 -0
  80. package/src/webview/components/KanbanBoard.tsx +144 -0
  81. package/src/webview/components/KanbanColumn.tsx +159 -0
  82. package/src/webview/components/MarkdownEditor.tsx +291 -0
  83. package/src/webview/components/PrioritySelect.tsx +39 -0
  84. package/src/webview/components/QuickAddInput.tsx +72 -0
  85. package/src/webview/components/SettingsPanel.tsx +284 -0
  86. package/src/webview/components/Toolbar.tsx +175 -0
  87. package/src/webview/components/UndoToast.tsx +70 -0
  88. package/src/webview/index.html +12 -0
  89. package/src/webview/lib/utils.ts +6 -0
  90. package/src/webview/main.tsx +11 -0
  91. package/src/webview/standalone-main.tsx +13 -0
  92. package/src/webview/standalone-shim.ts +132 -0
  93. package/src/webview/standalone.html +12 -0
  94. package/src/webview/store/index.ts +241 -0
  95. package/tailwind.config.js +53 -0
  96. package/tsconfig.json +22 -0
  97. package/vite.config.ts +36 -0
  98. package/vite.standalone.config.ts +62 -0
  99. package/vitest.config.ts +15 -0
@@ -0,0 +1,623 @@
1
+ import { useEffect, useCallback, useState, useRef, useMemo } from 'react'
2
+ import { X, User, ChevronDown, Wand2, Tag, Plus, Check, CircleDot, Signal, Calendar, Trash2, FileText, Paperclip } from 'lucide-react'
3
+ import type { FeatureFrontmatter, Priority, FeatureStatus } from '../../shared/types'
4
+ import { cn } from '../lib/utils'
5
+ import { useStore } from '../store'
6
+ import { MarkdownEditor } from './MarkdownEditor'
7
+
8
+ type AIAgent = 'claude' | 'codex' | 'opencode'
9
+ type PermissionMode = 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'
10
+
11
+ interface FeatureEditorProps {
12
+ featureId: string
13
+ content: string
14
+ frontmatter: FeatureFrontmatter
15
+ contentVersion?: number
16
+ onSave: (content: string, frontmatter: FeatureFrontmatter) => void
17
+ onClose: () => void
18
+ onDelete: () => void
19
+ onOpenFile: () => void
20
+ onStartWithAI: (agent: AIAgent, permissionMode: PermissionMode) => void
21
+ onAddAttachment: () => void
22
+ onOpenAttachment: (attachment: string) => void
23
+ onRemoveAttachment: (attachment: string) => void
24
+ }
25
+
26
+ const priorityLabels: Record<Priority, string> = {
27
+ critical: 'Critical',
28
+ high: 'High',
29
+ medium: 'Medium',
30
+ low: 'Low'
31
+ }
32
+
33
+ const statusLabels: Record<FeatureStatus, string> = {
34
+ backlog: 'Backlog',
35
+ todo: 'To Do',
36
+ 'in-progress': 'In Progress',
37
+ review: 'Review',
38
+ done: 'Done'
39
+ }
40
+
41
+ const priorities: Priority[] = ['critical', 'high', 'medium', 'low']
42
+ const statuses: FeatureStatus[] = ['backlog', 'todo', 'in-progress', 'review', 'done']
43
+
44
+ const priorityDots: Record<Priority, string> = {
45
+ critical: 'bg-red-500',
46
+ high: 'bg-orange-500',
47
+ medium: 'bg-yellow-500',
48
+ low: 'bg-green-500',
49
+ }
50
+
51
+ const statusDots: Record<FeatureStatus, string> = {
52
+ backlog: 'bg-zinc-400',
53
+ todo: 'bg-blue-400',
54
+ 'in-progress': 'bg-amber-400',
55
+ review: 'bg-purple-400',
56
+ done: 'bg-emerald-400',
57
+ }
58
+
59
+ const aiAgentTabs: { agent: AIAgent; label: string; color: string; activeColor: string }[] = [
60
+ { agent: 'claude', label: 'Claude', color: 'hover:bg-amber-100 dark:hover:bg-amber-900/30', activeColor: 'bg-amber-700 text-white' },
61
+ { agent: 'codex', label: 'Codex', color: 'hover:bg-emerald-100 dark:hover:bg-emerald-900/30', activeColor: 'bg-emerald-500 text-white' },
62
+ { agent: 'opencode', label: 'OpenCode', color: 'hover:bg-slate-100 dark:hover:bg-slate-700/30', activeColor: 'bg-slate-500 text-white' },
63
+ ]
64
+
65
+ const agentButtonColors: Record<AIAgent, { bg: string; hover: string; shadow: string; border: string }> = {
66
+ claude: {
67
+ bg: 'bg-amber-700',
68
+ hover: 'hover:bg-amber-800',
69
+ shadow: 'shadow-sm',
70
+ border: 'border border-amber-800/50'
71
+ },
72
+ codex: {
73
+ bg: 'bg-emerald-600',
74
+ hover: 'hover:bg-emerald-700',
75
+ shadow: 'shadow-sm',
76
+ border: 'border border-emerald-700/50'
77
+ },
78
+ opencode: {
79
+ bg: 'bg-slate-600',
80
+ hover: 'hover:bg-slate-700',
81
+ shadow: 'shadow-sm',
82
+ border: 'border border-slate-700/50'
83
+ },
84
+ }
85
+
86
+ const aiModesByAgent: Record<AIAgent, { permissionMode: PermissionMode; label: string; description: string }[]> = {
87
+ claude: [
88
+ { permissionMode: 'default', label: 'Default', description: 'With confirmations' },
89
+ { permissionMode: 'plan', label: 'Plan', description: 'Creates a plan first' },
90
+ { permissionMode: 'acceptEdits', label: 'Auto-edit', description: 'Auto-accepts file edits' },
91
+ { permissionMode: 'bypassPermissions', label: 'Full Auto', description: 'Bypasses all prompts' },
92
+ ],
93
+ codex: [
94
+ { permissionMode: 'default', label: 'Suggest', description: 'Suggests changes' },
95
+ { permissionMode: 'acceptEdits', label: 'Auto-edit', description: 'Auto-accepts edits' },
96
+ { permissionMode: 'bypassPermissions', label: 'Full Auto', description: 'Full automation' },
97
+ ],
98
+ opencode: [
99
+ { permissionMode: 'default', label: 'Default', description: 'Standard mode' },
100
+ ],
101
+ }
102
+
103
+ interface DropdownProps {
104
+ value: string
105
+ options: { value: string; label: string; dot?: string }[]
106
+ onChange: (value: string) => void
107
+ className?: string
108
+ }
109
+
110
+ function Dropdown({ value, options, onChange, className }: DropdownProps) {
111
+ const [isOpen, setIsOpen] = useState(false)
112
+ const current = options.find(o => o.value === value)
113
+
114
+ return (
115
+ <div className={cn('relative', className)}>
116
+ <button
117
+ onClick={() => setIsOpen(!isOpen)}
118
+ className="flex items-center gap-2 px-2 py-1 text-xs font-medium rounded transition-colors vscode-hover-bg"
119
+ style={{ color: 'var(--vscode-foreground)' }}
120
+ >
121
+ {current?.dot && <span className={cn('w-2 h-2 rounded-full shrink-0', current.dot)} />}
122
+ <span>{current?.label}</span>
123
+ <ChevronDown size={12} style={{ color: 'var(--vscode-descriptionForeground)' }} className="ml-0.5" />
124
+ </button>
125
+ {isOpen && (
126
+ <>
127
+ <div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
128
+ <div
129
+ className="absolute top-full left-0 mt-1 z-20 rounded-lg shadow-lg py-1 min-w-[140px]"
130
+ style={{
131
+ background: 'var(--vscode-dropdown-background)',
132
+ border: '1px solid var(--vscode-dropdown-border, var(--vscode-panel-border))',
133
+ }}
134
+ >
135
+ {options.map(option => (
136
+ <button
137
+ key={option.value}
138
+ onClick={() => {
139
+ onChange(option.value)
140
+ setIsOpen(false)
141
+ }}
142
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors"
143
+ style={{
144
+ color: 'var(--vscode-dropdown-foreground)',
145
+ background: option.value === value ? 'var(--vscode-list-activeSelectionBackground)' : undefined,
146
+ }}
147
+ onMouseEnter={e => {
148
+ if (option.value !== value) e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'
149
+ }}
150
+ onMouseLeave={e => {
151
+ if (option.value !== value) e.currentTarget.style.background = 'transparent'
152
+ }}
153
+ >
154
+ {option.dot && <span className={cn('w-2 h-2 rounded-full shrink-0', option.dot)} />}
155
+ <span className="flex-1 text-left">{option.label}</span>
156
+ {option.value === value && <Check size={12} style={{ color: 'var(--vscode-focusBorder)' }} className="shrink-0" />}
157
+ </button>
158
+ ))}
159
+ </div>
160
+ </>
161
+ )}
162
+ </div>
163
+ )
164
+ }
165
+
166
+ function PropertyRow({ label, icon, children }: { label: string; icon: React.ReactNode; children: React.ReactNode }) {
167
+ return (
168
+ <div
169
+ className="flex items-center gap-3 px-4 py-[5px] transition-colors vscode-hover-bg"
170
+ >
171
+ <div className="flex items-center gap-2 w-[90px] shrink-0">
172
+ <span style={{ color: 'var(--vscode-descriptionForeground)' }}>{icon}</span>
173
+ <span className="text-[11px]" style={{ color: 'var(--vscode-descriptionForeground)' }}>{label}</span>
174
+ </div>
175
+ <div className="flex-1 min-w-0">
176
+ {children}
177
+ </div>
178
+ </div>
179
+ )
180
+ }
181
+
182
+ interface AIDropdownProps {
183
+ onSelect: (agent: AIAgent, permissionMode: PermissionMode) => void
184
+ }
185
+
186
+ function AIDropdown({ onSelect }: AIDropdownProps) {
187
+ const [isOpen, setIsOpen] = useState(false)
188
+ const [selectedTab, setSelectedTab] = useState<AIAgent>('claude')
189
+
190
+ const modes = aiModesByAgent[selectedTab]
191
+ const buttonColors = agentButtonColors[selectedTab]
192
+
193
+ return (
194
+ <div className="relative">
195
+ <button
196
+ onClick={() => setIsOpen(!isOpen)}
197
+ className={cn(
198
+ 'flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-white rounded-md transition-colors',
199
+ buttonColors.bg,
200
+ buttonColors.hover,
201
+ buttonColors.shadow,
202
+ buttonColors.border
203
+ )}
204
+ >
205
+ <Wand2 size={13} />
206
+ <span>Build with AI</span>
207
+ <kbd className="ml-0.5 text-[9px] opacity-60 font-mono">⌘B</kbd>
208
+ <ChevronDown size={11} className={cn('ml-0.5 opacity-60 transition-transform', isOpen && 'rotate-180')} />
209
+ </button>
210
+ {isOpen && (
211
+ <>
212
+ <div className="fixed inset-0 z-10" onClick={() => setIsOpen(false)} />
213
+ <div className="absolute top-full right-0 mt-1 z-20 bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-lg shadow-xl min-w-[260px] overflow-hidden">
214
+ {/* Tabs */}
215
+ <div className="flex">
216
+ {aiAgentTabs.map((tab) => (
217
+ <button
218
+ key={tab.agent}
219
+ onClick={() => setSelectedTab(tab.agent)}
220
+ className={cn(
221
+ 'flex-1 px-3 py-2.5 text-xs font-medium transition-all',
222
+ selectedTab === tab.agent
223
+ ? tab.activeColor
224
+ : cn('text-zinc-600 dark:text-zinc-400', tab.color)
225
+ )}
226
+ >
227
+ {tab.label}
228
+ </button>
229
+ ))}
230
+ </div>
231
+ {/* Options */}
232
+ <div className="p-2 space-y-1">
233
+ {modes.map((mode) => (
234
+ <button
235
+ key={mode.permissionMode}
236
+ onClick={() => {
237
+ onSelect(selectedTab, mode.permissionMode)
238
+ setIsOpen(false)
239
+ }}
240
+ className="w-full text-left px-3 py-2.5 rounded-md hover:bg-zinc-100 dark:hover:bg-zinc-700/50 transition-colors"
241
+ >
242
+ <div className="text-xs font-medium text-zinc-900 dark:text-zinc-100">{mode.label}</div>
243
+ <div className="text-[10px] text-zinc-500 dark:text-zinc-400 mt-0.5">{mode.description}</div>
244
+ </button>
245
+ ))}
246
+ </div>
247
+ </div>
248
+ </>
249
+ )}
250
+ </div>
251
+ )
252
+ }
253
+
254
+ function LabelEditor({ labels, onChange }: { labels: string[]; onChange: (labels: string[]) => void }) {
255
+ const [newLabel, setNewLabel] = useState('')
256
+ const [isFocused, setIsFocused] = useState(false)
257
+ const inputRef = useRef<HTMLInputElement>(null)
258
+ const features = useStore(s => s.features)
259
+
260
+ const existingLabels = useMemo(() => {
261
+ const labelSet = new Set<string>()
262
+ features.forEach(f => f.labels.forEach(l => labelSet.add(l)))
263
+ return Array.from(labelSet).sort()
264
+ }, [features])
265
+
266
+ const suggestions = useMemo(() => {
267
+ const available = existingLabels.filter(l => !labels.includes(l))
268
+ if (!newLabel.trim()) return available
269
+ return available.filter(l => l.toLowerCase().includes(newLabel.toLowerCase()))
270
+ }, [newLabel, existingLabels, labels])
271
+
272
+ const showSuggestions = isFocused && suggestions.length > 0
273
+
274
+ const addLabel = (label?: string) => {
275
+ const l = (label || newLabel).trim()
276
+ if (l && !labels.includes(l)) {
277
+ onChange([...labels, l])
278
+ }
279
+ setNewLabel('')
280
+ }
281
+
282
+ const removeLabel = (label: string) => {
283
+ onChange(labels.filter(l => l !== label))
284
+ }
285
+
286
+ return (
287
+ <div className="relative flex items-center gap-1.5 flex-wrap">
288
+ {labels.map(label => (
289
+ <span
290
+ key={label}
291
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded"
292
+ style={{
293
+ background: 'var(--vscode-badge-background)',
294
+ color: 'var(--vscode-badge-foreground)',
295
+ }}
296
+ >
297
+ {label}
298
+ <button
299
+ onClick={() => removeLabel(label)}
300
+ className="hover:text-red-500 transition-colors"
301
+ >
302
+ <X size={9} />
303
+ </button>
304
+ </span>
305
+ ))}
306
+ <button
307
+ onClick={() => { setIsFocused(true); setTimeout(() => inputRef.current?.focus(), 0) }}
308
+ className="inline-flex items-center gap-0.5 px-1 py-0.5 text-[10px] rounded transition-colors vscode-hover-bg"
309
+ style={{ color: 'var(--vscode-descriptionForeground)' }}
310
+ >
311
+ <Plus size={10} />
312
+ </button>
313
+ <input
314
+ ref={inputRef}
315
+ type="text"
316
+ value={newLabel}
317
+ onChange={(e) => setNewLabel(e.target.value)}
318
+ onFocus={() => setIsFocused(true)}
319
+ onBlur={() => setTimeout(() => setIsFocused(false), 150)}
320
+ onKeyDown={(e) => {
321
+ if (e.key === 'Enter') { e.preventDefault(); addLabel() }
322
+ if (e.key === 'Backspace' && !newLabel && labels.length > 0) {
323
+ onChange(labels.slice(0, -1))
324
+ }
325
+ if (e.key === 'Escape') { setNewLabel(''); inputRef.current?.blur() }
326
+ }}
327
+ placeholder={labels.length === 0 ? 'Add labels...' : ''}
328
+ className="flex-1 min-w-[60px] bg-transparent border-none outline-none text-xs"
329
+ style={{ color: 'var(--vscode-foreground)', display: isFocused || newLabel ? 'block' : 'none' }}
330
+ />
331
+ {showSuggestions && (
332
+ <div
333
+ className="absolute top-full left-0 mt-1 z-20 rounded-lg shadow-lg py-1 max-h-[160px] overflow-auto min-w-[180px]"
334
+ style={{
335
+ background: 'var(--vscode-dropdown-background)',
336
+ border: '1px solid var(--vscode-dropdown-border, var(--vscode-panel-border))',
337
+ }}
338
+ >
339
+ {suggestions.map(label => (
340
+ <button
341
+ key={label}
342
+ type="button"
343
+ onMouseDown={(e) => { e.preventDefault(); addLabel(label) }}
344
+ className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors"
345
+ style={{ color: 'var(--vscode-dropdown-foreground)' }}
346
+ onMouseEnter={e => e.currentTarget.style.background = 'var(--vscode-list-hoverBackground)'}
347
+ onMouseLeave={e => e.currentTarget.style.background = 'transparent'}
348
+ >
349
+ <span
350
+ className="inline-block px-1.5 py-0.5 text-[10px] font-medium rounded"
351
+ style={{
352
+ background: 'var(--vscode-badge-background)',
353
+ color: 'var(--vscode-badge-foreground)',
354
+ }}
355
+ >{label}</span>
356
+ </button>
357
+ ))}
358
+ </div>
359
+ )}
360
+ </div>
361
+ )
362
+ }
363
+
364
+ export function FeatureEditor({ featureId, content, frontmatter, contentVersion, onSave, onClose, onDelete, onOpenFile, onStartWithAI, onAddAttachment, onOpenAttachment, onRemoveAttachment }: FeatureEditorProps) {
365
+ const { cardSettings } = useStore()
366
+ const [currentFrontmatter, setCurrentFrontmatter] = useState(frontmatter)
367
+ const [currentContent, setCurrentContent] = useState(content)
368
+ const [confirmingDelete, setConfirmingDelete] = useState(false)
369
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
370
+ const currentFrontmatterRef = useRef(currentFrontmatter)
371
+ const currentContentRef = useRef(currentContent)
372
+ currentFrontmatterRef.current = currentFrontmatter
373
+ currentContentRef.current = currentContent
374
+
375
+ const save = useCallback(() => {
376
+ onSave(currentContentRef.current, currentFrontmatterRef.current)
377
+ }, [onSave])
378
+
379
+ // Clean up debounce on unmount
380
+ useEffect(() => {
381
+ return () => {
382
+ if (debounceRef.current) clearTimeout(debounceRef.current)
383
+ }
384
+ }, [])
385
+
386
+ // Set content when a new feature is opened (keyed by featureId)
387
+ useEffect(() => {
388
+ setCurrentContent(content)
389
+ // eslint-disable-next-line react-hooks/exhaustive-deps
390
+ }, [featureId, contentVersion])
391
+
392
+ // Reset frontmatter when prop changes
393
+ useEffect(() => {
394
+ setCurrentFrontmatter(frontmatter)
395
+ }, [frontmatter])
396
+
397
+ const handleContentChange = useCallback((value: string) => {
398
+ setCurrentContent(value)
399
+ if (debounceRef.current) clearTimeout(debounceRef.current)
400
+ debounceRef.current = setTimeout(() => {
401
+ onSave(value, currentFrontmatterRef.current)
402
+ }, 800)
403
+ }, [onSave])
404
+
405
+ const handleFrontmatterUpdate = useCallback((updates: Partial<FeatureFrontmatter>) => {
406
+ setCurrentFrontmatter(prev => {
407
+ const next = { ...prev, ...updates }
408
+ // Schedule a save with the updated frontmatter
409
+ if (debounceRef.current) clearTimeout(debounceRef.current)
410
+ debounceRef.current = setTimeout(() => {
411
+ onSave(currentContentRef.current, next)
412
+ }, 800)
413
+ return next
414
+ })
415
+ }, [onSave])
416
+
417
+ // Keyboard shortcuts
418
+ useEffect(() => {
419
+ const handleKeyDown = (e: KeyboardEvent) => {
420
+ if ((e.metaKey || e.ctrlKey) && e.key === 's') {
421
+ e.preventDefault()
422
+ // Flush any pending debounce and save immediately
423
+ if (debounceRef.current) clearTimeout(debounceRef.current)
424
+ save()
425
+ }
426
+ if ((e.metaKey || e.ctrlKey) && e.key === 'b' && cardSettings.showBuildWithAI) {
427
+ e.preventDefault()
428
+ onStartWithAI('claude', 'default')
429
+ }
430
+ if (e.key === 'Escape') {
431
+ // Flush any pending save before closing
432
+ if (debounceRef.current) {
433
+ clearTimeout(debounceRef.current)
434
+ save()
435
+ }
436
+ onClose()
437
+ }
438
+ }
439
+ window.addEventListener('keydown', handleKeyDown)
440
+ return () => window.removeEventListener('keydown', handleKeyDown)
441
+ }, [save, onClose, onStartWithAI, cardSettings.showBuildWithAI])
442
+
443
+ return (
444
+ <div
445
+ className="h-full flex flex-col"
446
+ style={{
447
+ background: 'var(--vscode-editor-background)',
448
+ borderLeft: '1px solid var(--vscode-panel-border)',
449
+ }}
450
+ >
451
+ {/* Header */}
452
+ <div
453
+ className="flex items-center justify-between px-4 py-3"
454
+ style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
455
+ >
456
+ <div className="flex items-center gap-3">
457
+ <span className="text-xs font-mono" style={{ color: 'var(--vscode-descriptionForeground)' }}>{featureId}</span>
458
+ {confirmingDelete ? (
459
+ <div className="flex items-center gap-1.5">
460
+ <span className="text-xs" style={{ color: 'var(--vscode-errorForeground)' }}>Delete?</span>
461
+ <button
462
+ onClick={() => { setConfirmingDelete(false); onDelete() }}
463
+ className="px-2 py-1 text-xs font-medium rounded transition-colors text-white bg-red-600 hover:bg-red-700"
464
+ >
465
+ Yes
466
+ </button>
467
+ <button
468
+ onClick={() => setConfirmingDelete(false)}
469
+ className="px-2 py-1 text-xs font-medium rounded transition-colors vscode-hover-bg"
470
+ style={{ color: 'var(--vscode-foreground)' }}
471
+ >
472
+ No
473
+ </button>
474
+ </div>
475
+ ) : (
476
+ <>
477
+ <button
478
+ onClick={() => { onOpenFile(); onClose(); }}
479
+ className="p-1.5 px-2 rounded border transition-colors vscode-hover-bg flex items-center gap-1"
480
+ style={{ color: 'var(--vscode-descriptionForeground)', borderColor: 'var(--vscode-widget-border, var(--vscode-contrastBorder, rgba(128,128,128,0.35)))' }}
481
+ title="Open .md file"
482
+ >
483
+ <FileText size={16} />
484
+ <span className="text-xs">OPEN</span>
485
+ </button>
486
+ <button
487
+ onClick={() => setConfirmingDelete(true)}
488
+ className="p-1.5 px-2 rounded border transition-colors vscode-hover-bg flex items-center gap-1"
489
+ style={{ color: 'var(--vscode-descriptionForeground)', borderColor: 'var(--vscode-widget-border, var(--vscode-contrastBorder, rgba(128,128,128,0.35)))' }}
490
+ title="Delete ticket"
491
+ >
492
+ <Trash2 size={16} />
493
+ <span className="text-xs">DELETE</span>
494
+ </button>
495
+ </>
496
+ )}
497
+ </div>
498
+ <div className="flex items-center gap-2">
499
+ {cardSettings.showBuildWithAI && <AIDropdown onSelect={onStartWithAI} />}
500
+ <button
501
+ onClick={onClose}
502
+ className="p-1.5 rounded transition-colors vscode-hover-bg"
503
+ style={{ color: 'var(--vscode-descriptionForeground)' }}
504
+ >
505
+ <X size={18} />
506
+ </button>
507
+ </div>
508
+ </div>
509
+
510
+ {/* Metadata */}
511
+ <div
512
+ className="flex flex-col py-0.5"
513
+ style={{ borderBottom: '1px solid var(--vscode-panel-border)' }}
514
+ >
515
+ <PropertyRow label="Status" icon={<CircleDot size={13} />}>
516
+ <Dropdown
517
+ value={currentFrontmatter.status}
518
+ options={statuses.map(s => ({ value: s, label: statusLabels[s], dot: statusDots[s] }))}
519
+ onChange={(v) => handleFrontmatterUpdate({ status: v as FeatureStatus })}
520
+ />
521
+ </PropertyRow>
522
+ {cardSettings.showPriorityBadges && (
523
+ <PropertyRow label="Priority" icon={<Signal size={13} />}>
524
+ <Dropdown
525
+ value={currentFrontmatter.priority}
526
+ options={priorities.map(p => ({ value: p, label: priorityLabels[p], dot: priorityDots[p] }))}
527
+ onChange={(v) => handleFrontmatterUpdate({ priority: v as Priority })}
528
+ />
529
+ </PropertyRow>
530
+ )}
531
+ {cardSettings.showAssignee && (
532
+ <PropertyRow label="Assignee" icon={<User size={13} />}>
533
+ <div className="flex items-center gap-2">
534
+ {currentFrontmatter.assignee && (
535
+ <span
536
+ className="shrink-0 w-4 h-4 rounded-full flex items-center justify-center text-[8px] font-bold"
537
+ style={{
538
+ background: 'var(--vscode-badge-background)',
539
+ color: 'var(--vscode-badge-foreground)',
540
+ }}
541
+ >{currentFrontmatter.assignee.split(/\s+/).filter(Boolean).map(w => w[0]).join('').toUpperCase().slice(0, 2)}</span>
542
+ )}
543
+ <input
544
+ type="text"
545
+ value={currentFrontmatter.assignee || ''}
546
+ onChange={(e) => handleFrontmatterUpdate({ assignee: e.target.value || null })}
547
+ placeholder="No assignee"
548
+ className="bg-transparent border-none outline-none text-xs w-32"
549
+ style={{ color: currentFrontmatter.assignee ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)' }}
550
+ />
551
+ </div>
552
+ </PropertyRow>
553
+ )}
554
+ {cardSettings.showDueDate && (
555
+ <PropertyRow label="Due date" icon={<Calendar size={13} />}>
556
+ <input
557
+ type="date"
558
+ value={currentFrontmatter.dueDate || ''}
559
+ onChange={(e) => handleFrontmatterUpdate({ dueDate: e.target.value || null })}
560
+ className="bg-transparent border-none outline-none text-xs"
561
+ style={{ color: currentFrontmatter.dueDate ? 'var(--vscode-foreground)' : 'var(--vscode-descriptionForeground)' }}
562
+ />
563
+ </PropertyRow>
564
+ )}
565
+ {cardSettings.showLabels && (
566
+ <PropertyRow label="Labels" icon={<Tag size={13} />}>
567
+ <LabelEditor
568
+ labels={currentFrontmatter.labels}
569
+ onChange={(labels) => handleFrontmatterUpdate({ labels })}
570
+ />
571
+ </PropertyRow>
572
+ )}
573
+ <PropertyRow label="Attachments" icon={<Paperclip size={13} />}>
574
+ <div className="flex items-center gap-1.5 flex-wrap">
575
+ {currentFrontmatter.attachments.map(attachment => (
576
+ <span
577
+ key={attachment}
578
+ className="inline-flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium rounded"
579
+ style={{
580
+ background: 'var(--vscode-badge-background)',
581
+ color: 'var(--vscode-badge-foreground)',
582
+ }}
583
+ >
584
+ <button
585
+ type="button"
586
+ onClick={() => onOpenAttachment(attachment)}
587
+ className="inline-flex items-center gap-1 hover:underline"
588
+ title={attachment}
589
+ >
590
+ <Paperclip size={9} />
591
+ {attachment}
592
+ </button>
593
+ <button
594
+ type="button"
595
+ onClick={() => onRemoveAttachment(attachment)}
596
+ className="hover:text-red-500 transition-colors"
597
+ >
598
+ <X size={9} />
599
+ </button>
600
+ </span>
601
+ ))}
602
+ <button
603
+ type="button"
604
+ onClick={onAddAttachment}
605
+ className="inline-flex items-center gap-0.5 px-1 py-0.5 text-[10px] rounded transition-colors vscode-hover-bg"
606
+ style={{ color: 'var(--vscode-descriptionForeground)' }}
607
+ >
608
+ <Plus size={10} />
609
+ </button>
610
+ </div>
611
+ </PropertyRow>
612
+ </div>
613
+
614
+ {/* Editor */}
615
+ <MarkdownEditor
616
+ value={currentContent}
617
+ onChange={handleContentChange}
618
+ placeholder="Start writing..."
619
+ className="flex-1 min-h-0"
620
+ />
621
+ </div>
622
+ )
623
+ }