@startsimpli/ui 0.4.43 → 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/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/src/components/index.ts
CHANGED
|
@@ -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,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
|
+
}
|