import { useEffect, useMemo, useRef, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { cn } from '@renderer/lib/utils'; import { useStore } from '@renderer/store'; import { Search, Terminal, X } from 'lucide-react'; import { ClaudeLogsFilterPopover, DEFAULT_CLAUDE_LOGS_FILTER } from './ClaudeLogsFilterPopover'; import { CliLogsRichView } from './CliLogsRichView'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import type { ClaudeLogsFilterState } from './ClaudeLogsFilterPopover'; import type { TeamClaudeLogsResponse } from '@shared/types'; const PAGE_SIZE = 100; const POLL_MS = 2000; const ONLINE_WINDOW_MS = 10_000; type StreamType = 'stdout' | 'stderr'; interface ClaudeLogsSectionProps { teamName: string; } function isRecent(updatedAt: string | undefined): boolean { if (!updatedAt) return false; const t = Date.parse(updatedAt); if (Number.isNaN(t)) return false; return Date.now() - t <= ONLINE_WINDOW_MS; } function normalizeToStreamJsonText(linesNewestFirst: string[]): string { // We want to feed CliLogsRichView the exact format it expects: // - marker lines: "[stdout]" / "[stderr]" // - raw JSON lines without any "[stdout] " prefix const chronological = [...linesNewestFirst].reverse(); const out: string[] = []; let lastStream: StreamType | null = null; const pushMarker = (stream: StreamType): void => { if (lastStream === stream) return; lastStream = stream; out.push(stream === 'stdout' ? '[stdout]' : '[stderr]'); }; for (const rawLine of chronological) { const line = rawLine ?? ''; if (line === '[stdout]' || line === '[stderr]') { lastStream = line === '[stdout]' ? 'stdout' : 'stderr'; out.push(line); continue; } if (line.startsWith('[stdout] ')) { pushMarker('stdout'); out.push(line.slice('[stdout] '.length)); continue; } if (line.startsWith('[stderr] ')) { pushMarker('stderr'); out.push(line.slice('[stderr] '.length)); continue; } out.push(line); } return out.join('\n'); } type AssistantContentBlock = | { type: 'text'; text?: string } | { type: 'thinking'; thinking?: string } | { type: 'tool_use'; id?: string; name?: string; input?: Record } | { type: string; [key: string]: unknown }; function filterStreamJsonText( linesNewestFirst: string[], queryRaw: string, filter: ClaudeLogsFilterState ): string { const q = queryRaw.trim().toLowerCase(); const chronological = normalizeToStreamJsonText(linesNewestFirst).split('\n'); let currentStream: StreamType | null = null; let lastEmittedStream: StreamType | null = null; const out: string[] = []; const emitMarker = (): void => { if (!currentStream) return; if (lastEmittedStream === currentStream) return; out.push(currentStream === 'stdout' ? '[stdout]' : '[stderr]'); lastEmittedStream = currentStream; }; const extractBlocks = (parsed: Record): AssistantContentBlock[] | null => { if (parsed.type !== 'assistant') return null; if (Array.isArray(parsed.content)) { return parsed.content as AssistantContentBlock[]; } const msg = parsed.message; if (msg && typeof msg === 'object') { const inner = msg as Record; if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[]; } return null; }; const writeBlocks = ( parsed: Record, blocks: AssistantContentBlock[] ): Record => { if (Array.isArray(parsed.content)) { return { ...parsed, content: blocks }; } const msg = parsed.message; if (msg && typeof msg === 'object') { return { ...parsed, message: { ...(msg as Record), content: blocks } }; } return parsed; }; for (const rawLine of chronological) { const line = rawLine.trimEnd(); if (!line) continue; if (line === '[stdout]' || line === '[stderr]') { currentStream = line === '[stdout]' ? 'stdout' : 'stderr'; continue; } if (currentStream && !filter.streams.has(currentStream)) { continue; } let parsed: unknown; try { parsed = JSON.parse(line); } catch { // Non-JSON lines are ignored to keep view consistent with CliLogsRichView. continue; } if (!parsed || typeof parsed !== 'object') continue; const obj = parsed as Record; const blocks = extractBlocks(obj); if (!blocks) { // Keep only assistant messages for now (CliLogsRichView renders these richly). continue; } const filteredBlocks = blocks.filter((b) => { if (!b || typeof b !== 'object') return false; if (b.type === 'text') return filter.kinds.has('output'); if (b.type === 'thinking') return filter.kinds.has('thinking'); if (b.type === 'tool_use') return filter.kinds.has('tool'); // Unknown block types: keep (they're rare, and dropping can hide content) return true; }); if (filteredBlocks.length === 0) continue; const searchTextParts: string[] = []; for (const b of filteredBlocks) { if (b.type === 'text' && typeof b.text === 'string') searchTextParts.push(b.text); if (b.type === 'thinking' && typeof b.thinking === 'string') searchTextParts.push(b.thinking); if (b.type === 'tool_use') { if (typeof b.name === 'string') searchTextParts.push(b.name); if (b.input && typeof b.input === 'object') { try { searchTextParts.push(JSON.stringify(b.input)); } catch { // ignore } } } } const haystack = searchTextParts.join('\n').toLowerCase(); if (q && !haystack.includes(q)) { continue; } emitMarker(); const nextObj = writeBlocks(obj, filteredBlocks); out.push(JSON.stringify(nextObj)); } return out.join('\n'); } export const ClaudeLogsSection = ({ teamName }: ClaudeLogsSectionProps): React.JSX.Element => { const isAlive = useStore((s) => s.selectedTeamData?.isAlive ?? false); const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); const [data, setData] = useState({ lines: [], total: 0, hasMore: false }); const [pending, setPending] = useState(null); const [pendingNewCount, setPendingNewCount] = useState(0); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const inFlightRef = useRef(false); const atTopRef = useRef(true); const latestRef = useRef(null); const logContainerRef = useRef(null); const committedRef = useRef({ lines: [], total: 0, hasMore: false }); const pendingCountRef = useRef(0); const [searchQuery, setSearchQuery] = useState(''); const [filter, setFilter] = useState(() => ({ streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), })); const [filterOpen, setFilterOpen] = useState(false); useEffect(() => { setVisibleCount(PAGE_SIZE); setData({ lines: [], total: 0, hasMore: false }); setPending(null); setPendingNewCount(0); latestRef.current = null; atTopRef.current = true; setError(null); setSearchQuery(''); setFilter({ streams: new Set(DEFAULT_CLAUDE_LOGS_FILTER.streams), kinds: new Set(DEFAULT_CLAUDE_LOGS_FILTER.kinds), }); }, [teamName]); useEffect(() => { committedRef.current = data; }, [data]); useEffect(() => { pendingCountRef.current = pendingNewCount; }, [pendingNewCount]); useEffect(() => { let cancelled = false; const computeNewCount = ( committed: TeamClaudeLogsResponse, latest: TeamClaudeLogsResponse ): number => { if (committed.lines.length === 0) return latest.lines.length; const marker = committed.lines[0]; const idx = latest.lines.indexOf(marker); if (idx >= 0) return idx; const diff = (latest.total ?? latest.lines.length) - (committed.total ?? committed.lines.length); return Math.max(0, diff); }; const fetchLogs = async (): Promise => { if (inFlightRef.current) return; inFlightRef.current = true; try { setLoading(true); const next = await api.teams.getClaudeLogs(teamName, { offset: 0, limit: visibleCount }); if (cancelled) return; latestRef.current = next; if (atTopRef.current) { setData(next); setPending(null); setPendingNewCount(0); } else { setPending(next); const base = computeNewCount(committedRef.current, next); setPendingNewCount((prev) => Math.max(prev, base)); } setError(null); } catch (e) { if (cancelled) return; setError(e instanceof Error ? e.message : String(e)); } finally { inFlightRef.current = false; if (!cancelled) setLoading(false); } }; void fetchLogs(); const id = window.setInterval(() => void fetchLogs(), POLL_MS); return () => { cancelled = true; window.clearInterval(id); }; }, [teamName, visibleCount]); const online = useMemo(() => isRecent(data.updatedAt), [data.updatedAt]); const badge = data.total > 0 ? data.total : undefined; const showMoreVisible = data.hasMore; const headerExtra = online ? ( ) : null; const filteredText = useMemo(() => { if (data.lines.length === 0) return ''; const isDefault = filter.streams.size === DEFAULT_CLAUDE_LOGS_FILTER.streams.size && filter.kinds.size === DEFAULT_CLAUDE_LOGS_FILTER.kinds.size && [...DEFAULT_CLAUDE_LOGS_FILTER.streams].every((s) => filter.streams.has(s)) && [...DEFAULT_CLAUDE_LOGS_FILTER.kinds].every((k) => filter.kinds.has(k)); if (!searchQuery.trim() && isDefault) { return normalizeToStreamJsonText(data.lines); } return filterStreamJsonText(data.lines, searchQuery, filter); }, [data.lines, searchQuery, filter]); const applyPending = (): void => { const latest = latestRef.current ?? pending; if (!latest) return; setData(latest); setPending(null); setPendingNewCount(0); // Jump to newest if (logContainerRef.current) { logContainerRef.current.scrollTop = 0; } }; return ( } badge={badge} headerExtra={headerExtra} defaultOpen contentClassName="pt-0" >
{data.total > 0 ? ( <> Showing {Math.min(data.total, visibleCount)} of{' '} {data.total} ) : isAlive ? ( 'No logs yet.' ) : ( 'Team is not running.' )}
setSearchQuery(e.target.value)} className="min-w-0 flex-1 bg-transparent text-xs text-[var(--color-text)] placeholder:text-[var(--color-text-muted)] focus:outline-none" /> {searchQuery && ( )}
{pendingNewCount > 0 && ( )} {showMoreVisible && ( )}
{error ?

{error}

: null} {!error && filteredText.trim().length > 0 ? ( { logContainerRef.current = el; }} onScroll={({ scrollTop }) => { const atTop = scrollTop <= 8; atTopRef.current = atTop; if (atTop && pendingCountRef.current > 0) { applyPending(); } }} /> ) : null} {!error && data.lines.length === 0 ? (

{loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}

) : null} {!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (

No matching logs.

) : null}
); };