This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { ChatPane } from './ChatPane';
|
||||
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
|
||||
import type { LocalTask } from '../../api';
|
||||
|
||||
const DRAFT_KEY = 'maestro:draft:v1:chat:42';
|
||||
|
||||
// useJobStream が EventSource を張るため jsdom にスタブを入れる
|
||||
class FakeEventSource {
|
||||
onmessage: ((e: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
const task = {
|
||||
id: 42,
|
||||
title: 'テストタスク',
|
||||
pieceName: 'chat',
|
||||
latestJob: undefined,
|
||||
} as unknown as LocalTask;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetDraftPruneForTest();
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
|
||||
// ChatPetOverlay (rendered unconditionally by ChatPane) reads matchMedia for
|
||||
// prefers-reduced-motion; jsdom doesn't implement it. Minimal stub only —
|
||||
// not part of the brief, needed to get past mount.
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
});
|
||||
|
||||
describe('ChatPane drafts', () => {
|
||||
it('入力途中の内容がアンマウント後も復元される', async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = renderWithProviders(
|
||||
<ChatPane task={task} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await user.type(screen.getByRole('textbox'), '書きかけの返信');
|
||||
first.unmount();
|
||||
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('書きかけの返信');
|
||||
|
||||
renderWithProviders(
|
||||
<ChatPane task={task} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('書きかけの返信');
|
||||
});
|
||||
|
||||
it('送信成功で下書きが消える', async () => {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '送る内容', updatedAt: Date.now() }));
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn(async () => {});
|
||||
renderWithProviders(<ChatPane task={task} comments={[]} onSubmit={onSubmit} />);
|
||||
const textbox = screen.getByRole('textbox');
|
||||
expect(textbox).toHaveValue('送る内容');
|
||||
// Ctrl+Enter は textarea にフォーカスがないと発火しないため、まずフォーカスする。
|
||||
await user.click(textbox);
|
||||
await user.keyboard('{Control>}{Enter}{/Control}');
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -12,21 +12,10 @@ import { ToolRequestApproval } from './ToolRequestApproval';
|
||||
import { PackageRequestApproval } from './PackageRequestApproval';
|
||||
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
||||
import { useJobStream } from '../../hooks/useJobStream';
|
||||
import { useDraft } from '../../hooks/useDraft';
|
||||
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
|
||||
|
||||
|
||||
async function toBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result ?? '');
|
||||
resolve(result.includes(',') ? result.split(',')[1]! : result);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('file read error'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
import { toBase64 } from '../../lib/fileAttachments';
|
||||
|
||||
interface ChatPaneProps {
|
||||
task: LocalTask;
|
||||
@@ -43,7 +32,9 @@ interface ChatPaneProps {
|
||||
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const { t: dt } = useTranslation('detail');
|
||||
const [draft, setDraft] = useState('');
|
||||
// 下書き保存: ChatPane はチャット切替で remount されるため、初期値復元で足りる
|
||||
const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`);
|
||||
const [draft, setDraft] = useState(restoredDraft ?? '');
|
||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
@@ -147,6 +138,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
|
||||
setDraft('');
|
||||
setAttachments([]);
|
||||
clearDraft();
|
||||
// Hold the lock until the agent is visibly responding (see effect below).
|
||||
// Safety net: if the worker never picks the job up (queue stuck, server
|
||||
// crash, etc.), release the lock after 10s so the user isn't trapped.
|
||||
@@ -507,7 +499,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={e => setDraft(e.target.value)}
|
||||
onChange={e => { setDraft(e.target.value); saveDraft(e.target.value); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={e => void handlePaste(e)}
|
||||
rows={1}
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toBase64 } from '../../lib/fileAttachments';
|
||||
|
||||
interface AttachmentDropzoneProps {
|
||||
attachments: Array<{ name: string; contentBase64: string }>;
|
||||
onFilesChange: (files: Array<{ name: string; contentBase64: string }>) => void;
|
||||
}
|
||||
|
||||
async function toBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const result = String(reader.result ?? '');
|
||||
resolve(result.includes(',') ? result.split(',')[1]! : result);
|
||||
};
|
||||
reader.onerror = () => reject(reader.error ?? new Error('file read error'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function AttachmentDropzone({ attachments, onFilesChange }: AttachmentDropzoneProps) {
|
||||
const { t } = useTranslation('create');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { CreateTaskDialog } from './CreateTaskDialog';
|
||||
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetDraftPruneForTest();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
|
||||
});
|
||||
|
||||
// Radix の外側クリック検知は overlay への pointerdown で発火する。
|
||||
// jsdom には pointer capture API が無いので userEvent.pointer ではなく
|
||||
// click(内部で pointerdown/up を発火)を overlay に対して使う。
|
||||
async function clickOverlay(user: ReturnType<typeof userEvent.setup>) {
|
||||
// Dialog.Overlay は fixed inset-0 の要素。Portal 直下から取得する。
|
||||
const overlay = document.querySelector('.fixed.inset-0.bg-slate-900\\/50');
|
||||
expect(overlay).not.toBeNull();
|
||||
await user.click(overlay as Element);
|
||||
}
|
||||
|
||||
describe('CreateTaskDialog outside-click safety', () => {
|
||||
it('本文が空なら外側クリックで閉じる', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
|
||||
await clickOverlay(user);
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('本文に入力があると外側クリックでは閉じない', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
|
||||
await user.type(screen.getByTestId('create-task-body'), '書きかけ');
|
||||
await clickOverlay(user);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('入力があっても Esc では閉じる(下書きが残るため逃げ道は塞がない)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
|
||||
await user.type(screen.getByTestId('create-task-body'), '書きかけ');
|
||||
await user.keyboard('{Escape}');
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('入力があっても ×ボタンでは閉じる', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<CreateTaskDialog onClose={onClose} onSubmit={vi.fn(async () => {})} />);
|
||||
await user.type(screen.getByTestId('create-task-body'), '書きかけ');
|
||||
await user.click(screen.getByLabelText(/閉じる|close/i));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { CreateTaskDialog } from './CreateTaskDialog';
|
||||
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
|
||||
|
||||
const DRAFT_KEY = 'maestro:draft:v1:create-task:default:global';
|
||||
|
||||
// CreateTaskDialog は auth/me・pieces・spaces 等を fetch する。全部 404 で
|
||||
// 落としても各 hook は空データで動く(retry:false)。
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetDraftPruneForTest();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
|
||||
});
|
||||
|
||||
function renderDialog(props: Partial<Parameters<typeof CreateTaskDialog>[0]> = {}) {
|
||||
const onClose = vi.fn();
|
||||
const onSubmit = vi.fn(async () => {});
|
||||
const utils = renderWithProviders(
|
||||
<CreateTaskDialog onClose={onClose} onSubmit={onSubmit} {...props} />,
|
||||
);
|
||||
return { onClose, onSubmit, ...utils };
|
||||
}
|
||||
|
||||
describe('CreateTaskDialog drafts', () => {
|
||||
it('入力すると下書きが保存され、開き直すと復元される', async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = renderDialog();
|
||||
await user.type(screen.getByTestId('create-task-body'), '調査タスクの下書き');
|
||||
first.unmount(); // アンマウントフラッシュで保存される
|
||||
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('調査タスクの下書き');
|
||||
|
||||
renderDialog();
|
||||
expect(screen.getByTestId('create-task-body')).toHaveValue('調査タスクの下書き');
|
||||
});
|
||||
|
||||
it('initialBody があるときは下書きより優先し、下書きも書かない', async () => {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '古い下書き', updatedAt: Date.now() }));
|
||||
const user = userEvent.setup();
|
||||
renderDialog({ initialBody: '明示指定の本文' });
|
||||
const box = screen.getByTestId('create-task-body');
|
||||
expect(box).toHaveValue('明示指定の本文');
|
||||
await user.type(box, '追記');
|
||||
// initialBody フローでは既存下書きを上書きしない
|
||||
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('古い下書き');
|
||||
});
|
||||
|
||||
it('送信成功で下書きが消える', async () => {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '送信する本文', updatedAt: Date.now() }));
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderDialog();
|
||||
await user.click(screen.getByTestId('create-task-submit'));
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('スケジュール送信成功でも下書きが消える', async () => {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '定期実行する本文', updatedAt: Date.now() }));
|
||||
// /api/scheduled-tasks だけ 200 を返し、他は 404 のまま(scheduled 分岐に入る)
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes('/api/scheduled-tasks')) return new Response('{}', { status: 200 });
|
||||
return new Response('{}', { status: 404 });
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const user = userEvent.setup();
|
||||
const { onClose, onSubmit } = renderDialog();
|
||||
// 詳細設定を開いてスケジュール実行を ON にする(ラベルは i18n 言語依存なので両対応)
|
||||
await user.click(screen.getByRole('button', { name: /詳細設定を開く|show advanced settings/i }));
|
||||
const scheduleToggle = document.getElementById('schedule-toggle');
|
||||
expect(scheduleToggle).not.toBeNull();
|
||||
await user.click(scheduleToggle!);
|
||||
await user.click(screen.getByTestId('create-task-submit'));
|
||||
|
||||
// scheduled 分岐(POST /api/scheduled-tasks → clearDraft → onClose)を通ったこと
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(
|
||||
fetchMock.mock.calls.some(([input]) => String(input).includes('/api/scheduled-tasks')),
|
||||
).toBe(true);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { CreateTaskDialog } from './CreateTaskDialog';
|
||||
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
|
||||
import * as fileAttachments from '../../lib/fileAttachments';
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetDraftPruneForTest();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
|
||||
});
|
||||
|
||||
function pasteFiles(target: Element, files: File[]) {
|
||||
fireEvent.paste(target, {
|
||||
clipboardData: {
|
||||
files,
|
||||
items: files.map(f => ({ kind: 'file', getAsFile: () => f })),
|
||||
getData: () => '',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe('CreateTaskDialog paste-to-attach', () => {
|
||||
it('ファイル貼り付けで添付チップが追加される(image.png は日時名に変換)', async () => {
|
||||
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
|
||||
const body = screen.getByTestId('create-task-body');
|
||||
pasteFiles(body, [new File(['png-bytes'], 'image.png', { type: 'image/png' })]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/^pasted-\d{8}-\d{6}\.png$/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('固有名ファイルは元の名前のまま添付される', async () => {
|
||||
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
|
||||
pasteFiles(screen.getByTestId('create-task-body'), [
|
||||
new File(['csv'], 'data.csv', { type: 'text/csv' }),
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByText('data.csv')).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('テキストのみの貼り付けでは添付されない', () => {
|
||||
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
|
||||
const body = screen.getByTestId('create-task-body');
|
||||
fireEvent.paste(body, { clipboardData: { files: [], items: [], getData: () => 'ただの文字' } });
|
||||
expect(screen.queryByText(/pasted-/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('一部のファイル変換が失敗しても成功分は添付されエラーも表示される', async () => {
|
||||
const original = fileAttachments.toBase64;
|
||||
const spy = vi.spyOn(fileAttachments, 'toBase64').mockImplementation(async (f: File) => {
|
||||
if (f.name === 'broken.bin') throw new Error('read failed');
|
||||
return original(f);
|
||||
});
|
||||
renderWithProviders(<CreateTaskDialog onClose={vi.fn()} onSubmit={vi.fn(async () => {})} />);
|
||||
pasteFiles(screen.getByTestId('create-task-body'), [
|
||||
new File(['bad'], 'broken.bin', { type: 'application/octet-stream' }),
|
||||
new File(['ok'], 'good.csv', { type: 'text/csv' }),
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByText('good.csv')).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText('Could not read the pasted file')).toBeInTheDocument());
|
||||
expect(screen.queryByText('broken.bin')).not.toBeInTheDocument();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ 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;
|
||||
@@ -69,8 +71,14 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
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 ?? '',
|
||||
body: initialBody ?? draft ?? '',
|
||||
piece: initialPiece ?? 'auto',
|
||||
profile: 'auto',
|
||||
outputFormat: 'markdown',
|
||||
@@ -108,6 +116,32 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
)
|
||||
: [];
|
||||
|
||||
// ダイアログ全体でファイル貼り付けを受ける(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'));
|
||||
@@ -131,6 +165,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(t('errors.scheduleFailed'));
|
||||
clearDraft();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
@@ -152,6 +187,7 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
};
|
||||
await onSubmit(submitForm, attachments);
|
||||
clearDraft();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
@@ -159,6 +195,9 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
}
|
||||
};
|
||||
|
||||
// 入力があるときだけ外側クリックのクローズを止める(Esc/×/キャンセルは常に有効)
|
||||
const dirty = form.body.trim().length > 0 || attachments.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<Dialog.Portal>
|
||||
@@ -169,6 +208,9 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
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">
|
||||
@@ -200,7 +242,11 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
autoFocus
|
||||
data-testid="create-task-body"
|
||||
value={form.body}
|
||||
onChange={e => setForm(prev => ({ ...prev, body: e.target.value }))}
|
||||
onChange={e => {
|
||||
const body = e.target.value;
|
||||
setForm(prev => ({ ...prev, body }));
|
||||
saveDraft(body);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -275,7 +321,10 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
<PromptCoachPanel
|
||||
body={form.body}
|
||||
piece={initialPiece ?? form.piece}
|
||||
onApplyRewrite={(text) => setForm(prev => ({ ...prev, body: text }))}
|
||||
onApplyRewrite={(text) => {
|
||||
setForm(prev => ({ ...prev, body: text }));
|
||||
saveDraft(text);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* MCP warnings (always visible when applicable) */}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { ContinueWithPieceDialog } from './ContinueWithPieceDialog';
|
||||
import { __resetDraftPruneForTest } from '../../hooks/useDraft';
|
||||
|
||||
const DRAFT_KEY = 'maestro:draft:v1:continue:9';
|
||||
const prevJob = { id: 'j1', pieceName: 'chat', status: 'succeeded' };
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetDraftPruneForTest();
|
||||
// pieces / comments / continue API をまとめてスタブ。continue だけ 200 を返す
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: RequestInfo | URL) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/continue')) return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
return new Response('[]', { status: 200 });
|
||||
}));
|
||||
});
|
||||
|
||||
describe('ContinueWithPieceDialog draft', () => {
|
||||
it('指示の書きかけが再マウントで復元される', async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = renderWithProviders(
|
||||
<ContinueWithPieceDialog taskId={9} prevJob={prevJob} onClose={vi.fn()} />,
|
||||
);
|
||||
await user.type(screen.getByLabelText(/新しい指示|instruction/i), '続きをやって');
|
||||
first.unmount();
|
||||
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('続きをやって');
|
||||
|
||||
renderWithProviders(
|
||||
<ContinueWithPieceDialog taskId={9} prevJob={prevJob} onClose={vi.fn()} />,
|
||||
);
|
||||
expect(screen.getByLabelText(/新しい指示|instruction/i)).toHaveValue('続きをやって');
|
||||
});
|
||||
|
||||
it('送信成功で下書きが消える', async () => {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '続きをやって', updatedAt: Date.now() }));
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<ContinueWithPieceDialog taskId={9} prevJob={prevJob} onClose={onClose} />);
|
||||
await user.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { usePieceList } from '../../hooks/usePieces';
|
||||
import { resolvePieceOptions } from '../../lib/splitPieces';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { useBackdropClose } from '../../lib/useBackdropClose';
|
||||
import { useDraft } from '../../hooks/useDraft';
|
||||
|
||||
interface PrevJobInfo {
|
||||
id: string;
|
||||
@@ -26,7 +27,8 @@ export function ContinueWithPieceDialog({
|
||||
}: ContinueWithPieceDialogProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
const [piece, setPiece] = useState<string>(prevJob.pieceName);
|
||||
const [instruction, setInstruction] = useState<string>('');
|
||||
const { draft: instructionDraft, saveDraft, clearDraft } = useDraft(`continue:${taskId}`);
|
||||
const [instruction, setInstruction] = useState<string>(instructionDraft ?? '');
|
||||
const [resultExpanded, setResultExpanded] = useState<boolean>(false);
|
||||
const qc = useQueryClient();
|
||||
const backdrop = useBackdropClose(onClose);
|
||||
@@ -54,6 +56,7 @@ export function ContinueWithPieceDialog({
|
||||
const continueMutation = useMutation({
|
||||
mutationFn: () => continueTaskWithPiece(taskId, { piece, instruction: instruction.trim() }),
|
||||
onSuccess: () => {
|
||||
clearDraft();
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
onClose();
|
||||
@@ -145,7 +148,7 @@ export function ContinueWithPieceDialog({
|
||||
<textarea
|
||||
id="continue-instruction"
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)}
|
||||
onChange={e => { setInstruction(e.target.value); saveDraft(e.target.value); }}
|
||||
autoFocus
|
||||
rows={5}
|
||||
placeholder={t('continue.instructionPlaceholder')}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../../test/dom-setup';
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../../test/render-helpers';
|
||||
import { OverviewTab } from './OverviewTab';
|
||||
import { __resetDraftPruneForTest } from '../../../hooks/useDraft';
|
||||
import type { LocalTask } from '../../../api';
|
||||
|
||||
const DRAFT_KEY = 'maestro:draft:v1:feedback-comment:7';
|
||||
|
||||
const baseTask = {
|
||||
id: 7,
|
||||
title: 'タスク',
|
||||
body: '本文',
|
||||
pieceName: 'chat',
|
||||
priority: 'medium',
|
||||
latestJob: { status: 'succeeded' },
|
||||
feedbackRating: null,
|
||||
feedbackTags: [],
|
||||
feedbackComment: null,
|
||||
} as unknown as LocalTask;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetDraftPruneForTest();
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 })));
|
||||
});
|
||||
|
||||
describe('FeedbackPanel comment draft', () => {
|
||||
it('👍を押してコメントを書くと下書きが保存され、再マウントで復元される', async () => {
|
||||
const user = userEvent.setup();
|
||||
const first = renderWithProviders(<OverviewTab task={baseTask} />);
|
||||
await user.click(screen.getByRole('button', { name: /👍/ }));
|
||||
await user.type(screen.getByRole('textbox'), '精度が良かった');
|
||||
first.unmount();
|
||||
expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toBe('精度が良かった');
|
||||
|
||||
renderWithProviders(<OverviewTab task={baseTask} />);
|
||||
await user.click(screen.getByRole('button', { name: /👍/ }));
|
||||
expect(screen.getByRole('textbox')).toHaveValue('精度が良かった');
|
||||
});
|
||||
|
||||
it('保存済みコメントがあるタスクではそちらを優先する', async () => {
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '古い下書き', updatedAt: Date.now() }));
|
||||
const saved = {
|
||||
...baseTask,
|
||||
feedbackRating: 'good',
|
||||
feedbackComment: '確定済みコメント',
|
||||
} as unknown as LocalTask;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<OverviewTab task={saved} />);
|
||||
// 保存済み表示 → change で編集に入る
|
||||
await user.click(screen.getByRole('button', { name: /change|変更/i }));
|
||||
expect(screen.getByRole('textbox')).toHaveValue('確定済みコメント');
|
||||
});
|
||||
|
||||
it('フィードバック送信が成功すると下書きが削除される', async () => {
|
||||
// 送信前にデバウンス書き込みのタイミングに依存しないよう、下書きは
|
||||
// localStorage へ直接シードする(既存テストと同じ堅牢なパターン)。
|
||||
localStorage.setItem(DRAFT_KEY, JSON.stringify({ text: '送信予定のコメント', updatedAt: Date.now() }));
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (typeof url === 'string' && url.includes('/feedback') && init?.method === 'PUT') {
|
||||
return new Response(JSON.stringify({ task: { ...baseTask, feedbackRating: 'good' } }), { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 404 });
|
||||
}));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<OverviewTab task={baseTask} />);
|
||||
await user.click(screen.getByRole('button', { name: /👍/ }));
|
||||
await user.click(screen.getByRole('button', { name: /submit|送信/i }));
|
||||
await waitFor(() => expect(localStorage.getItem(DRAFT_KEY)).toBeNull());
|
||||
});
|
||||
|
||||
it('キャンセルすると下書きが削除される', async () => {
|
||||
const saved = {
|
||||
...baseTask,
|
||||
feedbackRating: 'good',
|
||||
feedbackComment: '確定済みコメント',
|
||||
} as unknown as LocalTask;
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<OverviewTab task={saved} />);
|
||||
await user.click(screen.getByRole('button', { name: /change|変更/i }));
|
||||
await user.type(screen.getByRole('textbox'), '書きかけの変更');
|
||||
// saveDraft はデバウンス書き込みのため、反映を待ってからキャンセルする
|
||||
await waitFor(() => expect(JSON.parse(localStorage.getItem(DRAFT_KEY)!).text).toContain('書きかけの変更'));
|
||||
await user.click(screen.getByRole('button', { name: /cancel|キャンセル/i }));
|
||||
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { DelegateRunsSection } from './DelegateRunsSection';
|
||||
import { ContextUsageGauge } from '../ContextUsageGauge';
|
||||
import { ReflectionBadge } from '../ReflectionBadge';
|
||||
import { showTitleEdit, showFeedback, showMissionEdit } from '../detail-readonly';
|
||||
import { useDraft } from '../../../hooks/useDraft';
|
||||
|
||||
// Feedback tags. `value` is the canonical string that gets persisted (and fed
|
||||
// to the reflection LLM prompt) — it stays in Japanese so existing stored rows
|
||||
@@ -44,7 +45,10 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
|
||||
|
||||
const [rating, setRating] = useState<'good' | 'bad' | null>(task.feedbackRating ?? null);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(task.feedbackTags ?? []);
|
||||
const [comment, setComment] = useState(task.feedbackComment ?? '');
|
||||
// 下書き: 確定済みコメントがあればそちらを優先。無ければ書きかけを復元
|
||||
// 既に評価済みのタスクでは下書きより確定値が常に勝つ(意図的なトレードオフ)。
|
||||
const { draft: commentDraft, saveDraft, clearDraft } = useDraft(`feedback-comment:${task.id}`);
|
||||
const [comment, setComment] = useState(task.feedbackComment ?? commentDraft ?? '');
|
||||
const [editing, setEditing] = useState(!hasFeedback);
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -54,6 +58,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
|
||||
setEditing(false);
|
||||
clearDraft();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -168,7 +173,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
|
||||
</div>
|
||||
<textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
onChange={e => { setComment(e.target.value); saveDraft(e.target.value); }}
|
||||
placeholder={t('feedback.commentPlaceholder')}
|
||||
maxLength={1000}
|
||||
rows={2}
|
||||
@@ -177,7 +182,7 @@ function FeedbackPanel({ task, readonly = false }: { task: LocalTask; readonly?:
|
||||
<div className="flex justify-end gap-2">
|
||||
{hasFeedback && (
|
||||
<button
|
||||
onClick={() => { setEditing(false); setRating(task.feedbackRating ?? null); setSelectedTags(task.feedbackTags ?? []); setComment(task.feedbackComment ?? ''); }}
|
||||
onClick={() => { setEditing(false); setRating(task.feedbackRating ?? null); setSelectedTags(task.feedbackTags ?? []); setComment(task.feedbackComment ?? ''); clearDraft(); }}
|
||||
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-600"
|
||||
>
|
||||
{t('feedback.cancel')}
|
||||
|
||||
Reference in New Issue
Block a user