sync: update from private repo (061c701c)
CI / build-and-test (push) Successful in 6m22s

This commit is contained in:
oss-sync
2026-07-09 03:01:06 +00:00
parent 96bffa9a15
commit 63d34d7cf6
36 changed files with 1180 additions and 92 deletions
@@ -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();
});
});
+7 -15
View File
@@ -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();
});
});
+52 -3
View File
@@ -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')}