@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.
- 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/kanban/KanbanBoard.tsx +168 -59
- package/src/components/kanban/__tests__/kanban.test.tsx +64 -1
- package/src/components/kanban/index.ts +2 -2
- 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
|
@@ -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
|
+
}
|