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; /** * 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 => { 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(defaultVis); const [visibilityScopeOrgId, setVisibilityScopeOrgId] = useState(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({ body: initialBody ?? draft ?? '', piece: initialPiece ?? 'auto', profile: 'auto', outputFormat: 'markdown', askPolicy: 'low', priority: 'medium', workspaceMode: 'persistent', llmWorkerId: null, llmEffort: null, }); // スペース起点で開いた場合は固定。それ以外はユーザーが任意で選択(既定=個人)。 const [selectedSpaceId, setSelectedSpaceId] = useState(initialSpaceId); const sortedSpaces = sortSpacesForRail(spacesData ?? []).filter(s => s.status === 'open'); const fixedSpace = initialSpaceId ? sortedSpaces.find(s => s.id === initialSpaceId) : undefined; const [attachments, setAttachments] = useState>([]); const [browserSessionProfileId, setBrowserSessionProfileId] = useState(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 ( { if (!open) onClose(); }}> {!inlineContainer && } { e.preventDefault(); }} onPointerDownOutside={e => { if (dirty) e.preventDefault(); }} onInteractOutside={e => { if (dirty) e.preventDefault(); }} onPaste={e => void handlePaste(e)} >
{initialPiece === 'help' ? t('title.help') : t('title.new')} {initialPiece === 'help' ? t('subtitle.help') : t('subtitle.new')}
{error &&
{error}
}
); }