maestro/ui/src/components/create/CreateTaskDialog.tsx
2026-06-03 05:08:00 +00:00

424 lines
20 KiB
TypeScript

import { useState, useEffect } from 'react';
import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
import { AttachmentDropzone } from './AttachmentDropzone';
import { ScheduleFields } from './ScheduleFields';
import { usePieceList } from '../../hooks/usePieces';
import { useAuthState } from '../../App';
interface CreateTaskDialogProps {
onClose: () => void;
onSubmit: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
/**
* Optional preselected piece. When set, the piece is locked (not overridden
* by the auto-classifier) and a placeholder hint explains the assistant.
* Used by the Help Center "AI に聞く" button to land users in the help piece.
*/
initialPiece?: string;
initialBody?: string;
placeholder?: string;
}
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder }: CreateTaskDialogProps) {
const { data: pieces } = usePieceList();
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs, staleTime: 5 * 60 * 1000 });
const { data: sessionProfiles = [] } = useQuery({
queryKey: ['browser-session-profiles'],
queryFn: listBrowserSessionProfiles,
staleTime: 60 * 1000,
});
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
interface ConnectionRow { serverId: string; serverName: string; connected: boolean }
const { data: connections } = useQuery({
queryKey: ['mcp-connections'],
queryFn: async (): Promise<ConnectionRow[]> => {
const res = await fetch('/api/mcp/connections', { credentials: 'include' });
if (!res.ok) return [];
const data = await res.json();
return (data.connections ?? []) as ConnectionRow[];
},
staleTime: 30_000,
});
const authState = useAuthState();
const defaultVis = (authState.mode === 'authenticated' ? authState.user?.defaultVisibility : undefined) ?? 'private';
const savedOrgId = (authState.mode === 'authenticated' ? authState.user?.defaultVisibilityOrgId : undefined) ?? null;
const [visibility, setVisibility] = useState<Visibility>(defaultVis);
const [visibilityScopeOrgId, setVisibilityScopeOrgId] = useState<string | null>(savedOrgId);
// Backfill the scope when orgs finish loading: useQuery starts with orgs=[]
// so without this the initial render freezes scopeId=null for users without
// a saved default, and picking 'Organization' would submit an unscoped task.
useEffect(() => {
if (visibilityScopeOrgId !== null) return;
if (orgs.length === 0) return;
setVisibilityScopeOrgId(orgs[0].orgId);
}, [orgs, visibilityScopeOrgId]);
const [form, setForm] = useState<CreateLocalTaskInput>({
body: initialBody ?? '',
piece: initialPiece ?? 'auto',
profile: 'auto',
outputFormat: 'markdown',
askPolicy: 'low',
priority: 'medium',
});
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
const [mcpDisabled, setMcpDisabled] = useState(false);
const [skillsDisabled, setSkillsDisabled] = useState(false);
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [showAdvanced, setShowAdvanced] = useState(false);
const [isScheduled, setIsScheduled] = useState(false);
const [schedule, setSchedule] = useState({
scheduleType: 'daily',
hour: 9,
minute: 0,
dayOfWeek: 1,
dayOfMonth: 1,
cronExpression: '',
scheduledAt: '',
});
const selectedPiece = (pieces ?? []).find(p => p.name === form.piece);
const missingMcp = selectedPiece?.requiredMcp
? selectedPiece.requiredMcp.filter(
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
)
: [];
const handleSubmit = async () => {
if (!form.body.trim()) {
setError('依頼内容は必須です');
return;
}
try {
setSubmitting(true);
setError('');
if (isScheduled) {
const res = await fetch('/api/scheduled-tasks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: form.body.trim().slice(0, 40),
body: form.body.trim(),
piece: form.piece,
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId: browserSessionProfileId ?? undefined,
...schedule,
}),
});
if (!res.ok) throw new Error('スケジュール作成に失敗しました');
onClose();
return;
}
const options: Record<string, boolean> = {};
if (mcpDisabled) options.mcpDisabled = true;
if (skillsDisabled) options.skillsDisabled = true;
const submitForm = {
...form,
// initialPiece が指定されているヘルプアシスタント等は piece を固定。
// それ以外は form.piece (詳細設定で選択した値、無指定なら 'auto') を尊重する。
piece: initialPiece ?? form.piece,
title: undefined,
body: form.body.trim(),
visibility,
visibilityScopeOrgId: visibility === 'org' ? visibilityScopeOrgId : null,
browserSessionProfileId: browserSessionProfileId ?? undefined,
...(Object.keys(options).length > 0 ? { options } : {}),
};
await onSubmit(submitForm, attachments);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
};
return (
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Portal>
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
<Dialog.Content
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-white rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
style={{ maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
onOpenAutoFocus={e => {
e.preventDefault();
}}
>
<div className="p-5">
<div className="flex items-start justify-between gap-3 mb-5">
<div>
<Dialog.Title className="text-xl font-extrabold text-slate-900 m-0">
{initialPiece === 'help' ? 'AI ヘルプに質問' : '新しい Task'}
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
{initialPiece === 'help'
? '使い方や設計について自由に質問してください'
: '依頼内容を入力して実行'}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label="閉じる"
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
<svg className="w-4 h-4" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d="M4 4l8 8M12 4l-8 8"/>
</svg>
</button>
</Dialog.Close>
</div>
<div className="flex flex-col gap-4">
{/* Textarea */}
<div>
<label className="block text-[13px] text-slate-600 mb-1.5"></label>
<textarea
autoFocus
value={form.body}
onChange={e => setForm(prev => ({ ...prev, body: e.target.value }))}
onKeyDown={e => {
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
void handleSubmit();
}
}}
rows={8}
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
placeholder={placeholder ?? (initialPiece === 'help'
? '例: 「ユーザーフォルダの memory/ と AGENTS.md の違いは?」 / 「MCP サーバーを個人で追加するには?」 / 「自分の最近のタスクは?」'
: '依頼内容を入力してください (Ctrl+Enter で送信)')}
/>
</div>
{/* Attachments */}
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
{/* MCP warnings (always visible when applicable) */}
{missingMcp.length > 0 && (
<div className="p-3 bg-yellow-50 border border-yellow-300 rounded text-xs text-yellow-900 space-y-2">
<div>
<strong> MCP :</strong> {missingMcp.join(', ')}
</div>
<div className="flex flex-wrap gap-2">
{missingMcp.map((id) => (
<a
key={id}
className="px-2 py-0.5 rounded bg-yellow-600 text-white hover:bg-yellow-700 text-2xs font-semibold"
href={`/auth/mcp/${encodeURIComponent(id)}/start`}
target="_blank"
rel="noopener noreferrer"
>
{id}
</a>
))}
</div>
<div className="text-2xs text-yellow-700">
waiting_human
</div>
</div>
)}
{/* Advanced Settings toggle + content */}
<div>
<button
onClick={() => setShowAdvanced(prev => !prev)}
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
>
{showAdvanced ? '詳細設定を隠す' : '詳細設定を開く'}
</button>
{showAdvanced && (
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
{/* Row 1: Piece, Profile, Priority */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<select
value={form.piece}
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value="auto"></option>
{(pieces ?? []).map(p => (
<option key={p.name} value={p.name}>{p.name}</option>
))}
</select>
</div>
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<select
value={form.profile}
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
{[['auto', 'auto'], ['fast', 'fast'], ['quality', 'quality']].map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<select
value={form.priority}
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
{[['low', 'low'], ['medium', 'medium'], ['high', 'high']].map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
</div>
{/* Row 2: Output Format, Ask Policy */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<select
value={form.outputFormat}
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
{[['markdown', 'markdown'], ['text', 'text'], ['json', 'json']].map(([v, l]) => (
<option key={v} value={v}>{l}</option>
))}
</select>
</div>
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<select
value={form.askPolicy}
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value }))}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value="low">low ()</option>
<option value="high">high ()</option>
</select>
</div>
</div>
{/* Row 3: MCP disable, Skills disable checkboxes */}
<div className="flex flex-wrap gap-x-6 gap-y-2">
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={mcpDisabled}
onChange={e => setMcpDisabled(e.target.checked)}
className="rounded"
/>
MCP ()
</label>
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={skillsDisabled}
onChange={e => setSkillsDisabled(e.target.checked)}
className="rounded"
/>
Skills
</label>
</div>
{/* Browser Session (only if active profiles exist) */}
{activeSessionProfiles.length > 0 && (
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<select
value={browserSessionProfileId ?? ''}
onChange={e =>
setBrowserSessionProfileId(e.target.value ? Number(e.target.value) : null)
}
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
>
<option value=""></option>
{activeSessionProfiles.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
<p className="text-2xs text-slate-400 mt-1">
使
</p>
</div>
)}
{/* Visibility */}
<div>
<label className="block text-2xs text-slate-500 mb-1"></label>
<div className="flex gap-3 text-xs">
<label className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
</label>
<label className="flex items-center gap-1 cursor-pointer">
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
</label>
</div>
{visibility === 'org' && orgs.length > 1 && (
<select
className="mt-1 px-2 py-1 border border-slate-200 rounded text-xs"
value={visibilityScopeOrgId ?? ''}
onChange={e => setVisibilityScopeOrgId(e.target.value)}
>
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
</select>
)}
{visibility === 'org' && orgs.length === 1 && (
<div className="mt-1 text-2xs text-slate-500">: {orgs[0].orgName}</div>
)}
{visibility === 'org' && orgs.length === 0 && (
<div className="mt-1 text-2xs text-slate-400">使 Gitea </div>
)}
</div>
{/* Schedule toggle + sub-form */}
<div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="schedule-toggle"
checked={isScheduled}
onChange={e => setIsScheduled(e.target.checked)}
className="rounded"
/>
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer"></label>
</div>
{isScheduled && (
<ScheduleFields schedule={schedule} onChange={setSchedule} />
)}
</div>
</div>
)}
</div>
{error && <div className="text-[13px] text-red-600">{error}</div>}
{/* Action buttons */}
<div className="flex justify-end items-center gap-2 pt-1">
<Dialog.Close asChild>
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
</button>
</Dialog.Close>
<button
disabled={submitting}
onClick={() => void handleSubmit()}
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
>
{submitting ? '作成中...' : isScheduled ? 'スケジュール作成' : 'Task 作成'}
</button>
</div>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}