agent-ecosystem/src/renderer/components/team/taskLogs/TaskActivitySection.tsx

463 lines
13 KiB
TypeScript

import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { enhanceAIGroup } from '@renderer/utils/aiGroupEnhancer';
import { transformChunksToConversation } from '@renderer/utils/groupTransformer';
import {
describeBoardTaskActivityLabel,
formatBoardTaskActivityTaskLabel,
} from '@shared/utils/boardTaskActivityLabels';
import {
describeBoardTaskActivityActorLabel,
describeBoardTaskActivityContextLines,
} from '@shared/utils/boardTaskActivityPresentation';
import { AlertCircle, ChevronDown, ChevronRight, Loader2 } from 'lucide-react';
import { TaskActivityLinkedToolCard } from './TaskActivityLinkedToolCard';
import type { AIGroupDisplayItem, LinkedToolItem } from '@renderer/types/groups';
import type {
BoardTaskActivityDetail,
BoardTaskActivityEntry,
BoardTaskActivityTaskRef,
} from '@shared/types';
interface TaskActivitySectionProps {
teamName: string;
taskId: string;
enabled?: boolean;
}
function isHighSignalTaskActivityEntry(entry: BoardTaskActivityEntry): boolean {
return entry.linkKind !== 'execution';
}
function compareTaskActivityEntriesDesc(
left: BoardTaskActivityEntry,
right: BoardTaskActivityEntry
): number {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return rightTs - leftTs;
}
if (left.source.filePath !== right.source.filePath) {
return right.source.filePath.localeCompare(left.source.filePath);
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return right.source.sourceOrder - left.source.sourceOrder;
}
return right.id.localeCompare(left.id);
}
function formatEntryTime(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return '--:--';
}
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function formatTaskLabel(task: BoardTaskActivityTaskRef | undefined): string | null {
return formatBoardTaskActivityTaskLabel(task);
}
function describeCollapsedContext(entry: BoardTaskActivityEntry): string | null {
const contextLines = describeBoardTaskActivityContextLines(entry);
return contextLines.length > 0 ? contextLines.join(' - ') : null;
}
type ActivityDetailState =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'missing' }
| { status: 'error'; error: string }
| { status: 'ok'; detail: BoardTaskActivityDetail };
type ActivityMetadataProps = Readonly<{
detail: BoardTaskActivityDetail;
}>;
type ActivityDetailPanelProps = Readonly<{
detailState: ActivityDetailState;
}>;
function normalizeDetail(detail: BoardTaskActivityDetail): BoardTaskActivityDetail {
if (!detail.logDetail) {
return detail;
}
return {
...detail,
logDetail: {
...detail.logDetail,
chunks: asEnhancedChunkArray(detail.logDetail.chunks) ?? detail.logDetail.chunks,
},
};
}
function getFirstRenderableLinkedTool(detail: BoardTaskActivityDetail): LinkedToolItem | null {
if (!detail.logDetail || detail.logDetail.chunks.length === 0) {
return null;
}
const conversation = transformChunksToConversation(detail.logDetail.chunks, [], false);
for (const item of conversation.items) {
if (item.type !== 'ai') {
continue;
}
const linkedTool = enhanceAIGroup(item.group).displayItems.find(
(displayItem): displayItem is Extract<AIGroupDisplayItem, { type: 'tool' }> =>
displayItem.type === 'tool'
);
if (linkedTool) {
return linkedTool.tool;
}
}
return null;
}
const ActivityMetadata = ({ detail }: ActivityMetadataProps): React.JSX.Element | null => {
const hasMetadata = detail.metadataRows.length > 0;
const hasContext = detail.contextLines.length > 0;
if (!hasMetadata && !hasContext) {
return null;
}
return (
<div className="space-y-3">
{hasContext ? (
<div className="space-y-1">
{detail.contextLines.map((line) => (
<p key={line} className="text-xs text-[var(--color-text-muted)]">
{line}
</p>
))}
</div>
) : null}
{hasMetadata ? (
<div className="grid gap-x-4 gap-y-2 sm:grid-cols-[max-content_1fr] sm:items-start">
{detail.metadataRows.map((row) => (
<Fragment key={`${row.label}:${row.value}`}>
<div className="text-[10px] font-semibold uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{row.label}
</div>
<div className="text-sm text-[var(--color-text)]">{row.value}</div>
</Fragment>
))}
</div>
) : null}
</div>
);
};
const ActivityDetailPanel = ({ detailState }: ActivityDetailPanelProps): React.JSX.Element => {
if (detailState.status === 'loading') {
return (
<div className="border-[var(--color-border)]/20 bg-[var(--color-bg-elevated)]/18 flex items-center gap-2 rounded-md border p-3 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading activity details...
</div>
);
}
if (detailState.status === 'error') {
return (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
<AlertCircle size={12} />
{detailState.error}
</div>
);
}
if (detailState.status === 'missing') {
return (
<div className="border-[var(--color-border)]/20 bg-[var(--color-bg-elevated)]/18 rounded-md border p-3 text-xs text-[var(--color-text-muted)]">
Detailed transcript context is no longer available for this activity.
</div>
);
}
if (detailState.status !== 'ok') {
return <></>;
}
const { detail } = detailState;
const linkedTool = getFirstRenderableLinkedTool(detail);
return (
<div className="border-[var(--color-border)]/18 space-y-3 border-t pt-3">
<ActivityMetadata detail={detail} />
{linkedTool ? <TaskActivityLinkedToolCard linkedTool={linkedTool} /> : null}
</div>
);
};
const Row = ({
detailState,
entry,
expanded,
onToggle,
}: {
detailState: ActivityDetailState;
entry: BoardTaskActivityEntry;
expanded: boolean;
onToggle: () => void;
}): React.JSX.Element => {
const context = describeCollapsedContext(entry);
const tone =
entry.task.resolution === 'resolved'
? 'text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]';
return (
<div
className={`bg-[var(--color-bg-elevated)]/20 rounded-md border shadow-sm shadow-black/10 transition-colors ${
expanded ? 'border-[var(--color-border-emphasis)]' : 'border-[var(--color-border-subtle)]'
}`}
>
<button
type="button"
className="hover:bg-[var(--color-bg-elevated)]/28 flex w-full items-start gap-3 px-3 py-2 text-left transition-colors"
onClick={onToggle}
>
<div className="pt-0.5 text-[var(--color-text-muted)]">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</div>
<div className="min-w-12 pt-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{formatEntryTime(entry.timestamp)}
</div>
<div className="min-w-0 flex-1">
<div className={`text-sm ${tone}`}>
<span className="font-medium">{describeBoardTaskActivityActorLabel(entry.actor)}</span>
<span className="text-[var(--color-text-muted)]"> - </span>
<span>{describeBoardTaskActivityLabel(entry)}</span>
</div>
{context ? (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{context}</p>
) : null}
</div>
</button>
{expanded ? (
<div className="px-3 pb-3">
<ActivityDetailPanel detailState={detailState} />
</div>
) : null}
</div>
);
};
export const TaskActivitySection = ({
teamName,
taskId,
enabled = true,
}: TaskActivitySectionProps): React.JSX.Element => {
const [detailStates, setDetailStates] = useState<Record<string, ActivityDetailState>>({});
const [entries, setEntries] = useState<BoardTaskActivityEntry[]>([]);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [loading, setLoading] = useState(enabled);
const [error, setError] = useState<string | null>(null);
const hasLoadedRef = useRef(false);
const fetchDetail = useCallback(
async (entry: BoardTaskActivityEntry): Promise<void> => {
setDetailStates((prev) => ({
...prev,
[entry.id]: { status: 'loading' },
}));
try {
const result = await api.teams.getTaskActivityDetail(teamName, taskId, entry.id);
setDetailStates((prev) => ({
...prev,
[entry.id]:
result.status === 'ok'
? { status: 'ok', detail: normalizeDetail(result.detail) }
: { status: 'missing' },
}));
} catch (detailError) {
setDetailStates((prev) => ({
...prev,
[entry.id]: {
status: 'error',
error:
detailError instanceof Error ? detailError.message : 'Failed to load activity detail',
},
}));
}
},
[taskId, teamName]
);
const handleToggle = useCallback(
async (entry: BoardTaskActivityEntry): Promise<void> => {
if (expandedId === entry.id) {
setExpandedId(null);
return;
}
setExpandedId(entry.id);
const existing = detailStates[entry.id];
if (
existing &&
existing.status !== 'idle' &&
existing.status !== 'error' &&
existing.status !== 'loading'
) {
return;
}
if (existing?.status === 'loading') {
return;
}
await fetchDetail(entry);
},
[detailStates, expandedId, fetchDetail]
);
useEffect(() => {
setEntries([]);
setExpandedId(null);
setDetailStates({});
setError(null);
setLoading(enabled);
hasLoadedRef.current = false;
}, [taskId, teamName]);
useEffect(() => {
if (!enabled) {
setLoading(false);
}
}, [enabled]);
useEffect(() => {
let cancelled = false;
if (!enabled) {
return () => {
cancelled = true;
};
}
const load = async (showSpinner: boolean): Promise<void> => {
try {
if (!cancelled && showSpinner) {
setLoading(true);
}
if (!cancelled) {
setError(null);
}
const result = await api.teams.getTaskActivity(teamName, taskId);
if (!cancelled) {
setEntries(result);
hasLoadedRef.current = true;
}
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : 'Failed to load task activity');
setEntries([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load(!hasLoadedRef.current);
const intervalId = window.setInterval(() => {
void load(false);
}, 8000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [enabled, teamName, taskId]);
const visibleEntries = useMemo(
() =>
entries
.filter((entry) => isHighSignalTaskActivityEntry(entry))
.sort(compareTaskActivityEntriesDesc),
[entries]
);
const hasOnlyLowSignalExecution = entries.length > 0 && visibleEntries.length === 0;
const content = useMemo(() => {
if (loading) {
return (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading task activity...
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
<AlertCircle size={12} />
{error}
</div>
);
}
if (visibleEntries.length === 0) {
return (
<p className="text-xs text-[var(--color-text-muted)]">
{hasOnlyLowSignalExecution
? 'No key task activity was found yet. Low-level execution details are available below in Task Log Stream.'
: 'No explicit task activity was found in the available transcripts yet. Older or heuristic session logs may still be available below in Execution Sessions.'}
</p>
);
}
return (
<div className="space-y-2">
{visibleEntries.map((entry) => (
<Row
key={entry.id}
detailState={detailStates[entry.id] ?? { status: 'idle' }}
entry={entry}
expanded={expandedId === entry.id}
onToggle={() => void handleToggle(entry)}
/>
))}
</div>
);
}, [
detailStates,
error,
expandedId,
handleToggle,
hasOnlyLowSignalExecution,
loading,
visibleEntries,
]);
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Activity
</h4>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Key explicit runtime activity linked to this task from transcript metadata.
</p>
{content}
</div>
);
};