feat: live workflow trace + completed execution diagram in chat
All checks were successful
Stuffle/nebula-os/pipeline/head This commit looks good

During pending:
- TypingBubble simplified to plain subtitle text (no bubble box):
  Sparkles icon + animated dots + status label (context-aware)
- LiveWorkflowTrace below the text: nodes appear one by one with
  timing (Input→Context→LLM→Tools→Response), each node glows purple
  while active, turns green when complete, current node spins Loader2

After response (on each Nebula message):
- WorkflowTrace renders below the message with a collapsible one-line
  summary: ' 285ms · 4o-mini · 2 tools · 3 chunks ▶'
- Expanded view: icon nodes in a horizontal scrollable flow with
  coloured circles, tool names, and detail labels per node
  (e.g. 'List Agents · 3 agents', 'Create Agent · ...abc123')
- Nodes built from real response data: toolActions, chunksUsed,
  latencyMs, modelUsed (now stored on ChatMessage)
- Shows RAG node only when chunks_used > 0
- Shows Synthesis node only when tools were called
- Model prefix stripped (gpt-, openai/, anthropic/) for compact display

Types:
- Added modelUsed?: string to ChatMessage
- aiMsg now stores resp.model_used
This commit is contained in:
2026-04-20 23:42:14 +05:30
parent 47dd3d4ffa
commit 75457e3f33

View File

@@ -45,6 +45,7 @@ interface ChatMessage {
toolActions?: ToolAction[]
chunksUsed?: number
latencyMs?: number
modelUsed?: string
tryItData?: TryItData
}
@@ -756,25 +757,138 @@ function InlineSteps({ taskId }: { taskId: string }) {
)
}
// ── Typing bubble — inline in thread, not a floating footer ───────────────
// ── Typing indicator + live workflow trace ───────────────────────────────────
function inferStatusLabel(lastUserMsg: string): string[] {
const TOOL_LABEL: Record<string, string> = {
list_agents: 'List Agents',
create_agent: 'Create Agent',
run_agent: 'Run Agent',
create_plugin: 'Create Plugin',
publish_plugin: 'Publish Plugin',
list_integrations: 'Integrations',
connect_integration: 'Connect',
list_provider_tools: 'List Tools',
try_it_out: 'Run Demo',
}
const TOOL_ICON: Record<string, React.ElementType> = {
list_agents: Bot,
create_agent: Bot,
run_agent: Zap,
create_plugin: Package,
publish_plugin: Globe,
list_integrations: Plug,
connect_integration: Plug,
list_provider_tools: Database,
try_it_out: Zap,
}
function toolDetail(ta: ToolAction): string {
const r = ta.result as Record<string, unknown>
if (ta.tool === 'list_agents') {
const agents = (r.agents as unknown[]) ?? []
return `${agents.length} agent${agents.length !== 1 ? 's' : ''}`
}
if (ta.tool === 'create_agent') return r.agent_id ? String(r.agent_id).slice(-6) : 'created'
if (ta.tool === 'run_agent') return r.task_id ? String(r.task_id).slice(-6) : 'dispatched'
if (ta.tool === 'create_plugin') return r.draft ? 'draft' : (r.name as string) ?? 'saved'
if (ta.tool === 'publish_plugin') return 'published'
if (ta.tool === 'list_integrations') return 'catalog'
if (ta.tool === 'try_it_out') return (r.template_name as string) ?? 'demo'
return r.success === false ? 'error' : 'done'
}
// — Live trace: builds up during pending ————————————————————————————
type LiveNode = { id: string; Icon: React.ElementType; label: string }
function LiveWorkflowTrace({ lastUserMsg }: { lastUserMsg: string }) {
const m = lastUserMsg.toLowerCase()
if (/plugin|tool|build.*plugin|write.*plugin/.test(m))
return ['Thinking…', 'Writing plugin code…', 'Validating code…', 'Almost ready…']
if (/run|execute|launch|start.*agent|dispatch/.test(m))
return ['Thinking…', 'Dispatching task…', 'Agent is running…']
const likelyTools = /plugin|agent|run|execute|search|integrat|connect|remember|save|fetch/.test(m)
const allNodes: LiveNode[] = [
{ id: 'input', Icon: Send, label: 'Input' },
{ id: 'context', Icon: Database, label: 'Context' },
{ id: 'llm', Icon: Cpu, label: 'LLM' },
...(likelyTools ? [{ id: 'tools', Icon: Zap, label: 'Tools' }] : []),
{ id: 'response', Icon: CheckCircle2, label: 'Response' },
]
const [visible, setVisible] = useState(1)
const [running, setRunning] = useState(0)
useEffect(() => {
const DELAYS = [150, 900, 2200, 4200]
let cancelled = false
DELAYS.forEach((delay, i) => {
setTimeout(() => {
if (cancelled) return
setVisible(i + 2)
setRunning(i + 1)
}, delay)
})
return () => { cancelled = true }
}, [])
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 3, marginTop: 6, flexWrap: 'nowrap' }}>
{allNodes.slice(0, visible).map((node, i) => {
const { Icon } = node
const isRunning = i === running
const isDone = i < running
return (
<React.Fragment key={node.id}>
{i > 0 && (
<ChevronRight size={8} style={{ color: 'var(--nbl-border)', flexShrink: 0 }} />
)}
<div style={{
display: 'flex', alignItems: 'center', gap: 3,
padding: '2px 6px', borderRadius: 20, whiteSpace: 'nowrap',
fontSize: 10, fontWeight: 500,
background: isDone
? 'color-mix(in srgb, var(--nbl-green) 10%, transparent)'
: isRunning
? 'color-mix(in srgb, var(--nbl-nebula) 12%, transparent)'
: 'transparent',
border: `1px solid ${isDone
? 'color-mix(in srgb, var(--nbl-green) 25%, transparent)'
: isRunning
? 'color-mix(in srgb, var(--nbl-nebula) 30%, transparent)'
: 'transparent'}`,
color: isDone
? 'var(--nbl-green)'
: isRunning
? 'var(--nbl-nebula)'
: 'var(--nbl-text-ghost)',
opacity: isDone || isRunning ? 1 : 0.45,
}}>
{isRunning
? <Loader2 size={9} style={{ animation: 'spin 0.8s linear infinite', flexShrink: 0 }} />
: <Icon size={9} style={{ flexShrink: 0 }} />}
{node.label}
</div>
</React.Fragment>
)
})}
</div>
)
}
function inferStatusLabel(msg: string): string[] {
const m = msg.toLowerCase()
if (/plugin|build.*plugin|write.*plugin/.test(m))
return ['Writing plugin code…', 'Validating…', 'Almost ready…']
if (/run|execute|launch|dispatch/.test(m))
return ['Dispatching task…', 'Agent running…']
if (/agent|create.*agent|new.*agent/.test(m))
return ['Thinking…', 'Checking agents…', 'Preparing response…']
return ['Checking agents…', 'Preparing…']
if (/remember|save.*this|add.*memory|store/.test(m))
return ['Thinking…', 'Checking memory…', 'Saving…']
return ['Checking memory…']
if (/search|find|look.*up|fetch/.test(m))
return ['Thinking…', 'Searching knowledge base…', 'Gathering results…']
return ['Searching knowledge…', 'Gathering results…']
if (/integrat|connect|slack|github|jira|notion/.test(m))
return ['Thinking…', 'Checking integrations…', 'Preparing response…']
if (/status|health|running|active/.test(m))
return ['Thinking…', 'Querying system state…', 'Preparing summary…']
return ['Thinking…', 'Calling tools…', 'Preparing response…']
return ['Checking integrations…', 'Preparing…']
return ['Calling tools…', 'Preparing response…']
}
function TypingBubble({ lastUserMsg }: { lastUserMsg: string }) {
@@ -782,44 +896,161 @@ function TypingBubble({ lastUserMsg }: { lastUserMsg: string }) {
const [step, setStep] = useState(0)
useEffect(() => {
const DELAYS = [2000, 3500, 5000]
setStep(0)
const DELAYS = [2500, 5000]
let idx = 0
function advance() {
idx += 1
if (idx >= steps.length) return
setStep(idx)
if (idx < steps.length - 1) {
setTimeout(advance, DELAYS[idx] ?? 4000)
}
if (idx < steps.length - 1) setTimeout(advance, DELAYS[idx] ?? 4000)
}
const t = setTimeout(advance, DELAYS[0])
return () => clearTimeout(t)
}, [steps.length])
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 }}>
<Sparkles size={11} style={{ color: 'var(--nbl-nebula)' }} />
<span style={{ fontSize: 10, color: 'var(--nbl-text-ghost)', fontWeight: 500 }}>Nebula</span>
</div>
<div className="ne-ChatBubble" style={{
padding: '10px 14px',
borderRadius: '4px 14px 14px 14px',
background: 'color-mix(in srgb, var(--nbl-nebula) 6%, var(--nbl-bg-surface))',
border: '1px solid color-mix(in srgb, var(--nbl-nebula) 18%, var(--nbl-border))',
display: 'flex', alignItems: 'center', gap: 10,
fontSize: 13,
}}>
<span className="ne-TypingDots">
<span /><span /><span />
</span>
<span style={{ fontSize: 12, color: 'var(--nbl-text-ghost)', transition: 'opacity 0.3s' }}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: 6, marginBottom: 14, paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Sparkles size={10} style={{ color: 'var(--nbl-nebula)' }} />
<span className="ne-TypingDots"><span /><span /><span /></span>
<span style={{ fontSize: 11, color: 'var(--nbl-text-ghost)', fontStyle: 'italic' }}>
{steps[step]}
</span>
</div>
<LiveWorkflowTrace lastUserMsg={lastUserMsg} />
</div>
)
}
// — Completed workflow trace: attached to the AI message after response ——————
interface TraceNodeDef {
id: string
Icon: React.ElementType
label: string
detail?: string
color: string
}
function WorkflowTrace({
toolActions, chunksUsed, latencyMs, modelUsed,
}: {
toolActions?: ToolAction[]
chunksUsed?: number
latencyMs?: number
modelUsed?: string
}) {
const [open, setOpen] = useState(false)
const nodes: TraceNodeDef[] = []
nodes.push({ id: 'input', Icon: Send, label: 'Input', color: 'var(--nbl-cyan)' })
if (chunksUsed && chunksUsed > 0) {
nodes.push({
id: 'rag', Icon: BookOpen, label: 'Knowledge',
detail: `${chunksUsed} chunk${chunksUsed !== 1 ? 's' : ''}`,
color: 'var(--nbl-green)',
})
}
const hasTools = toolActions && toolActions.length > 0
nodes.push({
id: 'llm1', Icon: Cpu,
label: modelUsed ? modelUsed.replace(/^gpt-/, '') : 'LLM',
detail: hasTools ? undefined : latencyMs ? `${latencyMs}ms` : undefined,
color: 'var(--nbl-nebula)',
})
if (hasTools) {
for (const ta of toolActions) {
nodes.push({
id: `tool-${ta.tool}`, Icon: TOOL_ICON[ta.tool] ?? Code2,
label: TOOL_LABEL[ta.tool] ?? ta.tool,
detail: toolDetail(ta),
color: 'var(--nbl-amber, #f59e0b)',
})
}
nodes.push({
id: 'synth', Icon: Sparkles, label: 'Synthesis',
detail: latencyMs ? `${latencyMs}ms` : undefined,
color: 'var(--nbl-nebula)',
})
}
nodes.push({ id: 'done', Icon: CheckCircle2, label: 'Done', color: 'var(--nbl-green)' })
const parts = [
latencyMs ? `${latencyMs}ms` : null,
modelUsed ? modelUsed.replace(/^(openai\/|anthropic\/|gpt-)/, '') : null,
hasTools ? `${toolActions.length} tool${toolActions.length !== 1 ? 's' : ''}` : null,
chunksUsed ? `${chunksUsed} chunk${chunksUsed !== 1 ? 's' : ''}` : null,
].filter(Boolean)
if (parts.length === 0) return null
return (
<div style={{ marginTop: 8 }}>
<button
onClick={() => setOpen(o => !o)}
style={{
display: 'flex', alignItems: 'center', gap: 5,
fontSize: 10, color: 'var(--nbl-text-ghost)',
background: 'none', border: 'none', cursor: 'pointer', padding: 0,
fontFamily: 'monospace', letterSpacing: '0.01em',
}}
>
<Zap size={9} style={{ color: 'var(--nbl-amber, #f59e0b)', flexShrink: 0 }} />
{parts.join(' · ')}
<ChevronRight
size={9}
style={{
transform: open ? 'rotate(90deg)' : 'none',
transition: 'transform 0.15s',
opacity: 0.6,
}}
/>
</button>
{open && (
<div style={{ marginTop: 8, overflowX: 'auto', paddingBottom: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 4, minWidth: 'max-content' }}>
{nodes.map((node, i) => (
<React.Fragment key={node.id}>
{i > 0 && (
<div style={{
alignSelf: 'flex-start', marginTop: 10,
color: 'var(--nbl-border)', flexShrink: 0, fontSize: 12,
}}></div>
)}
<div style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
gap: 3, minWidth: 52,
}}>
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'center',
width: 24, height: 24, borderRadius: '50%',
background: `color-mix(in srgb, ${node.color} 12%, transparent)`,
border: `1px solid color-mix(in srgb, ${node.color} 30%, transparent)`,
flexShrink: 0,
}}>
<node.Icon size={11} style={{ color: node.color }} />
</div>
<span style={{ fontSize: 9, fontWeight: 600, color: 'var(--nbl-text-primary)', textAlign: 'center', lineHeight: 1.2 }}>
{node.label}
</span>
{node.detail && (
<span style={{ fontSize: 9, color: 'var(--nbl-text-ghost)', textAlign: 'center', lineHeight: 1.2 }}>
{node.detail}
</span>
)}
</div>
</React.Fragment>
))}
</div>
</div>
)}
</div>
)
}
@@ -1033,6 +1264,14 @@ function MessageBubble({
/>
</div>
)}
{msg.mode === 'nebula' && msg.role === 'agent' && (
<WorkflowTrace
toolActions={msg.toolActions}
chunksUsed={msg.chunksUsed}
latencyMs={msg.latencyMs}
modelUsed={msg.modelUsed}
/>
)}
{stepsOpen && msg.taskId && (
<div style={{ maxWidth: '88%' }}>
<InlineSteps taskId={msg.taskId} />
@@ -1531,6 +1770,7 @@ export function ChatWorkspace() {
toolActions: resp.tool_actions,
chunksUsed: resp.chunks_used,
latencyMs: resp.latency_ms,
modelUsed: resp.model_used,
createdAt: new Date().toISOString(),
tryItData,
}