maestro/ui/src/components/create/CreateTaskDialog.tsx
oss-sync ed3dc5529a
All checks were successful
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s
sync: update from private repo (9a86f49b)
2026-07-12 23:51:48 +00:00

298 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import * as Dialog from '@radix-ui/react-dialog';
import { useQuery } from '@tanstack/react-query';
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles, fetchLlmWorkers } from '../../api';
import { CreateTaskDialogAdvancedSection } from './CreateTaskDialogAdvancedSection';
import { CreateTaskDialogCoreSection } from './CreateTaskDialogCoreSection';
import { usePieceList } from '../../hooks/usePieces';
import { useSpaces } from '../../hooks/useSpaces';
import { sortSpacesForRail } from '../../lib/spaceSort';
import { resolvePieceOptions } from '../../lib/splitPieces';
import { useAuthState } from '../../App';
import { useDraft } from '../../hooks/useDraft';
import { toBase64, uniqueAttachmentName } from '../../lib/fileAttachments';
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;
/**
* Spaces foundation: スペース詳細から開いた場合に紐付けるスペース。
* 指定時はそのスペースに固定(ピッカー非表示・名称のみ表示)。
* 未指定(グローバルのタスク一覧から開いた)時は個人スペースが既定。
*/
initialSpaceId?: string;
/** Optional in-page portal target. When set, the form is non-modal. */
inlineContainer?: HTMLElement | null;
}
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder, initialSpaceId, inlineContainer }: CreateTaskDialogProps) {
const { t } = useTranslation('create');
const { data: pieces } = usePieceList();
const { data: spacesData } = useSpaces();
const { data: orgs = [] } = useQuery({ queryKey: ['my-orgs'], queryFn: fetchMyOrgs, staleTime: 5 * 60 * 1000 });
const { data: sessionProfiles = [] } = useQuery({
queryKey: ['browser-session-profiles'],
// ラップ必須: react-query は queryFn に QueryFunctionContext を渡すため、直接渡すと
// spaceId 引数にオブジェクトが入り ?spaceId=[object Object] になる(WS2 回帰)。
queryFn: () => listBrowserSessionProfiles(),
staleTime: 60 * 1000,
});
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
const { data: llmWorkers = [] } = useQuery({ queryKey: ['llm-workers'], queryFn: fetchLlmWorkers });
const enabledLlmWorkers = llmWorkers.filter(w => w.enabled !== false);
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]);
// 下書き保存: ヘルプ/継続フロー等で initialBody が来たときは明示指定を優先し、
// 下書きの復元も上書きもしないkey=null で opt-out
const draftKey = initialBody
? null
: `create-task:${initialPiece ?? 'default'}:${initialSpaceId ?? 'global'}`;
const { draft, saveDraft, clearDraft } = useDraft(draftKey);
const [form, setForm] = useState<CreateLocalTaskInput>({
body: initialBody ?? draft ?? '',
piece: initialPiece ?? 'auto',
profile: 'auto',
outputFormat: 'markdown',
askPolicy: 'low',
priority: 'medium',
workspaceMode: 'persistent',
llmWorkerId: null,
llmEffort: null,
});
// スペース起点で開いた場合は固定。それ以外はユーザーが任意で選択(既定=個人)。
const [selectedSpaceId, setSelectedSpaceId] = useState<string | undefined>(initialSpaceId);
const sortedSpaces = sortSpacesForRail(spacesData ?? []).filter(s => s.status === 'open');
const fixedSpace = initialSpaceId ? sortedSpaces.find(s => s.id === initialSpaceId) : undefined;
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
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 resolvedPieces = resolvePieceOptions(pieces ?? []);
const selectedPiece = resolvedPieces.find(p => p.name === form.piece);
const missingMcp = selectedPiece?.requiredMcp
? selectedPiece.requiredMcp.filter(
(id) => !(connections ?? []).find((c) => c.serverId === id && c.connected),
)
: [];
// ダイアログ全体でファイル貼り付けを受けるtextarea 外でも効く)。
// テキストだけの貼り付けには一切介入しない。
const handlePaste = async (e: React.ClipboardEvent) => {
const files = Array.from(e.clipboardData?.files ?? []);
if (files.length === 0) return;
e.preventDefault();
const taken = new Set(attachments.map(a => a.name));
const converted: Array<{ name: string; contentBase64: string }> = [];
let failed = 0;
for (const f of files) {
const name = uniqueAttachmentName(f.name, taken);
taken.add(name);
try {
converted.push({ name, contentBase64: await toBase64(f) });
} catch {
failed += 1;
}
}
if (converted.length > 0) {
setAttachments(prev => [...prev, ...converted]);
}
if (failed > 0) {
setError(t('errors.pasteAttachFailed'));
}
};
const handleSubmit = async () => {
if (!form.body.trim()) {
setError(t('errors.bodyRequired'));
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(t('errors.scheduleFailed'));
clearDraft();
onClose();
return;
}
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,
// 固定スペースを最優先、無ければ選択値。未指定なら個人スペースに解決される。
spaceId: initialSpaceId ?? selectedSpaceId ?? undefined,
};
await onSubmit(submitForm, attachments);
clearDraft();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSubmitting(false);
}
};
// 入力があるときだけ外側クリックのクローズを止めるEsc/×/キャンセルは常に有効)
const dirty = form.body.trim().length > 0 || attachments.length > 0;
return (
<Dialog.Root open modal={!inlineContainer} onOpenChange={(open) => { if (!open) onClose(); }}>
<Dialog.Portal container={inlineContainer ?? undefined}>
{!inlineContainer && <Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />}
<Dialog.Content
className={inlineContainer
? 'h-full w-full overflow-auto bg-surface focus:outline-none'
: 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none'}
style={inlineContainer ? undefined : { maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
onOpenAutoFocus={e => {
e.preventDefault();
}}
onPointerDownOutside={e => { if (dirty) e.preventDefault(); }}
onInteractOutside={e => { if (dirty) e.preventDefault(); }}
onPaste={e => void handlePaste(e)}
>
<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' ? t('title.help') : t('title.new')}
</Dialog.Title>
<Dialog.Description className="mt-1 text-[13px] text-slate-500">
{initialPiece === 'help' ? t('subtitle.help') : t('subtitle.new')}
</Dialog.Description>
</div>
<Dialog.Close asChild>
<button
aria-label={t('close')}
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>
<CreateTaskDialogCoreSection
initialPiece={initialPiece}
placeholder={placeholder}
initialSpaceId={initialSpaceId}
fixedSpace={fixedSpace}
selectedSpaceId={selectedSpaceId}
setSelectedSpaceId={setSelectedSpaceId}
form={form}
setForm={setForm}
attachments={attachments}
setAttachments={setAttachments}
resolvedPieces={resolvedPieces}
missingMcp={missingMcp}
onSubmit={handleSubmit}
saveDraft={saveDraft}
sortedSpaces={sortedSpaces}
orgs={orgs}
visibility={visibility}
/>
<CreateTaskDialogAdvancedSection
form={form}
setForm={setForm}
resolvedPieces={resolvedPieces}
activeSessionProfiles={activeSessionProfiles}
enabledLlmWorkers={enabledLlmWorkers}
isScheduled={isScheduled}
setIsScheduled={setIsScheduled}
schedule={schedule}
setSchedule={setSchedule}
browserSessionProfileId={browserSessionProfileId}
setBrowserSessionProfileId={setBrowserSessionProfileId}
visibility={visibility}
setVisibility={setVisibility}
visibilityScopeOrgId={visibilityScopeOrgId}
setVisibilityScopeOrgId={setVisibilityScopeOrgId}
orgs={orgs}
showAdvanced={showAdvanced}
setShowAdvanced={setShowAdvanced}
/>
{error && <div className="mt-4 text-[13px] text-red-600">{error}</div>}
<div className="mt-4 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">
{t('cancel')}
</button>
</Dialog.Close>
<button
disabled={submitting}
data-testid="create-task-submit"
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 ? t('submitting') : isScheduled ? t('submitSchedule') : t('submit')}
</button>
</div>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}