maestro/ui/src/components/detail/tabs/SubtasksPanel.tsx
oss-sync d061ad08d8
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e62f5c7)
2026-06-11 01:52:48 +00:00

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>
);
}