215 lines
8.7 KiB
TypeScript
215 lines
8.7 KiB
TypeScript
import { useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import i18n from '../../../i18n';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { POLLING } from '../../../lib/constants.js';
|
|
import { SubtaskInfo, SubtaskActivity, SubtaskFiles, fetchSubtaskFiles, subtaskFileRawUrl, fetchSubtaskActivity } from '../../../api';
|
|
import { statusTone, formatStatusLabel, parseActivityLog, isPreviewable } from '../../../lib/utils';
|
|
import { ActivityTimeline } from '../../activity/ActivityTimeline';
|
|
import { LinkifiedText } from '../../../lib/linkified-text';
|
|
import { OutputPreviewProvider } from '../../../lib/output-preview-context';
|
|
import { stripOutputPrefix } from '../../../lib/output-path-detect';
|
|
|
|
export type SubtaskFilePreviewHandler = (taskId: number, jobId: string, category: string, filePath: string) => void;
|
|
|
|
interface SubtasksPanelProps {
|
|
taskId: number;
|
|
subtasks: SubtaskInfo[];
|
|
subtaskCount: number;
|
|
subtaskCompleted: number;
|
|
subtaskActivities?: SubtaskActivity[];
|
|
onFilePreview?: SubtaskFilePreviewHandler;
|
|
}
|
|
|
|
interface SubtaskCardProps {
|
|
taskId: number;
|
|
subtask: SubtaskInfo;
|
|
activity?: SubtaskActivity;
|
|
onFilePreview?: SubtaskFilePreviewHandler;
|
|
}
|
|
|
|
const ACTIVE_STATUSES = new Set(['running', 'waiting_human', 'waiting_subtasks']);
|
|
|
|
const CATEGORY_LABELS: Record<string, string> = {
|
|
output: 'Output files',
|
|
logs: 'Logs',
|
|
input: 'Input files',
|
|
};
|
|
|
|
const CATEGORY_ORDER = ['output', 'logs', 'input'];
|
|
|
|
function FileList({ taskId, jobId, category, files, onFilePreview }: { taskId: number; jobId: string; category: string; files: string[]; onFilePreview?: SubtaskFilePreviewHandler }) {
|
|
const label = i18n.t('detail:subtasks.category.' + category, { defaultValue: CATEGORY_LABELS[category] ?? category });
|
|
return (
|
|
<div className="mt-2">
|
|
<div className="text-[10px] font-semibold text-slate-400 uppercase tracking-wide mb-1">{label}</div>
|
|
<ul className="flex flex-col gap-0.5">
|
|
{files.map(filePath => {
|
|
const previewable = isPreviewable(filePath);
|
|
return (
|
|
<li key={filePath} className="flex items-center gap-1.5">
|
|
{previewable && onFilePreview ? (
|
|
<button
|
|
onClick={() => onFilePreview(taskId, jobId, category, filePath)}
|
|
className="text-xs text-blue-600 hover:underline break-all text-left"
|
|
>
|
|
{filePath}
|
|
</button>
|
|
) : (
|
|
<a
|
|
href={subtaskFileRawUrl(taskId, jobId, `${category}/${filePath}`)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-xs text-blue-600 hover:underline break-all"
|
|
>
|
|
{filePath}
|
|
</a>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SubtaskCard({ taskId, subtask, activity, onFilePreview }: SubtaskCardProps) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const tone = statusTone(subtask.status);
|
|
const title = subtask.instruction.split('\n')[0]?.slice(0, 100) ?? '';
|
|
const isActive = ACTIVE_STATUSES.has(subtask.status);
|
|
|
|
const { data: activityLog } = useQuery({
|
|
queryKey: ['subtaskActivity', taskId, subtask.id],
|
|
queryFn: () => fetchSubtaskActivity(taskId, subtask.id),
|
|
refetchInterval: POLLING.FAST,
|
|
enabled: expanded && isActive,
|
|
});
|
|
|
|
const displayLog = expanded && !isActive ? (activity?.activityLog ?? '') : (activityLog ?? '');
|
|
const activityEvents = expanded ? parseActivityLog(displayLog) : [];
|
|
|
|
const { data: subtaskFiles, isLoading: filesLoading } = useQuery({
|
|
queryKey: ['subtask-files', taskId, subtask.id],
|
|
queryFn: () => fetchSubtaskFiles(taskId, subtask.id),
|
|
enabled: expanded,
|
|
refetchInterval: isActive ? POLLING.MEDIUM : false,
|
|
});
|
|
|
|
const currentMovement = activity?.currentMovement;
|
|
const categories = subtaskFiles?.categories ?? {};
|
|
const hasFiles = Object.values(categories).some(f => f.length > 0);
|
|
|
|
return (
|
|
<div className="border border-slate-200 rounded-lg bg-canvas overflow-hidden">
|
|
<button
|
|
className="w-full text-left px-3 py-2.5 flex items-start gap-2 hover:bg-slate-50 transition-colors"
|
|
onClick={() => setExpanded(prev => !prev)}
|
|
>
|
|
<span
|
|
className="flex-shrink-0 mt-0.5 px-1.5 py-0.5 rounded-full text-[10px] font-bold"
|
|
style={{ background: tone.bg, color: tone.fg }}
|
|
>
|
|
{formatStatusLabel(subtask.status)}
|
|
</span>
|
|
<span className="text-[13px] text-slate-800 font-medium leading-snug min-w-0 truncate flex-1">
|
|
#{subtask.issueNumber} {title}
|
|
</span>
|
|
{subtask.children && subtask.children.length > 0 && (
|
|
<span className="flex-shrink-0 text-[10px] text-indigo-500 font-medium">
|
|
({subtask.childCompleted ?? 0}/{subtask.childCount ?? subtask.children.length})
|
|
</span>
|
|
)}
|
|
{currentMovement && isActive && (
|
|
<span className="flex-shrink-0 text-2xs text-slate-400 font-mono">
|
|
{currentMovement}
|
|
</span>
|
|
)}
|
|
<span className="flex-shrink-0 ml-auto text-slate-400 text-xs">
|
|
{expanded ? '▲' : '▼'}
|
|
</span>
|
|
</button>
|
|
|
|
{expanded && (
|
|
// Subtask-scoped preview context: any `output/...` link inside
|
|
// this card opens the SUBTASK's workspace file (not the main
|
|
// task's). Wrapping just the expanded body keeps the outer
|
|
// (main task) provider in charge of everything else.
|
|
<OutputPreviewProvider
|
|
openOutputPath={(matchedPath) => {
|
|
if (!onFilePreview) return;
|
|
const relative = stripOutputPrefix(matchedPath);
|
|
onFilePreview(taskId, subtask.id, 'output', relative);
|
|
}}
|
|
>
|
|
<div className="px-3 pb-3 border-t border-slate-100">
|
|
<LinkifiedText
|
|
as="pre"
|
|
className="mt-2 text-xs text-slate-600 whitespace-pre-wrap leading-relaxed font-sans"
|
|
text={subtask.instruction}
|
|
/>
|
|
|
|
{activityEvents.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="text-2xs font-semibold text-slate-500 mb-1">Activity</div>
|
|
<ActivityTimeline
|
|
events={activityEvents}
|
|
emptyLabel=""
|
|
limit={isActive ? 5 : undefined}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{filesLoading && <div className="mt-3 text-xs text-slate-400">{t('subtasks.filesLoading')}</div>}
|
|
{hasFiles && (
|
|
<div className="mt-3">
|
|
<div className="text-2xs font-semibold text-slate-500 mb-1">{t('subtasks.files')}</div>
|
|
{CATEGORY_ORDER.map(cat =>
|
|
categories[cat] && categories[cat].length > 0 ? (
|
|
<FileList key={cat} taskId={taskId} jobId={subtask.id} category={cat} files={categories[cat]} onFilePreview={onFilePreview} />
|
|
) : null
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{subtask.children && subtask.children.length > 0 && (
|
|
<div className="mt-3">
|
|
<div className="text-2xs font-semibold text-slate-500 mb-1">
|
|
{t('subtasks.childTasks', { done: subtask.childCompleted ?? 0, total: subtask.childCount ?? subtask.children.length })}
|
|
</div>
|
|
<div className="flex flex-col gap-1.5 ml-2 border-l-2 border-indigo-100 pl-2">
|
|
{subtask.children.map(child => (
|
|
<SubtaskCard key={child.id} taskId={taskId} subtask={child} onFilePreview={onFilePreview} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</OutputPreviewProvider>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function SubtasksPanel({ taskId, subtasks, subtaskCount, subtaskCompleted, subtaskActivities, onFilePreview }: SubtasksPanelProps) {
|
|
const { t } = useTranslation('detail');
|
|
const progressPct = subtaskCount > 0 ? Math.round((subtaskCompleted / subtaskCount) * 100) : 0;
|
|
|
|
return (
|
|
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className="text-sm font-bold text-slate-800">{t('subtasks.title')}</div>
|
|
<div className="text-xs text-slate-500">{subtaskCompleted}/{subtaskCount} {t('subtasks.done')}</div>
|
|
</div>
|
|
<div className="w-full bg-slate-100 rounded-full h-1.5 mb-4">
|
|
<div className="bg-accent h-1.5 rounded-full transition-all" style={{ width: `${progressPct}%` }} />
|
|
</div>
|
|
<div className="flex flex-col gap-2">
|
|
{subtasks.map(subtask => (
|
|
<SubtaskCard key={subtask.id} taskId={taskId} subtask={subtask} activity={subtaskActivities?.find(a => a.jobId === subtask.id)} onFilePreview={onFilePreview} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|