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