@startsimpli/ui 0.4.43 → 0.4.46
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 +12 -3
- 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/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 +26 -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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { formatDistanceToNow } from 'date-fns'
|
|
5
|
+
import { Eye, TerminalSquare } from 'lucide-react'
|
|
6
|
+
import { cn } from '../../lib/utils'
|
|
7
|
+
import { SessionStatusBadge } from './SessionStatusBadge'
|
|
8
|
+
import type { SessionSummary } from './types'
|
|
9
|
+
|
|
10
|
+
export interface SessionCardProps {
|
|
11
|
+
session: SessionSummary
|
|
12
|
+
/** Inject routing: return an href for the session detail. Renders the title as a link. */
|
|
13
|
+
href?: (id: string) => string
|
|
14
|
+
/** Called when the card / Details is activated (used when `href` is absent). */
|
|
15
|
+
onOpen?: (id: string) => void
|
|
16
|
+
/** Called when the Terminal action is activated. */
|
|
17
|
+
onOpenTerminal?: (id: string) => void
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Relative "created …" label, tolerant of string | number timestamps. */
|
|
22
|
+
export function formatRelative(value: string | number): string {
|
|
23
|
+
const date = typeof value === 'number' ? new Date(value) : new Date(value)
|
|
24
|
+
if (Number.isNaN(date.getTime())) return String(value)
|
|
25
|
+
return formatDistanceToNow(date, { addSuffix: true })
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function SessionCardComponent({ session, href, onOpen, onOpenTerminal, className }: SessionCardProps) {
|
|
29
|
+
const open = React.useCallback(() => onOpen?.(session.id), [onOpen, session.id])
|
|
30
|
+
|
|
31
|
+
const handleKeyDown = React.useCallback(
|
|
32
|
+
(e: React.KeyboardEvent) => {
|
|
33
|
+
if (href) return // native link handles keyboard
|
|
34
|
+
if (e.key === 'Enter' || e.key === ' ') {
|
|
35
|
+
e.preventDefault()
|
|
36
|
+
open()
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
[href, open],
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
const titleNode = href ? (
|
|
43
|
+
<a
|
|
44
|
+
href={href(session.id)}
|
|
45
|
+
className="truncate text-lg font-semibold text-foreground hover:text-primary"
|
|
46
|
+
title={session.name}
|
|
47
|
+
>
|
|
48
|
+
{session.name}
|
|
49
|
+
</a>
|
|
50
|
+
) : (
|
|
51
|
+
<h3 className="truncate text-lg font-semibold text-foreground" title={session.name}>
|
|
52
|
+
{session.name}
|
|
53
|
+
</h3>
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
const clickable = !href && !!onOpen
|
|
57
|
+
|
|
58
|
+
return (
|
|
59
|
+
<div
|
|
60
|
+
data-testid="session-card"
|
|
61
|
+
role={clickable ? 'button' : undefined}
|
|
62
|
+
tabIndex={clickable ? 0 : undefined}
|
|
63
|
+
onClick={clickable ? open : undefined}
|
|
64
|
+
onKeyDown={clickable ? handleKeyDown : undefined}
|
|
65
|
+
aria-label={clickable ? `Open session ${session.name}` : undefined}
|
|
66
|
+
className={cn(
|
|
67
|
+
'relative rounded-lg border border-border bg-card p-4 transition-colors',
|
|
68
|
+
clickable && 'cursor-pointer hover:border-primary/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{session.hasUnread && (
|
|
73
|
+
<span
|
|
74
|
+
data-testid="session-card-unread"
|
|
75
|
+
aria-label="Has unread activity"
|
|
76
|
+
className="absolute right-4 top-4 h-2 w-2 rounded-full bg-primary"
|
|
77
|
+
/>
|
|
78
|
+
)}
|
|
79
|
+
|
|
80
|
+
<div className="flex items-start gap-4">
|
|
81
|
+
{session.number != null && (
|
|
82
|
+
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-lg border border-border bg-muted">
|
|
83
|
+
<span className="font-mono text-sm font-semibold text-muted-foreground">
|
|
84
|
+
{String(session.number).padStart(3, '0')}
|
|
85
|
+
</span>
|
|
86
|
+
</div>
|
|
87
|
+
)}
|
|
88
|
+
|
|
89
|
+
<div className="min-w-0 flex-1">
|
|
90
|
+
<div className="mb-2 flex items-start justify-between gap-2">
|
|
91
|
+
{titleNode}
|
|
92
|
+
<SessionStatusBadge status={session.status} label={session.statusLabel} isActive={session.isActive} />
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div className="space-y-1 text-sm text-muted-foreground">
|
|
96
|
+
{session.agentName && (
|
|
97
|
+
<div className="flex items-center gap-2">
|
|
98
|
+
<span className="text-muted-foreground/70">Agent:</span>
|
|
99
|
+
<span className="truncate text-foreground" title={session.agentName}>
|
|
100
|
+
{session.agentName}
|
|
101
|
+
</span>
|
|
102
|
+
</div>
|
|
103
|
+
)}
|
|
104
|
+
{session.projectName && (
|
|
105
|
+
<div className="flex items-center gap-2">
|
|
106
|
+
<span className="text-muted-foreground/70">Project:</span>
|
|
107
|
+
<span className="truncate text-foreground" title={session.projectName}>
|
|
108
|
+
{session.projectName}
|
|
109
|
+
</span>
|
|
110
|
+
</div>
|
|
111
|
+
)}
|
|
112
|
+
{session.branch && (
|
|
113
|
+
<div className="flex items-center gap-2">
|
|
114
|
+
<span className="text-muted-foreground/70">Branch:</span>
|
|
115
|
+
<span className="truncate font-mono text-xs text-foreground" title={session.branch}>
|
|
116
|
+
{session.branch}
|
|
117
|
+
</span>
|
|
118
|
+
</div>
|
|
119
|
+
)}
|
|
120
|
+
<div className="text-xs text-muted-foreground/70">Created {formatRelative(session.createdAt)}</div>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
{(onOpen || href || onOpenTerminal) && (
|
|
124
|
+
<div className="mt-4 flex gap-2 border-t border-border pt-4">
|
|
125
|
+
{href ? (
|
|
126
|
+
<a
|
|
127
|
+
href={href(session.id)}
|
|
128
|
+
onClick={(e) => e.stopPropagation()}
|
|
129
|
+
className="inline-flex flex-1 items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
130
|
+
>
|
|
131
|
+
<Eye className="h-4 w-4" aria-hidden="true" />
|
|
132
|
+
Details
|
|
133
|
+
</a>
|
|
134
|
+
) : (
|
|
135
|
+
onOpen && (
|
|
136
|
+
<button
|
|
137
|
+
type="button"
|
|
138
|
+
onClick={(e) => {
|
|
139
|
+
e.stopPropagation()
|
|
140
|
+
open()
|
|
141
|
+
}}
|
|
142
|
+
className="inline-flex flex-1 items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
143
|
+
>
|
|
144
|
+
<Eye className="h-4 w-4" aria-hidden="true" />
|
|
145
|
+
Details
|
|
146
|
+
</button>
|
|
147
|
+
)
|
|
148
|
+
)}
|
|
149
|
+
{onOpenTerminal && (
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={(e) => {
|
|
153
|
+
e.stopPropagation()
|
|
154
|
+
onOpenTerminal(session.id)
|
|
155
|
+
}}
|
|
156
|
+
className="inline-flex flex-1 items-center justify-center gap-1 rounded-md border border-border px-3 py-1.5 text-sm text-foreground transition-colors hover:bg-accent"
|
|
157
|
+
>
|
|
158
|
+
<TerminalSquare className="h-4 w-4" aria-hidden="true" />
|
|
159
|
+
Terminal
|
|
160
|
+
</button>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Memoised — cheap re-renders in long lists. */
|
|
171
|
+
export const SessionCard = React.memo(SessionCardComponent)
|
|
172
|
+
|
|
173
|
+
/** Skeleton loader matching the card layout. */
|
|
174
|
+
export function SessionCardSkeleton({ className }: { className?: string }) {
|
|
175
|
+
return (
|
|
176
|
+
<div className={cn('rounded-lg border border-border bg-card p-4', className)}>
|
|
177
|
+
<div className="flex items-start gap-4">
|
|
178
|
+
<div className="h-12 w-12 flex-shrink-0 animate-pulse rounded-lg bg-muted" />
|
|
179
|
+
<div className="flex-1 space-y-3">
|
|
180
|
+
<div className="h-6 w-3/4 animate-pulse rounded bg-muted" />
|
|
181
|
+
<div className="space-y-2">
|
|
182
|
+
<div className="h-4 w-full animate-pulse rounded bg-muted" />
|
|
183
|
+
<div className="h-4 w-5/6 animate-pulse rounded bg-muted" />
|
|
184
|
+
<div className="h-4 w-4/6 animate-pulse rounded bg-muted" />
|
|
185
|
+
</div>
|
|
186
|
+
<div className="flex gap-2 border-t border-border pt-4">
|
|
187
|
+
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
|
|
188
|
+
<div className="h-9 flex-1 animate-pulse rounded bg-muted" />
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import {
|
|
5
|
+
AlertCircle,
|
|
6
|
+
AlertTriangle,
|
|
7
|
+
CheckCircle2,
|
|
8
|
+
ChevronDown,
|
|
9
|
+
ChevronRight,
|
|
10
|
+
Clock,
|
|
11
|
+
Inbox,
|
|
12
|
+
TerminalSquare,
|
|
13
|
+
} from 'lucide-react'
|
|
14
|
+
import { cn } from '../../lib/utils'
|
|
15
|
+
import { SessionCard, SessionCardSkeleton, formatRelative } from './SessionCard'
|
|
16
|
+
import { resolveSessionStatus } from './status'
|
|
17
|
+
import type { SessionStatusKind, SessionSummary } from './types'
|
|
18
|
+
|
|
19
|
+
export interface SessionListProps {
|
|
20
|
+
sessions: SessionSummary[]
|
|
21
|
+
isLoading?: boolean
|
|
22
|
+
error?: string | null
|
|
23
|
+
/** Grid of cards (default) or a compact table of rows. */
|
|
24
|
+
layout?: 'grid' | 'table'
|
|
25
|
+
/** In `table` layout, group rows under collapsible project headers. */
|
|
26
|
+
groupByProject?: boolean
|
|
27
|
+
/** Slot rendered above the list (filters/search — the app owns them). */
|
|
28
|
+
renderFilters?: React.ReactNode
|
|
29
|
+
/** Inject routing for a card/row (grid: card title/Details become links). */
|
|
30
|
+
href?: (id: string) => string
|
|
31
|
+
onSessionClick?: (session: SessionSummary) => void
|
|
32
|
+
onOpenTerminal?: (id: string) => void
|
|
33
|
+
/** Message shown when there are no sessions. */
|
|
34
|
+
emptyLabel?: string
|
|
35
|
+
/** Number of skeletons while loading. Default 6. */
|
|
36
|
+
skeletonCount?: number
|
|
37
|
+
className?: string
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const STATUS_ICON: Record<SessionStatusKind, React.ComponentType<{ className?: string }>> = {
|
|
41
|
+
active: TerminalSquare,
|
|
42
|
+
success: CheckCircle2,
|
|
43
|
+
completed: CheckCircle2,
|
|
44
|
+
error: AlertCircle,
|
|
45
|
+
warning: AlertTriangle,
|
|
46
|
+
idle: Clock,
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface ProjectGroup {
|
|
50
|
+
project: string
|
|
51
|
+
sessions: SessionSummary[]
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Group + sort sessions by project name for the grouped table layout. */
|
|
55
|
+
function groupByProjectName(sessions: SessionSummary[]): ProjectGroup[] {
|
|
56
|
+
const groups = new Map<string, ProjectGroup>()
|
|
57
|
+
for (const session of sessions) {
|
|
58
|
+
const key = session.projectName ?? 'Ungrouped'
|
|
59
|
+
if (!groups.has(key)) groups.set(key, { project: key, sessions: [] })
|
|
60
|
+
groups.get(key)!.sessions.push(session)
|
|
61
|
+
}
|
|
62
|
+
return Array.from(groups.values())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function SessionRow({
|
|
66
|
+
session,
|
|
67
|
+
onSessionClick,
|
|
68
|
+
onOpenTerminal,
|
|
69
|
+
}: {
|
|
70
|
+
session: SessionSummary
|
|
71
|
+
onSessionClick?: (s: SessionSummary) => void
|
|
72
|
+
onOpenTerminal?: (id: string) => void
|
|
73
|
+
}) {
|
|
74
|
+
const status = resolveSessionStatus(session)
|
|
75
|
+
const Icon = STATUS_ICON[status.kind]
|
|
76
|
+
return (
|
|
77
|
+
<button
|
|
78
|
+
type="button"
|
|
79
|
+
data-testid="session-row"
|
|
80
|
+
onClick={() => onSessionClick?.(session)}
|
|
81
|
+
className="group flex w-full items-center gap-4 border-b border-border p-4 text-left transition-colors last:border-b-0 hover:bg-accent"
|
|
82
|
+
>
|
|
83
|
+
<Icon className={cn('h-4 w-4 flex-shrink-0', status.toneClassName)} />
|
|
84
|
+
<div className="min-w-0 flex-1">
|
|
85
|
+
<div className="flex items-baseline gap-2">
|
|
86
|
+
<span className="truncate font-medium text-foreground group-hover:text-primary">{session.name}</span>
|
|
87
|
+
{session.number != null && <span className="text-xs text-muted-foreground">#{session.number}</span>}
|
|
88
|
+
</div>
|
|
89
|
+
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
|
|
90
|
+
<span className={status.toneClassName}>{status.label}</span>
|
|
91
|
+
{session.branch && (
|
|
92
|
+
<span className="flex items-center gap-1">
|
|
93
|
+
<span className="h-1 w-1 rounded-full bg-current" />
|
|
94
|
+
<span className="font-mono">{session.branch}</span>
|
|
95
|
+
</span>
|
|
96
|
+
)}
|
|
97
|
+
<span className="flex items-center gap-1">
|
|
98
|
+
<Clock className="h-3 w-3" aria-hidden="true" />
|
|
99
|
+
{formatRelative(session.updatedAt ?? session.createdAt)}
|
|
100
|
+
</span>
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
{onOpenTerminal && (
|
|
104
|
+
<span
|
|
105
|
+
role="button"
|
|
106
|
+
tabIndex={-1}
|
|
107
|
+
onClick={(e) => {
|
|
108
|
+
e.stopPropagation()
|
|
109
|
+
onOpenTerminal(session.id)
|
|
110
|
+
}}
|
|
111
|
+
className="flex-shrink-0 rounded-md p-2 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
|
|
112
|
+
title="Open terminal"
|
|
113
|
+
>
|
|
114
|
+
<TerminalSquare className="h-4 w-4" aria-hidden="true" />
|
|
115
|
+
</span>
|
|
116
|
+
)}
|
|
117
|
+
</button>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function EmptyState({ label }: { label: string }) {
|
|
122
|
+
return (
|
|
123
|
+
<div
|
|
124
|
+
data-testid="session-list-empty"
|
|
125
|
+
className="flex flex-col items-center justify-center rounded-xl border border-border bg-card px-4 py-16 text-center"
|
|
126
|
+
>
|
|
127
|
+
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full border border-border bg-muted">
|
|
128
|
+
<Inbox className="h-8 w-8 text-muted-foreground" aria-hidden="true" />
|
|
129
|
+
</div>
|
|
130
|
+
<h3 className="text-lg font-semibold text-foreground">{label}</h3>
|
|
131
|
+
</div>
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A PURELY presentational list of sessions. It does NO data fetching and issues NO
|
|
137
|
+
* per-row queries — status is injected on each `SessionSummary`. Renders a card
|
|
138
|
+
* grid (`layout="grid"`) or a compact table (`layout="table"`, optionally grouped
|
|
139
|
+
* by project). The app owns filters (pass them via `renderFilters`).
|
|
140
|
+
*/
|
|
141
|
+
export function SessionList({
|
|
142
|
+
sessions,
|
|
143
|
+
isLoading = false,
|
|
144
|
+
error = null,
|
|
145
|
+
layout = 'grid',
|
|
146
|
+
groupByProject = false,
|
|
147
|
+
renderFilters,
|
|
148
|
+
href,
|
|
149
|
+
onSessionClick,
|
|
150
|
+
onOpenTerminal,
|
|
151
|
+
emptyLabel = 'No sessions yet',
|
|
152
|
+
skeletonCount = 6,
|
|
153
|
+
className,
|
|
154
|
+
}: SessionListProps) {
|
|
155
|
+
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set())
|
|
156
|
+
const groups = React.useMemo(
|
|
157
|
+
() => (layout === 'table' && groupByProject ? groupByProjectName(sessions) : []),
|
|
158
|
+
[layout, groupByProject, sessions],
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
const toggle = (project: string) =>
|
|
162
|
+
setCollapsed((prev) => {
|
|
163
|
+
const next = new Set(prev)
|
|
164
|
+
if (next.has(project)) next.delete(project)
|
|
165
|
+
else next.add(project)
|
|
166
|
+
return next
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
let body: React.ReactNode
|
|
170
|
+
|
|
171
|
+
if (isLoading) {
|
|
172
|
+
body = (
|
|
173
|
+
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
174
|
+
{Array.from({ length: skeletonCount }).map((_, i) => (
|
|
175
|
+
<SessionCardSkeleton key={i} />
|
|
176
|
+
))}
|
|
177
|
+
</div>
|
|
178
|
+
)
|
|
179
|
+
} else if (error) {
|
|
180
|
+
body = (
|
|
181
|
+
<div
|
|
182
|
+
data-testid="session-list-error"
|
|
183
|
+
className="rounded-xl border border-red-500/30 bg-card px-4 py-16 text-center"
|
|
184
|
+
>
|
|
185
|
+
<h3 className="mb-2 text-lg font-semibold text-foreground">Failed to load sessions</h3>
|
|
186
|
+
<p className="text-sm text-muted-foreground">{error}</p>
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
} else if (sessions.length === 0) {
|
|
190
|
+
body = <EmptyState label={emptyLabel} />
|
|
191
|
+
} else if (layout === 'table') {
|
|
192
|
+
if (groupByProject) {
|
|
193
|
+
body = (
|
|
194
|
+
<div className="space-y-2">
|
|
195
|
+
{groups.map((group) => {
|
|
196
|
+
const isCollapsed = collapsed.has(group.project)
|
|
197
|
+
const activeCount = group.sessions.filter((s) => s.isActive).length
|
|
198
|
+
return (
|
|
199
|
+
<div key={group.project} className="overflow-hidden rounded-lg border border-border">
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
onClick={() => toggle(group.project)}
|
|
203
|
+
className="flex w-full items-center gap-3 p-4 text-left transition-colors hover:bg-accent"
|
|
204
|
+
>
|
|
205
|
+
{isCollapsed ? (
|
|
206
|
+
<ChevronRight className="h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
207
|
+
) : (
|
|
208
|
+
<ChevronDown className="h-4 w-4 flex-shrink-0 text-muted-foreground" aria-hidden="true" />
|
|
209
|
+
)}
|
|
210
|
+
<div className="min-w-0 flex-1">
|
|
211
|
+
<h3 className="truncate font-medium text-foreground">{group.project}</h3>
|
|
212
|
+
<p className="mt-0.5 text-xs text-muted-foreground">
|
|
213
|
+
{group.sessions.length} session{group.sessions.length !== 1 ? 's' : ''}
|
|
214
|
+
{activeCount > 0 && <span className="ml-2 text-primary">{activeCount} active</span>}
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
</button>
|
|
218
|
+
{!isCollapsed && (
|
|
219
|
+
<div className="border-t border-border">
|
|
220
|
+
{group.sessions.map((session) => (
|
|
221
|
+
<SessionRow
|
|
222
|
+
key={session.id}
|
|
223
|
+
session={session}
|
|
224
|
+
onSessionClick={onSessionClick}
|
|
225
|
+
onOpenTerminal={onOpenTerminal}
|
|
226
|
+
/>
|
|
227
|
+
))}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
})}
|
|
233
|
+
</div>
|
|
234
|
+
)
|
|
235
|
+
} else {
|
|
236
|
+
body = (
|
|
237
|
+
<div className="overflow-hidden rounded-lg border border-border">
|
|
238
|
+
{sessions.map((session) => (
|
|
239
|
+
<SessionRow
|
|
240
|
+
key={session.id}
|
|
241
|
+
session={session}
|
|
242
|
+
onSessionClick={onSessionClick}
|
|
243
|
+
onOpenTerminal={onOpenTerminal}
|
|
244
|
+
/>
|
|
245
|
+
))}
|
|
246
|
+
</div>
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
body = (
|
|
251
|
+
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
|
252
|
+
{sessions.map((session) => (
|
|
253
|
+
<SessionCard
|
|
254
|
+
key={session.id}
|
|
255
|
+
session={session}
|
|
256
|
+
href={href}
|
|
257
|
+
onOpen={onSessionClick ? () => onSessionClick(session) : undefined}
|
|
258
|
+
onOpenTerminal={onOpenTerminal}
|
|
259
|
+
/>
|
|
260
|
+
))}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return (
|
|
266
|
+
<div className={cn('space-y-6', className)}>
|
|
267
|
+
{renderFilters}
|
|
268
|
+
{body}
|
|
269
|
+
</div>
|
|
270
|
+
)
|
|
271
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
import { resolveSessionStatus } from './status'
|
|
6
|
+
import type { SessionStatusKind } from './types'
|
|
7
|
+
|
|
8
|
+
export interface SessionStatusBadgeProps {
|
|
9
|
+
/** Explicit status kind. If omitted, derived from `isActive`. */
|
|
10
|
+
status?: SessionStatusKind
|
|
11
|
+
/** Human label. Defaults from the resolved kind. */
|
|
12
|
+
label?: string
|
|
13
|
+
/** Fallback when `status` is absent. */
|
|
14
|
+
isActive?: boolean
|
|
15
|
+
/** Show the leading state dot. Default true. */
|
|
16
|
+
showDot?: boolean
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A generic session status pill. Colour + label come from `resolveSessionStatus`
|
|
22
|
+
* using tailwind tokens — no app-specific palette. (Named `SessionStatusBadge` to
|
|
23
|
+
* avoid the existing `StatusBadge` export in `../badge`.)
|
|
24
|
+
*/
|
|
25
|
+
export function SessionStatusBadge({
|
|
26
|
+
status,
|
|
27
|
+
label,
|
|
28
|
+
isActive = false,
|
|
29
|
+
showDot = true,
|
|
30
|
+
className,
|
|
31
|
+
}: SessionStatusBadgeProps) {
|
|
32
|
+
const resolved = resolveSessionStatus({ status, statusLabel: label, isActive })
|
|
33
|
+
const isLive = resolved.kind === 'active'
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<span
|
|
37
|
+
role="status"
|
|
38
|
+
data-status={resolved.kind}
|
|
39
|
+
aria-label={`Status: ${resolved.label}`}
|
|
40
|
+
className={cn(
|
|
41
|
+
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-semibold',
|
|
42
|
+
resolved.className,
|
|
43
|
+
className,
|
|
44
|
+
)}
|
|
45
|
+
>
|
|
46
|
+
{showDot && (
|
|
47
|
+
<span
|
|
48
|
+
aria-hidden="true"
|
|
49
|
+
className={cn('h-1.5 w-1.5 rounded-full bg-current', isLive && 'animate-pulse')}
|
|
50
|
+
/>
|
|
51
|
+
)}
|
|
52
|
+
<span>{resolved.label}</span>
|
|
53
|
+
</span>
|
|
54
|
+
)
|
|
55
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export { SessionStatusBadge } from './SessionStatusBadge'
|
|
2
|
+
export type { SessionStatusBadgeProps } from './SessionStatusBadge'
|
|
3
|
+
|
|
4
|
+
export { SessionCard, SessionCardSkeleton, formatRelative } from './SessionCard'
|
|
5
|
+
export type { SessionCardProps } from './SessionCard'
|
|
6
|
+
|
|
7
|
+
export { SessionList } from './SessionList'
|
|
8
|
+
export type { SessionListProps } from './SessionList'
|
|
9
|
+
|
|
10
|
+
export { ConversationTimeline } from './ConversationTimeline'
|
|
11
|
+
export type { ConversationTimelineProps } from './ConversationTimeline'
|
|
12
|
+
|
|
13
|
+
// AgentSessionConsole imports @startsimpli/realtime, which is not on npm (the org
|
|
14
|
+
// won't create the new name yet). To keep @startsimpli/ui installable WITHOUT
|
|
15
|
+
// realtime, the Console is NOT in this main barrel — it ships via the
|
|
16
|
+
// `@startsimpli/ui/session-console` subpath, which live-console consumers import
|
|
17
|
+
// (and add @startsimpli/realtime themselves). See package.json exports.
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
resolveSessionStatus,
|
|
21
|
+
mapSocketStatus,
|
|
22
|
+
type SessionStatusVisual,
|
|
23
|
+
type AgentConnectionStatus,
|
|
24
|
+
} from './status'
|
|
25
|
+
|
|
26
|
+
export type { SessionSummary, SessionStatusKind } from './types'
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure status helpers for the session UI — NO React, NO realtime, NO xterm imports,
|
|
3
|
+
* so they're trivially unit-testable and safe to pull anywhere.
|
|
4
|
+
*/
|
|
5
|
+
import type { TerminalStatus } from '../terminal/themes'
|
|
6
|
+
import type { SessionStatusKind, SessionSummary } from './types'
|
|
7
|
+
|
|
8
|
+
/** Visual tokens for a resolved session status. */
|
|
9
|
+
export interface SessionStatusVisual {
|
|
10
|
+
kind: SessionStatusKind
|
|
11
|
+
label: string
|
|
12
|
+
/** Tailwind-token classes for the badge pill (light + dark friendly). */
|
|
13
|
+
className: string
|
|
14
|
+
/** Tailwind-token colour class for a status icon/text. */
|
|
15
|
+
toneClassName: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const DEFAULT_LABELS: Record<SessionStatusKind, string> = {
|
|
19
|
+
active: 'Active',
|
|
20
|
+
success: 'Success',
|
|
21
|
+
error: 'Error',
|
|
22
|
+
warning: 'Needs review',
|
|
23
|
+
idle: 'Idle',
|
|
24
|
+
completed: 'Completed',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const KIND_CLASSES: Record<SessionStatusKind, string> = {
|
|
28
|
+
active: 'bg-primary/15 text-primary border border-primary/30',
|
|
29
|
+
success: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30',
|
|
30
|
+
error: 'bg-red-500/15 text-red-600 dark:text-red-400 border border-red-500/30',
|
|
31
|
+
warning: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30',
|
|
32
|
+
idle: 'bg-muted text-muted-foreground border border-border',
|
|
33
|
+
completed: 'bg-muted text-muted-foreground border border-border',
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const KIND_TONE: Record<SessionStatusKind, string> = {
|
|
37
|
+
active: 'text-primary',
|
|
38
|
+
success: 'text-emerald-600 dark:text-emerald-400',
|
|
39
|
+
error: 'text-red-600 dark:text-red-400',
|
|
40
|
+
warning: 'text-amber-600 dark:text-amber-400',
|
|
41
|
+
idle: 'text-muted-foreground',
|
|
42
|
+
completed: 'text-muted-foreground',
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the effective status of a session view-model. Prefers an explicit
|
|
47
|
+
* `status`; otherwise derives `active`/`idle` from `isActive`.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveSessionStatus(
|
|
50
|
+
session: Pick<SessionSummary, 'status' | 'statusLabel' | 'isActive'>,
|
|
51
|
+
): SessionStatusVisual {
|
|
52
|
+
const kind: SessionStatusKind = session.status ?? (session.isActive ? 'active' : 'idle')
|
|
53
|
+
return {
|
|
54
|
+
kind,
|
|
55
|
+
label: session.statusLabel ?? DEFAULT_LABELS[kind],
|
|
56
|
+
className: KIND_CLASSES[kind],
|
|
57
|
+
toneClassName: KIND_TONE[kind],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The connection states an `AgentSocket` reports (mirrors `@startsimpli/realtime`'s
|
|
63
|
+
* `AgentSocketStatus`). Redefined locally so this module stays realtime-free.
|
|
64
|
+
*/
|
|
65
|
+
export type AgentConnectionStatus = 'idle' | 'connecting' | 'open' | 'reconnecting' | 'closed'
|
|
66
|
+
|
|
67
|
+
/** Map an agent socket status onto the terminal chrome's `TerminalStatus`. */
|
|
68
|
+
export function mapSocketStatus(status: AgentConnectionStatus): TerminalStatus {
|
|
69
|
+
switch (status) {
|
|
70
|
+
case 'open':
|
|
71
|
+
return 'connected'
|
|
72
|
+
case 'connecting':
|
|
73
|
+
case 'reconnecting':
|
|
74
|
+
return 'connecting'
|
|
75
|
+
case 'idle':
|
|
76
|
+
case 'closed':
|
|
77
|
+
default:
|
|
78
|
+
return 'disconnected'
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generic, app-agnostic view-models for the agent-session UI. These decouple the
|
|
3
|
+
* shared components from any app's data model (no dev-hq `CodingSession`, no
|
|
4
|
+
* Foundry types) — an app maps its own records into these shapes.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** The coarse lifecycle state a session badge/row reflects. */
|
|
8
|
+
export type SessionStatusKind = 'active' | 'success' | 'error' | 'warning' | 'idle' | 'completed'
|
|
9
|
+
|
|
10
|
+
/** A single session as the list/card/badge components consume it. */
|
|
11
|
+
export interface SessionSummary {
|
|
12
|
+
id: string
|
|
13
|
+
/** Optional short sequence number (rendered as a #NNN avatar). */
|
|
14
|
+
number?: number
|
|
15
|
+
name: string
|
|
16
|
+
/** Whether the underlying session is currently running. */
|
|
17
|
+
isActive: boolean
|
|
18
|
+
/** Coarse status; if omitted the components fall back to `isActive`. */
|
|
19
|
+
status?: SessionStatusKind
|
|
20
|
+
/** Human label for the status (defaults derived from `status`/`isActive`). */
|
|
21
|
+
statusLabel?: string
|
|
22
|
+
projectName?: string
|
|
23
|
+
agentName?: string
|
|
24
|
+
branch?: string
|
|
25
|
+
createdAt: string | number
|
|
26
|
+
updatedAt?: string | number
|
|
27
|
+
/** Shows an unread dot on the card/row. */
|
|
28
|
+
hasUnread?: boolean
|
|
29
|
+
}
|