maestro/ui/src/components/detail/tabs/OverviewTab.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

321 lines
13 KiB
TypeScript

import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { LocalTask, MissionBrief, SubtaskActivity, putFeedback, updateMissionBrief } from '../../../api';
import { StatusBadge } from '../../shared/StatusBadge';
import { SubtasksPanel, type SubtaskFilePreviewHandler } from './SubtasksPanel';
import { ContextUsageGauge } from '../ContextUsageGauge';
import { ReflectionBadge } from '../ReflectionBadge';
const GOOD_TAGS = ['出力の精度が高い', 'フォーマットが適切', '指示をよく理解していた', '速度が適切だった'];
const BAD_TAGS = ['出力の精度が低い', 'フォーマットが不適切', '指示と違う結果になった', '不要な作業をしていた', '途中で止まった / ASKが多すぎた'];
function FeedbackPanel({ task }: { task: LocalTask }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const isComplete = task.latestJob?.status === 'succeeded' || task.latestJob?.status === 'failed';
const hasFeedback = !!task.feedbackRating;
const [rating, setRating] = useState<'good' | 'bad' | null>(task.feedbackRating ?? null);
const [selectedTags, setSelectedTags] = useState<string[]>(task.feedbackTags ?? []);
const [comment, setComment] = useState(task.feedbackComment ?? '');
const [editing, setEditing] = useState(!hasFeedback);
const mutation = useMutation({
mutationFn: (fb: { rating: 'good' | 'bad'; tags: string[]; comment?: string }) =>
putFeedback(task.id, fb),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['localTasks'] });
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
setEditing(false);
},
});
if (!isComplete) return null;
const tags = rating === 'good' ? GOOD_TAGS : rating === 'bad' ? BAD_TAGS : [];
const toggleTag = (tag: string) => {
setSelectedTags(prev => prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]);
};
const handleSubmit = () => {
if (!rating) return;
mutation.mutate({ rating, tags: selectedTags, comment: comment || undefined });
};
const handleRatingClick = (r: 'good' | 'bad') => {
setRating(r);
setSelectedTags([]);
setEditing(true);
};
if (!editing && hasFeedback) {
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-slate-700">{t('feedback.title')}</span>
<span className={`text-lg ${task.feedbackRating === 'good' ? 'text-green-500' : 'text-red-500'}`}>
{task.feedbackRating === 'good' ? '👍' : '👎'}
</span>
</div>
<button
onClick={() => setEditing(true)}
className="text-xs text-slate-400 hover:text-slate-600"
>
{t('feedback.change')}
</button>
</div>
{task.feedbackTags && task.feedbackTags.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{task.feedbackTags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{tag}</span>
))}
</div>
)}
{task.feedbackComment && (
<div className="mt-2 text-xs text-slate-500">{task.feedbackComment}</div>
)}
</div>
);
}
return (
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="text-sm font-semibold text-slate-700 mb-2">{t('feedback.title')}</div>
<div className="flex gap-2 mb-3">
<button
onClick={() => handleRatingClick('good')}
className={`px-3 py-1.5 rounded-lg text-sm border transition-colors ${
rating === 'good' ? 'bg-green-50 dark:bg-green-500/15 border-green-300 dark:border-green-500/30 text-green-700 dark:text-green-300' : 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
👍 {t('feedback.good')}
</button>
<button
onClick={() => handleRatingClick('bad')}
className={`px-3 py-1.5 rounded-lg text-sm border transition-colors ${
rating === 'bad' ? 'bg-red-50 dark:bg-red-500/15 border-red-300 dark:border-red-500/30 text-red-700 dark:text-red-300' : 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
👎 {t('feedback.bad')}
</button>
</div>
{rating && (
<>
<div className="flex flex-wrap gap-1.5 mb-3">
{tags.map(tag => (
<button
key={tag}
onClick={() => toggleTag(tag)}
className={`px-2 py-0.5 rounded-full text-2xs border transition-colors ${
selectedTags.includes(tag)
? 'bg-accent-soft border-accent text-accent'
: 'border-slate-200 text-slate-500 hover:border-slate-300'
}`}
>
{tag}
</button>
))}
</div>
<textarea
value={comment}
onChange={e => setComment(e.target.value)}
placeholder={t('feedback.commentPlaceholder')}
maxLength={1000}
rows={2}
className="w-full px-3 py-2 text-xs border border-slate-200 rounded-lg resize-none focus:outline-none focus:ring-1 focus:ring-accent-ring mb-2"
/>
<div className="flex justify-end gap-2">
{hasFeedback && (
<button
onClick={() => { setEditing(false); setRating(task.feedbackRating ?? null); setSelectedTags(task.feedbackTags ?? []); setComment(task.feedbackComment ?? ''); }}
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-600"
>
{t('feedback.cancel')}
</button>
)}
<button
onClick={handleSubmit}
disabled={mutation.isPending}
className="px-3 py-1 text-xs bg-accent text-accent-fg rounded-lg hover:bg-accent-deep disabled:opacity-50"
>
{mutation.isPending ? t('feedback.submitting') : t('feedback.submit')}
</button>
</div>
</>
)}
</div>
);
}
/**
* Mission Brief card. Per-task pinned memo with goal / done / open /
* clarifications. The LLM updates these via the MissionUpdate tool;
* the user can edit them here to anchor or correct the agent. Always
* shown so the user can guide the agent before the conversation drifts.
*/
const MISSION_FIELDS: Array<{ key: keyof MissionBrief }> = [
{ key: 'goal' },
{ key: 'done' },
{ key: 'open' },
{ key: 'clarifications' },
];
const EMPTY_MISSION: MissionBrief = { goal: '', done: '', open: '', clarifications: '' };
function MissionCard({ task }: { task: LocalTask }) {
const { t } = useTranslation('detail');
const qc = useQueryClient();
const current = task.missionBrief ?? EMPTY_MISSION;
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState<MissionBrief>(current);
const [error, setError] = useState<string | null>(null);
// Keep draft in sync with server-side updates (e.g. LLM writes via
// MissionUpdate while we're not editing). Don't clobber an active edit.
useEffect(() => {
if (!editing) setDraft(task.missionBrief ?? EMPTY_MISSION);
}, [task.missionBrief, editing]);
const mutation = useMutation({
mutationFn: () => updateMissionBrief(task.id, draft),
onSuccess: () => {
setEditing(false);
setError(null);
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
qc.invalidateQueries({ queryKey: ['localTasks'] });
},
onError: (err: unknown) => {
setError(err instanceof Error ? err.message : 'Failed to save mission brief');
},
});
const isEmpty = !current.goal && !current.done && !current.open && !current.clarifications;
return (
<div className="bg-canvas border border-hairline rounded-md p-3.5">
<div className="flex items-center justify-between mb-2.5">
<div className="flex items-center gap-1.5">
<svg className="w-3.5 h-3.5 text-slate-500" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 2v12M3 2h7l-1 2 1 2H3" />
</svg>
<span className="section-label">Mission Brief</span>
<span className="text-[10px] text-slate-400"> {t('mission.pinnedMemo')}</span>
</div>
{!editing ? (
<button
type="button"
onClick={() => { setDraft(current); setEditing(true); setError(null); }}
className="px-2 h-7 text-2xs font-medium border border-hairline bg-canvas text-slate-700 hover:bg-surface rounded-md transition-colors"
>
{t('mission.edit')}
</button>
) : null}
</div>
{editing ? (
<div className="flex flex-col gap-2.5">
{MISSION_FIELDS.map(({ key }) => (
<div key={key}>
<label className="block text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-1">{t(`mission.fields.${key}.label`)}</label>
<textarea
value={draft[key] ?? ''}
onChange={(e) => setDraft({ ...draft, [key]: e.target.value })}
placeholder={t(`mission.fields.${key}.placeholder`)}
rows={key === 'goal' ? 2 : 3}
className="w-full px-2.5 py-1.5 text-xs border border-hairline rounded-md focus:outline-none focus:ring-2 focus:ring-accent-ring focus:border-accent transition-shadow font-mono leading-snug"
/>
</div>
))}
{error && <div className="text-2xs text-red-600">{error}</div>}
<div className="flex justify-end gap-1.5">
<button
type="button"
onClick={() => { setEditing(false); setError(null); setDraft(current); }}
disabled={mutation.isPending}
className="px-3 h-7 text-xs rounded-md border border-hairline bg-canvas text-slate-700 hover:bg-surface transition-colors disabled:opacity-50"
>
{t('mission.cancel')}
</button>
<button
type="button"
onClick={() => mutation.mutate()}
disabled={mutation.isPending}
className="px-3 h-7 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep transition-colors disabled:opacity-50"
>
{mutation.isPending ? t('mission.saving') : t('mission.save')}
</button>
</div>
</div>
) : isEmpty ? (
<div className="text-xs text-slate-500 leading-relaxed">
{t('mission.emptyHelp')}
</div>
) : (
<div className="flex flex-col gap-2.5">
{MISSION_FIELDS.map(({ key }) => {
const value = current[key];
return (
<div key={key}>
<div className="text-[10px] font-mono uppercase tracking-wider text-slate-500 mb-0.5">{t(`mission.fields.${key}.label`)}</div>
{value ? (
<div className="text-xs text-slate-800 whitespace-pre-wrap leading-snug font-mono">{value}</div>
) : (
<div className="text-2xs text-slate-400 italic">{t(`mission.fields.${key}.emptyHint`)}</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
interface OverviewTabProps {
task: LocalTask;
subtaskActivities?: SubtaskActivity[];
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
}
export function OverviewTab({ task, subtaskActivities, onSubtaskFilePreview }: OverviewTabProps) {
const status = task.latestJob?.status ?? 'queued';
return (
<div className="flex flex-col gap-3">
<div className="bg-canvas border border-slate-200 rounded-xl p-4 shadow-sm">
<div className="text-lg font-extrabold text-slate-900">{task.title}</div>
<div className="flex flex-wrap gap-2 mt-2">
<StatusBadge status={status} />
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.pieceName}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-2xs bg-slate-100 text-slate-600">{task.priority}</span>
</div>
<div className="mt-3 text-[13px] text-slate-600 whitespace-pre-wrap leading-relaxed">{task.body || '(no body)'}</div>
</div>
<MissionCard task={task} />
<ContextUsageGauge
promptTokens={task.latestJob?.contextPromptTokens}
limitTokens={task.latestJob?.contextLimitTokens}
jobStatus={task.latestJob?.status}
/>
<FeedbackPanel task={task} />
<ReflectionBadge taskId={task.id} />
{task.subtasks && task.subtasks.length > 0 && (
<SubtasksPanel
taskId={task.id}
subtasks={task.subtasks}
subtaskCount={task.subtaskCount ?? task.subtasks.length}
subtaskCompleted={task.subtaskCompleted ?? 0}
subtaskActivities={subtaskActivities}
onFilePreview={onSubtaskFilePreview}
/>
)}
</div>
);
}