| | import React, { useEffect, useRef, useState } from 'react'; |
| |
|
| | |
| | const BORDER_COLORS: Record<string, string> = { |
| | thought: '#5b8fb9', |
| | tool_call: '#d47c3a', |
| | tool_result: '#4a7c59', |
| | observation: '#5b8fb9', |
| | decision: '#8b6cc1', |
| | user_action: '#8b6cc1', |
| | warning: '#c0392b', |
| | }; |
| |
|
| | interface ActivityEvent { |
| | type: string; |
| | message: string; |
| | state: string; |
| | timestamp: number | string; |
| | agent_name?: string; |
| | thought_type?: string; |
| | content?: string; |
| | stage_name?: string; |
| | summary?: string; |
| | artifacts?: Array<{ name: string; path: string; description: string }>; |
| | decisions?: string[]; |
| | warnings?: string[]; |
| | next_stage_name?: string; |
| | next_stage_preview?: string; |
| | } |
| |
|
| | interface Props { |
| | events: ActivityEvent[]; |
| | thinkingData?: { agent_name: string; message: string } | null; |
| | } |
| |
|
| | |
| | const NOISE_PATTERNS = [ |
| | /^Transitioning:/i, |
| | /^Beginning stage:/i, |
| | /^Generating approval summary/i, |
| | /^Waiting for user approval/i, |
| | /^User approved stage/i, |
| | /^User rejected stage/i, |
| | /^Human-in-the-loop mode enabled/i, |
| | /^LLM backend:/i, |
| | /^Approved:/i, |
| | /^AgentIC Compute Engine selected/i, |
| | /^Compute engine ready/i, |
| | /^Starting Build Process for/i, |
| | /^Build started for/i, |
| | /^Stage \w+ complete.*awaiting approval/i, |
| | /^\[Orchestrator\] (Beginning|Generating|Waiting|Human-in)/i, |
| | ]; |
| |
|
| | |
| | function isNoise(evt: ActivityEvent): boolean { |
| | const msg = (evt.content || evt.message || '').trim(); |
| | if (!msg) return true; |
| | if (/^\/[a-zA-Z0-9/_.\-]+$/.test(msg)) return true; |
| | if (evt.type === 'transition') return true; |
| | for (const pattern of NOISE_PATTERNS) { |
| | if (pattern.test(msg)) return true; |
| | } |
| | return false; |
| | } |
| |
|
| | |
| | function shortenPaths(text: string): string { |
| | return text.replace(/\/(?:home|tmp|var|opt)\/[^\s]+\/([^\s/]+)/g, '$1'); |
| | } |
| |
|
| | |
| | function truncate(text: string, max = 120): { display: string; isTruncated: boolean } { |
| | if (text.length <= max) return { display: text, isTruncated: false }; |
| | return { display: text.slice(0, max) + '…', isTruncated: true }; |
| | } |
| |
|
| | export const ActivityFeed: React.FC<Props> = ({ events, thinkingData }) => { |
| | const ref = useRef<HTMLDivElement>(null); |
| | const [autoScroll, setAutoScroll] = useState(true); |
| | const [expanded, setExpanded] = useState<Set<number>>(new Set()); |
| | const lastTop = useRef(0); |
| |
|
| | useEffect(() => { |
| | if (autoScroll && ref.current) { |
| | ref.current.scrollTop = ref.current.scrollHeight; |
| | } |
| | }, [events, autoScroll]); |
| |
|
| | const onScroll = () => { |
| | if (!ref.current) return; |
| | const el = ref.current; |
| | const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60; |
| | if (el.scrollTop < lastTop.current && !atBottom) setAutoScroll(false); |
| | else if (atBottom) setAutoScroll(true); |
| | lastTop.current = el.scrollTop; |
| | }; |
| |
|
| | const toggle = (i: number) => |
| | setExpanded(prev => { |
| | const s = new Set(prev); |
| | s.has(i) ? s.delete(i) : s.add(i); |
| | return s; |
| | }); |
| |
|
| | |
| | const filtered = events.filter(e => { |
| | if (e.type === 'ping' || e.type === 'stream_end' || e.type === 'stage_complete') return false; |
| | if (isNoise(e)) return false; |
| | return true; |
| | }); |
| | const rows: typeof filtered = []; |
| | for (const evt of filtered) { |
| | const prev = rows[rows.length - 1]; |
| | if (prev && (prev.content || prev.message) === (evt.content || evt.message)) continue; |
| | rows.push(evt); |
| | } |
| |
|
| | return ( |
| | <div className="hitl-log"> |
| | <div className="hitl-log-header"> |
| | <span className="hitl-log-title">Live Log</span> |
| | {!autoScroll && ( |
| | <button |
| | className="hitl-log-scroll" |
| | onClick={() => { |
| | setAutoScroll(true); |
| | ref.current?.scrollTo({ top: ref.current.scrollHeight, behavior: 'smooth' }); |
| | }} |
| | > |
| | ↓ Latest |
| | </button> |
| | )} |
| | </div> |
| | <div className="hitl-log-body" ref={ref} onScroll={onScroll}> |
| | {rows.length === 0 ? ( |
| | <div className="hitl-log-empty">Waiting for agent activity…</div> |
| | ) : ( |
| | rows.map((evt, i) => { |
| | const type = evt.thought_type || 'thought'; |
| | const border = BORDER_COLORS[type] || BORDER_COLORS.thought; |
| | const raw = shortenPaths(evt.content || evt.message || ''); |
| | const isExp = expanded.has(i); |
| | const { display, isTruncated } = isExp |
| | ? { display: raw, isTruncated: false } |
| | : truncate(raw); |
| | |
| | const ts = |
| | typeof evt.timestamp === 'string' |
| | ? evt.timestamp.split('T')[1]?.substring(0, 8) || evt.timestamp |
| | : new Date((evt.timestamp as number) * 1000).toLocaleTimeString('en-US', { |
| | hour12: false, |
| | }); |
| | const stage = evt.state?.replace(/_/g, ' ') || ''; |
| | |
| | return ( |
| | <div |
| | key={i} |
| | className={`hitl-log-row${isTruncated || isExp ? ' hitl-log-row--expandable' : ''}`} |
| | style={{ borderLeftColor: border }} |
| | onClick={() => (isTruncated || isExp) && toggle(i)} |
| | > |
| | <span className="hitl-log-ts">{ts}</span> |
| | {stage && <span className="hitl-log-badge">{stage}</span>} |
| | <span className="hitl-log-msg"> |
| | {display} |
| | {isTruncated && <span className="hitl-log-expand"> ›</span>} |
| | </span> |
| | </div> |
| | ); |
| | }) |
| | )} |
| | {/* Thinking indicator */} |
| | {thinkingData && ( |
| | <div className="hitl-log-row hitl-log-row--thinking" style={{ borderLeftColor: '#d47c3a' }}> |
| | <span className="hitl-thinking-pulse" /> |
| | <span className="hitl-log-badge" style={{ fontStyle: 'italic' }}> |
| | {thinkingData.agent_name || 'Agent'} |
| | </span> |
| | <span className="hitl-log-msg" style={{ fontStyle: 'italic', color: 'var(--text-secondary, #6b6560)' }}> |
| | {thinkingData.message} |
| | </span> |
| | </div> |
| | )} |
| | </div> |
| | </div> |
| | ); |
| | }; |
| |
|