@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.
@@ -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
+ }