fix: retain workflow trace on response — linger + default open
All checks were successful
Stuffle/nebula-os/pipeline/head This commit was not built

- showTyping state lingers 400ms after isPending drops so TypingBubble
  stays visible during the handoff to the completed message
- WorkflowTrace defaults to open=true so the execution diagram is
  immediately visible below the response (no need to click to expand)
This commit is contained in:
2026-04-21 00:14:05 +05:30
parent 75457e3f33
commit d872254106

View File

@@ -9,9 +9,10 @@ import {
Plug, Cpu, Database, GitBranch,
ChevronRight, CheckCircle2, XCircle, Clock, ListTree,
Package, Code2, Globe, Tag, Square,
ShieldAlert, Copy, Pencil, Download,
} from 'lucide-react'
import {
agentsApi, tasksApi, chatApi, chatSessionsApi, debuggerApi, agentDescription,
agentsApi, tasksApi, chatApi, chatSessionsApi, debuggerApi, approvalsApi, agentDescription,
type ApiAgent, type ApiTask, type ApiChatMessage, type ApiGraphNode,
type ApiChatMessageRecord, type ApiChatToolAction,
} from '@/api/client'
@@ -47,6 +48,12 @@ interface ChatMessage {
latencyMs?: number
modelUsed?: string
tryItData?: TryItData
// Inline approval card fields
approvalId?: string
approvalAction?: string
approvalResource?: string
approvalAgentId?: string
approvalStatus?: 'pending' | 'approved' | 'denied' | 'sent_back'
}
// ── Slash Commands ─────────────────────────────────────────────────────────
@@ -543,6 +550,130 @@ function ToolStatusCard({ taskId, agentName, navigate }: {
)
}
// ── Inline Approval Card ───────────────────────────────────────────────────
function ApprovalCard({
approvalId,
action,
resource,
agentId,
status,
onResolved,
}: {
approvalId: string
action: string
resource: string
agentId?: string
status: 'pending' | 'approved' | 'denied' | 'sent_back'
onResolved: (id: string, newStatus: 'approved' | 'denied' | 'sent_back') => void
}) {
const [feedback, setFeedback] = useState('')
const [showFeedback, setShowFeedback] = useState(false)
const [resolving, setResolving] = useState(false)
const resolve = async (act: 'approve' | 'deny' | 'sendBack') => {
if (resolving) return
setResolving(true)
try {
if (act === 'approve') {
await approvalsApi.approve(approvalId, { resolver_id: 'user' })
onResolved(approvalId, 'approved')
} else if (act === 'deny') {
await approvalsApi.deny(approvalId, { resolver_id: 'user' })
onResolved(approvalId, 'denied')
} else {
await approvalsApi.sendBack(approvalId, { resolver_id: 'user', feedback })
onResolved(approvalId, 'sent_back')
}
} catch {
setResolving(false)
}
}
if (status !== 'pending') {
const statusColor = status === 'approved' ? 'var(--nbl-green)' : status === 'denied' ? 'var(--nbl-red)' : 'var(--nbl-amber)'
const statusLabel = status === 'approved' ? 'Approved' : status === 'denied' ? 'Denied' : 'Sent back'
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '7px 10px', borderRadius: 8, background: `color-mix(in srgb, ${statusColor} 6%, var(--nbl-bg-surface))`, border: `1px solid color-mix(in srgb, ${statusColor} 20%, var(--nbl-border))`, fontSize: 11 }}>
<ShieldAlert size={11} style={{ color: statusColor }} />
<span style={{ color: 'var(--nbl-text-secondary)' }}>{action} on {resource}</span>
<span style={{ marginLeft: 'auto', color: statusColor, fontWeight: 600 }}>{statusLabel}</span>
</div>
)
}
return (
<div style={{ borderRadius: 10, overflow: 'hidden', border: '1px solid color-mix(in srgb, var(--nbl-amber) 35%, var(--nbl-border))', background: 'color-mix(in srgb, var(--nbl-amber) 5%, var(--nbl-bg-surface))', marginTop: 4 }}>
<div style={{ padding: '9px 12px', borderBottom: '1px solid var(--nbl-border)', display: 'flex', alignItems: 'center', gap: 7 }}>
<ShieldAlert size={12} style={{ color: 'var(--nbl-amber)', flexShrink: 0 }} />
<span style={{ fontSize: 11, fontWeight: 700, color: 'var(--nbl-text-primary)', flex: 1 }}>Approval Required</span>
<span style={{ fontSize: 9, padding: '1px 6px', borderRadius: 4, background: 'color-mix(in srgb, var(--nbl-amber) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-amber) 25%, transparent)', color: 'var(--nbl-amber)', fontWeight: 600 }}>PENDING</span>
</div>
<div style={{ padding: '8px 12px', display: 'flex', flexDirection: 'column', gap: 4, borderBottom: '1px solid var(--nbl-border)' }}>
<div style={{ display: 'flex', gap: 8, fontSize: 11 }}>
<span style={{ color: 'var(--nbl-text-ghost)', minWidth: 60 }}>Action</span>
<span style={{ color: 'var(--nbl-text-primary)', fontFamily: 'monospace', fontWeight: 500 }}>{action}</span>
</div>
<div style={{ display: 'flex', gap: 8, fontSize: 11 }}>
<span style={{ color: 'var(--nbl-text-ghost)', minWidth: 60 }}>Resource</span>
<span style={{ color: 'var(--nbl-text-secondary)', fontFamily: 'monospace', wordBreak: 'break-all' }}>{resource}</span>
</div>
{agentId && (
<div style={{ display: 'flex', gap: 8, fontSize: 11 }}>
<span style={{ color: 'var(--nbl-text-ghost)', minWidth: 60 }}>Agent</span>
<span style={{ color: 'var(--nbl-text-secondary)', fontFamily: 'monospace' }}>{agentId.slice(-12)}</span>
</div>
)}
</div>
{showFeedback && (
<div style={{ padding: '8px 12px', borderBottom: '1px solid var(--nbl-border)' }}>
<textarea
autoFocus
value={feedback}
onChange={e => setFeedback(e.target.value)}
placeholder="Feedback for the agent…"
rows={2}
style={{ width: '100%', background: 'var(--nbl-bg-input)', border: '1px solid var(--nbl-border)', borderRadius: 6, padding: '6px 8px', fontSize: 11, color: 'var(--nbl-text-primary)', resize: 'none', outline: 'none', boxSizing: 'border-box' }}
/>
</div>
)}
<div style={{ padding: '8px 12px', display: 'flex', gap: 6 }}>
<button
onClick={() => resolve('approve')}
disabled={resolving}
style={{ flex: 1, padding: '6px', borderRadius: 7, fontSize: 11, fontWeight: 600, cursor: resolving ? 'default' : 'pointer', background: 'color-mix(in srgb, var(--nbl-green) 15%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-green) 30%, var(--nbl-border))', color: 'var(--nbl-green)' }}
>
{resolving ? '…' : '✓ Approve'}
</button>
<button
onClick={() => resolve('deny')}
disabled={resolving}
style={{ padding: '6px 12px', borderRadius: 7, fontSize: 11, cursor: resolving ? 'default' : 'pointer', background: 'color-mix(in srgb, var(--nbl-red) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-red) 20%, var(--nbl-border))', color: 'var(--nbl-red)' }}
>
Deny
</button>
{!showFeedback ? (
<button
onClick={() => setShowFeedback(true)}
disabled={resolving}
style={{ padding: '6px 12px', borderRadius: 7, fontSize: 11, cursor: resolving ? 'default' : 'pointer', background: 'transparent', border: '1px solid var(--nbl-border)', color: 'var(--nbl-text-ghost)' }}
>
Send back
</button>
) : (
<button
onClick={() => resolve('sendBack')}
disabled={resolving || !feedback.trim()}
style={{ padding: '6px 12px', borderRadius: 7, fontSize: 11, cursor: (resolving || !feedback.trim()) ? 'default' : 'pointer', background: 'color-mix(in srgb, var(--nbl-amber) 12%, transparent)', border: '1px solid color-mix(in srgb, var(--nbl-amber) 25%, var(--nbl-border))', color: 'var(--nbl-amber)' }}
>
Send
</button>
)}
</div>
</div>
)
}
// ── Tool Action Card ────────────────────────────────────────────────────────
function ToolActionCard({ actions, navigate, onSendMessage }: { actions: ToolAction[]; navigate: ReturnType<typeof useNavigate>; onSendMessage?: (msg: string) => void }) {
@@ -941,7 +1072,7 @@ function WorkflowTrace({
latencyMs?: number
modelUsed?: string
}) {
const [open, setOpen] = useState(false)
const [open, setOpen] = useState(true)
const nodes: TraceNodeDef[] = []
@@ -1100,11 +1231,15 @@ function MessageBubble({
navigate,
onTryIt,
onSendMessage,
onEditAndResend,
onApprovalResolved,
}: {
msg: ChatMessage
navigate: ReturnType<typeof useNavigate>
onTryIt?: (templateId: string) => void
onSendMessage?: (msg: string) => void
onEditAndResend?: (msgId: string, newText: string) => void
onApprovalResolved?: (id: string, status: 'approved' | 'denied' | 'sent_back') => void
}) {
const isUser = msg.role === 'user'
const running = msg.taskStatus === 'running' || msg.taskStatus === 'scheduled'
@@ -1113,15 +1248,57 @@ function MessageBubble({
const [stepsOpen, setStepsOpen] = useState(false)
const hasTask = !!msg.taskId && !running
const isTryItPicker = msg.content === '__tryit_picker__'
const isApproval = !!msg.approvalId
const [hovered, setHovered] = useState(false)
const [editing, setEditing] = useState(false)
const [editText, setEditText] = useState(msg.content)
const handleCopy = () => { navigator.clipboard.writeText(msg.content).catch(() => { }) }
const handleEditConfirm = () => {
if (editText.trim()) onEditAndResend?.(msg.id, editText.trim())
setEditing(false)
}
const handleEditCancel = () => { setEditText(msg.content); setEditing(false) }
if (isApproval && msg.approvalId) {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 4, marginBottom: 14 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingLeft: 2, marginBottom: 2 }}>
<ShieldAlert size={11} style={{ color: 'var(--nbl-amber)' }} />
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)', fontWeight: 500 }}>Approval Request</span>
</div>
<div style={{ maxWidth: '88%', width: '100%' }}>
<ApprovalCard
approvalId={msg.approvalId}
action={msg.approvalAction ?? 'unknown'}
resource={msg.approvalResource ?? ''}
agentId={msg.approvalAgentId}
status={msg.approvalStatus ?? 'pending'}
onResolved={(id, newStatus) => onApprovalResolved?.(id, newStatus)}
/>
</div>
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)', paddingLeft: 2 }}>
{formatRelativeTime(msg.createdAt)}
</span>
</div>
)
}
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
gap: 4,
marginBottom: 14,
}}>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: isUser ? 'flex-end' : 'flex-start',
gap: 4,
marginBottom: 14,
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{!isUser && (
<div style={{ display: 'flex', alignItems: 'center', gap: 5, paddingLeft: 2, marginBottom: 2 }}>
{isNebula
@@ -1153,7 +1330,25 @@ function MessageBubble({
color: 'var(--nbl-text-primary)',
wordBreak: 'break-word',
}}>
{running ? (
{editing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<textarea
autoFocus
value={editText}
onChange={e => setEditText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleEditConfirm() }
if (e.key === 'Escape') handleEditCancel()
}}
rows={Math.min(Math.max(editText.split('\n').length, 2), 8)}
style={{ width: '100%', background: 'transparent', border: 'none', outline: 'none', resize: 'none', fontSize: 13, lineHeight: 1.6, color: 'var(--nbl-text-primary)', fontFamily: 'inherit', boxSizing: 'border-box' }}
/>
<div style={{ display: 'flex', gap: 5, justifyContent: 'flex-end' }}>
<button onClick={handleEditCancel} style={{ padding: '3px 9px', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: 'transparent', border: '1px solid var(--nbl-border)', color: 'var(--nbl-text-ghost)' }}>Cancel</button>
<button onClick={handleEditConfirm} disabled={!editText.trim()} style={{ padding: '3px 9px', borderRadius: 5, fontSize: 11, cursor: 'pointer', background: 'var(--nbl-nebula)', border: 'none', color: '#fff', fontWeight: 600 }}>Send </button>
</div>
</div>
) : running ? (
<span style={{ display: 'flex', alignItems: 'center', gap: 7, color: 'var(--nbl-text-secondary)' }}>
<Loader2 size={12} style={{ animation: 'spin 1s linear infinite', flexShrink: 0, color: 'var(--nbl-cyan)' }} />
Running
@@ -1206,6 +1401,37 @@ function MessageBubble({
)}
</div>
{hovered && !editing && !running && !isTryItPicker && (
<div style={{
display: 'flex', alignItems: 'center', gap: 2,
padding: '2px 4px', borderRadius: 8,
background: 'var(--nbl-bg-surface)',
border: '1px solid var(--nbl-border)',
boxShadow: '0 2px 6px rgba(0,0,0,0.2)',
}}>
<button
onClick={handleCopy}
title="Copy message"
style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '3px 7px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--nbl-text-ghost)', fontSize: 10 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--nbl-text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--nbl-text-ghost)')}
>
<Copy size={10} /> Copy
</button>
{isUser && onEditAndResend && (
<button
onClick={() => { setEditText(msg.content); setEditing(true) }}
title="Edit & resend"
style={{ display: 'flex', alignItems: 'center', gap: 3, padding: '3px 7px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', color: 'var(--nbl-text-ghost)', fontSize: 10 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--nbl-nebula)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--nbl-text-ghost)')}
>
<Pencil size={10} /> Edit
</button>
)}
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 6, paddingInline: 2 }}>
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)' }}>
{formatRelativeTime(msg.createdAt)}
@@ -1585,6 +1811,7 @@ export function ChatWorkspace() {
const abortRef = useRef<AbortController | null>(null)
const [suggestions, setSuggestions] = useState<string[]>([])
const [exportMenuOpen, setExportMenuOpen] = useState(false)
const { activeSessionId, setActiveSessionId, selectItem, setRightTab } = useShell()
const bridge = useLocalBridge()
@@ -1669,6 +1896,23 @@ export function ChatWorkspace() {
// ── WS — real-time task updates ─────────────────────────────────────────
const handleWsMessage = useCallback((msg: { type: string; entity_id?: string; payload?: Record<string, unknown> }) => {
// Approval request — inject inline approval card regardless of pending task tracking
if (msg.type === 'approval_requested' && msg.entity_id) {
const payload = msg.payload as Record<string, unknown> | undefined
setMessages(prev => [...prev, {
id: `apr-${msg.entity_id}`,
role: 'agent' as const,
content: '__approval_card__',
mode: 'nebula',
approvalId: msg.entity_id!,
approvalAction: (payload?.action as string) ?? 'execute',
approvalResource: (payload?.resource as string) ?? '',
approvalAgentId: payload?.agent_id as string | undefined,
approvalStatus: 'pending' as const,
createdAt: new Date().toISOString(),
}])
return
}
const taskId = msg.entity_id
if (!taskId || !pendingTaskIds.has(taskId)) return
if (msg.type === 'task.completed' || msg.type === 'task.failed') {
@@ -1839,6 +2083,17 @@ export function ChatWorkspace() {
const isPending = nebulaMut.isPending || runMut.isPending
// Keep the typing indicator visible briefly after response arrives (smooth handoff)
const [showTyping, setShowTyping] = useState(false)
useEffect(() => {
if (isPending) {
setShowTyping(true)
} else {
const t = setTimeout(() => setShowTyping(false), 400)
return () => clearTimeout(t)
}
}, [isPending])
const handleStop = useCallback(() => {
abortRef.current?.abort()
nebulaMut.reset()
@@ -1849,6 +2104,50 @@ export function ChatWorkspace() {
}])
}, [nebulaMut])
// ── Approval resolution ─────────────────────────────────────────────────
const handleApprovalResolved = useCallback((approvalId: string, status: 'approved' | 'denied' | 'sent_back') => {
setMessages(prev => prev.map(m =>
m.approvalId === approvalId ? { ...m, approvalStatus: status } : m
))
}, [])
// ── Edit + regenerate ───────────────────────────────────────────────────
const handleEditAndResend = useCallback(async (msgId: string, newText: string) => {
const idx = messages.findIndex(m => m.id === msgId)
if (idx === -1 || !newText.trim()) return
setMessages(prev => prev.slice(0, idx))
setHistory(prev => prev.slice(0, Math.max(0, idx - 1)))
const userMsg: ChatMessage = { id: `u-${Date.now()}`, role: 'user', content: newText, createdAt: new Date().toISOString(), mode: 'nebula' }
setMessages(prev => [...prev, userMsg])
const sid = await ensureSession(newText).catch(() => undefined)
nebulaMut.mutate({ message: newText, sid })
}, [messages, ensureSession, nebulaMut])
// ── Export chat ──────────────────────────────────────────────────────────
const exportChat = useCallback((format: 'md' | 'json') => {
const visibleMsgs = messages.filter(m => m.content && m.content !== '__tryit_picker__' && m.content !== '__approval_card__')
if (format === 'json') {
const blob = new Blob([JSON.stringify(visibleMsgs, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = `nebula-chat-${Date.now()}.json`; a.click()
URL.revokeObjectURL(url)
} else {
const lines = visibleMsgs.map(m => {
const role = m.role === 'user' ? '**You**' : '**Nebula**'
const ts = new Date(m.createdAt).toLocaleTimeString()
return `${role} _(${ts})_\n\n${m.content}`
})
const blob = new Blob([lines.join('\n\n---\n\n')], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = `nebula-chat-${Date.now()}.md`; a.click()
URL.revokeObjectURL(url)
}
setExportMenuOpen(false)
}, [messages])
// ── Slash command handler ────────────────────────────────────────────────
const startNewChat = useCallback(() => {
@@ -2006,7 +2305,45 @@ export function ChatWorkspace() {
<Hash size={10} style={{ color: 'var(--nbl-text-ghost)', margin: '0 2px' }} />
<span style={{ fontSize: 11, color: 'var(--nbl-text-ghost)' }}>workspace</span>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4 }}>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 4, alignItems: 'center' }}>
{messages.length > 0 && (
<div style={{ position: 'relative' }}>
<button
onClick={() => setExportMenuOpen(o => !o)}
title="Export chat"
style={{ display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none', cursor: 'pointer', color: 'var(--nbl-text-ghost)', padding: '3px 6px', borderRadius: 5, fontSize: 11 }}
onMouseEnter={e => (e.currentTarget.style.color = 'var(--nbl-text-primary)')}
onMouseLeave={e => (e.currentTarget.style.color = 'var(--nbl-text-ghost)')}
>
<Download size={11} />
</button>
{exportMenuOpen && (
<div style={{
position: 'absolute', top: 'calc(100% + 4px)', right: 0, zIndex: 50,
background: 'var(--nbl-surface)', border: '1px solid var(--nbl-border)',
borderRadius: 8, padding: 4, minWidth: 150,
boxShadow: '0 8px 20px rgba(0,0,0,0.4)',
}}>
<button
onClick={() => exportChat('md')}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '6px 10px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--nbl-text-primary)', textAlign: 'left' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--nbl-bg-surface)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
Export as Markdown
</button>
<button
onClick={() => exportChat('json')}
style={{ display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '6px 10px', borderRadius: 5, background: 'transparent', border: 'none', cursor: 'pointer', fontSize: 11, color: 'var(--nbl-text-primary)', textAlign: 'left' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--nbl-bg-surface)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
>
Export as JSON
</button>
</div>
)}
</div>
)}
<button
onClick={() => { setMessages([]); setHistory([]) }}
title="Clear view"
@@ -2055,10 +2392,12 @@ export function ChatWorkspace() {
navigate={navigate}
onTryIt={(templateId) => handleSend(`/tryit ${templateId}`)}
onSendMessage={(m) => handleSend(m)}
onEditAndResend={handleEditAndResend}
onApprovalResolved={handleApprovalResolved}
/>
))}
{!loadingSession && isPending && (
{!loadingSession && showTyping && (
<TypingBubble
lastUserMsg={[...messages].reverse().find(m => m.role === 'user')?.content ?? ''}
/>