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,135 @@
1
+ import * as fs from 'fs'
2
+ import * as path from 'path'
3
+ import * as http from 'http'
4
+ import * as https from 'https'
5
+ import * as crypto from 'crypto'
6
+
7
+ export interface Webhook {
8
+ id: string
9
+ url: string
10
+ events: string[] // e.g. ["task.created", "task.moved"] or ["*"] for all
11
+ secret?: string // optional HMAC-SHA256 signing key
12
+ active: boolean
13
+ }
14
+
15
+ export type WebhookEvent =
16
+ | 'task.created'
17
+ | 'task.updated'
18
+ | 'task.moved'
19
+ | 'task.deleted'
20
+ | 'column.created'
21
+ | 'column.updated'
22
+ | 'column.deleted'
23
+
24
+ const WEBHOOKS_FILENAME = '.kanban-webhooks.json'
25
+
26
+ function webhooksPath(workspaceRoot: string): string {
27
+ return path.join(workspaceRoot, WEBHOOKS_FILENAME)
28
+ }
29
+
30
+ export function loadWebhooks(workspaceRoot: string): Webhook[] {
31
+ try {
32
+ const raw = fs.readFileSync(webhooksPath(workspaceRoot), 'utf-8')
33
+ const data = JSON.parse(raw)
34
+ return Array.isArray(data) ? data : []
35
+ } catch {
36
+ return []
37
+ }
38
+ }
39
+
40
+ export function saveWebhooks(workspaceRoot: string, webhooks: Webhook[]): void {
41
+ fs.writeFileSync(webhooksPath(workspaceRoot), JSON.stringify(webhooks, null, 2) + '\n', 'utf-8')
42
+ }
43
+
44
+ export function createWebhook(
45
+ workspaceRoot: string,
46
+ config: { url: string; events: string[]; secret?: string }
47
+ ): Webhook {
48
+ const webhooks = loadWebhooks(workspaceRoot)
49
+ const webhook: Webhook = {
50
+ id: 'wh_' + crypto.randomBytes(8).toString('hex'),
51
+ url: config.url,
52
+ events: config.events,
53
+ secret: config.secret,
54
+ active: true
55
+ }
56
+ webhooks.push(webhook)
57
+ saveWebhooks(workspaceRoot, webhooks)
58
+ return webhook
59
+ }
60
+
61
+ export function deleteWebhook(workspaceRoot: string, id: string): boolean {
62
+ const webhooks = loadWebhooks(workspaceRoot)
63
+ const filtered = webhooks.filter(w => w.id !== id)
64
+ if (filtered.length === webhooks.length) return false
65
+ saveWebhooks(workspaceRoot, filtered)
66
+ return true
67
+ }
68
+
69
+ export function fireWebhooks(workspaceRoot: string, event: WebhookEvent, data: unknown): void {
70
+ const webhooks = loadWebhooks(workspaceRoot)
71
+ const matching = webhooks.filter(
72
+ w => w.active && (w.events.includes('*') || w.events.includes(event))
73
+ )
74
+ if (matching.length === 0) return
75
+
76
+ const payload = JSON.stringify({
77
+ event,
78
+ timestamp: new Date().toISOString(),
79
+ data
80
+ })
81
+
82
+ for (const webhook of matching) {
83
+ deliverWebhook(webhook, event, payload).catch(err => {
84
+ console.error(`Webhook delivery failed for ${webhook.id} (${webhook.url}):`, err.message || err)
85
+ })
86
+ }
87
+ }
88
+
89
+ async function deliverWebhook(webhook: Webhook, event: string, payload: string): Promise<void> {
90
+ const url = new URL(webhook.url)
91
+ const isHttps = url.protocol === 'https:'
92
+ const transport = isHttps ? https : http
93
+
94
+ const headers: Record<string, string> = {
95
+ 'Content-Type': 'application/json',
96
+ 'Content-Length': Buffer.byteLength(payload).toString(),
97
+ 'X-Webhook-Event': event
98
+ }
99
+
100
+ if (webhook.secret) {
101
+ const signature = crypto
102
+ .createHmac('sha256', webhook.secret)
103
+ .update(payload)
104
+ .digest('hex')
105
+ headers['X-Webhook-Signature'] = `sha256=${signature}`
106
+ }
107
+
108
+ return new Promise((resolve, reject) => {
109
+ const req = transport.request(
110
+ {
111
+ hostname: url.hostname,
112
+ port: url.port || (isHttps ? 443 : 80),
113
+ path: url.pathname + url.search,
114
+ method: 'POST',
115
+ headers,
116
+ timeout: 10000
117
+ },
118
+ (res) => {
119
+ res.resume() // drain response
120
+ if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) {
121
+ resolve()
122
+ } else {
123
+ reject(new Error(`HTTP ${res.statusCode}`))
124
+ }
125
+ }
126
+ )
127
+ req.on('error', reject)
128
+ req.on('timeout', () => {
129
+ req.destroy()
130
+ reject(new Error('Timeout'))
131
+ })
132
+ req.write(payload)
133
+ req.end()
134
+ })
135
+ }
@@ -0,0 +1,469 @@
1
+ import { useEffect, useState, useRef, useCallback } from 'react'
2
+ import { generateKeyBetween } from 'fractional-indexing'
3
+ import { useStore } from './store'
4
+ import { KanbanBoard } from './components/KanbanBoard'
5
+ import { CreateFeatureDialog } from './components/CreateFeatureDialog'
6
+ import { FeatureEditor } from './components/FeatureEditor'
7
+ import { Toolbar } from './components/Toolbar'
8
+ import { UndoToast } from './components/UndoToast'
9
+ import { SettingsPanel } from './components/SettingsPanel'
10
+ import { ColumnDialog } from './components/ColumnDialog'
11
+ import type { Feature, FeatureStatus, KanbanColumn, Priority, ExtensionMessage, FeatureFrontmatter, CardDisplaySettings } from '../shared/types'
12
+ import { getTitleFromContent } from '../shared/types'
13
+
14
+ // Declare vscode API type
15
+ declare const acquireVsCodeApi: () => {
16
+ postMessage: (message: unknown) => void
17
+ getState: () => unknown
18
+ setState: (state: unknown) => void
19
+ }
20
+
21
+ const vscode = acquireVsCodeApi()
22
+
23
+ function App(): React.JSX.Element {
24
+
25
+ const {
26
+ columns,
27
+ cardSettings,
28
+ settingsOpen,
29
+ setFeatures,
30
+ setColumns,
31
+ setIsDarkMode,
32
+ setCardSettings,
33
+ setSettingsOpen
34
+ } = useStore()
35
+
36
+ const [createFeatureOpen, setCreateFeatureOpen] = useState(false)
37
+ const [createFeatureStatus, setCreateFeatureStatus] = useState<FeatureStatus>('backlog')
38
+
39
+ // Column dialog state
40
+ const [columnDialogOpen, setColumnDialogOpen] = useState(false)
41
+ const [editingColumn, setEditingColumn] = useState<KanbanColumn | null>(null)
42
+
43
+ // Editor state
44
+ const contentVersionRef = useRef(0)
45
+ const [editingFeature, setEditingFeature] = useState<{
46
+ id: string
47
+ content: string
48
+ frontmatter: FeatureFrontmatter
49
+ contentVersion: number
50
+ } | null>(null)
51
+
52
+ // Undo delete stack
53
+ const [pendingDeletes, setPendingDeletes] = useState<{ id: string; feature: Feature }[]>([])
54
+ const pendingDeletesRef = useRef(pendingDeletes)
55
+ useEffect(() => {
56
+ pendingDeletesRef.current = pendingDeletes
57
+ }, [pendingDeletes])
58
+
59
+ const nextIdRef = useRef(0)
60
+
61
+ const handleDeleteFeatureFromCard = useCallback((featureId: string) => {
62
+ const { features } = useStore.getState()
63
+ const feature = features.find(f => f.id === featureId)
64
+ if (!feature) return
65
+
66
+ // Optimistically remove from local state
67
+ setFeatures(features.filter(f => f.id !== featureId))
68
+
69
+ // Close editor if this feature is open
70
+ if (editingFeature?.id === featureId) {
71
+ setEditingFeature(null)
72
+ }
73
+
74
+ // Push onto the undo stack
75
+ const id = String(nextIdRef.current++)
76
+ setPendingDeletes(prev => [...prev, { id, feature }])
77
+ }, [editingFeature, setFeatures])
78
+
79
+ const commitDelete = useCallback((entryId: string) => {
80
+ const entry = pendingDeletesRef.current.find(d => d.id === entryId)
81
+ if (!entry) return
82
+ vscode.postMessage({ type: 'deleteFeature', featureId: entry.feature.id })
83
+ setPendingDeletes(prev => prev.filter(d => d.id !== entryId))
84
+ }, [])
85
+
86
+ const handleUndoDelete = useCallback((entryId: string) => {
87
+ const entry = pendingDeletesRef.current.find(d => d.id === entryId)
88
+ if (!entry) return
89
+ // Restore the feature
90
+ const { features } = useStore.getState()
91
+ setFeatures([...features, entry.feature])
92
+ setPendingDeletes(prev => prev.filter(d => d.id !== entryId))
93
+ }, [setFeatures])
94
+
95
+ const handleUndoLatest = useCallback(() => {
96
+ const stack = pendingDeletesRef.current
97
+ if (stack.length === 0) return
98
+ handleUndoDelete(stack[stack.length - 1].id)
99
+ }, [handleUndoDelete])
100
+
101
+ // Keyboard shortcuts
102
+ useEffect(() => {
103
+ let altPressedAlone = false
104
+ let altDownTimer: ReturnType<typeof setTimeout> | null = null
105
+
106
+ const handleKeyDown = (e: KeyboardEvent) => {
107
+ // Track bare ALT press to forward to VS Code menu bar
108
+ if (e.key === 'Alt') {
109
+ altPressedAlone = true
110
+ // If ALT is held >1s it's likely a modifier hold or window drag, not a menu toggle
111
+ if (altDownTimer) clearTimeout(altDownTimer)
112
+ altDownTimer = setTimeout(() => { altPressedAlone = false }, 1000)
113
+ return
114
+ }
115
+ if (e.altKey) {
116
+ altPressedAlone = false
117
+ if (altDownTimer) { clearTimeout(altDownTimer); altDownTimer = null }
118
+ }
119
+
120
+ // Ctrl/Cmd+Z to undo delete (works even in inputs)
121
+ if (e.key === 'z' && (e.ctrlKey || e.metaKey) && !e.shiftKey && pendingDeletesRef.current.length > 0) {
122
+ e.preventDefault()
123
+ handleUndoLatest()
124
+ return
125
+ }
126
+
127
+ // Ignore if user is typing in an input or contentEditable (e.g. TipTap editor)
128
+ if (
129
+ e.target instanceof HTMLInputElement ||
130
+ e.target instanceof HTMLTextAreaElement ||
131
+ e.target instanceof HTMLSelectElement ||
132
+ (e.target instanceof HTMLElement && e.target.isContentEditable)
133
+ ) {
134
+ return
135
+ }
136
+
137
+ switch (e.key) {
138
+ case 'n':
139
+ if (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) {
140
+ return
141
+ }
142
+ e.preventDefault()
143
+ setCreateFeatureStatus('backlog')
144
+ setCreateFeatureOpen(true)
145
+ break
146
+ case 'Escape':
147
+ if (createFeatureOpen) {
148
+ setCreateFeatureOpen(false)
149
+ }
150
+ break
151
+ }
152
+ }
153
+
154
+ const handleKeyUp = (e: KeyboardEvent) => {
155
+ if (e.key === 'Alt' && altPressedAlone) {
156
+ altPressedAlone = false
157
+ if (altDownTimer) { clearTimeout(altDownTimer); altDownTimer = null }
158
+ vscode.postMessage({ type: 'focusMenuBar' })
159
+ }
160
+ }
161
+
162
+ // Cancel ALT-alone if mouse is clicked while ALT is held (e.g. ALT+click window drag on Linux)
163
+ const handleMouseDown = () => { altPressedAlone = false }
164
+
165
+ window.addEventListener('keydown', handleKeyDown)
166
+ window.addEventListener('keyup', handleKeyUp)
167
+ window.addEventListener('mousedown', handleMouseDown)
168
+ return () => {
169
+ window.removeEventListener('keydown', handleKeyDown)
170
+ window.removeEventListener('keyup', handleKeyUp)
171
+ window.removeEventListener('mousedown', handleMouseDown)
172
+ if (altDownTimer) clearTimeout(altDownTimer)
173
+ }
174
+ }, [createFeatureOpen, handleUndoLatest])
175
+
176
+ // Listen for VSCode theme changes
177
+ useEffect(() => {
178
+ const updateTheme = () => {
179
+ const isDark = document.body.classList.contains('vscode-dark') ||
180
+ document.body.classList.contains('vscode-high-contrast')
181
+ setIsDarkMode(isDark)
182
+ if (isDark) {
183
+ document.documentElement.classList.add('dark')
184
+ } else {
185
+ document.documentElement.classList.remove('dark')
186
+ }
187
+ }
188
+
189
+ updateTheme()
190
+
191
+ // Watch for class changes on body
192
+ const observer = new MutationObserver(updateTheme)
193
+ observer.observe(document.body, { attributes: true, attributeFilter: ['class'] })
194
+
195
+ return () => observer.disconnect()
196
+ }, [setIsDarkMode])
197
+
198
+ // Listen for messages from extension
199
+ useEffect(() => {
200
+ const handleMessage = (event: MessageEvent<ExtensionMessage>) => {
201
+ const message = event.data
202
+ if (!message || typeof message.type !== 'string') return
203
+
204
+ switch (message.type) {
205
+ case 'init':
206
+ setFeatures(message.features)
207
+ setColumns(message.columns)
208
+ if (message.settings) {
209
+ if (message.settings.markdownEditorMode && editingFeature) {
210
+ setEditingFeature(null)
211
+ }
212
+ setCardSettings(message.settings)
213
+ }
214
+ break
215
+ case 'featuresUpdated':
216
+ setFeatures(message.features)
217
+ break
218
+ case 'triggerCreateDialog':
219
+ setCreateFeatureStatus('backlog')
220
+ setCreateFeatureOpen(true)
221
+ break
222
+ case 'showSettings':
223
+ setCardSettings(message.settings)
224
+ setSettingsOpen(true)
225
+ break
226
+ case 'featureContent': {
227
+ const { cardSettings } = useStore.getState()
228
+ if (cardSettings.markdownEditorMode) break
229
+ contentVersionRef.current += 1
230
+ setEditingFeature({
231
+ id: message.featureId,
232
+ content: message.content,
233
+ frontmatter: message.frontmatter,
234
+ contentVersion: contentVersionRef.current
235
+ })
236
+ break
237
+ }
238
+ }
239
+ }
240
+
241
+ window.addEventListener('message', handleMessage)
242
+
243
+ // Tell extension we're ready
244
+ vscode.postMessage({ type: 'ready' })
245
+
246
+ return () => window.removeEventListener('message', handleMessage)
247
+ }, [setFeatures, setColumns, setCardSettings, setSettingsOpen])
248
+
249
+ const handleFeatureClick = (feature: Feature): void => {
250
+ // Request feature content for inline editing
251
+ vscode.postMessage({
252
+ type: 'openFeature',
253
+ featureId: feature.id
254
+ })
255
+ }
256
+
257
+ const handleSaveFeature = (content: string, frontmatter: FeatureFrontmatter): void => {
258
+ if (!editingFeature) return
259
+ vscode.postMessage({
260
+ type: 'saveFeatureContent',
261
+ featureId: editingFeature.id,
262
+ content,
263
+ frontmatter
264
+ })
265
+ }
266
+
267
+ const handleCloseEditor = (): void => {
268
+ setEditingFeature(null)
269
+ vscode.postMessage({ type: 'closeFeature' })
270
+ }
271
+
272
+ const handleDeleteFeature = (): void => {
273
+ if (!editingFeature) return
274
+ handleDeleteFeatureFromCard(editingFeature.id)
275
+ }
276
+
277
+ const handleOpenFile = (): void => {
278
+ if (!editingFeature) return
279
+ vscode.postMessage({ type: 'openFile', featureId: editingFeature.id })
280
+ }
281
+
282
+ const handleStartWithAI = (agent: 'claude' | 'codex' | 'opencode', permissionMode: 'default' | 'plan' | 'acceptEdits' | 'bypassPermissions'): void => {
283
+ vscode.postMessage({ type: 'startWithAI', agent, permissionMode })
284
+ }
285
+
286
+ const handleAddAttachment = (): void => {
287
+ if (!editingFeature) return
288
+ vscode.postMessage({ type: 'addAttachment', featureId: editingFeature.id })
289
+ }
290
+
291
+ const handleOpenAttachment = (attachment: string): void => {
292
+ if (!editingFeature) return
293
+ vscode.postMessage({ type: 'openAttachment', featureId: editingFeature.id, attachment })
294
+ }
295
+
296
+ const handleRemoveAttachment = (attachment: string): void => {
297
+ if (!editingFeature) return
298
+ vscode.postMessage({ type: 'removeAttachment', featureId: editingFeature.id, attachment })
299
+ }
300
+
301
+ const handleSaveSettings = (settings: CardDisplaySettings): void => {
302
+ vscode.postMessage({ type: 'saveSettings', settings })
303
+ }
304
+
305
+ const handleAddColumn = (): void => {
306
+ setEditingColumn(null)
307
+ setColumnDialogOpen(true)
308
+ }
309
+
310
+ const handleEditColumn = (columnId: string): void => {
311
+ const col = columns.find(c => c.id === columnId)
312
+ if (col) {
313
+ setEditingColumn(col)
314
+ setColumnDialogOpen(true)
315
+ }
316
+ }
317
+
318
+ const handleRemoveColumn = (columnId: string): void => {
319
+ const col = columns.find(c => c.id === columnId)
320
+ if (!col) return
321
+ const featuresInColumn = useStore.getState().features.filter(f => f.status === columnId)
322
+ if (featuresInColumn.length > 0) {
323
+ // Don't remove columns that still have features
324
+ return
325
+ }
326
+ vscode.postMessage({ type: 'removeColumn', columnId })
327
+ }
328
+
329
+ const handleSaveColumn = (data: { name: string; color: string }): void => {
330
+ if (editingColumn) {
331
+ vscode.postMessage({ type: 'editColumn', columnId: editingColumn.id, updates: data })
332
+ } else {
333
+ vscode.postMessage({ type: 'addColumn', column: data })
334
+ }
335
+ setColumnDialogOpen(false)
336
+ setEditingColumn(null)
337
+ }
338
+
339
+ const handleAddFeatureInColumn = (status: string): void => {
340
+ setCreateFeatureStatus(status as FeatureStatus)
341
+ setCreateFeatureOpen(true)
342
+ }
343
+
344
+ const handleCreateFeature = (data: {
345
+ status: FeatureStatus
346
+ priority: Priority
347
+ content: string
348
+ }): void => {
349
+ vscode.postMessage({
350
+ type: 'createFeature',
351
+ data
352
+ })
353
+ }
354
+
355
+ const handleMoveFeature = (
356
+ featureId: string,
357
+ newStatus: string,
358
+ newOrder: number
359
+ ): void => {
360
+ // Optimistic update: compute fractional index locally before server confirms
361
+ const { features } = useStore.getState()
362
+ const feature = features.find(f => f.id === featureId)
363
+ if (feature) {
364
+ // Get sorted target column features (excluding the moved feature)
365
+ const targetColumn = features
366
+ .filter(f => f.status === newStatus && f.id !== featureId)
367
+ .sort((a, b) => (a.order < b.order ? -1 : a.order > b.order ? 1 : 0))
368
+
369
+ const clampedOrder = Math.max(0, Math.min(newOrder, targetColumn.length))
370
+ const before = clampedOrder > 0 ? targetColumn[clampedOrder - 1].order : null
371
+ const after = clampedOrder < targetColumn.length ? targetColumn[clampedOrder].order : null
372
+ const newOrderKey = generateKeyBetween(before, after)
373
+
374
+ const updated = features.map(f =>
375
+ f.id === featureId
376
+ ? { ...f, status: newStatus as FeatureStatus, order: newOrderKey }
377
+ : f
378
+ )
379
+ setFeatures(updated)
380
+ }
381
+
382
+ // Tell extension to persist
383
+ vscode.postMessage({
384
+ type: 'moveFeature',
385
+ featureId,
386
+ newStatus,
387
+ newOrder
388
+ })
389
+ }
390
+
391
+ // Show loading if no columns yet
392
+ if (columns.length === 0) {
393
+ return (
394
+ <div className="h-full w-full flex items-center justify-center bg-[var(--vscode-editor-background)]">
395
+ <div className="text-[var(--vscode-foreground)] opacity-60">Loading...</div>
396
+ </div>
397
+ )
398
+ }
399
+
400
+ return (
401
+ <div className="h-full w-full flex flex-col bg-[var(--vscode-editor-background)]">
402
+ <Toolbar onOpenSettings={() => vscode.postMessage({ type: 'openSettings' })} onAddColumn={handleAddColumn} />
403
+ <div className="flex-1 flex overflow-hidden">
404
+ <div className={editingFeature ? 'w-1/2' : 'w-full'}>
405
+ <KanbanBoard
406
+ onFeatureClick={handleFeatureClick}
407
+ onAddFeature={handleAddFeatureInColumn}
408
+ onMoveFeature={handleMoveFeature}
409
+ onEditColumn={handleEditColumn}
410
+ onRemoveColumn={handleRemoveColumn}
411
+ />
412
+ </div>
413
+ {editingFeature && (
414
+ <div className="w-1/2">
415
+ <FeatureEditor
416
+ featureId={editingFeature.id}
417
+ content={editingFeature.content}
418
+ frontmatter={editingFeature.frontmatter}
419
+ contentVersion={editingFeature.contentVersion}
420
+ onSave={handleSaveFeature}
421
+ onClose={handleCloseEditor}
422
+ onDelete={handleDeleteFeature}
423
+ onOpenFile={handleOpenFile}
424
+ onStartWithAI={handleStartWithAI}
425
+ onAddAttachment={handleAddAttachment}
426
+ onOpenAttachment={handleOpenAttachment}
427
+ onRemoveAttachment={handleRemoveAttachment}
428
+ />
429
+ </div>
430
+ )}
431
+ </div>
432
+
433
+ <CreateFeatureDialog
434
+ isOpen={createFeatureOpen}
435
+ onClose={() => setCreateFeatureOpen(false)}
436
+ onCreate={handleCreateFeature}
437
+ initialStatus={createFeatureStatus}
438
+ />
439
+
440
+ <SettingsPanel
441
+ isOpen={settingsOpen}
442
+ settings={cardSettings}
443
+ onClose={() => setSettingsOpen(false)}
444
+ onSave={handleSaveSettings}
445
+ />
446
+
447
+ <ColumnDialog
448
+ isOpen={columnDialogOpen}
449
+ onClose={() => { setColumnDialogOpen(false); setEditingColumn(null) }}
450
+ onSave={handleSaveColumn}
451
+ initial={editingColumn ? { name: editingColumn.name, color: editingColumn.color } : undefined}
452
+ title={editingColumn ? 'Edit List' : 'Add List'}
453
+ />
454
+
455
+ {pendingDeletes.map((entry, i) => (
456
+ <UndoToast
457
+ key={entry.id}
458
+ message={`Deleted "${getTitleFromContent(entry.feature.content)}"`}
459
+ onUndo={() => handleUndoDelete(entry.id)}
460
+ onExpire={() => commitDelete(entry.id)}
461
+ duration={5000}
462
+ index={i}
463
+ />
464
+ ))}
465
+ </div>
466
+ )
467
+ }
468
+
469
+ export default App