197 lines
7.1 KiB
TypeScript
197 lines
7.1 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
import { useAppTranslation } from '@features/localization/renderer';
|
|
import {
|
|
ExecutionLogStreamView,
|
|
normalizeExecutionLogStream,
|
|
} from '@features/member-log-stream/renderer';
|
|
import { api } from '@renderer/api';
|
|
import { useStore } from '@renderer/store';
|
|
import { selectResolvedMembersForTeamName } from '@renderer/store/slices/teamSlice';
|
|
import { isTaskLogActivityChangeEvent } from '@renderer/utils/teamChangeEvents';
|
|
|
|
import type { BoardTaskLogStreamResponse } from '@shared/types';
|
|
|
|
interface TaskLogStreamSectionProps {
|
|
teamName: string;
|
|
taskId: string;
|
|
taskStatus?: string;
|
|
liveEnabled?: boolean;
|
|
}
|
|
|
|
const LIVE_RELOAD_DEBOUNCE_MS = 350;
|
|
|
|
function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string {
|
|
if (stream?.source === 'opencode_runtime_attribution') {
|
|
return 'Task-scoped OpenCode runtime logs projected from explicit task attribution into the same execution-log components used in Logs.';
|
|
}
|
|
if (stream?.source === 'opencode_runtime_fallback') {
|
|
if (stream.runtimeProjection?.fallbackReason === 'task_tool_markers') {
|
|
const spanCount = stream.runtimeProjection.markerSpanCount;
|
|
const spanDetails =
|
|
typeof spanCount === 'number' && spanCount > 1 ? ` across ${spanCount} spans` : '';
|
|
return `Task-scoped OpenCode runtime logs projected from matched task tool markers${spanDetails} into the same execution-log components used in Logs.`;
|
|
}
|
|
return 'Task-scoped OpenCode runtime logs projected into the same execution-log components used in Logs.';
|
|
}
|
|
if (stream?.source === 'codex_native_trace_fallback') {
|
|
return 'Task-scoped Codex native trace logs projected into the same execution-log components used in Logs.';
|
|
}
|
|
if (stream?.source === 'mixed_transcript_codex_native_trace') {
|
|
return 'Task-scoped transcript logs merged with Codex native trace logs and rendered with the same execution-log components used in Logs.';
|
|
}
|
|
if (stream?.runtimeProjection?.provider === 'opencode') {
|
|
return 'Task-scoped transcript logs merged with OpenCode runtime logs and rendered with the same execution-log components used in Logs.';
|
|
}
|
|
if (stream?.runtimeProjection?.provider === 'codex_native') {
|
|
return 'Task-scoped transcript logs merged with Codex native trace logs and rendered with the same execution-log components used in Logs.';
|
|
}
|
|
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
|
|
}
|
|
|
|
export const TaskLogStreamSection = ({
|
|
teamName,
|
|
taskId,
|
|
taskStatus,
|
|
liveEnabled = true,
|
|
}: TaskLogStreamSectionProps): React.JSX.Element => {
|
|
const { t } = useAppTranslation('team');
|
|
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const teamMembers = useStore((s) => selectResolvedMembersForTeamName(s, teamName));
|
|
const requestSeqRef = useRef(0);
|
|
const streamRef = useRef<BoardTaskLogStreamResponse | null>(null);
|
|
const reloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
|
|
useEffect(() => {
|
|
streamRef.current = stream;
|
|
}, [stream]);
|
|
|
|
const loadStream = useCallback(
|
|
async (options?: { background?: boolean }): Promise<void> => {
|
|
const background = options?.background ?? false;
|
|
const hadExistingStream = streamRef.current != null;
|
|
const requestSeq = requestSeqRef.current + 1;
|
|
requestSeqRef.current = requestSeq;
|
|
|
|
if (!background) setLoading(true);
|
|
setError((prev) => (background ? prev : null));
|
|
|
|
try {
|
|
const response = normalizeExecutionLogStream(
|
|
await api.teams.getTaskLogStream(teamName, taskId)
|
|
);
|
|
if (requestSeqRef.current !== requestSeq) return;
|
|
|
|
setStream(response);
|
|
setError(null);
|
|
} catch (loadError) {
|
|
if (requestSeqRef.current !== requestSeq) return;
|
|
if (!background || streamRef.current == null) {
|
|
setError(
|
|
loadError instanceof Error ? loadError.message : 'Failed to load task log stream'
|
|
);
|
|
setStream(null);
|
|
}
|
|
} finally {
|
|
if (requestSeqRef.current === requestSeq && (!background || !hadExistingStream)) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
},
|
|
[taskId, teamName]
|
|
);
|
|
|
|
useEffect(() => {
|
|
setStream(null);
|
|
streamRef.current = null;
|
|
setError(null);
|
|
requestSeqRef.current += 1;
|
|
if (reloadTimerRef.current) {
|
|
clearTimeout(reloadTimerRef.current);
|
|
reloadTimerRef.current = null;
|
|
}
|
|
void loadStream();
|
|
}, [loadStream]);
|
|
|
|
const previousTaskMetaRef = useRef({ taskId, taskStatus });
|
|
|
|
useEffect(() => {
|
|
const previousTaskMeta = previousTaskMetaRef.current;
|
|
previousTaskMetaRef.current = { taskId, taskStatus };
|
|
|
|
if (previousTaskMeta.taskId !== taskId) return;
|
|
if (
|
|
previousTaskMeta.taskStatus === 'in_progress' &&
|
|
taskStatus &&
|
|
taskStatus !== 'in_progress'
|
|
) {
|
|
void loadStream({ background: true });
|
|
}
|
|
}, [loadStream, taskId, taskStatus]);
|
|
|
|
useEffect(() => {
|
|
if (!liveEnabled) {
|
|
if (reloadTimerRef.current) {
|
|
clearTimeout(reloadTimerRef.current);
|
|
reloadTimerRef.current = null;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const scheduleReload = (): void => {
|
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
if (reloadTimerRef.current) clearTimeout(reloadTimerRef.current);
|
|
reloadTimerRef.current = setTimeout(() => {
|
|
reloadTimerRef.current = null;
|
|
void loadStream({ background: true });
|
|
}, LIVE_RELOAD_DEBOUNCE_MS);
|
|
};
|
|
|
|
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
|
if (event.teamName !== teamName) return;
|
|
const shouldReload =
|
|
event.type === 'log-source-change' ||
|
|
(isTaskLogActivityChangeEvent(event) && event.taskId === taskId);
|
|
if (shouldReload) scheduleReload();
|
|
});
|
|
|
|
const handleVisibilityChange = (): void => {
|
|
if (document.visibilityState === 'visible') scheduleReload();
|
|
};
|
|
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
|
}
|
|
|
|
return () => {
|
|
if (reloadTimerRef.current) {
|
|
clearTimeout(reloadTimerRef.current);
|
|
reloadTimerRef.current = null;
|
|
}
|
|
if (typeof document !== 'undefined') {
|
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
|
}
|
|
if (typeof unsubscribe === 'function') unsubscribe();
|
|
};
|
|
}, [liveEnabled, loadStream, taskId, teamName]);
|
|
|
|
const streamDescription = useMemo(() => describeStreamSource(stream), [stream]);
|
|
|
|
return (
|
|
<ExecutionLogStreamView
|
|
title={t('taskLogs.stream.title')}
|
|
description={streamDescription}
|
|
stream={stream}
|
|
loading={loading}
|
|
error={error}
|
|
teamName={teamName}
|
|
teamMembers={teamMembers}
|
|
loadingText="Loading task log stream..."
|
|
emptyTitle="No task log stream yet"
|
|
emptyDescription="Task-linked logs will appear here when transcript metadata or runtime projection is available."
|
|
selectionResetKey={`${teamName}:${taskId}`}
|
|
/>
|
|
);
|
|
};
|