321 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|