@startsimpli/ui 0.4.42 → 0.4.44
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/package.json +8 -4
- package/src/catalog/catalog.ts +29 -0
- package/src/components/__tests__/conversation-timeline.test.tsx +56 -0
- package/src/components/__tests__/diff-viewer.test.tsx +101 -0
- package/src/components/__tests__/session-components.test.tsx +184 -0
- package/src/components/diff/DiffViewer.tsx +235 -0
- package/src/components/diff/index.ts +2 -0
- package/src/components/index.ts +15 -0
- package/src/components/kanban/KanbanBoard.tsx +168 -59
- package/src/components/kanban/__tests__/kanban.test.tsx +64 -1
- package/src/components/kanban/index.ts +2 -2
- package/src/components/record-detail/RecordDetail.tsx +92 -0
- package/src/components/record-detail/__tests__/record-detail.test.tsx +42 -0
- package/src/components/record-detail/index.ts +2 -0
- package/src/components/session/AgentSessionConsole.tsx +200 -0
- package/src/components/session/ConversationTimeline.tsx +131 -0
- package/src/components/session/SessionCard.tsx +194 -0
- package/src/components/session/SessionList.tsx +271 -0
- package/src/components/session/SessionStatusBadge.tsx +55 -0
- package/src/components/session/index.ts +23 -0
- package/src/components/session/status.ts +80 -0
- package/src/components/session/types.ts +29 -0
- package/src/components/terminal/Terminal.tsx +369 -0
- package/src/components/terminal/index.ts +4 -0
- package/src/components/terminal/themes.ts +91 -0
- package/src/components/terminal/xterm.css +8 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.44",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
"./email-editor": "./src/components/email-editor/index.ts",
|
|
21
21
|
"./workflows": "./src/components/workflows/index.ts",
|
|
22
22
|
"./workflows/styles": "./src/components/workflows/workflows.css",
|
|
23
|
+
"./terminal/styles": "./src/components/terminal/xterm.css",
|
|
23
24
|
"./tailwind": "./tailwind.preset.js"
|
|
24
25
|
},
|
|
25
26
|
"files": [
|
|
@@ -35,8 +36,8 @@
|
|
|
35
36
|
"next": "^14.0.0 || ^15.0.0 || ^16.0.0",
|
|
36
37
|
"react": "^18.0.0 || ^19.0.0",
|
|
37
38
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
38
|
-
"@startsimpli/
|
|
39
|
-
"@startsimpli/
|
|
39
|
+
"@startsimpli/auth": "0.4.29",
|
|
40
|
+
"@startsimpli/api": "0.5.42"
|
|
40
41
|
},
|
|
41
42
|
"peerDependenciesMeta": {
|
|
42
43
|
"@startsimpli/api": {
|
|
@@ -51,8 +52,10 @@
|
|
|
51
52
|
},
|
|
52
53
|
"dependencies": {
|
|
53
54
|
"@hello-pangea/dnd": "^18.0.1",
|
|
55
|
+
"@xterm/addon-fit": "^0.10.0",
|
|
54
56
|
"@xyflow/react": "^12.10.2",
|
|
55
57
|
"dagre": "^0.8.5",
|
|
58
|
+
"xterm": "^5.3.0",
|
|
56
59
|
"@radix-ui/react-accordion": "^1.2.12",
|
|
57
60
|
"@radix-ui/react-checkbox": "^1.3.3",
|
|
58
61
|
"@radix-ui/react-collapsible": "^1.1.12",
|
|
@@ -79,7 +82,8 @@
|
|
|
79
82
|
"remark-gfm": "^4.0.1",
|
|
80
83
|
"tailwind-merge": "^2.6.1",
|
|
81
84
|
"tailwindcss-animate": "^1.0.7",
|
|
82
|
-
"xlsx": "^0.18.5"
|
|
85
|
+
"xlsx": "^0.18.5",
|
|
86
|
+
"@startsimpli/realtime": "0.0.1"
|
|
83
87
|
},
|
|
84
88
|
"devDependencies": {
|
|
85
89
|
"@swc/core": "^1.15.30",
|
package/src/catalog/catalog.ts
CHANGED
|
@@ -135,4 +135,33 @@ export const UI_CATALOG: CatalogEntry[] = [
|
|
|
135
135
|
dataNeeds: 'A messages/thread type.',
|
|
136
136
|
source: { export: 'ChatThread', from: '@startsimpli/ui' },
|
|
137
137
|
},
|
|
138
|
+
{
|
|
139
|
+
id: 'session-list',
|
|
140
|
+
category: 'view',
|
|
141
|
+
title: 'Agent sessions list',
|
|
142
|
+
description:
|
|
143
|
+
"A presentational grid/table of agent-run sessions (name, status, branch, project) — cards or a project-grouped table. Use for a coding/agent workspace's session index. Status is injected per row (no per-row queries).",
|
|
144
|
+
dataNeeds: 'A session/run type mapped to the SessionSummary view-model.',
|
|
145
|
+
source: { export: 'SessionList', from: '@startsimpli/ui' },
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
id: 'agent-terminal',
|
|
149
|
+
category: 'component',
|
|
150
|
+
title: 'Agent terminal',
|
|
151
|
+
description:
|
|
152
|
+
'A live xterm.js terminal you drive imperatively (the parent owns the transport and pumps output in). Use for a session/agent surface that streams a PTY. Import styles from @startsimpli/ui/terminal/styles.',
|
|
153
|
+
dataNeeds: 'A streaming agent/PTY transport (not schema data).',
|
|
154
|
+
heavy: true,
|
|
155
|
+
source: { export: 'Terminal', from: '@startsimpli/ui' },
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
id: 'agent-session-console',
|
|
159
|
+
category: 'component',
|
|
160
|
+
title: 'Agent session console',
|
|
161
|
+
description:
|
|
162
|
+
'The full live session surface: a terminal + a steer-the-agent conversation timeline + a diff tab, driven by @startsimpli/realtime over a websocket. Use for the session-detail page of an agent/coding workspace.',
|
|
163
|
+
dataNeeds: 'A realtime agent-session websocket URL (not schema data).',
|
|
164
|
+
heavy: true,
|
|
165
|
+
source: { export: 'AgentSessionConsole', from: '@startsimpli/ui' },
|
|
166
|
+
},
|
|
138
167
|
];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { ConversationTimeline } from '../session/ConversationTimeline'
|
|
3
|
+
import type { ChatMessageData } from '../chat/types'
|
|
4
|
+
|
|
5
|
+
beforeAll(() => {
|
|
6
|
+
Element.prototype.scrollIntoView = jest.fn()
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
const entries: ChatMessageData[] = [
|
|
10
|
+
{ id: '1', role: 'assistant', content: 'Started the task' },
|
|
11
|
+
{ id: '2', role: 'tool', kind: 'tool', toolName: 'bash', content: 'npm test' },
|
|
12
|
+
{ id: '3', role: 'user', content: 'Focus on the billing page' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
describe('ConversationTimeline', () => {
|
|
16
|
+
it('renders the timeline entries', () => {
|
|
17
|
+
render(<ConversationTimeline entries={entries} />)
|
|
18
|
+
expect(screen.getByText('Started the task')).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByText('npm test')).toBeInTheDocument()
|
|
20
|
+
expect(screen.getByText('Focus on the billing page')).toBeInTheDocument()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('renders header title, status label, and a progress bar', () => {
|
|
24
|
+
render(<ConversationTimeline entries={entries} title="Session goal" statusLabel="Connected" progressPercent={42} />)
|
|
25
|
+
expect(screen.getByText('Session goal')).toBeInTheDocument()
|
|
26
|
+
expect(screen.getByText('Connected')).toBeInTheDocument()
|
|
27
|
+
const bar = screen.getByRole('progressbar')
|
|
28
|
+
expect(bar).toHaveAttribute('aria-valuenow', '42')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('sends composed text via onSend', () => {
|
|
32
|
+
const onSend = jest.fn()
|
|
33
|
+
render(<ConversationTimeline entries={entries} onSend={onSend} />)
|
|
34
|
+
const textarea = screen.getByRole('textbox')
|
|
35
|
+
fireEvent.change(textarea, { target: { value: 'steer left' } })
|
|
36
|
+
fireEvent.keyDown(textarea, { key: 'Enter' })
|
|
37
|
+
expect(onSend).toHaveBeenCalledWith('steer left')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('does not render a composer when onSend is omitted', () => {
|
|
41
|
+
render(<ConversationTimeline entries={entries} />)
|
|
42
|
+
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders a "Load earlier messages" control when hasMore', () => {
|
|
46
|
+
const onLoadMore = jest.fn()
|
|
47
|
+
render(<ConversationTimeline entries={entries} hasMore onLoadMore={onLoadMore} />)
|
|
48
|
+
fireEvent.click(screen.getByRole('button', { name: /load earlier messages/i }))
|
|
49
|
+
expect(onLoadMore).toHaveBeenCalledTimes(1)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('shows the working indicator when isLoading', () => {
|
|
53
|
+
render(<ConversationTimeline entries={entries} isLoading />)
|
|
54
|
+
expect(screen.getByTestId('chat-working-indicator')).toBeInTheDocument()
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { DiffViewer, parseDiffFiles, diffLineClass } from '../diff/DiffViewer'
|
|
3
|
+
|
|
4
|
+
const SAMPLE_DIFF = `diff --git a/src/foo.ts b/src/foo.ts
|
|
5
|
+
index 1234567..89abcde 100644
|
|
6
|
+
--- a/src/foo.ts
|
|
7
|
+
+++ b/src/foo.ts
|
|
8
|
+
@@ -1,3 +1,3 @@
|
|
9
|
+
const a = 1
|
|
10
|
+
-const b = 2
|
|
11
|
+
+const b = 3
|
|
12
|
+
diff --git a/src/bar.ts b/src/bar.ts
|
|
13
|
+
new file mode 100644
|
|
14
|
+
index 0000000..1111111
|
|
15
|
+
--- /dev/null
|
|
16
|
+
+++ b/src/bar.ts
|
|
17
|
+
@@ -0,0 +1,2 @@
|
|
18
|
+
+export const bar = 'baz'
|
|
19
|
+
+export default bar
|
|
20
|
+
`
|
|
21
|
+
|
|
22
|
+
describe('parseDiffFiles', () => {
|
|
23
|
+
it('splits a unified diff into one block per file with the b/ path', () => {
|
|
24
|
+
const files = parseDiffFiles(SAMPLE_DIFF)
|
|
25
|
+
expect(files).toHaveLength(2)
|
|
26
|
+
expect(files[0].filePath).toBe('src/foo.ts')
|
|
27
|
+
expect(files[1].filePath).toBe('src/bar.ts')
|
|
28
|
+
// the block carries its own lines (including the hunk header)
|
|
29
|
+
expect(files[0].lines.some((l) => l.startsWith('@@'))).toBe(true)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('returns an empty array for empty / whitespace input', () => {
|
|
33
|
+
expect(parseDiffFiles('')).toEqual([])
|
|
34
|
+
expect(parseDiffFiles(' \n ')).toEqual([])
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('diffLineClass', () => {
|
|
39
|
+
it('colourises added / removed / hunk / context lines', () => {
|
|
40
|
+
expect(diffLineClass('+added')).toContain('emerald')
|
|
41
|
+
expect(diffLineClass('-removed')).toContain('red')
|
|
42
|
+
expect(diffLineClass('@@ -1 +1 @@')).toContain('cyan')
|
|
43
|
+
expect(diffLineClass(' context')).toContain('muted-foreground')
|
|
44
|
+
})
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
describe('DiffViewer', () => {
|
|
48
|
+
it('renders per-file blocks and colourised +/-/@@ lines', () => {
|
|
49
|
+
render(<DiffViewer diff={SAMPLE_DIFF} baseRef="main" />)
|
|
50
|
+
// both file headers render
|
|
51
|
+
expect(screen.getByText('src/foo.ts')).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByText('src/bar.ts')).toBeInTheDocument()
|
|
53
|
+
// an added line is present and coloured; metadata lines are hidden
|
|
54
|
+
expect(screen.getByText('+const b = 3')).toHaveClass('bg-emerald-500/10')
|
|
55
|
+
expect(screen.getByText('-const b = 2')).toHaveClass('bg-red-500/10')
|
|
56
|
+
expect(screen.queryByText(/^index 1234567/)).not.toBeInTheDocument()
|
|
57
|
+
// base ref shown in header
|
|
58
|
+
expect(screen.getByText('main')).toBeInTheDocument()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders a summary line with staged/unstaged counts', () => {
|
|
62
|
+
render(<DiffViewer diff={SAMPLE_DIFF} summary={{ totalFiles: 2, staged: 1, unstaged: 1 }} />)
|
|
63
|
+
const summary = screen.getByTestId('diff-viewer-summary')
|
|
64
|
+
expect(summary).toHaveTextContent('2 files changed')
|
|
65
|
+
expect(summary).toHaveTextContent('1 staged')
|
|
66
|
+
expect(summary).toHaveTextContent('1 unstaged')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('shows the empty state (with custom label) when the diff is empty', () => {
|
|
70
|
+
render(<DiffViewer diff="" emptyLabel="Nothing to review" />)
|
|
71
|
+
expect(screen.getByTestId('diff-viewer-empty')).toBeInTheDocument()
|
|
72
|
+
expect(screen.getByText('Nothing to review')).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('shows a loading state', () => {
|
|
76
|
+
render(<DiffViewer diff="" isLoading />)
|
|
77
|
+
expect(screen.getByTestId('diff-viewer-loading')).toBeInTheDocument()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('shows an error state and fires onRefresh from Try again', () => {
|
|
81
|
+
const onRefresh = jest.fn()
|
|
82
|
+
render(<DiffViewer diff="" error="boom" onRefresh={onRefresh} />)
|
|
83
|
+
expect(screen.getByTestId('diff-viewer-error')).toBeInTheDocument()
|
|
84
|
+
expect(screen.getByText('boom')).toBeInTheDocument()
|
|
85
|
+
fireEvent.click(screen.getByRole('button', { name: /try again/i }))
|
|
86
|
+
expect(onRefresh).toHaveBeenCalledTimes(1)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('fires onRefresh from the header refresh button', () => {
|
|
90
|
+
const onRefresh = jest.fn()
|
|
91
|
+
render(<DiffViewer diff={SAMPLE_DIFF} onRefresh={onRefresh} />)
|
|
92
|
+
fireEvent.click(screen.getByRole('button', { name: /refresh diff/i }))
|
|
93
|
+
expect(onRefresh).toHaveBeenCalledTimes(1)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('renders a collapsible working-tree status block', () => {
|
|
97
|
+
render(<DiffViewer diff={SAMPLE_DIFF} status="?? untracked.ts" />)
|
|
98
|
+
expect(screen.getByText('Working tree status')).toBeInTheDocument()
|
|
99
|
+
expect(screen.getByText('?? untracked.ts')).toBeInTheDocument()
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { SessionCard, formatRelative } from '../session/SessionCard'
|
|
3
|
+
import { SessionList } from '../session/SessionList'
|
|
4
|
+
import { SessionStatusBadge } from '../session/SessionStatusBadge'
|
|
5
|
+
import { resolveSessionStatus, mapSocketStatus } from '../session/status'
|
|
6
|
+
import type { SessionSummary } from '../session/types'
|
|
7
|
+
|
|
8
|
+
const base: SessionSummary = {
|
|
9
|
+
id: 's1',
|
|
10
|
+
number: 7,
|
|
11
|
+
name: 'Add billing page',
|
|
12
|
+
isActive: true,
|
|
13
|
+
projectName: 'raise-simpli',
|
|
14
|
+
agentName: 'Claude',
|
|
15
|
+
branch: 'feat/billing',
|
|
16
|
+
createdAt: '2026-07-01T00:00:00.000Z',
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
// status helpers (pure)
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
describe('resolveSessionStatus', () => {
|
|
24
|
+
it('prefers an explicit status kind + label', () => {
|
|
25
|
+
const r = resolveSessionStatus({ status: 'error', statusLabel: 'Crashed', isActive: false })
|
|
26
|
+
expect(r.kind).toBe('error')
|
|
27
|
+
expect(r.label).toBe('Crashed')
|
|
28
|
+
expect(r.className).toContain('red')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('derives active/idle from isActive when status is absent', () => {
|
|
32
|
+
expect(resolveSessionStatus({ isActive: true }).kind).toBe('active')
|
|
33
|
+
expect(resolveSessionStatus({ isActive: false }).kind).toBe('idle')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('supplies a default label per kind', () => {
|
|
37
|
+
expect(resolveSessionStatus({ status: 'success', isActive: false }).label).toBe('Success')
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('mapSocketStatus', () => {
|
|
42
|
+
it('maps agent socket status onto the terminal status', () => {
|
|
43
|
+
expect(mapSocketStatus('open')).toBe('connected')
|
|
44
|
+
expect(mapSocketStatus('connecting')).toBe('connecting')
|
|
45
|
+
expect(mapSocketStatus('reconnecting')).toBe('connecting')
|
|
46
|
+
expect(mapSocketStatus('idle')).toBe('disconnected')
|
|
47
|
+
expect(mapSocketStatus('closed')).toBe('disconnected')
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('formatRelative', () => {
|
|
52
|
+
it('formats a valid timestamp and passes an unparseable one through', () => {
|
|
53
|
+
expect(formatRelative('2026-07-01T00:00:00.000Z')).toMatch(/ago|in /)
|
|
54
|
+
expect(formatRelative('not-a-date')).toBe('not-a-date')
|
|
55
|
+
})
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
// SessionStatusBadge
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
describe('SessionStatusBadge', () => {
|
|
63
|
+
it('renders the resolved status label and tags the kind', () => {
|
|
64
|
+
render(<SessionStatusBadge status="warning" />)
|
|
65
|
+
const badge = screen.getByRole('status')
|
|
66
|
+
expect(badge).toHaveAttribute('data-status', 'warning')
|
|
67
|
+
expect(badge).toHaveTextContent('Needs review')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('falls back to isActive when no status is given', () => {
|
|
71
|
+
render(<SessionStatusBadge isActive />)
|
|
72
|
+
expect(screen.getByRole('status')).toHaveAttribute('data-status', 'active')
|
|
73
|
+
})
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// SessionCard
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
describe('SessionCard', () => {
|
|
81
|
+
it('renders name, branch, project, agent, number, and status', () => {
|
|
82
|
+
render(<SessionCard session={base} />)
|
|
83
|
+
expect(screen.getByText('Add billing page')).toBeInTheDocument()
|
|
84
|
+
expect(screen.getByText('feat/billing')).toBeInTheDocument()
|
|
85
|
+
expect(screen.getByText('raise-simpli')).toBeInTheDocument()
|
|
86
|
+
expect(screen.getByText('Claude')).toBeInTheDocument()
|
|
87
|
+
expect(screen.getByText('007')).toBeInTheDocument() // zero-padded number
|
|
88
|
+
expect(screen.getByRole('status')).toHaveTextContent('Active')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders the title as a link when href is provided', () => {
|
|
92
|
+
render(<SessionCard session={base} href={(id) => `/sessions/${id}`} />)
|
|
93
|
+
const link = screen.getByRole('link', { name: 'Add billing page' })
|
|
94
|
+
expect(link).toHaveAttribute('href', '/sessions/s1')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('calls onOpen when clicked (no href)', () => {
|
|
98
|
+
const onOpen = jest.fn()
|
|
99
|
+
render(<SessionCard session={base} onOpen={onOpen} />)
|
|
100
|
+
fireEvent.click(screen.getByTestId('session-card'))
|
|
101
|
+
expect(onOpen).toHaveBeenCalledWith('s1')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('calls onOpenTerminal from the Terminal action without bubbling to the card', () => {
|
|
105
|
+
const onOpen = jest.fn()
|
|
106
|
+
const onOpenTerminal = jest.fn()
|
|
107
|
+
render(<SessionCard session={base} onOpen={onOpen} onOpenTerminal={onOpenTerminal} />)
|
|
108
|
+
fireEvent.click(screen.getByRole('button', { name: /terminal/i }))
|
|
109
|
+
expect(onOpenTerminal).toHaveBeenCalledWith('s1')
|
|
110
|
+
expect(onOpen).not.toHaveBeenCalled()
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('shows an unread dot when hasUnread', () => {
|
|
114
|
+
render(<SessionCard session={{ ...base, hasUnread: true }} />)
|
|
115
|
+
expect(screen.getByTestId('session-card-unread')).toBeInTheDocument()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// SessionList
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
const sessions: SessionSummary[] = [
|
|
124
|
+
base,
|
|
125
|
+
{ id: 's2', name: 'Fix auth bug', isActive: false, status: 'success', projectName: 'auth-web', branch: 'fix/auth', createdAt: '2026-07-02T00:00:00.000Z' },
|
|
126
|
+
{ id: 's3', name: 'Draft README', isActive: false, status: 'idle', projectName: 'raise-simpli', createdAt: '2026-07-03T00:00:00.000Z' },
|
|
127
|
+
]
|
|
128
|
+
|
|
129
|
+
describe('SessionList', () => {
|
|
130
|
+
it('renders a card grid by default', () => {
|
|
131
|
+
render(<SessionList sessions={sessions} />)
|
|
132
|
+
expect(screen.getAllByTestId('session-card')).toHaveLength(3)
|
|
133
|
+
expect(screen.getByText('Fix auth bug')).toBeInTheDocument()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('renders a flat table layout', () => {
|
|
137
|
+
render(<SessionList sessions={sessions} layout="table" />)
|
|
138
|
+
expect(screen.getAllByTestId('session-row')).toHaveLength(3)
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('groups rows by project when groupByProject in table layout', () => {
|
|
142
|
+
render(<SessionList sessions={sessions} layout="table" groupByProject />)
|
|
143
|
+
// two project groups: raise-simpli (2) + auth-web (1)
|
|
144
|
+
expect(screen.getByText('raise-simpli')).toBeInTheDocument()
|
|
145
|
+
expect(screen.getByText('auth-web')).toBeInTheDocument()
|
|
146
|
+
expect(screen.getByText('2 sessions')).toBeInTheDocument()
|
|
147
|
+
expect(screen.getByText('1 session')).toBeInTheDocument()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('collapses / expands a project group on header click', () => {
|
|
151
|
+
render(<SessionList sessions={sessions} layout="table" groupByProject />)
|
|
152
|
+
expect(screen.getAllByTestId('session-row')).toHaveLength(3)
|
|
153
|
+
// collapse the raise-simpli group (its header carries the "2 sessions" count)
|
|
154
|
+
fireEvent.click(screen.getByText('raise-simpli'))
|
|
155
|
+
expect(screen.getAllByTestId('session-row')).toHaveLength(1)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('fires onSessionClick from a row', () => {
|
|
159
|
+
const onSessionClick = jest.fn()
|
|
160
|
+
render(<SessionList sessions={sessions} layout="table" onSessionClick={onSessionClick} />)
|
|
161
|
+
fireEvent.click(screen.getByText('Fix auth bug'))
|
|
162
|
+
expect(onSessionClick).toHaveBeenCalledWith(expect.objectContaining({ id: 's2' }))
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('renders loading skeletons', () => {
|
|
166
|
+
const { container } = render(<SessionList sessions={[]} isLoading skeletonCount={4} />)
|
|
167
|
+
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('renders an error state', () => {
|
|
171
|
+
render(<SessionList sessions={[]} error="nope" />)
|
|
172
|
+
expect(screen.getByTestId('session-list-error')).toHaveTextContent('nope')
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('renders an empty state with a custom label', () => {
|
|
176
|
+
render(<SessionList sessions={[]} emptyLabel="No runs yet" />)
|
|
177
|
+
expect(screen.getByTestId('session-list-empty')).toHaveTextContent('No runs yet')
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
it('renders injected filters above the list', () => {
|
|
181
|
+
render(<SessionList sessions={sessions} renderFilters={<div>my-filters</div>} />)
|
|
182
|
+
expect(screen.getByText('my-filters')).toBeInTheDocument()
|
|
183
|
+
})
|
|
184
|
+
})
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { FileText, GitBranch, Loader2, RefreshCw } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
|
|
7
|
+
export interface DiffSummary {
|
|
8
|
+
totalFiles: number
|
|
9
|
+
staged?: number
|
|
10
|
+
unstaged?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface DiffViewerProps {
|
|
14
|
+
/** A raw unified diff (concatenated `git diff` output). */
|
|
15
|
+
diff: string
|
|
16
|
+
/** The ref/branch the diff is computed against (shown in the header). */
|
|
17
|
+
baseRef?: string
|
|
18
|
+
/** Optional counts summary rendered next to the base ref. */
|
|
19
|
+
summary?: DiffSummary
|
|
20
|
+
/** Raw `git status` text, shown in a collapsible "Working tree status" block. */
|
|
21
|
+
status?: string
|
|
22
|
+
isLoading?: boolean
|
|
23
|
+
error?: string | null
|
|
24
|
+
onRefresh?: () => void
|
|
25
|
+
/** Message shown when the diff is empty. Default "No changes yet". */
|
|
26
|
+
emptyLabel?: string
|
|
27
|
+
className?: string
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ParsedDiffFile {
|
|
31
|
+
filePath: string
|
|
32
|
+
/** All lines of the file's diff block, including headers. */
|
|
33
|
+
lines: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Split a raw unified diff into per-file blocks. Ported from dev-hq's GitDiffViewer:
|
|
38
|
+
* split on `diff --git`, derive the file path from the `b/…` side of the header.
|
|
39
|
+
* Pure + exported so it can be unit-tested without rendering.
|
|
40
|
+
*/
|
|
41
|
+
export function parseDiffFiles(diff: string): ParsedDiffFile[] {
|
|
42
|
+
if (!diff || !diff.trim()) return []
|
|
43
|
+
return diff
|
|
44
|
+
.split(/(?=diff --git)/g)
|
|
45
|
+
.filter((block) => block.trim().length > 0)
|
|
46
|
+
.map((block) => {
|
|
47
|
+
const lines = block.split('\n')
|
|
48
|
+
const header = lines.find((l) => l.startsWith('diff --git'))
|
|
49
|
+
const filePath = header?.match(/ b\/(.+)$/)?.[1] ?? header?.match(/b\/(.+)$/)?.[1] ?? 'unknown'
|
|
50
|
+
return { filePath, lines }
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Tailwind-token class for a single diff line based on its leading character. */
|
|
55
|
+
export function diffLineClass(line: string): string {
|
|
56
|
+
if (line.startsWith('@@')) return 'text-cyan-600 dark:text-cyan-400 bg-cyan-500/10 font-semibold'
|
|
57
|
+
if (line.startsWith('+')) return 'text-emerald-600 dark:text-emerald-400 bg-emerald-500/10'
|
|
58
|
+
if (line.startsWith('-')) return 'text-red-600 dark:text-red-400 bg-red-500/10'
|
|
59
|
+
return 'text-muted-foreground'
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Metadata lines that carry no visual value in the rendered diff. */
|
|
63
|
+
function isMetadataLine(line: string): boolean {
|
|
64
|
+
return (
|
|
65
|
+
line.startsWith('diff --git') ||
|
|
66
|
+
line.startsWith('index ') ||
|
|
67
|
+
line.startsWith('--- ') ||
|
|
68
|
+
line.startsWith('+++ ') ||
|
|
69
|
+
line.startsWith('new file mode') ||
|
|
70
|
+
line.startsWith('deleted file mode') ||
|
|
71
|
+
line.startsWith('similarity index') ||
|
|
72
|
+
line.startsWith('rename from') ||
|
|
73
|
+
line.startsWith('rename to')
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A presentational unified-diff viewer. It does NO fetching — pass `diff` plus
|
|
79
|
+
* loading/error flags and an `onRefresh` callback; the parent owns the data.
|
|
80
|
+
* Colourised with tailwind tokens (works in light + dark).
|
|
81
|
+
*/
|
|
82
|
+
export function DiffViewer({
|
|
83
|
+
diff,
|
|
84
|
+
baseRef,
|
|
85
|
+
summary,
|
|
86
|
+
status,
|
|
87
|
+
isLoading = false,
|
|
88
|
+
error = null,
|
|
89
|
+
onRefresh,
|
|
90
|
+
emptyLabel = 'No changes yet',
|
|
91
|
+
className,
|
|
92
|
+
}: DiffViewerProps) {
|
|
93
|
+
const files = React.useMemo(() => parseDiffFiles(diff), [diff])
|
|
94
|
+
|
|
95
|
+
if (isLoading) {
|
|
96
|
+
return (
|
|
97
|
+
<div
|
|
98
|
+
data-testid="diff-viewer-loading"
|
|
99
|
+
className={cn('flex min-h-[300px] items-center justify-center bg-card', className)}
|
|
100
|
+
>
|
|
101
|
+
<div className="flex flex-col items-center gap-3 text-muted-foreground">
|
|
102
|
+
<Loader2 className="h-8 w-8 animate-spin" aria-hidden="true" />
|
|
103
|
+
<p className="text-sm">Loading diff…</p>
|
|
104
|
+
</div>
|
|
105
|
+
</div>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (error) {
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
data-testid="diff-viewer-error"
|
|
113
|
+
className={cn('flex min-h-[300px] items-center justify-center bg-card', className)}
|
|
114
|
+
>
|
|
115
|
+
<div className="max-w-md text-center">
|
|
116
|
+
<h3 className="mb-2 text-base font-semibold text-foreground">Failed to load diff</h3>
|
|
117
|
+
<p className="mb-4 text-sm text-muted-foreground">{error}</p>
|
|
118
|
+
{onRefresh && (
|
|
119
|
+
<button
|
|
120
|
+
type="button"
|
|
121
|
+
onClick={onRefresh}
|
|
122
|
+
className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
|
123
|
+
>
|
|
124
|
+
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
|
125
|
+
Try again
|
|
126
|
+
</button>
|
|
127
|
+
)}
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (files.length === 0) {
|
|
134
|
+
return (
|
|
135
|
+
<div
|
|
136
|
+
data-testid="diff-viewer-empty"
|
|
137
|
+
className={cn('flex min-h-[300px] items-center justify-center bg-card', className)}
|
|
138
|
+
>
|
|
139
|
+
<div className="max-w-md text-center">
|
|
140
|
+
<FileText className="mx-auto mb-4 h-12 w-12 text-muted-foreground" aria-hidden="true" />
|
|
141
|
+
<h3 className="mb-2 text-base font-semibold text-foreground">{emptyLabel}</h3>
|
|
142
|
+
{baseRef && (
|
|
143
|
+
<p className="mb-4 text-sm text-muted-foreground">
|
|
144
|
+
No changes compared to{' '}
|
|
145
|
+
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground">{baseRef}</code>
|
|
146
|
+
</p>
|
|
147
|
+
)}
|
|
148
|
+
{onRefresh && (
|
|
149
|
+
<button
|
|
150
|
+
type="button"
|
|
151
|
+
onClick={onRefresh}
|
|
152
|
+
className="inline-flex items-center gap-2 rounded-lg border border-border px-4 py-2 text-sm text-foreground transition-colors hover:bg-accent"
|
|
153
|
+
>
|
|
154
|
+
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
|
155
|
+
Refresh
|
|
156
|
+
</button>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<div className={cn('flex h-full flex-col bg-card', className)}>
|
|
165
|
+
<div className="flex flex-shrink-0 items-center justify-between border-b border-border bg-muted/40 px-4 py-3">
|
|
166
|
+
<div className="flex flex-wrap items-center gap-4">
|
|
167
|
+
{baseRef && (
|
|
168
|
+
<div className="flex items-center gap-2 text-sm">
|
|
169
|
+
<GitBranch className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
170
|
+
<span className="text-muted-foreground">Base:</span>
|
|
171
|
+
<code className="rounded bg-muted px-1.5 py-0.5 text-foreground">{baseRef}</code>
|
|
172
|
+
</div>
|
|
173
|
+
)}
|
|
174
|
+
{summary && (
|
|
175
|
+
<div className="text-sm text-muted-foreground" data-testid="diff-viewer-summary">
|
|
176
|
+
{summary.totalFiles} {summary.totalFiles === 1 ? 'file' : 'files'} changed
|
|
177
|
+
{summary.staged != null && summary.staged > 0 && (
|
|
178
|
+
<span className="ml-2 text-emerald-600 dark:text-emerald-400">({summary.staged} staged)</span>
|
|
179
|
+
)}
|
|
180
|
+
{summary.unstaged != null && summary.unstaged > 0 && (
|
|
181
|
+
<span className="ml-2 text-amber-600 dark:text-amber-400">({summary.unstaged} unstaged)</span>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
</div>
|
|
186
|
+
{onRefresh && (
|
|
187
|
+
<button
|
|
188
|
+
type="button"
|
|
189
|
+
onClick={onRefresh}
|
|
190
|
+
title="Refresh diff"
|
|
191
|
+
aria-label="Refresh diff"
|
|
192
|
+
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
193
|
+
>
|
|
194
|
+
<RefreshCw className="h-4 w-4" aria-hidden="true" />
|
|
195
|
+
</button>
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
|
|
199
|
+
{status && (
|
|
200
|
+
<div className="flex-shrink-0 border-b border-border bg-muted/20 px-4 py-2">
|
|
201
|
+
<details className="cursor-pointer">
|
|
202
|
+
<summary className="select-none text-sm text-muted-foreground transition-colors hover:text-foreground">
|
|
203
|
+
Working tree status
|
|
204
|
+
</summary>
|
|
205
|
+
<pre className="mt-2 overflow-x-auto font-mono text-xs text-muted-foreground">{status}</pre>
|
|
206
|
+
</details>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
|
|
210
|
+
<div className="flex-1 space-y-4 overflow-auto p-4">
|
|
211
|
+
{files.map((file, fileIndex) => (
|
|
212
|
+
<div
|
|
213
|
+
key={`${file.filePath}-${fileIndex}`}
|
|
214
|
+
data-testid="diff-file"
|
|
215
|
+
className="overflow-hidden rounded-lg border border-border"
|
|
216
|
+
>
|
|
217
|
+
<div className="flex items-center gap-2 border-b border-border bg-muted/40 px-4 py-2">
|
|
218
|
+
<FileText className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
|
219
|
+
<span className="font-mono text-sm text-foreground">{file.filePath}</span>
|
|
220
|
+
</div>
|
|
221
|
+
<pre className="overflow-x-auto p-4 font-mono text-sm">
|
|
222
|
+
{file.lines.map((line, index) =>
|
|
223
|
+
isMetadataLine(line) ? null : (
|
|
224
|
+
<div key={index} className={diffLineClass(line)}>
|
|
225
|
+
{line || ' '}
|
|
226
|
+
</div>
|
|
227
|
+
),
|
|
228
|
+
)}
|
|
229
|
+
</pre>
|
|
230
|
+
</div>
|
|
231
|
+
))}
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
}
|