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.
- package/.env.example +35 -0
- package/BRANDING.md +131 -0
- package/CLAUDE.md +252 -0
- package/README.md +262 -0
- package/SETUP.md +337 -0
- package/app/agents/[id]/page.tsx +727 -0
- package/app/api/agents/route.ts +12 -0
- package/app/api/chat/[id]/route.ts +139 -0
- package/app/api/cron-runs/route.ts +13 -0
- package/app/api/crons/route.ts +12 -0
- package/app/api/kanban/chat/[id]/route.ts +119 -0
- package/app/api/kanban/chat-history/[ticketId]/route.ts +36 -0
- package/app/api/memory/route.ts +12 -0
- package/app/api/transcribe/route.ts +37 -0
- package/app/api/tts/route.ts +42 -0
- package/app/chat/[id]/page.tsx +10 -0
- package/app/chat/page.tsx +200 -0
- package/app/crons/page.tsx +870 -0
- package/app/docs/page.tsx +399 -0
- package/app/favicon.ico +0 -0
- package/app/globals.css +692 -0
- package/app/kanban/page.tsx +327 -0
- package/app/layout.tsx +45 -0
- package/app/memory/page.tsx +685 -0
- package/app/page.tsx +817 -0
- package/app/providers.tsx +37 -0
- package/app/settings/page.tsx +901 -0
- package/app/settings-provider.tsx +209 -0
- package/components/AgentAvatar.tsx +54 -0
- package/components/AgentNode.tsx +122 -0
- package/components/Breadcrumbs.tsx +126 -0
- package/components/DynamicFavicon.tsx +62 -0
- package/components/ErrorState.tsx +97 -0
- package/components/FeedView.tsx +494 -0
- package/components/GlobalSearch.tsx +571 -0
- package/components/GridView.tsx +532 -0
- package/components/ManorMap.tsx +157 -0
- package/components/MobileSidebar.tsx +251 -0
- package/components/NavLinks.tsx +271 -0
- package/components/OnboardingWizard.tsx +1067 -0
- package/components/Sidebar.tsx +115 -0
- package/components/ThemeToggle.tsx +108 -0
- package/components/chat/AgentList.tsx +537 -0
- package/components/chat/ConversationView.tsx +1047 -0
- package/components/chat/FileAttachment.tsx +140 -0
- package/components/chat/MediaPreview.tsx +111 -0
- package/components/chat/VoiceMessage.tsx +139 -0
- package/components/crons/PipelineGraph.tsx +327 -0
- package/components/crons/WeeklySchedule.tsx +630 -0
- package/components/docs/AgentsSection.tsx +209 -0
- package/components/docs/ApiReferenceSection.tsx +256 -0
- package/components/docs/ArchitectureSection.tsx +221 -0
- package/components/docs/ComponentsSection.tsx +253 -0
- package/components/docs/CronSystemSection.tsx +235 -0
- package/components/docs/DocSection.tsx +346 -0
- package/components/docs/GettingStartedSection.tsx +169 -0
- package/components/docs/ThemingSection.tsx +257 -0
- package/components/docs/TroubleshootingSection.tsx +200 -0
- package/components/kanban/AgentPicker.tsx +321 -0
- package/components/kanban/CreateTicketModal.tsx +333 -0
- package/components/kanban/KanbanBoard.tsx +70 -0
- package/components/kanban/KanbanColumn.tsx +166 -0
- package/components/kanban/TicketCard.tsx +245 -0
- package/components/kanban/TicketDetailPanel.tsx +850 -0
- package/components/ui/badge.tsx +48 -0
- package/components/ui/button.tsx +64 -0
- package/components/ui/card.tsx +92 -0
- package/components/ui/dialog.tsx +158 -0
- package/components/ui/scroll-area.tsx +58 -0
- package/components/ui/separator.tsx +28 -0
- package/components/ui/skeleton.tsx +27 -0
- package/components/ui/tabs.tsx +91 -0
- package/components/ui/tooltip.tsx +57 -0
- package/components.json +23 -0
- package/docs/API.md +648 -0
- package/docs/COMPONENTS.md +1059 -0
- package/docs/THEMING.md +795 -0
- package/lib/agents-registry.ts +35 -0
- package/lib/agents.json +282 -0
- package/lib/agents.test.ts +367 -0
- package/lib/agents.ts +32 -0
- package/lib/anthropic.test.ts +422 -0
- package/lib/anthropic.ts +220 -0
- package/lib/api-error.ts +16 -0
- package/lib/audio-recorder.test.ts +72 -0
- package/lib/audio-recorder.ts +169 -0
- package/lib/conversations.test.ts +331 -0
- package/lib/conversations.ts +117 -0
- package/lib/cron-pipelines.test.ts +69 -0
- package/lib/cron-pipelines.ts +58 -0
- package/lib/cron-runs.test.ts +118 -0
- package/lib/cron-runs.ts +67 -0
- package/lib/cron-utils.test.ts +222 -0
- package/lib/cron-utils.ts +160 -0
- package/lib/crons.test.ts +502 -0
- package/lib/crons.ts +114 -0
- package/lib/env.test.ts +44 -0
- package/lib/env.ts +14 -0
- package/lib/kanban/automation.test.ts +245 -0
- package/lib/kanban/automation.ts +143 -0
- package/lib/kanban/chat-store.test.ts +149 -0
- package/lib/kanban/chat-store.ts +81 -0
- package/lib/kanban/store.test.ts +238 -0
- package/lib/kanban/store.ts +98 -0
- package/lib/kanban/types.ts +50 -0
- package/lib/kanban/useAgentWork.ts +78 -0
- package/lib/memory.ts +45 -0
- package/lib/multimodal.test.ts +219 -0
- package/lib/multimodal.ts +68 -0
- package/lib/pipeline.integration.test.ts +343 -0
- package/lib/sanitize.ts +194 -0
- package/lib/settings.test.ts +137 -0
- package/lib/settings.ts +94 -0
- package/lib/styles.ts +24 -0
- package/lib/themes.ts +9 -0
- package/lib/transcribe.test.ts +141 -0
- package/lib/transcribe.ts +111 -0
- package/lib/types.ts +66 -0
- package/lib/utils.ts +6 -0
- package/lib/validation.test.ts +132 -0
- package/lib/validation.ts +80 -0
- package/next.config.ts +7 -0
- package/package.json +56 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/scripts/setup.mjs +215 -0
- package/tsconfig.json +34 -0
- package/vitest.config.ts +17 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
loadTickets,
|
|
4
|
+
saveTickets,
|
|
5
|
+
createTicket,
|
|
6
|
+
updateTicket,
|
|
7
|
+
moveTicket,
|
|
8
|
+
deleteTicket,
|
|
9
|
+
getTicketsByStatus,
|
|
10
|
+
type KanbanStore,
|
|
11
|
+
} from './store'
|
|
12
|
+
|
|
13
|
+
// Mock localStorage
|
|
14
|
+
const storage: Record<string, string> = {}
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
Object.keys(storage).forEach((k) => delete storage[k])
|
|
17
|
+
vi.stubGlobal('localStorage', {
|
|
18
|
+
getItem: (key: string) => storage[key] ?? null,
|
|
19
|
+
setItem: (key: string, val: string) => { storage[key] = val },
|
|
20
|
+
removeItem: (key: string) => { delete storage[key] },
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Mock crypto.randomUUID
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
let counter = 0
|
|
27
|
+
vi.stubGlobal('crypto', {
|
|
28
|
+
randomUUID: () => `test-uuid-${++counter}`,
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
// Default work state fields for test tickets
|
|
33
|
+
const WORK_DEFAULTS = { workState: 'idle' as const, workStartedAt: null, workError: null, workResult: null }
|
|
34
|
+
|
|
35
|
+
describe('loadTickets', () => {
|
|
36
|
+
it('returns empty object when nothing stored', () => {
|
|
37
|
+
expect(loadTickets()).toEqual({})
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('returns parsed data from localStorage', () => {
|
|
41
|
+
const data = { 'id-1': { id: 'id-1', title: 'Test' } }
|
|
42
|
+
storage['clawport-kanban'] = JSON.stringify(data)
|
|
43
|
+
const loaded = loadTickets()
|
|
44
|
+
expect(loaded['id-1'].id).toBe('id-1')
|
|
45
|
+
expect(loaded['id-1'].title).toBe('Test')
|
|
46
|
+
// Backfilled work state fields
|
|
47
|
+
expect(loaded['id-1'].workState).toBe('idle')
|
|
48
|
+
expect(loaded['id-1'].workStartedAt).toBeNull()
|
|
49
|
+
expect(loaded['id-1'].workError).toBeNull()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('returns empty object on invalid JSON', () => {
|
|
53
|
+
storage['clawport-kanban'] = 'not-json'
|
|
54
|
+
expect(loadTickets()).toEqual({})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
describe('saveTickets', () => {
|
|
59
|
+
it('persists to localStorage', () => {
|
|
60
|
+
const store: KanbanStore = {}
|
|
61
|
+
saveTickets(store)
|
|
62
|
+
expect(storage['clawport-kanban']).toBe('{}')
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('createTicket', () => {
|
|
67
|
+
it('adds a ticket with generated id and timestamps', () => {
|
|
68
|
+
const store: KanbanStore = {}
|
|
69
|
+
const result = createTicket(store, {
|
|
70
|
+
title: 'New ticket',
|
|
71
|
+
description: 'Do the thing',
|
|
72
|
+
status: 'backlog',
|
|
73
|
+
priority: 'medium',
|
|
74
|
+
assigneeId: null,
|
|
75
|
+
assigneeRole: null,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const ticket = result['test-uuid-1']
|
|
79
|
+
expect(ticket).toBeDefined()
|
|
80
|
+
expect(ticket.title).toBe('New ticket')
|
|
81
|
+
expect(ticket.status).toBe('backlog')
|
|
82
|
+
expect(ticket.id).toBe('test-uuid-1')
|
|
83
|
+
expect(ticket.createdAt).toBeTypeOf('number')
|
|
84
|
+
expect(ticket.updatedAt).toBe(ticket.createdAt)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('preserves existing tickets', () => {
|
|
88
|
+
const store: KanbanStore = {
|
|
89
|
+
existing: {
|
|
90
|
+
id: 'existing',
|
|
91
|
+
title: 'Existing',
|
|
92
|
+
description: '',
|
|
93
|
+
status: 'todo',
|
|
94
|
+
priority: 'low',
|
|
95
|
+
assigneeId: null,
|
|
96
|
+
assigneeRole: null,
|
|
97
|
+
...WORK_DEFAULTS,
|
|
98
|
+
createdAt: 1000,
|
|
99
|
+
updatedAt: 1000,
|
|
100
|
+
},
|
|
101
|
+
}
|
|
102
|
+
const result = createTicket(store, {
|
|
103
|
+
title: 'New',
|
|
104
|
+
description: '',
|
|
105
|
+
status: 'backlog',
|
|
106
|
+
priority: 'medium',
|
|
107
|
+
assigneeId: null,
|
|
108
|
+
assigneeRole: null,
|
|
109
|
+
})
|
|
110
|
+
expect(result['existing']).toBeDefined()
|
|
111
|
+
expect(Object.keys(result)).toHaveLength(2)
|
|
112
|
+
})
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
describe('updateTicket', () => {
|
|
116
|
+
const baseStore: KanbanStore = {
|
|
117
|
+
't1': {
|
|
118
|
+
id: 't1',
|
|
119
|
+
title: 'Original',
|
|
120
|
+
description: 'Desc',
|
|
121
|
+
status: 'backlog',
|
|
122
|
+
priority: 'low',
|
|
123
|
+
assigneeId: null,
|
|
124
|
+
assigneeRole: null,
|
|
125
|
+
...WORK_DEFAULTS,
|
|
126
|
+
createdAt: 1000,
|
|
127
|
+
updatedAt: 1000,
|
|
128
|
+
},
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
it('updates specified fields', () => {
|
|
132
|
+
const result = updateTicket(baseStore, 't1', { title: 'Updated' })
|
|
133
|
+
expect(result['t1'].title).toBe('Updated')
|
|
134
|
+
expect(result['t1'].description).toBe('Desc')
|
|
135
|
+
expect(result['t1'].updatedAt).toBeGreaterThan(1000)
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('returns store unchanged for missing ticket', () => {
|
|
139
|
+
const result = updateTicket(baseStore, 'missing', { title: 'X' })
|
|
140
|
+
expect(result).toBe(baseStore)
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
describe('moveTicket', () => {
|
|
145
|
+
it('changes ticket status', () => {
|
|
146
|
+
const store: KanbanStore = {
|
|
147
|
+
't1': {
|
|
148
|
+
id: 't1',
|
|
149
|
+
title: 'Task',
|
|
150
|
+
description: '',
|
|
151
|
+
status: 'backlog',
|
|
152
|
+
priority: 'medium',
|
|
153
|
+
assigneeId: null,
|
|
154
|
+
assigneeRole: null,
|
|
155
|
+
...WORK_DEFAULTS,
|
|
156
|
+
createdAt: 1000,
|
|
157
|
+
updatedAt: 1000,
|
|
158
|
+
},
|
|
159
|
+
}
|
|
160
|
+
const result = moveTicket(store, 't1', 'in-progress')
|
|
161
|
+
expect(result['t1'].status).toBe('in-progress')
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('deleteTicket', () => {
|
|
166
|
+
it('removes the ticket', () => {
|
|
167
|
+
const store: KanbanStore = {
|
|
168
|
+
't1': {
|
|
169
|
+
id: 't1',
|
|
170
|
+
title: 'Task',
|
|
171
|
+
description: '',
|
|
172
|
+
status: 'backlog',
|
|
173
|
+
priority: 'medium',
|
|
174
|
+
assigneeId: null,
|
|
175
|
+
assigneeRole: null,
|
|
176
|
+
...WORK_DEFAULTS,
|
|
177
|
+
createdAt: 1000,
|
|
178
|
+
updatedAt: 1000,
|
|
179
|
+
},
|
|
180
|
+
}
|
|
181
|
+
const result = deleteTicket(store, 't1')
|
|
182
|
+
expect(result['t1']).toBeUndefined()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('preserves other tickets', () => {
|
|
186
|
+
const store: KanbanStore = {
|
|
187
|
+
't1': {
|
|
188
|
+
id: 't1', title: 'A', description: '', status: 'backlog',
|
|
189
|
+
priority: 'low', assigneeId: null, assigneeRole: null,
|
|
190
|
+
...WORK_DEFAULTS, createdAt: 1000, updatedAt: 1000,
|
|
191
|
+
},
|
|
192
|
+
't2': {
|
|
193
|
+
id: 't2', title: 'B', description: '', status: 'todo',
|
|
194
|
+
priority: 'high', assigneeId: null, assigneeRole: null,
|
|
195
|
+
...WORK_DEFAULTS, createdAt: 2000, updatedAt: 2000,
|
|
196
|
+
},
|
|
197
|
+
}
|
|
198
|
+
const result = deleteTicket(store, 't1')
|
|
199
|
+
expect(result['t2']).toBeDefined()
|
|
200
|
+
expect(Object.keys(result)).toHaveLength(1)
|
|
201
|
+
})
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
describe('getTicketsByStatus', () => {
|
|
205
|
+
const store: KanbanStore = {
|
|
206
|
+
't1': {
|
|
207
|
+
id: 't1', title: 'Old', description: '', status: 'backlog',
|
|
208
|
+
priority: 'low', assigneeId: null, assigneeRole: null,
|
|
209
|
+
...WORK_DEFAULTS, createdAt: 1000, updatedAt: 1000,
|
|
210
|
+
},
|
|
211
|
+
't2': {
|
|
212
|
+
id: 't2', title: 'New', description: '', status: 'backlog',
|
|
213
|
+
priority: 'medium', assigneeId: null, assigneeRole: null,
|
|
214
|
+
...WORK_DEFAULTS, createdAt: 2000, updatedAt: 3000,
|
|
215
|
+
},
|
|
216
|
+
't3': {
|
|
217
|
+
id: 't3', title: 'Other', description: '', status: 'todo',
|
|
218
|
+
priority: 'high', assigneeId: null, assigneeRole: null,
|
|
219
|
+
...WORK_DEFAULTS, createdAt: 1500, updatedAt: 1500,
|
|
220
|
+
},
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
it('filters by status', () => {
|
|
224
|
+
const backlog = getTicketsByStatus(store, 'backlog')
|
|
225
|
+
expect(backlog).toHaveLength(2)
|
|
226
|
+
expect(backlog.every((t) => t.status === 'backlog')).toBe(true)
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
it('sorts by updatedAt descending', () => {
|
|
230
|
+
const backlog = getTicketsByStatus(store, 'backlog')
|
|
231
|
+
expect(backlog[0].id).toBe('t2')
|
|
232
|
+
expect(backlog[1].id).toBe('t1')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('returns empty array for empty column', () => {
|
|
236
|
+
expect(getTicketsByStatus(store, 'done')).toEqual([])
|
|
237
|
+
})
|
|
238
|
+
})
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import type { KanbanTicket, TicketStatus } from './types'
|
|
4
|
+
|
|
5
|
+
export type KanbanStore = Record<string, KanbanTicket>
|
|
6
|
+
|
|
7
|
+
const STORAGE_KEY = 'clawport-kanban'
|
|
8
|
+
|
|
9
|
+
export function loadTickets(): KanbanStore {
|
|
10
|
+
if (typeof window === 'undefined') return {}
|
|
11
|
+
try {
|
|
12
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
13
|
+
if (!raw) return {}
|
|
14
|
+
const store: KanbanStore = JSON.parse(raw)
|
|
15
|
+
// Backfill work state fields for existing tickets
|
|
16
|
+
for (const id of Object.keys(store)) {
|
|
17
|
+
store[id] = {
|
|
18
|
+
...store[id],
|
|
19
|
+
workState: store[id].workState ?? 'idle',
|
|
20
|
+
workStartedAt: store[id].workStartedAt ?? null,
|
|
21
|
+
workError: store[id].workError ?? null,
|
|
22
|
+
workResult: store[id].workResult ?? null,
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return store
|
|
26
|
+
} catch {
|
|
27
|
+
return {}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveTickets(store: KanbanStore): void {
|
|
32
|
+
if (typeof window === 'undefined') return
|
|
33
|
+
try {
|
|
34
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(store))
|
|
35
|
+
} catch {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createTicket(
|
|
39
|
+
store: KanbanStore,
|
|
40
|
+
ticket: Omit<KanbanTicket, 'id' | 'createdAt' | 'updatedAt' | 'workState' | 'workStartedAt' | 'workError' | 'workResult'> & {
|
|
41
|
+
workState?: KanbanTicket['workState']
|
|
42
|
+
workStartedAt?: KanbanTicket['workStartedAt']
|
|
43
|
+
workError?: KanbanTicket['workError']
|
|
44
|
+
workResult?: KanbanTicket['workResult']
|
|
45
|
+
},
|
|
46
|
+
): KanbanStore {
|
|
47
|
+
const id = crypto.randomUUID()
|
|
48
|
+
const now = Date.now()
|
|
49
|
+
return {
|
|
50
|
+
...store,
|
|
51
|
+
[id]: {
|
|
52
|
+
...ticket,
|
|
53
|
+
id,
|
|
54
|
+
workState: ticket.workState ?? 'idle',
|
|
55
|
+
workStartedAt: ticket.workStartedAt ?? null,
|
|
56
|
+
workError: ticket.workError ?? null,
|
|
57
|
+
workResult: ticket.workResult ?? null,
|
|
58
|
+
createdAt: now,
|
|
59
|
+
updatedAt: now,
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function updateTicket(
|
|
65
|
+
store: KanbanStore,
|
|
66
|
+
id: string,
|
|
67
|
+
updates: Partial<Omit<KanbanTicket, 'id' | 'createdAt'>>,
|
|
68
|
+
): KanbanStore {
|
|
69
|
+
const existing = store[id]
|
|
70
|
+
if (!existing) return store
|
|
71
|
+
return {
|
|
72
|
+
...store,
|
|
73
|
+
[id]: { ...existing, ...updates, updatedAt: Date.now() },
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function moveTicket(
|
|
78
|
+
store: KanbanStore,
|
|
79
|
+
id: string,
|
|
80
|
+
status: TicketStatus,
|
|
81
|
+
): KanbanStore {
|
|
82
|
+
return updateTicket(store, id, { status })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function deleteTicket(store: KanbanStore, id: string): KanbanStore {
|
|
86
|
+
const next = { ...store }
|
|
87
|
+
delete next[id]
|
|
88
|
+
return next
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getTicketsByStatus(
|
|
92
|
+
store: KanbanStore,
|
|
93
|
+
status: TicketStatus,
|
|
94
|
+
): KanbanTicket[] {
|
|
95
|
+
return Object.values(store)
|
|
96
|
+
.filter((t) => t.status === status)
|
|
97
|
+
.sort((a, b) => b.updatedAt - a.updatedAt)
|
|
98
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// Kanban board types
|
|
2
|
+
|
|
3
|
+
export type TicketStatus = 'backlog' | 'todo' | 'in-progress' | 'review' | 'done'
|
|
4
|
+
|
|
5
|
+
export type TicketPriority = 'low' | 'medium' | 'high'
|
|
6
|
+
|
|
7
|
+
export type TeamRole = 'lead-dev' | 'ux-ui' | 'qa'
|
|
8
|
+
|
|
9
|
+
export type WorkState = 'idle' | 'starting' | 'working' | 'done' | 'failed'
|
|
10
|
+
|
|
11
|
+
export interface KanbanTicket {
|
|
12
|
+
id: string
|
|
13
|
+
title: string
|
|
14
|
+
description: string
|
|
15
|
+
status: TicketStatus
|
|
16
|
+
priority: TicketPriority
|
|
17
|
+
assigneeId: string | null // agent id from agents.json
|
|
18
|
+
assigneeRole: TeamRole | null
|
|
19
|
+
workState: WorkState
|
|
20
|
+
workStartedAt: number | null
|
|
21
|
+
workError: string | null
|
|
22
|
+
workResult: string | null
|
|
23
|
+
createdAt: number
|
|
24
|
+
updatedAt: number
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface KanbanColumn {
|
|
28
|
+
id: TicketStatus
|
|
29
|
+
title: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const COLUMNS: KanbanColumn[] = [
|
|
33
|
+
{ id: 'backlog', title: 'Backlog' },
|
|
34
|
+
{ id: 'todo', title: 'To Do' },
|
|
35
|
+
{ id: 'in-progress', title: 'In Progress' },
|
|
36
|
+
{ id: 'review', title: 'Review' },
|
|
37
|
+
{ id: 'done', title: 'Done' },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
export const PRIORITY_COLORS: Record<TicketPriority, string> = {
|
|
41
|
+
low: 'var(--system-green)',
|
|
42
|
+
medium: 'var(--system-orange)',
|
|
43
|
+
high: 'var(--system-red)',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const ROLE_LABELS: Record<TeamRole, string> = {
|
|
47
|
+
'lead-dev': 'Lead Dev',
|
|
48
|
+
'ux-ui': 'UX/UI Lead',
|
|
49
|
+
'qa': 'QA',
|
|
50
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useRef, useCallback } from 'react'
|
|
4
|
+
import type { KanbanTicket, TicketStatus } from './types'
|
|
5
|
+
import type { KanbanStore } from './store'
|
|
6
|
+
import { executeWork, getWorkPrompt, persistWorkChat } from './automation'
|
|
7
|
+
|
|
8
|
+
interface UseAgentWorkOptions {
|
|
9
|
+
tickets: KanbanStore
|
|
10
|
+
onUpdateTicket: (ticketId: string, updates: Partial<KanbanTicket>) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function useAgentWork({ tickets, onUpdateTicket }: UseAgentWorkOptions) {
|
|
14
|
+
const activeWork = useRef<Set<string>>(new Set())
|
|
15
|
+
|
|
16
|
+
const runWork = useCallback(async (ticket: KanbanTicket) => {
|
|
17
|
+
const { id, assigneeId } = ticket
|
|
18
|
+
if (!assigneeId) return
|
|
19
|
+
|
|
20
|
+
// Move to in-progress + set working state
|
|
21
|
+
onUpdateTicket(id, {
|
|
22
|
+
status: 'in-progress' as TicketStatus,
|
|
23
|
+
workState: 'working',
|
|
24
|
+
workStartedAt: Date.now(),
|
|
25
|
+
workError: null,
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const result = await executeWork(assigneeId, ticket)
|
|
29
|
+
|
|
30
|
+
if (result.success) {
|
|
31
|
+
// Save chat history so TicketDetailPanel picks it up
|
|
32
|
+
const prompt = getWorkPrompt(ticket)
|
|
33
|
+
persistWorkChat(id, prompt, result.content)
|
|
34
|
+
|
|
35
|
+
// Move to review with result
|
|
36
|
+
onUpdateTicket(id, {
|
|
37
|
+
status: 'review' as TicketStatus,
|
|
38
|
+
workState: 'done',
|
|
39
|
+
workResult: result.content,
|
|
40
|
+
})
|
|
41
|
+
} else {
|
|
42
|
+
// Stay in-progress with error
|
|
43
|
+
onUpdateTicket(id, {
|
|
44
|
+
workState: 'failed',
|
|
45
|
+
workError: result.error || 'Agent work failed',
|
|
46
|
+
})
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
activeWork.current.delete(id)
|
|
50
|
+
}, [onUpdateTicket])
|
|
51
|
+
|
|
52
|
+
// Scan for eligible tickets
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
const eligible = Object.values(tickets).filter(
|
|
55
|
+
(t) => t.status === 'todo' && t.assigneeId && t.workState === 'idle',
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
for (const ticket of eligible) {
|
|
59
|
+
if (activeWork.current.has(ticket.id)) continue
|
|
60
|
+
|
|
61
|
+
// Mark as active immediately to prevent double-execution
|
|
62
|
+
activeWork.current.add(ticket.id)
|
|
63
|
+
|
|
64
|
+
// Set starting state synchronously to prevent re-triggers on next render
|
|
65
|
+
onUpdateTicket(ticket.id, { workState: 'starting' })
|
|
66
|
+
|
|
67
|
+
// Fire async work
|
|
68
|
+
runWork(ticket)
|
|
69
|
+
}
|
|
70
|
+
}, [tickets, onUpdateTicket, runWork])
|
|
71
|
+
|
|
72
|
+
const isWorking = useCallback(
|
|
73
|
+
(ticketId: string): boolean => activeWork.current.has(ticketId),
|
|
74
|
+
[],
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
return { isWorking }
|
|
78
|
+
}
|
package/lib/memory.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { MemoryFile } from '@/lib/types'
|
|
2
|
+
import { readFileSync, existsSync, statSync } from 'fs'
|
|
3
|
+
import { requireEnv } from '@/lib/env'
|
|
4
|
+
|
|
5
|
+
function readMemoryFile(label: string, filePath: string): MemoryFile | null {
|
|
6
|
+
try {
|
|
7
|
+
if (!existsSync(filePath)) return null
|
|
8
|
+
const content = readFileSync(filePath, 'utf-8')
|
|
9
|
+
const stats = statSync(filePath)
|
|
10
|
+
return {
|
|
11
|
+
label,
|
|
12
|
+
path: filePath,
|
|
13
|
+
content,
|
|
14
|
+
lastModified: stats.mtime.toISOString(),
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
return null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function getMemoryFiles(): Promise<MemoryFile[]> {
|
|
22
|
+
const workspacePath = requireEnv('WORKSPACE_PATH')
|
|
23
|
+
const today = new Date()
|
|
24
|
+
const yesterday = new Date(today)
|
|
25
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
26
|
+
|
|
27
|
+
const todayStr = today.toISOString().slice(0, 10)
|
|
28
|
+
const yesterdayStr = yesterday.toISOString().slice(0, 10)
|
|
29
|
+
|
|
30
|
+
const candidates: [string, string][] = [
|
|
31
|
+
['Long-Term Memory (Jarvis)', workspacePath + '/MEMORY.md'],
|
|
32
|
+
['Team Memory', workspacePath + '/memory/team-memory.md'],
|
|
33
|
+
['Team Intel (JSON)', workspacePath + '/memory/team-intel.json'],
|
|
34
|
+
['Daily Log (Today)', workspacePath + '/memory/' + todayStr + '.md'],
|
|
35
|
+
['Daily Log (Yesterday)', workspacePath + '/memory/' + yesterdayStr + '.md'],
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
const files: MemoryFile[] = []
|
|
39
|
+
for (const [label, path] of candidates) {
|
|
40
|
+
const file = readMemoryFile(label, path)
|
|
41
|
+
if (file) files.push(file)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return files
|
|
45
|
+
}
|