@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.
@@ -60,6 +60,18 @@ export * from './workspace'
60
60
  // Chat surface primitives (ChatThread, ChatMessage, ChatComposer)
61
61
  export * from './chat'
62
62
 
63
+ // Terminal (xterm.js) — generic presentational terminal + skeleton + themes
64
+ // (startsim-768w.16.7.E). Import styles from '@startsimpli/ui/terminal/styles'.
65
+ export * from './terminal'
66
+
67
+ // Diff viewer — presentational unified-diff renderer (no fetching)
68
+ export * from './diff'
69
+
70
+ // Agent-session surfaces (startsim-768w.16.7.E): SessionCard/List/StatusBadge,
71
+ // ConversationTimeline, and the live AgentSessionConsole (driven by
72
+ // @startsimpli/realtime/react). All generic — decoupled from any app model.
73
+ export * from './session'
74
+
63
75
  // Slide-deck viewer (Google-Slides-style canvas + filmstrip)
64
76
  export * from './slide-deck'
65
77
 
@@ -147,3 +159,6 @@ export type {
147
159
  ActivityTypeOption,
148
160
  OutcomeOption,
149
161
  } from './LogActivityDialog'
162
+
163
+ // Record detail read-view
164
+ export * from './record-detail'
@@ -0,0 +1,92 @@
1
+ 'use client'
2
+
3
+ import * as React from 'react'
4
+ import { cn } from '../../lib/utils'
5
+
6
+ export type RecordFieldKind = 'text' | 'longtext' | 'url' | 'boolean' | 'json'
7
+
8
+ export interface RecordField {
9
+ label: string
10
+ value: string
11
+ kind?: RecordFieldKind
12
+ }
13
+
14
+ export interface RecordDetailProps {
15
+ fields: RecordField[]
16
+ emptyMessage?: string
17
+ className?: string
18
+ }
19
+
20
+ const URL_RE = /^https?:\/\//i
21
+
22
+ /**
23
+ * Read-optimized view of a record's fields (startsim-768w.17.4). Renders short
24
+ * fields as a compact label/value list (auto-linking urls), long-text fields as
25
+ * readable, newline-preserving prose blocks (right for extracted article bodies),
26
+ * and json as a scrollable code block. Generic: a consumer maps its own schema to
27
+ * `fields` and this owns the layout, so every Foundry tenant's detail drawer reads
28
+ * the same without duplicating the view.
29
+ */
30
+ export function RecordDetail({ fields, emptyMessage = 'No details.', className }: RecordDetailProps) {
31
+ const present = fields.filter((f) => f.value != null && String(f.value).trim() !== '')
32
+ const shorts = present.filter((f) => f.kind !== 'longtext' && f.kind !== 'json')
33
+ const longs = present.filter((f) => f.kind === 'longtext')
34
+ const jsons = present.filter((f) => f.kind === 'json')
35
+
36
+ if (present.length === 0) {
37
+ return <p className={cn('text-sm text-muted-foreground', className)}>{emptyMessage}</p>
38
+ }
39
+
40
+ return (
41
+ <div className={cn('space-y-4', className)}>
42
+ {shorts.length > 0 && (
43
+ <dl className="space-y-1.5">
44
+ {shorts.map((f, i) => {
45
+ const isUrl = f.kind === 'url' || URL_RE.test(f.value)
46
+ return (
47
+ <div key={i} className="flex gap-2 text-sm">
48
+ <dt className="w-28 shrink-0 capitalize text-muted-foreground">{f.label}</dt>
49
+ <dd className="min-w-0 flex-1 break-words">
50
+ {isUrl ? (
51
+ <a
52
+ href={f.value}
53
+ target="_blank"
54
+ rel="noopener noreferrer"
55
+ className="break-all text-primary underline underline-offset-2 hover:no-underline"
56
+ >
57
+ {f.value}
58
+ </a>
59
+ ) : (
60
+ f.value
61
+ )}
62
+ </dd>
63
+ </div>
64
+ )
65
+ })}
66
+ </dl>
67
+ )}
68
+
69
+ {longs.map((f, i) => (
70
+ <section key={i} className="space-y-1.5">
71
+ <h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
72
+ {f.label}
73
+ </h4>
74
+ <div className="whitespace-pre-wrap text-sm leading-relaxed text-foreground">
75
+ {f.value}
76
+ </div>
77
+ </section>
78
+ ))}
79
+
80
+ {jsons.map((f, i) => (
81
+ <section key={i} className="space-y-1.5">
82
+ <h4 className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
83
+ {f.label}
84
+ </h4>
85
+ <pre className="overflow-x-auto rounded-md bg-muted p-3 text-xs leading-relaxed">
86
+ {f.value}
87
+ </pre>
88
+ </section>
89
+ ))}
90
+ </div>
91
+ )
92
+ }
@@ -0,0 +1,42 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import { RecordDetail, type RecordField } from '../RecordDetail'
3
+
4
+ const fields: RecordField[] = [
5
+ { label: 'url', value: 'https://example.com/a', kind: 'url' },
6
+ { label: 'source', value: 'The National', kind: 'text' },
7
+ { label: 'content', value: 'Para one.\nPara two.', kind: 'longtext' },
8
+ { label: 'empty', value: '', kind: 'text' },
9
+ ]
10
+
11
+ describe('RecordDetail', () => {
12
+ it('renders a url field as a new-tab clickable link', () => {
13
+ render(<RecordDetail fields={fields} />)
14
+ const link = screen.getByRole('link', { name: 'https://example.com/a' })
15
+ expect(link).toHaveAttribute('href', 'https://example.com/a')
16
+ expect(link).toHaveAttribute('target', '_blank')
17
+ expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'))
18
+ })
19
+
20
+ it('renders long-text as a newline-preserving prose block', () => {
21
+ const { container } = render(<RecordDetail fields={fields} />)
22
+ const prose = container.querySelector('.whitespace-pre-wrap')
23
+ expect(prose).toBeInTheDocument()
24
+ expect(prose?.textContent).toContain('Para one.')
25
+ expect(prose?.textContent).toContain('Para two.')
26
+ })
27
+
28
+ it('skips empty fields', () => {
29
+ render(<RecordDetail fields={fields} />)
30
+ expect(screen.queryByText('empty')).not.toBeInTheDocument()
31
+ })
32
+
33
+ it('auto-links a text field whose value looks like a url', () => {
34
+ render(<RecordDetail fields={[{ label: 'link', value: 'https://x.io', kind: 'text' }]} />)
35
+ expect(screen.getByRole('link', { name: 'https://x.io' })).toBeInTheDocument()
36
+ })
37
+
38
+ it('shows the empty message when no field has a value', () => {
39
+ render(<RecordDetail fields={[{ label: 'a', value: '' }]} emptyMessage="Nothing here" />)
40
+ expect(screen.getByText('Nothing here')).toBeInTheDocument()
41
+ })
42
+ })
@@ -0,0 +1,2 @@
1
+ export { RecordDetail } from './RecordDetail'
2
+ export type { RecordDetailProps, RecordField, RecordFieldKind } from './RecordDetail'
@@ -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
+ }