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,245 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
2
+ import { getWorkPrompt, executeWork, persistWorkChat } from './automation'
3
+ import type { KanbanTicket } from './types'
4
+
5
+ /* ── Helpers ─────────────────────────────────────────── */
6
+
7
+ function makeTicket(overrides: Partial<KanbanTicket> = {}): KanbanTicket {
8
+ return {
9
+ id: 'ticket-1',
10
+ title: 'Build login page',
11
+ description: 'Implement login with email/password',
12
+ status: 'todo',
13
+ priority: 'high',
14
+ assigneeId: 'agent-1',
15
+ assigneeRole: 'lead-dev',
16
+ workState: 'idle',
17
+ workStartedAt: null,
18
+ workError: null,
19
+ workResult: null,
20
+ createdAt: 1000,
21
+ updatedAt: 1000,
22
+ ...overrides,
23
+ }
24
+ }
25
+
26
+ // Mock localStorage
27
+ const storage: Record<string, string> = {}
28
+ beforeEach(() => {
29
+ Object.keys(storage).forEach((k) => delete storage[k])
30
+ vi.stubGlobal('localStorage', {
31
+ getItem: (key: string) => storage[key] ?? null,
32
+ setItem: (key: string, val: string) => { storage[key] = val },
33
+ removeItem: (key: string) => { delete storage[key] },
34
+ })
35
+ })
36
+
37
+ // Mock crypto.randomUUID
38
+ beforeEach(() => {
39
+ let counter = 0
40
+ vi.stubGlobal('crypto', {
41
+ randomUUID: () => `test-uuid-${++counter}`,
42
+ })
43
+ })
44
+
45
+ /* ── getWorkPrompt ───────────────────────────────────── */
46
+
47
+ describe('getWorkPrompt', () => {
48
+ it('returns lead-dev prompt for lead-dev role', () => {
49
+ const prompt = getWorkPrompt(makeTicket({ assigneeRole: 'lead-dev' }))
50
+ expect(prompt).toContain('Lead Dev')
51
+ expect(prompt).toContain('Technical breakdown')
52
+ expect(prompt).toContain('Implementation plan')
53
+ expect(prompt).toContain('Build login page')
54
+ })
55
+
56
+ it('returns ux-ui prompt for ux-ui role', () => {
57
+ const prompt = getWorkPrompt(makeTicket({ assigneeRole: 'ux-ui' }))
58
+ expect(prompt).toContain('UX/UI Lead')
59
+ expect(prompt).toContain('Design review')
60
+ expect(prompt).toContain('Accessibility')
61
+ })
62
+
63
+ it('returns qa prompt for qa role', () => {
64
+ const prompt = getWorkPrompt(makeTicket({ assigneeRole: 'qa' }))
65
+ expect(prompt).toContain('QA')
66
+ expect(prompt).toContain('Test scenarios')
67
+ expect(prompt).toContain('Acceptance criteria')
68
+ })
69
+
70
+ it('returns fallback prompt when no role assigned', () => {
71
+ const prompt = getWorkPrompt(makeTicket({ assigneeRole: null }))
72
+ expect(prompt).toContain('Analysis of what needs to be done')
73
+ expect(prompt).toContain('Build login page')
74
+ })
75
+
76
+ it('includes ticket description when present', () => {
77
+ const prompt = getWorkPrompt(makeTicket({ description: 'Custom desc' }))
78
+ expect(prompt).toContain('Description: Custom desc')
79
+ })
80
+
81
+ it('handles empty description', () => {
82
+ const prompt = getWorkPrompt(makeTicket({ description: '' }))
83
+ expect(prompt).toContain('No description provided')
84
+ })
85
+ })
86
+
87
+ /* ── executeWork ─────────────────────────────────────── */
88
+
89
+ describe('executeWork', () => {
90
+ beforeEach(() => {
91
+ vi.restoreAllMocks()
92
+ })
93
+
94
+ it('returns success with streamed content', async () => {
95
+ const sseData = [
96
+ 'data: {"content":"Hello "}\n\n',
97
+ 'data: {"content":"world"}\n\n',
98
+ 'data: [DONE]\n\n',
99
+ ].join('')
100
+
101
+ const stream = new ReadableStream({
102
+ start(controller) {
103
+ controller.enqueue(new TextEncoder().encode(sseData))
104
+ controller.close()
105
+ },
106
+ })
107
+
108
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
109
+ ok: true,
110
+ body: stream,
111
+ }))
112
+
113
+ const result = await executeWork('agent-1', makeTicket())
114
+ expect(result.success).toBe(true)
115
+ expect(result.content).toBe('Hello world')
116
+ })
117
+
118
+ it('calls onChunk for each SSE chunk', async () => {
119
+ const sseData = 'data: {"content":"A"}\n\ndata: {"content":"B"}\n\ndata: [DONE]\n\n'
120
+
121
+ const stream = new ReadableStream({
122
+ start(controller) {
123
+ controller.enqueue(new TextEncoder().encode(sseData))
124
+ controller.close()
125
+ },
126
+ })
127
+
128
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
129
+ ok: true,
130
+ body: stream,
131
+ }))
132
+
133
+ const chunks: string[] = []
134
+ await executeWork('agent-1', makeTicket(), (c) => chunks.push(c))
135
+ expect(chunks).toEqual(['A', 'B'])
136
+ })
137
+
138
+ it('returns error on non-ok response', async () => {
139
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
140
+ ok: false,
141
+ status: 500,
142
+ body: null,
143
+ }))
144
+
145
+ const result = await executeWork('agent-1', makeTicket())
146
+ expect(result.success).toBe(false)
147
+ expect(result.error).toContain('500')
148
+ })
149
+
150
+ it('returns error on empty response', async () => {
151
+ const stream = new ReadableStream({
152
+ start(controller) {
153
+ controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'))
154
+ controller.close()
155
+ },
156
+ })
157
+
158
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
159
+ ok: true,
160
+ body: stream,
161
+ }))
162
+
163
+ const result = await executeWork('agent-1', makeTicket())
164
+ expect(result.success).toBe(false)
165
+ expect(result.error).toContain('Empty response')
166
+ })
167
+
168
+ it('returns error on network failure', async () => {
169
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network down')))
170
+
171
+ const result = await executeWork('agent-1', makeTicket())
172
+ expect(result.success).toBe(false)
173
+ expect(result.error).toBe('Network down')
174
+ })
175
+
176
+ it('skips malformed SSE chunks gracefully', async () => {
177
+ const sseData = 'data: {"content":"Good"}\n\ndata: not-json\n\ndata: {"content":"Also good"}\n\ndata: [DONE]\n\n'
178
+
179
+ const stream = new ReadableStream({
180
+ start(controller) {
181
+ controller.enqueue(new TextEncoder().encode(sseData))
182
+ controller.close()
183
+ },
184
+ })
185
+
186
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
187
+ ok: true,
188
+ body: stream,
189
+ }))
190
+
191
+ const result = await executeWork('agent-1', makeTicket())
192
+ expect(result.success).toBe(true)
193
+ expect(result.content).toBe('GoodAlso good')
194
+ })
195
+ })
196
+
197
+ /* ── persistWorkChat ─────────────────────────────────── */
198
+
199
+ describe('persistWorkChat', () => {
200
+ let fetchMock: ReturnType<typeof vi.fn>
201
+
202
+ beforeEach(() => {
203
+ fetchMock = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({ ok: true }) })
204
+ vi.stubGlobal('fetch', fetchMock)
205
+ })
206
+
207
+ it('posts prompt and response to chat-history API', () => {
208
+ persistWorkChat('ticket-1', 'Do the work', 'Here is the result')
209
+
210
+ expect(fetchMock).toHaveBeenCalledWith(
211
+ '/api/kanban/chat-history/ticket-1',
212
+ expect.objectContaining({
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ }),
216
+ )
217
+
218
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body)
219
+ expect(body.messages).toHaveLength(2)
220
+ expect(body.messages[0].role).toBe('user')
221
+ expect(body.messages[0].content).toBe('Do the work')
222
+ expect(body.messages[1].role).toBe('assistant')
223
+ expect(body.messages[1].content).toBe('Here is the result')
224
+ })
225
+
226
+ it('generates unique IDs for messages', () => {
227
+ persistWorkChat('ticket-1', 'Prompt', 'Response')
228
+
229
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body)
230
+ expect(body.messages[0].id).toBe('test-uuid-1')
231
+ expect(body.messages[1].id).toBe('test-uuid-2')
232
+ })
233
+
234
+ it('sets assistant timestamp 1ms after user timestamp', () => {
235
+ persistWorkChat('ticket-1', 'Prompt', 'Response')
236
+
237
+ const body = JSON.parse(fetchMock.mock.calls[0][1].body)
238
+ expect(body.messages[1].timestamp).toBe(body.messages[0].timestamp + 1)
239
+ })
240
+
241
+ it('does not throw when fetch fails', () => {
242
+ fetchMock.mockRejectedValue(new Error('Network error'))
243
+ expect(() => persistWorkChat('ticket-1', 'Prompt', 'Response')).not.toThrow()
244
+ })
245
+ })
@@ -0,0 +1,143 @@
1
+ 'use client'
2
+
3
+ import type { KanbanTicket, TeamRole } from './types'
4
+
5
+ /* ── Role-specific work prompts ──────────────────────── */
6
+
7
+ const ROLE_PROMPTS: Record<TeamRole, string> = {
8
+ 'lead-dev': `You are working this ticket as the Lead Dev. Provide:
9
+ 1. Technical breakdown of the work needed
10
+ 2. Implementation plan with clear steps
11
+ 3. Key technical decisions or trade-offs
12
+ 4. Dependencies or blockers to flag
13
+
14
+ Be specific and actionable. Reference concrete files, APIs, or patterns where relevant.`,
15
+
16
+ 'ux-ui': `You are working this ticket as the UX/UI Lead. Provide:
17
+ 1. Design review and recommendations
18
+ 2. User flow walkthrough
19
+ 3. Accessibility considerations (WCAG)
20
+ 4. Visual/interaction suggestions
21
+
22
+ Focus on the user experience. Call out any usability concerns or improvements.`,
23
+
24
+ 'qa': `You are working this ticket as QA. Provide:
25
+ 1. Test scenarios (happy path + edge cases)
26
+ 2. Acceptance criteria checklist
27
+ 3. Potential regression areas
28
+ 4. Edge cases and boundary conditions to verify
29
+
30
+ Be thorough. Think about what could break and how to verify it works.`,
31
+ }
32
+
33
+ const FALLBACK_PROMPT = `You are working this ticket. Provide:
34
+ 1. Analysis of what needs to be done
35
+ 2. Recommended approach
36
+ 3. Key considerations or risks
37
+ 4. Next steps
38
+
39
+ Be concise and actionable.`
40
+
41
+ export function getWorkPrompt(ticket: KanbanTicket): string {
42
+ const rolePrompt = ticket.assigneeRole
43
+ ? ROLE_PROMPTS[ticket.assigneeRole] ?? FALLBACK_PROMPT
44
+ : FALLBACK_PROMPT
45
+
46
+ return `${rolePrompt}
47
+
48
+ Ticket: ${ticket.title}
49
+ ${ticket.description ? `Description: ${ticket.description}` : 'No description provided.'}
50
+ Priority: ${ticket.priority}`
51
+ }
52
+
53
+ /* ── Execute work via chat API ───────────────────────── */
54
+
55
+ interface WorkResult {
56
+ success: boolean
57
+ content: string
58
+ error?: string
59
+ }
60
+
61
+ export async function executeWork(
62
+ agentId: string,
63
+ ticket: KanbanTicket,
64
+ onChunk?: (chunk: string) => void,
65
+ ): Promise<WorkResult> {
66
+ const prompt = getWorkPrompt(ticket)
67
+
68
+ try {
69
+ const res = await fetch(`/api/kanban/chat/${agentId}`, {
70
+ method: 'POST',
71
+ headers: { 'Content-Type': 'application/json' },
72
+ body: JSON.stringify({
73
+ messages: [{ role: 'user', content: prompt }],
74
+ ticket: {
75
+ title: ticket.title,
76
+ description: ticket.description,
77
+ status: ticket.status,
78
+ priority: ticket.priority,
79
+ assigneeRole: ticket.assigneeRole,
80
+ },
81
+ }),
82
+ })
83
+
84
+ if (!res.ok || !res.body) {
85
+ return { success: false, content: '', error: `API error: ${res.status}` }
86
+ }
87
+
88
+ const reader = res.body.getReader()
89
+ const decoder = new TextDecoder()
90
+ let buffer = ''
91
+ let fullContent = ''
92
+
93
+ while (true) {
94
+ const { done, value } = await reader.read()
95
+ if (done) break
96
+
97
+ buffer += decoder.decode(value, { stream: true })
98
+ const lines = buffer.split('\n')
99
+ buffer = lines.pop() || ''
100
+
101
+ for (const line of lines) {
102
+ if (line.startsWith('data: ') && line !== 'data: [DONE]') {
103
+ try {
104
+ const chunk = JSON.parse(line.slice(6))
105
+ if (chunk.content) {
106
+ fullContent += chunk.content
107
+ onChunk?.(chunk.content)
108
+ }
109
+ } catch { /* skip malformed chunks */ }
110
+ }
111
+ }
112
+ }
113
+
114
+ if (!fullContent) {
115
+ return { success: false, content: '', error: 'Empty response from agent' }
116
+ }
117
+
118
+ return { success: true, content: fullContent }
119
+ } catch (err) {
120
+ const message = err instanceof Error ? err.message : 'Unknown error'
121
+ return { success: false, content: '', error: message }
122
+ }
123
+ }
124
+
125
+ /* ── Persist work chat to filesystem via API ─────────── */
126
+
127
+ export function persistWorkChat(
128
+ ticketId: string,
129
+ prompt: string,
130
+ response: string,
131
+ ): void {
132
+ const now = Date.now()
133
+ const messages = [
134
+ { id: crypto.randomUUID(), role: 'user' as const, content: prompt, timestamp: now },
135
+ { id: crypto.randomUUID(), role: 'assistant' as const, content: response, timestamp: now + 1 },
136
+ ]
137
+
138
+ fetch(`/api/kanban/chat-history/${ticketId}`, {
139
+ method: 'POST',
140
+ headers: { 'Content-Type': 'application/json' },
141
+ body: JSON.stringify({ messages }),
142
+ }).catch(() => { /* persist best-effort */ })
143
+ }
@@ -0,0 +1,149 @@
1
+ // @vitest-environment node
2
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
3
+
4
+ const { mockReadFileSync, mockAppendFileSync, mockMkdirSync, mockExistsSync } = vi.hoisted(() => ({
5
+ mockReadFileSync: vi.fn(),
6
+ mockAppendFileSync: vi.fn(),
7
+ mockMkdirSync: vi.fn(),
8
+ mockExistsSync: vi.fn(),
9
+ }))
10
+
11
+ vi.mock('fs', () => ({
12
+ readFileSync: mockReadFileSync,
13
+ appendFileSync: mockAppendFileSync,
14
+ mkdirSync: mockMkdirSync,
15
+ existsSync: mockExistsSync,
16
+ default: {
17
+ readFileSync: mockReadFileSync,
18
+ appendFileSync: mockAppendFileSync,
19
+ mkdirSync: mockMkdirSync,
20
+ existsSync: mockExistsSync,
21
+ },
22
+ }))
23
+
24
+ import { getChatMessages, appendChatMessages, StoredChatMessage } from './chat-store'
25
+
26
+ beforeEach(() => {
27
+ vi.clearAllMocks()
28
+ vi.stubEnv('WORKSPACE_PATH', '/tmp/test-workspace')
29
+ mockExistsSync.mockReturnValue(true)
30
+ })
31
+
32
+ describe('getChatMessages', () => {
33
+ it('parses JSONL lines and returns sorted oldest-first', () => {
34
+ const lines = [
35
+ JSON.stringify({ id: 'c', role: 'assistant', content: 'last', timestamp: 3000 }),
36
+ JSON.stringify({ id: 'a', role: 'user', content: 'first', timestamp: 1000 }),
37
+ JSON.stringify({ id: 'b', role: 'assistant', content: 'second', timestamp: 2000 }),
38
+ ].join('\n')
39
+
40
+ mockReadFileSync.mockReturnValue(lines)
41
+
42
+ const messages = getChatMessages('ticket-1')
43
+ expect(messages).toHaveLength(3)
44
+ expect(messages[0].id).toBe('a')
45
+ expect(messages[0].timestamp).toBe(1000)
46
+ expect(messages[1].id).toBe('b')
47
+ expect(messages[2].id).toBe('c')
48
+ })
49
+
50
+ it('returns empty array when file does not exist', () => {
51
+ mockExistsSync.mockReturnValue(false)
52
+ const messages = getChatMessages('missing-ticket')
53
+ expect(messages).toEqual([])
54
+ expect(mockReadFileSync).not.toHaveBeenCalled()
55
+ })
56
+
57
+ it('returns empty array when file is empty', () => {
58
+ mockReadFileSync.mockReturnValue('')
59
+ const messages = getChatMessages('empty-ticket')
60
+ expect(messages).toEqual([])
61
+ })
62
+
63
+ it('skips malformed JSON lines', () => {
64
+ const lines = [
65
+ 'not valid json',
66
+ JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 1000 }),
67
+ '{ broken',
68
+ '',
69
+ ].join('\n')
70
+
71
+ mockReadFileSync.mockReturnValue(lines)
72
+
73
+ const messages = getChatMessages('ticket-1')
74
+ expect(messages).toHaveLength(1)
75
+ expect(messages[0].id).toBe('a')
76
+ })
77
+
78
+ it('skips lines with missing required fields', () => {
79
+ const lines = [
80
+ JSON.stringify({ role: 'user', content: 'no id', timestamp: 1000 }),
81
+ JSON.stringify({ id: '', role: 'user', content: 'empty id', timestamp: 1000 }),
82
+ JSON.stringify({ id: 'a', role: 'system', content: 'bad role', timestamp: 1000 }),
83
+ JSON.stringify({ id: 'b', role: 'user', content: 'valid', timestamp: 2000 }),
84
+ ].join('\n')
85
+
86
+ mockReadFileSync.mockReturnValue(lines)
87
+
88
+ const messages = getChatMessages('ticket-1')
89
+ expect(messages).toHaveLength(1)
90
+ expect(messages[0].id).toBe('b')
91
+ })
92
+
93
+ it('handles unreadable files gracefully', () => {
94
+ mockReadFileSync.mockImplementation(() => { throw new Error('permission denied') })
95
+ const messages = getChatMessages('ticket-1')
96
+ expect(messages).toEqual([])
97
+ })
98
+
99
+ it('defaults timestamp to 0 for non-numeric values', () => {
100
+ const lines = JSON.stringify({ id: 'a', role: 'user', content: 'hi', timestamp: 'bad' })
101
+ mockReadFileSync.mockReturnValue(lines)
102
+
103
+ const messages = getChatMessages('ticket-1')
104
+ expect(messages).toHaveLength(1)
105
+ expect(messages[0].timestamp).toBe(0)
106
+ })
107
+ })
108
+
109
+ describe('appendChatMessages', () => {
110
+ it('creates directory and appends messages as JSONL', () => {
111
+ const messages: StoredChatMessage[] = [
112
+ { id: 'a', role: 'user', content: 'hello', timestamp: 1000 },
113
+ { id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 },
114
+ ]
115
+
116
+ appendChatMessages('ticket-1', messages)
117
+
118
+ expect(mockMkdirSync).toHaveBeenCalledWith(
119
+ expect.stringContaining('kanban/chats'),
120
+ { recursive: true },
121
+ )
122
+
123
+ const written = mockAppendFileSync.mock.calls[0][1] as string
124
+ const lines = written.trim().split('\n')
125
+ expect(lines).toHaveLength(2)
126
+ expect(JSON.parse(lines[0])).toEqual({ id: 'a', role: 'user', content: 'hello', timestamp: 1000 })
127
+ expect(JSON.parse(lines[1])).toEqual({ id: 'b', role: 'assistant', content: 'hi there', timestamp: 2000 })
128
+ })
129
+
130
+ it('appends single message correctly', () => {
131
+ const messages: StoredChatMessage[] = [
132
+ { id: 'x', role: 'user', content: 'test', timestamp: 5000 },
133
+ ]
134
+
135
+ appendChatMessages('ticket-2', messages)
136
+
137
+ const written = mockAppendFileSync.mock.calls[0][1] as string
138
+ expect(written).toBe('{"id":"x","role":"user","content":"test","timestamp":5000}\n')
139
+ })
140
+
141
+ it('writes to correct file path based on ticketId', () => {
142
+ appendChatMessages('my-ticket-id', [
143
+ { id: 'a', role: 'user', content: 'hi', timestamp: 1000 },
144
+ ])
145
+
146
+ const filePath = mockAppendFileSync.mock.calls[0][0] as string
147
+ expect(filePath).toContain('my-ticket-id.jsonl')
148
+ })
149
+ })
@@ -0,0 +1,81 @@
1
+ import { readFileSync, appendFileSync, mkdirSync, existsSync } from 'fs'
2
+ import path from 'path'
3
+ import { requireEnv } from '@/lib/env'
4
+
5
+ /** Serializable chat message (no isStreaming — UI-only field) */
6
+ export interface StoredChatMessage {
7
+ id: string
8
+ role: 'user' | 'assistant'
9
+ content: string
10
+ timestamp: number
11
+ }
12
+
13
+ /** Derive the chats directory from WORKSPACE_PATH */
14
+ function getChatsDir(): string {
15
+ return path.resolve(requireEnv('WORKSPACE_PATH'), '..', 'kanban', 'chats')
16
+ }
17
+
18
+ /**
19
+ * Parse a single JSONL line into a StoredChatMessage.
20
+ * Returns null if the line can't be parsed or is missing required fields.
21
+ */
22
+ function parseLine(line: string): StoredChatMessage | null {
23
+ if (!line.trim()) return null
24
+ try {
25
+ const obj = JSON.parse(line)
26
+ if (typeof obj.id !== 'string' || !obj.id) return null
27
+ if (obj.role !== 'user' && obj.role !== 'assistant') return null
28
+ if (typeof obj.content !== 'string') return null
29
+ return {
30
+ id: obj.id,
31
+ role: obj.role,
32
+ content: obj.content,
33
+ timestamp: typeof obj.timestamp === 'number' ? obj.timestamp : 0,
34
+ }
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Read chat messages for a ticket from its JSONL file.
42
+ * Returns StoredChatMessage[] sorted oldest-first by timestamp.
43
+ */
44
+ export function getChatMessages(ticketId: string): StoredChatMessage[] {
45
+ const chatsDir = getChatsDir()
46
+ const filePath = path.join(chatsDir, `${ticketId}.jsonl`)
47
+
48
+ if (!existsSync(filePath)) return []
49
+
50
+ try {
51
+ const content = readFileSync(filePath, 'utf-8')
52
+ const messages: StoredChatMessage[] = []
53
+ for (const line of content.split('\n')) {
54
+ const msg = parseLine(line)
55
+ if (msg) messages.push(msg)
56
+ }
57
+ messages.sort((a, b) => a.timestamp - b.timestamp)
58
+ return messages
59
+ } catch {
60
+ return []
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Append chat messages to a ticket's JSONL file.
66
+ * Creates the chats directory and file if they don't exist.
67
+ */
68
+ export function appendChatMessages(ticketId: string, messages: StoredChatMessage[]): void {
69
+ const chatsDir = getChatsDir()
70
+ mkdirSync(chatsDir, { recursive: true })
71
+
72
+ const filePath = path.join(chatsDir, `${ticketId}.jsonl`)
73
+ const lines = messages.map(m => JSON.stringify({
74
+ id: m.id,
75
+ role: m.role,
76
+ content: m.content,
77
+ timestamp: m.timestamp,
78
+ }))
79
+
80
+ appendFileSync(filePath, lines.join('\n') + '\n', 'utf-8')
81
+ }