# Conflicts: # src/main/services/team/TeamProvisioningService.ts # src/renderer/components/team/ClaudeLogsSection.tsx # src/renderer/components/team/dialogs/SendMessageDialog.tsx # src/renderer/components/team/dialogs/TaskCommentsSection.tsx # src/renderer/components/team/members/MemberLogsTab.tsx
431 lines
14 KiB
TypeScript
431 lines
14 KiB
TypeScript
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<string, unknown> }
|
|
| { 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<string, unknown>): 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<string, unknown>;
|
|
if (Array.isArray(inner.content)) return inner.content as AssistantContentBlock[];
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const writeBlocks = (
|
|
parsed: Record<string, unknown>,
|
|
blocks: AssistantContentBlock[]
|
|
): Record<string, unknown> => {
|
|
if (Array.isArray(parsed.content)) {
|
|
return { ...parsed, content: blocks };
|
|
}
|
|
const msg = parsed.message;
|
|
if (msg && typeof msg === 'object') {
|
|
return { ...parsed, message: { ...(msg as Record<string, unknown>), 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<string, unknown>;
|
|
|
|
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<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
|
const [pending, setPending] = useState<TeamClaudeLogsResponse | null>(null);
|
|
const [pendingNewCount, setPendingNewCount] = useState(0);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const inFlightRef = useRef(false);
|
|
const atTopRef = useRef(true);
|
|
const latestRef = useRef<TeamClaudeLogsResponse | null>(null);
|
|
const logContainerRef = useRef<HTMLDivElement | null>(null);
|
|
const committedRef = useRef<TeamClaudeLogsResponse>({ lines: [], total: 0, hasMore: false });
|
|
const pendingCountRef = useRef(0);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filter, setFilter] = useState<ClaudeLogsFilterState>(() => ({
|
|
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<void> => {
|
|
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 ? (
|
|
<span className="pointer-events-none relative inline-flex size-2 shrink-0" title="Updating">
|
|
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
|
|
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
|
|
</span>
|
|
) : 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 (
|
|
<CollapsibleTeamSection
|
|
sectionId="claude-logs"
|
|
title="Claude logs"
|
|
icon={<Terminal size={14} />}
|
|
badge={badge}
|
|
headerExtra={headerExtra}
|
|
defaultOpen
|
|
contentClassName="pt-0"
|
|
>
|
|
<div className="flex items-center justify-between gap-2 pb-2">
|
|
<span className="text-[11px] text-[var(--color-text-muted)]">
|
|
{data.total > 0 ? (
|
|
<>
|
|
Showing <span className="font-mono">{Math.min(data.total, visibleCount)}</span> of{' '}
|
|
<span className="font-mono">{data.total}</span>
|
|
</>
|
|
) : isAlive ? (
|
|
'No logs yet.'
|
|
) : (
|
|
'Team is not running.'
|
|
)}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex w-48 items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-transparent px-2 py-1">
|
|
<Search size={12} className="shrink-0 text-[var(--color-text-muted)]" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search logs..."
|
|
value={searchQuery}
|
|
onChange={(e) => 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 && (
|
|
<button
|
|
type="button"
|
|
className="shrink-0 rounded p-0.5 text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
|
onClick={() => setSearchQuery('')}
|
|
aria-label="Clear search"
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<ClaudeLogsFilterPopover
|
|
filter={filter}
|
|
open={filterOpen}
|
|
onOpenChange={setFilterOpen}
|
|
onApply={setFilter}
|
|
/>
|
|
{pendingNewCount > 0 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-7 border-blue-500/30 bg-blue-600 px-2 text-xs text-white hover:bg-blue-500"
|
|
onClick={applyPending}
|
|
title="Show newest logs"
|
|
>
|
|
+{pendingNewCount} new
|
|
</Button>
|
|
)}
|
|
{showMoreVisible && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 px-2 text-xs"
|
|
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
|
>
|
|
Show more
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn('rounded', loading && 'opacity-80')}>
|
|
{error ? <p className="p-2 text-xs text-red-300">{error}</p> : null}
|
|
{!error && filteredText.trim().length > 0 ? (
|
|
<CliLogsRichView
|
|
// Parser expects chronological order; UI shows newest-first.
|
|
cliLogsTail={filteredText}
|
|
order="newest-first"
|
|
searchQueryOverride={searchQuery.trim() ? searchQuery : undefined}
|
|
className="max-h-[320px] p-2"
|
|
containerRefCallback={(el) => {
|
|
logContainerRef.current = el;
|
|
}}
|
|
onScroll={({ scrollTop }) => {
|
|
const atTop = scrollTop <= 8;
|
|
atTopRef.current = atTop;
|
|
if (atTop && pendingCountRef.current > 0) {
|
|
applyPending();
|
|
}
|
|
}}
|
|
/>
|
|
) : null}
|
|
{!error && data.lines.length === 0 ? (
|
|
<p className="p-2 text-xs text-[var(--color-text-muted)]">
|
|
{loading ? 'Loading…' : isAlive ? 'No logs captured.' : 'Team is not running.'}
|
|
</p>
|
|
) : null}
|
|
{!error && data.lines.length > 0 && filteredText.trim().length === 0 ? (
|
|
<p className="p-2 text-xs text-[var(--color-text-muted)]">No matching logs.</p>
|
|
) : null}
|
|
</div>
|
|
</CollapsibleTeamSection>
|
|
);
|
|
};
|