/** * CliLogsRichView * * Renders CLI stream-json logs using the same rich components as session views: * thinking blocks, tool call cards, markdown text output. * * Replaces raw JSON display in ProvisioningProgressBlock. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DisplayItemList } from '@renderer/components/chat/DisplayItemList'; import { highlightQueryInText } from '@renderer/components/chat/searchHighlightUtils'; import { cn } from '@renderer/lib/utils'; import { groupBySubagent, parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; import { Bot, ChevronRight } from 'lucide-react'; import type { StreamJsonGroup, SubagentSection } from '@renderer/utils/streamJsonParser'; type CliLogsOrder = 'oldest-first' | 'newest-first'; interface CliLogsRichViewProps { cliLogsTail: string; order?: CliLogsOrder; onScroll?: (params: { scrollTop: number; scrollHeight: number; clientHeight: number }) => void; containerRefCallback?: (el: HTMLDivElement | null) => void; /** Optional local search query override for inline highlighting */ searchQueryOverride?: string; className?: string; /** Content rendered at the very bottom of the scroll container (e.g. "Show more" button). */ footer?: React.ReactNode; } /** * Derives a scoped Set for a single group from the global prefixed Set. * Global keys are stored as `groupId::itemId`; this strips the prefix. */ function scopedItemIds(globalIds: Set, groupId: string): Set { const prefix = `${groupId}::`; const scoped = new Set(); for (const key of globalIds) { if (key.startsWith(prefix)) { scoped.add(key.slice(prefix.length)); } } return scoped; } /** * Single-item group rendered flat (no collapsible wrapper). */ const FlatGroupItem = ({ group, expandedItemIds, onItemClick, searchQueryOverride, }: { group: StreamJsonGroup; expandedItemIds: Set; onItemClick: (itemId: string) => void; searchQueryOverride?: string; }): React.JSX.Element => { const groupItemIds = useMemo( () => scopedItemIds(expandedItemIds, group.id), [expandedItemIds, group.id] ); const handleItemClick = useCallback( (itemId: string) => onItemClick(`${group.id}::${itemId}`), [group.id, onItemClick] ); return ( ); }; /** * A single collapsible group of assistant items (2+ items). */ const StreamGroup = ({ group, isExpanded, onToggle, expandedItemIds, onItemClick, searchQueryOverride, }: { group: StreamJsonGroup; isExpanded: boolean; onToggle: () => void; expandedItemIds: Set; onItemClick: (itemId: string) => void; searchQueryOverride?: string; }): React.JSX.Element => { // Scope item IDs to this group to avoid cross-group collisions const groupItemIds = useMemo( () => scopedItemIds(expandedItemIds, group.id), [expandedItemIds, group.id] ); const handleItemClick = useCallback( (itemId: string) => onItemClick(`${group.id}::${itemId}`), [group.id, onItemClick] ); return (
{isExpanded && (
)}
); }; /** * Collapsible section wrapping all groups from one subagent. * Collapsed by default. */ const SubagentSectionBlock = ({ section, isExpanded, onToggle, collapsedGroupIds, onGroupToggle, expandedItemIds, onItemClick, searchQueryOverride, }: { section: SubagentSection; isExpanded: boolean; onToggle: () => void; collapsedGroupIds: Set; onGroupToggle: (groupId: string) => void; expandedItemIds: Set; onItemClick: (itemId: string) => void; searchQueryOverride?: string; }): React.JSX.Element => { const label = `Agent — ${section.description} (${section.toolCount} tool${section.toolCount !== 1 ? 's' : ''})`; return (
{isExpanded && (
{section.groups.map((group) => group.items.length === 1 ? ( ) : ( onGroupToggle(group.id)} expandedItemIds={expandedItemIds} onItemClick={onItemClick} searchQueryOverride={searchQueryOverride} /> ) )}
)}
); }; export const CliLogsRichView = ({ cliLogsTail, order = 'oldest-first', onScroll, containerRefCallback, searchQueryOverride, className, footer, }: CliLogsRichViewProps): React.JSX.Element => { const scrollRef = useRef(null); const stickToEdgeRef = useRef(true); const lastOrderRef = useRef(order); // Tracks groups manually collapsed by user (default: all auto-expanded) const [collapsedGroupIds, setCollapsedGroupIds] = useState>(new Set()); const [expandedItemIds, setExpandedItemIds] = useState>(new Set()); // Subagent sections are collapsed by default; track which are expanded const [expandedSubagentIds, setExpandedSubagentIds] = useState>(new Set()); const groups = useMemo(() => parseStreamJsonToGroups(cliLogsTail), [cliLogsTail]); const entries = useMemo(() => groupBySubagent(groups), [groups]); // Derive expanded state: all groups expanded unless manually collapsed const expandedGroupIds = useMemo(() => { const expanded = new Set(); const addGroups = (gs: StreamJsonGroup[]): void => { for (const g of gs) { if (!collapsedGroupIds.has(g.id)) expanded.add(g.id); } }; for (const entry of entries) { if (entry.type === 'group') { if (!collapsedGroupIds.has(entry.group.id)) expanded.add(entry.group.id); } else { addGroups(entry.section.groups); } } return expanded; }, [entries, collapsedGroupIds]); const computeShouldStickToEdge = useCallback( (el: HTMLDivElement): boolean => { // Small threshold makes it feel "sticky" but still allows reading slightly away from the edge const thresholdPx = 16; if (order === 'newest-first') { return el.scrollTop <= thresholdPx; } const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; return distanceFromBottom <= thresholdPx; }, [order] ); // Auto-scroll only when user is pinned to the edge. useEffect(() => { const el = scrollRef.current; if (!el) return; // If the sort order changes, always snap once (expectation: show the "newest edge"). if (lastOrderRef.current !== order) { lastOrderRef.current = order; stickToEdgeRef.current = true; } if (!stickToEdgeRef.current) return; if (order === 'newest-first') { el.scrollTop = 0; return; } el.scrollTop = el.scrollHeight; }, [cliLogsTail, order]); const handleGroupToggle = useCallback((groupId: string) => { setCollapsedGroupIds((prev) => { const next = new Set(prev); if (next.has(groupId)) { next.delete(groupId); } else { next.add(groupId); } return next; }); }, []); const handleItemClick = useCallback((itemId: string) => { setExpandedItemIds((prev) => { const next = new Set(prev); if (next.has(itemId)) { next.delete(itemId); } else { next.add(itemId); } return next; }); }, []); const handleSubagentToggle = useCallback((sectionId: string) => { setExpandedSubagentIds((prev) => { const next = new Set(prev); if (next.has(sectionId)) { next.delete(sectionId); } else { next.add(sectionId); } return next; }); }, []); if (entries.length === 0) { // cliLogsTail has data but no parseable assistant messages — show raw text fallback const hasContent = cliLogsTail.trim().length > 0; return (
{ scrollRef.current = el; containerRefCallback?.(el); }} className={cn( 'max-h-[400px] overflow-y-auto rounded border border-[var(--color-border)] bg-[var(--color-surface)]', className )} onScroll={(e) => { const el = e.currentTarget; stickToEdgeRef.current = computeShouldStickToEdge(el); onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, }); }} > {hasContent ? (
            {cliLogsTail}
          
) : (

Waiting for CLI output...

)} {footer}
); } const visibleEntries = order === 'newest-first' ? [...entries].reverse() : entries; return (
{ scrollRef.current = el; containerRefCallback?.(el); }} className={cn('cli-logs-compact max-h-[400px] space-y-1 overflow-y-auto', className)} onScroll={(e) => { const el = e.currentTarget; stickToEdgeRef.current = computeShouldStickToEdge(el); onScroll?.({ scrollTop: el.scrollTop, scrollHeight: el.scrollHeight, clientHeight: el.clientHeight, }); }} > {visibleEntries.map((entry) => entry.type === 'subagent-section' ? ( handleSubagentToggle(entry.section.id)} collapsedGroupIds={collapsedGroupIds} onGroupToggle={handleGroupToggle} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} searchQueryOverride={searchQueryOverride} /> ) : entry.group.items.length === 1 ? ( ) : ( handleGroupToggle(entry.group.id)} expandedItemIds={expandedItemIds} onItemClick={handleItemClick} searchQueryOverride={searchQueryOverride} /> ) )} {footer}
); };