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')}
|
||||
|
||||
@@ -12,9 +12,20 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
|
||||
|
||||
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
|
||||
|
||||
## 2026-07-09 — 入力の下書き自動保存・貼り付けでファイル添付・誤クローズ防止
|
||||
|
||||
書きかけのテキストが消えてしまう事故への対策をまとめて入れました。新規タスクの本文、チャット入力欄、フィードバックのコメント、継続指示は入力中の内容がブラウザに自動保存され、誤ってダイアログを閉じたりリロードしたりしても復元されます(送信すると消えます)。また、新規タスクのダイアログでスクリーンショットやコピーしたファイルを Ctrl+V で貼り付けて添付できるようになりました。あわせて、本文や添付を入力した状態ではダイアログの外側をクリックしても閉じないようにしています(×・キャンセル・Esc では閉じられます)。
|
||||
|
||||
## 2026-07-09 — 委譲(delegate)実行の「ツール N 回」が常に 0 と表示される問題を修正
|
||||
|
||||
概要タブの委譲実行カードで、サブエージェントが何回ツールを呼んでも「ツール 0 回」と表示され、ツール内訳や変更ファイルも空になっていました。トークン数や経過時間は正しく出るのにツールだけ数えられない、という状態です。原因は、ツール呼び出しの記録が「呼び出しと結果を紐づける識別子」を使い回していて、実行への帰属が失われていたことでした。実行ごとの帰属を別のタグで持たせるようにして、ツール回数・内訳・変更ファイル、さらに実行を展開したときのイベント一覧や「実行中のツール」表示も正しく出るようにしました。過去に実行済みのタスクはログに帰属タグが残っていないため 0 のままですが、今後の実行では正しく表示されます。
|
||||
|
||||
## 2026-07-09 — 設定画面に「スキルのクォータ」を追加し、これまで隠れていた設定を編集可能に
|
||||
|
||||
これまで `config.yaml` を直接いじらないと変えられなかった項目を、設定画面から編集できるようにしました。Agent Runtime に新しく「スキルのクォータ」セクションが増え、1 ユーザーあたりのスキル数やサイズ上限を調整できます。あわせて、LLM の暴走を止める総時間の上限(Max Stream)、サブタスク発火の間引き遅延、履歴要約の headroom 上限、Outlook メール(.msg)の読み込みサイズ上限、TLS の最小バージョン、Gateway の内部チーム、ワーカーのヘルスチェック間隔も、それぞれの設定セクションから変更できます。さらに Execution 画面に「Python サンドボックスのパッケージ」案内を追加し、どのパッケージが使えるか・どう増やすかを確認できるようにしました(→[設定](./17-settings.md))。
|
||||
## 2026-07-08 — 調査系エージェントのレポートが日付別に積み上がるように
|
||||
|
||||
`research` / `sns-research` / `sns-deep-sweep` の成果物ファイル名に実行日が入るようになりました(例: `output/report-2026-07-08.md`、深掘りメモは `output/deepdive/2026-07-08/` 配下)。これまでは毎回 `output/report.md` などの固定名で書いていたため、同じワークスペースで繰り返し実行すると前回のレポートが上書きされて消えていました。今後は過去の実行分がそのまま残り、日付をまたいで情報が蓄積されます。あわせて、エージェントのシステムプロンプトに現在日時が常に入るようになり、「今日」「最新」などの相対表現やファイル名の日付がずれにくくなりました。
|
||||
|
||||
## 2026-07-08 — 名前が食い違ったスキルを削除・編集できない問題を修正
|
||||
|
||||
|
||||
@@ -37,6 +37,16 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
|
||||
|
||||
`input/` に同じ名前のファイルが既にある場合は、上書きせず `名前 (2).ext` のように自動でリネームして保存します(元のファイルは残ります)。
|
||||
|
||||
### 入力内容の自動下書き保存
|
||||
|
||||
新規タスクの本文・チャット入力欄・フィードバックのコメント欄・継続指示は、入力するそばから下書きとしてブラウザに自動保存されます。誤ってダイアログを閉じたり、ページをリロードしたりしても、次に開いたときに書きかけの内容が復元されます。下書きは送信すると消え、14日間使われなかったものは自動的に削除されます。保存先はお使いのブラウザ内なので、別の端末には引き継がれません。
|
||||
|
||||
### 貼り付けでファイルを添付する
|
||||
|
||||
新規タスクのダイアログでは、スクリーンショットやコピーしたファイルを Ctrl+V(Mac は Cmd+V)で貼り付けるだけで添付できます。ドラッグ&ドロップ・ファイル選択も従来どおり使えます。クリップボードから貼り付けた画像には日時ベースのファイル名が自動で付きます。
|
||||
|
||||
なお、本文か添付ファイルを入力した状態では、ダイアログの外側をクリックしても閉じなくなります(書きかけの誤消去防止)。閉じたいときは ×ボタン・キャンセル・Esc キーを使ってください。入力が空のときは従来どおり外側クリックで閉じられます。
|
||||
|
||||
## 詳細設定
|
||||
|
||||
「詳細設定を開く」を押すと、次の項目を調整できます。
|
||||
|
||||
@@ -52,6 +52,8 @@ Piece は「タスクの種類ごとの実行手順」を定義したもので
|
||||
|
||||
`ssh-ops` / `ssh-console` は admin が `config.yaml` で SSH を有効化し、接続登録・grant が済んでいる場合のみ使えます。詳細は [SSH 連携](14-ssh.md) を参照。
|
||||
|
||||
調査系(`research` / `sns-research` / `sns-deep-sweep`)のレポートは実行日入りのファイル名(例: `output/report-2026-07-08.md`)で保存されます。同じワークスペースで繰り返し実行しても前回の成果物は上書きされず、日付別に積み上がります。
|
||||
|
||||
> このほか組織が独自に追加した Piece もここに加わります。利用可能なツールの一覧は [ツール一覧](16-tools.md) を参照。
|
||||
|
||||
## Default Pieces と Custom Pieces
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../test/dom-setup';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useDraft, __resetDraftPruneForTest } from './useDraft';
|
||||
|
||||
const PREFIX = 'maestro:draft:v1:';
|
||||
|
||||
describe('useDraft', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
__resetDraftPruneForTest();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('保存済み下書きを初回マウント時に復元する', () => {
|
||||
localStorage.setItem(PREFIX + 'k1', JSON.stringify({ text: 'こんにちは', updatedAt: Date.now() }));
|
||||
const { result } = renderHook(() => useDraft('k1'));
|
||||
expect(result.current.draft).toBe('こんにちは');
|
||||
});
|
||||
|
||||
it('下書きが無ければ draft は null', () => {
|
||||
const { result } = renderHook(() => useDraft('k1'));
|
||||
expect(result.current.draft).toBeNull();
|
||||
});
|
||||
|
||||
it('saveDraft は約400msデバウンスして書き込む', () => {
|
||||
const { result } = renderHook(() => useDraft('k1'));
|
||||
act(() => result.current.saveDraft('a'));
|
||||
act(() => result.current.saveDraft('ab'));
|
||||
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
const stored = JSON.parse(localStorage.getItem(PREFIX + 'k1')!);
|
||||
expect(stored.text).toBe('ab');
|
||||
expect(typeof stored.updatedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('空白のみのテキストはキー削除として扱う', () => {
|
||||
localStorage.setItem(PREFIX + 'k1', JSON.stringify({ text: 'old', updatedAt: Date.now() }));
|
||||
const { result } = renderHook(() => useDraft('k1'));
|
||||
act(() => result.current.saveDraft(' '));
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
|
||||
});
|
||||
|
||||
it('clearDraft はキーを消し、保留中の書き込みも取り消す', () => {
|
||||
const { result } = renderHook(() => useDraft('k1'));
|
||||
act(() => result.current.saveDraft('pending'));
|
||||
act(() => result.current.clearDraft());
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
|
||||
});
|
||||
|
||||
it('アンマウント時に保留中の書き込みをフラッシュする', () => {
|
||||
const { result, unmount } = renderHook(() => useDraft('k1'));
|
||||
act(() => result.current.saveDraft('flush me'));
|
||||
unmount();
|
||||
expect(JSON.parse(localStorage.getItem(PREFIX + 'k1')!).text).toBe('flush me');
|
||||
});
|
||||
|
||||
it('clearDraft 後のアンマウントで下書きが復活しない', () => {
|
||||
const { result, unmount } = renderHook(() => useDraft('k1'));
|
||||
act(() => result.current.saveDraft('typed'));
|
||||
act(() => result.current.clearDraft());
|
||||
unmount();
|
||||
expect(localStorage.getItem(PREFIX + 'k1')).toBeNull();
|
||||
});
|
||||
|
||||
it('key=null では何もしない(draft は null、保存も no-op)', () => {
|
||||
const { result, unmount } = renderHook(() => useDraft(null));
|
||||
expect(result.current.draft).toBeNull();
|
||||
act(() => result.current.saveDraft('x'));
|
||||
act(() => vi.advanceTimersByTime(400));
|
||||
unmount();
|
||||
expect(localStorage.length).toBe(0);
|
||||
});
|
||||
|
||||
it('14日より古い下書きを初回利用時に掃除する(壊れた値も対象)', () => {
|
||||
const old = Date.now() - 15 * 24 * 60 * 60 * 1000;
|
||||
localStorage.setItem(PREFIX + 'stale', JSON.stringify({ text: 'old', updatedAt: old }));
|
||||
localStorage.setItem(PREFIX + 'broken', 'not-json');
|
||||
localStorage.setItem(PREFIX + 'fresh', JSON.stringify({ text: 'new', updatedAt: Date.now() }));
|
||||
localStorage.setItem('unrelated', 'keep');
|
||||
renderHook(() => useDraft('k1'));
|
||||
expect(localStorage.getItem(PREFIX + 'stale')).toBeNull();
|
||||
expect(localStorage.getItem(PREFIX + 'broken')).toBeNull();
|
||||
expect(localStorage.getItem(PREFIX + 'fresh')).not.toBeNull();
|
||||
expect(localStorage.getItem('unrelated')).toBe('keep');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
/**
|
||||
* useDraft — 作文系入力欄の下書きを localStorage に best-effort で保存する。
|
||||
* - 初回マウント時に復元(draft)。saveDraft はデバウンス書き込み、
|
||||
* アンマウント時に保留分をフラッシュ。送信成功時は clearDraft を呼ぶ。
|
||||
* - key=null で無効化(フック呼び出し順を保ったまま opt-out できる)。
|
||||
* - 空白のみはキー削除として扱い、空下書きを残さない。
|
||||
* - localStorage 不可(プライベートモード・quota)は黙って無視する。
|
||||
* - key はマウント中に変えないこと(draft は初回マウント時に一度だけ読む)。
|
||||
* 入力欄を切り替えるときはコンポーネントごと remount する前提。
|
||||
*/
|
||||
const PREFIX = 'maestro:draft:v1:';
|
||||
const TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const DEBOUNCE_MS = 400;
|
||||
|
||||
let pruned = false;
|
||||
export function __resetDraftPruneForTest(): void {
|
||||
pruned = false;
|
||||
}
|
||||
|
||||
function pruneOldDrafts(): void {
|
||||
if (pruned) return;
|
||||
pruned = true;
|
||||
try {
|
||||
const now = Date.now();
|
||||
const stale: string[] = [];
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
const k = window.localStorage.key(i);
|
||||
if (!k || !k.startsWith(PREFIX)) continue;
|
||||
try {
|
||||
const v = JSON.parse(window.localStorage.getItem(k) ?? '');
|
||||
if (typeof v?.updatedAt !== 'number' || now - v.updatedAt > TTL_MS) stale.push(k);
|
||||
} catch {
|
||||
stale.push(k);
|
||||
}
|
||||
}
|
||||
for (const k of stale) window.localStorage.removeItem(k);
|
||||
} catch {
|
||||
// localStorage 不可 — 下書き機能ごと best-effort
|
||||
}
|
||||
}
|
||||
|
||||
function writeDraft(key: string, text: string): void {
|
||||
try {
|
||||
if (text.trim().length === 0) {
|
||||
window.localStorage.removeItem(PREFIX + key);
|
||||
} else {
|
||||
window.localStorage.setItem(PREFIX + key, JSON.stringify({ text, updatedAt: Date.now() }));
|
||||
}
|
||||
} catch {
|
||||
// quota / SecurityError — ignore
|
||||
}
|
||||
}
|
||||
|
||||
export function useDraft(key: string | null): {
|
||||
draft: string | null;
|
||||
saveDraft: (text: string) => void;
|
||||
clearDraft: () => void;
|
||||
} {
|
||||
const [draft] = useState<string | null>(() => {
|
||||
if (key === null || typeof window === 'undefined') return null;
|
||||
pruneOldDrafts();
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PREFIX + key);
|
||||
if (raw === null) return null;
|
||||
const parsed = JSON.parse(raw) as { text?: unknown };
|
||||
return typeof parsed.text === 'string' && parsed.text.length > 0 ? parsed.text : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<string | null>(null);
|
||||
|
||||
const saveDraft = useCallback(
|
||||
(text: string) => {
|
||||
if (key === null || typeof window === 'undefined') return;
|
||||
pendingRef.current = text;
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => {
|
||||
timerRef.current = null;
|
||||
if (pendingRef.current !== null) {
|
||||
writeDraft(key, pendingRef.current);
|
||||
pendingRef.current = null;
|
||||
}
|
||||
}, DEBOUNCE_MS);
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const clearDraft = useCallback(() => {
|
||||
if (key === null || typeof window === 'undefined') return;
|
||||
pendingRef.current = null;
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
try {
|
||||
window.localStorage.removeItem(PREFIX + key);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [key]);
|
||||
|
||||
// アンマウント時に保留分をフラッシュ(clearDraft 済みなら pendingRef=null で no-op)
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
if (key !== null && typeof window !== 'undefined' && pendingRef.current !== null) {
|
||||
writeDraft(key, pendingRef.current);
|
||||
pendingRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [key]);
|
||||
|
||||
return { draft, saveDraft, clearDraft };
|
||||
}
|
||||
@@ -73,8 +73,8 @@
|
||||
"maestroTips": "MAESTRO features you could use",
|
||||
"personalized": "Notes for you"
|
||||
},
|
||||
"attachments": { "title": "Attachments", "hint": "Drag & drop or choose files" },
|
||||
"errors": { "bodyRequired": "Request is required", "scheduleFailed": "Failed to create schedule" },
|
||||
"attachments": { "title": "Attachments", "hint": "Drag & drop, choose files, or paste (Ctrl+V)" },
|
||||
"errors": { "bodyRequired": "Request is required", "scheduleFailed": "Failed to create schedule", "pasteAttachFailed": "Could not read the pasted file" },
|
||||
"cancel": "Cancel",
|
||||
"submit": "Create Task",
|
||||
"submitSchedule": "Create schedule",
|
||||
|
||||
@@ -73,8 +73,8 @@
|
||||
"maestroTips": "活用できる MAESTRO 機能",
|
||||
"personalized": "あなた向けの指摘"
|
||||
},
|
||||
"attachments": { "title": "添付ファイル", "hint": "ドラッグ&ドロップまたはファイル選択" },
|
||||
"errors": { "bodyRequired": "依頼内容は必須です", "scheduleFailed": "スケジュール作成に失敗しました" },
|
||||
"attachments": { "title": "添付ファイル", "hint": "ドラッグ&ドロップ・ファイル選択・貼り付け(Ctrl+V)で追加" },
|
||||
"errors": { "bodyRequired": "依頼内容は必須です", "scheduleFailed": "スケジュール作成に失敗しました", "pasteAttachFailed": "貼り付けたファイルを読み取れませんでした" },
|
||||
"cancel": "キャンセル",
|
||||
"submit": "Task 作成",
|
||||
"submitSchedule": "スケジュール作成",
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { uniqueAttachmentName } from './fileAttachments';
|
||||
|
||||
describe('uniqueAttachmentName', () => {
|
||||
const at = new Date('2026-07-08T15:30:12');
|
||||
|
||||
it('固有名のファイルはそのまま使う', () => {
|
||||
expect(uniqueAttachmentName('report.pdf', new Set(), at)).toBe('report.pdf');
|
||||
});
|
||||
|
||||
it('クリップボード画像 (image.png) は日時ベースの名前を付ける', () => {
|
||||
expect(uniqueAttachmentName('image.png', new Set(), at)).toBe('pasted-20260708-153012.png');
|
||||
});
|
||||
|
||||
it('既存名と衝突したら連番サフィックスで回避する', () => {
|
||||
const taken = new Set(['pasted-20260708-153012.png', 'pasted-20260708-153012-2.png']);
|
||||
expect(uniqueAttachmentName('image.png', taken, at)).toBe('pasted-20260708-153012-3.png');
|
||||
});
|
||||
|
||||
it('固有名でも既存と衝突したら連番を付ける(拡張子は保持)', () => {
|
||||
expect(uniqueAttachmentName('report.pdf', new Set(['report.pdf']), at)).toBe('report-2.pdf');
|
||||
});
|
||||
|
||||
it('拡張子なしのファイル名も扱える', () => {
|
||||
expect(uniqueAttachmentName('Makefile', new Set(['Makefile']), at)).toBe('Makefile-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 添付ファイル共通ヘルパー。
|
||||
* - toBase64: File → base64(data: URL のヘッダを除去)
|
||||
* - uniqueAttachmentName: クリップボード画像の "image.png" 問題と名前衝突を解決。
|
||||
* 添付一覧は name を React key / 削除キーに使うため一意である必要がある。
|
||||
*/
|
||||
export 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);
|
||||
});
|
||||
}
|
||||
|
||||
function splitExt(name: string): { stem: string; ext: string } {
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot <= 0) return { stem: name, ext: '' };
|
||||
return { stem: name.slice(0, dot), ext: name.slice(dot) };
|
||||
}
|
||||
|
||||
export function uniqueAttachmentName(
|
||||
original: string,
|
||||
taken: Set<string>,
|
||||
now: Date = new Date(),
|
||||
): string {
|
||||
let base = original;
|
||||
// ブラウザのクリップボード画像は一律 "image.png" で来るため日時名に差し替える
|
||||
if (original === 'image.png') {
|
||||
const pad = (n: number, w = 2) => String(n).padStart(w, '0');
|
||||
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
||||
base = `pasted-${stamp}.png`;
|
||||
}
|
||||
if (!taken.has(base)) return base;
|
||||
const { stem, ext } = splitExt(base);
|
||||
for (let i = 2; ; i++) {
|
||||
const candidate = `${stem}-${i}${ext}`;
|
||||
if (!taken.has(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user