@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.
@@ -0,0 +1,200 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { useAgentSession } from '@startsimpli/realtime/react'
5
+ import type { AgentLifecycleMsg } from '@startsimpli/realtime'
6
+ import { cn } from '../../lib/utils'
7
+ import { Terminal, type TerminalHandle } from '../terminal/Terminal'
8
+ import type { ChatMessageData } from '../chat/types'
9
+ import { ConversationTimeline } from './ConversationTimeline'
10
+ import { DiffViewer } from '../diff/DiffViewer'
11
+ import { mapSocketStatus } from './status'
12
+
13
+ export interface AgentSessionConsoleProps {
14
+ /** WebSocket URL for the agent session (ws(s)://…). */
15
+ url: string
16
+ /** Auth token appended by the socket (string or getter re-read on reconnect). */
17
+ token?: string | (() => string | null | undefined)
18
+ /** A raw unified diff to show in the Diff tab (the parent fetches it). */
19
+ diff?: string
20
+ /** Load/refresh the diff (wired to the Diff tab's refresh button). */
21
+ onLoadDiff?: () => void
22
+ diffLoading?: boolean
23
+ diffError?: string | null
24
+ /** Ref/branch the diff is against (shown in the diff header). */
25
+ diffBaseRef?: string
26
+ title?: string
27
+ className?: string
28
+ }
29
+
30
+ type Tab = 'session' | 'diff'
31
+
32
+ /** Turn a structured lifecycle event into a timeline row. Generic — no app knowledge. */
33
+ export function lifecycleToMessage(msg: AgentLifecycleMsg): ChatMessageData {
34
+ const data = msg.data as { message?: string; tool?: string } | undefined
35
+ const looksLikeTool = /tool|command|exec|edit|write|read|bash/i.test(msg.event)
36
+ const content = data?.message ?? msg.event.replace(/[_-]/g, ' ')
37
+ return {
38
+ id: `lifecycle-${msg.ts ?? Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
39
+ role: looksLikeTool ? 'tool' : 'system',
40
+ kind: looksLikeTool ? 'tool' : 'status',
41
+ toolName: looksLikeTool ? (data?.tool ?? msg.event) : undefined,
42
+ content,
43
+ timestamp: msg.ts,
44
+ }
45
+ }
46
+
47
+ /**
48
+ * The live agent-session surface: a status header + a `<Terminal>` and
49
+ * `<ConversationTimeline>` side by side (with a `<DiffViewer>` in a tab). It is
50
+ * driven by `useAgentSession` from `@startsimpli/realtime/react`:
51
+ * - inbound output is pumped into the terminal imperatively (no re-render/byte),
52
+ * - keystrokes/resizes are forwarded to `session.send`/`session.resize`,
53
+ * - lifecycle events accumulate into the conversation timeline,
54
+ * - the composer steers the agent via `session.send`.
55
+ *
56
+ * Fully generic — no Foundry/app types. The parent supplies the URL, token, and
57
+ * (optionally) a diff.
58
+ */
59
+ export function AgentSessionConsole({
60
+ url,
61
+ token,
62
+ diff,
63
+ onLoadDiff,
64
+ diffLoading,
65
+ diffError,
66
+ diffBaseRef,
67
+ title,
68
+ className,
69
+ }: AgentSessionConsoleProps) {
70
+ const termHandleRef = React.useRef<TerminalHandle | null>(null)
71
+ const [tab, setTab] = React.useState<Tab>('session')
72
+ const [entries, setEntries] = React.useState<ChatMessageData[]>([])
73
+
74
+ const session = useAgentSession({
75
+ url,
76
+ token,
77
+ onOutput: (d) => termHandleRef.current?.write(d),
78
+ onLifecycle: (m) => setEntries((prev) => [...prev, lifecycleToMessage(m)]),
79
+ })
80
+
81
+ const { status, lastEvent, send, resize, socket } = session
82
+
83
+ // An error event trumps the socket status for the terminal indicator.
84
+ const isError = lastEvent?.type === 'error'
85
+ const terminalStatus = isError ? 'error' : mapSocketStatus(status)
86
+
87
+ const exited = lastEvent?.type === 'exited' ? lastEvent : null
88
+
89
+ const handleSend = React.useCallback(
90
+ (text: string) => {
91
+ send(text)
92
+ setEntries((prev) => [
93
+ ...prev,
94
+ { id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, role: 'user', content: text },
95
+ ])
96
+ },
97
+ [send],
98
+ )
99
+
100
+ const reconnect = React.useCallback(() => socket?.connect(), [socket])
101
+
102
+ const statusLabel = exited
103
+ ? `Exited (code ${exited.exitCode})`
104
+ : isError
105
+ ? 'Error'
106
+ : status === 'open'
107
+ ? 'Connected'
108
+ : status === 'connecting'
109
+ ? 'Connecting…'
110
+ : status === 'reconnecting'
111
+ ? 'Reconnecting…'
112
+ : 'Disconnected'
113
+
114
+ return (
115
+ <div className={cn('flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card', className)}>
116
+ {/* Status header + tabs */}
117
+ <div className="flex flex-shrink-0 flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-2">
118
+ <div className="flex items-center gap-3">
119
+ {title && <span className="text-sm font-semibold text-foreground">{title}</span>}
120
+ <span
121
+ data-testid="agent-session-status"
122
+ className={cn(
123
+ 'inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium',
124
+ terminalStatus === 'connected' && 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400',
125
+ terminalStatus === 'connecting' && 'bg-muted text-muted-foreground',
126
+ terminalStatus === 'error' && 'bg-red-500/15 text-red-600 dark:text-red-400',
127
+ terminalStatus === 'disconnected' && 'bg-muted text-muted-foreground',
128
+ )}
129
+ >
130
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
131
+ {statusLabel}
132
+ </span>
133
+ </div>
134
+ <div className="flex items-center gap-1 rounded-lg bg-muted p-0.5" role="tablist">
135
+ <button
136
+ type="button"
137
+ role="tab"
138
+ aria-selected={tab === 'session'}
139
+ onClick={() => setTab('session')}
140
+ className={cn(
141
+ 'rounded-md px-3 py-1 text-xs font-medium transition-colors',
142
+ tab === 'session' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
143
+ )}
144
+ >
145
+ Session
146
+ </button>
147
+ <button
148
+ type="button"
149
+ role="tab"
150
+ aria-selected={tab === 'diff'}
151
+ onClick={() => setTab('diff')}
152
+ className={cn(
153
+ 'rounded-md px-3 py-1 text-xs font-medium transition-colors',
154
+ tab === 'diff' ? 'bg-card text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground',
155
+ )}
156
+ >
157
+ Diff
158
+ </button>
159
+ </div>
160
+ </div>
161
+
162
+ <div className="relative min-h-0 flex-1">
163
+ {/* Session tab: Terminal | Conversation split. Terminal stays MOUNTED
164
+ across tab switches (via `hidden`) so xterm is never re-initialised. */}
165
+ <div className={cn('grid h-full grid-cols-1 gap-3 p-3 lg:grid-cols-2', tab !== 'session' && 'hidden')}>
166
+ <Terminal
167
+ onReady={(h) => {
168
+ termHandleRef.current = h
169
+ }}
170
+ onData={send}
171
+ onResize={resize}
172
+ status={terminalStatus}
173
+ onReconnect={reconnect}
174
+ className="min-h-[320px]"
175
+ />
176
+ <ConversationTimeline
177
+ entries={entries}
178
+ title="Conversation"
179
+ statusLabel={statusLabel}
180
+ onSend={handleSend}
181
+ emptyState={<span className="text-sm text-muted-foreground">Steer the agent by sending a message.</span>}
182
+ className="min-h-[320px]"
183
+ />
184
+ </div>
185
+
186
+ {/* Diff tab */}
187
+ <div className={cn('h-full p-3', tab !== 'diff' && 'hidden')}>
188
+ <DiffViewer
189
+ diff={diff ?? ''}
190
+ baseRef={diffBaseRef}
191
+ isLoading={diffLoading}
192
+ error={diffError}
193
+ onRefresh={onLoadDiff}
194
+ className="h-full rounded-lg border border-border"
195
+ />
196
+ </div>
197
+ </div>
198
+ </div>
199
+ )
200
+ }
@@ -0,0 +1,131 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+ import { ChatThread } from '../chat/ChatThread'
6
+ import { ChatComposer } from '../chat/ChatComposer'
7
+ import type { ChatMessageData } from '../chat/types'
8
+
9
+ export interface ConversationTimelineProps {
10
+ /** The agent-shaped timeline entries (already chat-shaped: status/kind/toolName). */
11
+ entries: ChatMessageData[]
12
+ /** Header title. */
13
+ title?: string
14
+ /** 0..100 progress bar shown under the header (omit to hide). */
15
+ progressPercent?: number
16
+ /** Short status label shown in the header. */
17
+ statusLabel?: string
18
+ /** Called with the composed message text. Sending state is tracked internally. */
19
+ onSend?: (text: string) => void | Promise<void>
20
+ /** The agent is working / entries are loading — shows the working indicator. */
21
+ isLoading?: boolean
22
+ /** Load older entries (renders a "Load more" control when `hasMore`). */
23
+ onLoadMore?: () => void
24
+ hasMore?: boolean
25
+ composerPlaceholder?: string
26
+ /** Rendered when there are no entries. */
27
+ emptyState?: React.ReactNode
28
+ /** Custom message-body renderer passed through to ChatThread (e.g. markdown). */
29
+ renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
30
+ className?: string
31
+ }
32
+
33
+ /**
34
+ * The "steer the agent" surface: a scrolling timeline of agent events plus a
35
+ * composer that sends a message back to the agent. Built on the existing
36
+ * ChatThread / ChatComposer, so it inherits their auto-scroll, streaming
37
+ * indicators, tool/status rows, and keyboard handling. Fully generic — it takes
38
+ * `ChatMessageData[]` and an `onSend` callback; it knows nothing about the
39
+ * transport or any app model.
40
+ */
41
+ export function ConversationTimeline({
42
+ entries,
43
+ title,
44
+ progressPercent,
45
+ statusLabel,
46
+ onSend,
47
+ isLoading = false,
48
+ onLoadMore,
49
+ hasMore = false,
50
+ composerPlaceholder = 'Message the agent…',
51
+ emptyState,
52
+ renderContent,
53
+ className,
54
+ }: ConversationTimelineProps) {
55
+ const [sending, setSending] = React.useState(false)
56
+
57
+ const handleSubmit = React.useCallback(
58
+ (text: string) => {
59
+ if (!onSend) return
60
+ const result = onSend(text)
61
+ if (result && typeof (result as Promise<void>).then === 'function') {
62
+ setSending(true)
63
+ void (result as Promise<void>).finally(() => setSending(false))
64
+ }
65
+ },
66
+ [onSend],
67
+ )
68
+
69
+ const showHeader = !!title || !!statusLabel || progressPercent != null
70
+
71
+ return (
72
+ <div className={cn('flex h-full flex-col overflow-hidden rounded-lg border border-border bg-card', className)}>
73
+ {showHeader && (
74
+ <div className="flex-shrink-0 border-b border-border px-4 py-3">
75
+ <div className="flex items-center justify-between gap-2">
76
+ {title && <h3 className="truncate text-sm font-semibold text-foreground">{title}</h3>}
77
+ {statusLabel && <span className="text-xs text-muted-foreground">{statusLabel}</span>}
78
+ </div>
79
+ {progressPercent != null && (
80
+ <div className="mt-2">
81
+ <div className="mb-1 flex items-center justify-between text-xs text-muted-foreground">
82
+ <span>Progress</span>
83
+ <span>{Math.round(progressPercent)}%</span>
84
+ </div>
85
+ <div className="h-2 overflow-hidden rounded-full bg-muted">
86
+ <div
87
+ className="h-full rounded-full bg-primary transition-[width] duration-500"
88
+ style={{ width: `${Math.max(0, Math.min(100, progressPercent))}%` }}
89
+ role="progressbar"
90
+ aria-valuemin={0}
91
+ aria-valuemax={100}
92
+ aria-valuenow={Math.round(progressPercent)}
93
+ />
94
+ </div>
95
+ </div>
96
+ )}
97
+ </div>
98
+ )}
99
+
100
+ <div className="flex min-h-0 flex-1 flex-col">
101
+ {hasMore && onLoadMore && (
102
+ <div className="flex-shrink-0 border-b border-border p-2 text-center">
103
+ <button
104
+ type="button"
105
+ onClick={onLoadMore}
106
+ className="text-xs text-primary transition-colors hover:underline"
107
+ >
108
+ Load earlier messages
109
+ </button>
110
+ </div>
111
+ )}
112
+ <ChatThread
113
+ messages={entries}
114
+ busy={isLoading}
115
+ emptyState={emptyState}
116
+ renderContent={renderContent}
117
+ className="flex-1"
118
+ />
119
+ </div>
120
+
121
+ {onSend && (
122
+ <ChatComposer
123
+ onSubmit={handleSubmit}
124
+ placeholder={composerPlaceholder}
125
+ busy={sending}
126
+ className="flex-shrink-0"
127
+ />
128
+ )}
129
+ </div>
130
+ )
131
+ }
@@ -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
+ }