sync: update from private repo (061c701c)
All checks were successful
CI / build-and-test (push) Successful in 6m22s
All checks were successful
CI / build-and-test (push) Successful in 6m22s
This commit is contained in:
parent
96bffa9a15
commit
63d34d7cf6
22
CHANGELOG.md
22
CHANGELOG.md
@ -6,6 +6,28 @@ follow semantic versioning.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- Research pieces (`research` / `sns-research` / `sns-deep-sweep`) now write
|
||||
date-stamped outputs (e.g. `output/report-2026-07-09.md`,
|
||||
`output/deepdive/2026-07-09/`) so repeated runs in a persistent workspace
|
||||
accumulate instead of overwriting the previous report. The agent system
|
||||
prompt now always includes the current date/time (2026-07-09).
|
||||
- Draft autosave for the new-task dialog, chat composer, feedback comments,
|
||||
and continue instructions — text survives an accidental close or reload.
|
||||
Files can be attached by pasting (Ctrl+V) into the new-task dialog
|
||||
(2026-07-09).
|
||||
- Settings: a "Skill quotas" section plus editors for several previously
|
||||
config-file-only options (max stream time, subtask debounce, summary
|
||||
headroom, .msg size limit, TLS minimum version, worker health-check
|
||||
interval) (2026-07-09).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Delegate run cards showed "0 tool calls" (and empty tool/file breakdowns)
|
||||
regardless of actual activity; tool events now carry their own run
|
||||
attribution (2026-07-09).
|
||||
|
||||
## v0.2.0 (2026-07-09)
|
||||
|
||||
A month of work since the initial release, centered on shared workspaces,
|
||||
|
||||
@ -16,6 +16,12 @@ movements:
|
||||
dig で立てた並列調査計画に従い、各テーマを delegate で1件ずつ直列に深掘りさせ、
|
||||
最後に統合レポートにまとめる。重い調査は各サブに任せ、自分の文脈は軽く保つ。
|
||||
|
||||
成果物の上書き防止: システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を {DATE} とし、
|
||||
成果物は {DATE} 入りのパスに書く。**過去の実行が残したファイルは編集も削除もしない**。
|
||||
{DATE} 入りの成果物(output/report-{DATE}.md や output/research/{DATE}/ 等、途中で
|
||||
中断した実行の残骸も含む)が既にあり、それが別タスクのものなら、{DATE}-2, {DATE}-3 ...
|
||||
を全ファイル共通で使う。
|
||||
|
||||
手順:
|
||||
1. dig で立てた調査テーマを思い出す(2〜5 個程度)。各テーマに上から 1 始まりの
|
||||
連番を振る(1, 2, 3 ...)。
|
||||
@ -25,21 +31,21 @@ movements:
|
||||
- 調べるテーマと目的・背景
|
||||
- 「WebSearch / WebFetch / 一次情報で裏を取る。モデルの内部知識だけで書かない」指示
|
||||
- 「概要・主要な特徴・数値や事例・まとめと考察 の構成で
|
||||
output/research/theme-{連番}.md に Write せよ。関連する画像・グラフは
|
||||
output/images/ に保存し Markdown で埋め込め」指示
|
||||
output/research/{DATE}/theme-{連番}.md に Write せよ({DATE} は展開済みの実日付で渡す)。
|
||||
関連する画像・グラフは output/images/{DATE}/ に保存し Markdown で埋め込め」指示
|
||||
- 「完了したら 3〜5 行の要約だけを返せ。深掘り本文は返さずファイルに書け」
|
||||
- 「あなたは末端の調査担当。delegate / SpawnSubTask は呼ぶな」
|
||||
各サブの戻り値(短い要約)だけが手元に残る。本文はファイルにある。
|
||||
3. Glob で output/research/*.md の件数を確認する。
|
||||
4. 各サブの要約とファイルを基に統合レポートを output/report.md に Write する:
|
||||
3. Glob で output/research/{DATE}/*.md の件数を確認する。
|
||||
4. 各サブの要約とファイルを基に統合レポートを output/report-{DATE}.md に Write する:
|
||||
- 各テーマの主要な知見を統合(矛盾・重複は整理)
|
||||
- 比較・対照が必要なら表形式で整理
|
||||
- 全体のまとめと考察を付ける
|
||||
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/theme-{連番}.md) で繋ぐ
|
||||
5. output/report.md を書き終えたら verify へ遷移する
|
||||
- 各テーマの詳細へ相対リンク [theme-{連番}](./research/{DATE}/theme-{連番}.md) で繋ぐ
|
||||
5. output/report-{DATE}.md を書き終えたら verify へ遷移する
|
||||
default_next: verify
|
||||
rules:
|
||||
- condition: output/report.md に統合レポートを作成した
|
||||
- condition: output/report-{DATE}.md に統合レポートを作成した
|
||||
next: verify
|
||||
|
||||
- name: dig
|
||||
@ -71,6 +77,15 @@ movements:
|
||||
WebSearch、WebFetch、ファイル読み込み等で情報を集め、必ず Write で output/ にファイルとして書き出すこと。
|
||||
テキストで回答するだけでは不十分。
|
||||
|
||||
上書き防止(実行キーの決定・以後の全 movement で共有):
|
||||
システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を実行キーとする。ただし
|
||||
同じ日付入りの成果物(output/report-{日付}.md や作業ファイル・画像等)が既にあり
|
||||
別タスクのものなら、空いている {日付}-2, {日付}-3 ... を実行キーにする。
|
||||
作業ファイルはファイル名に実行キーを含め、画像は output/images/{実行キー}/ に保存する。
|
||||
キーを決めたら最初の作業ファイルをすぐ書き出してキーを確保する(並行する別タスクとの
|
||||
衝突の余地を減らす)。analyze・verify もこの実行キーを引き継いで使う。
|
||||
**過去の実行が残したファイルは編集も削除もしない**(読むのは可。内容は鵜呑みにしない)。
|
||||
|
||||
## 検索の原則(必須)
|
||||
|
||||
- モデルの内部知識だけで情報を書かないこと。主張・事実・数値は必ず WebSearch/WebFetch で裏付けを取る
|
||||
@ -88,7 +103,7 @@ movements:
|
||||
|
||||
## 画像・ビジュアル素材の収集(必須)
|
||||
|
||||
調査中は画像・グラフ・図表を積極的に収集し、output/images/ に保存すること。
|
||||
調査中は画像・グラフ・図表を積極的に収集し、output/images/{実行キー}/ に保存すること。
|
||||
テキストだけの調査で終わらせない。ビジュアル素材がレポートの品質を大きく左右する。
|
||||
|
||||
収集すべきもの:
|
||||
@ -97,7 +112,7 @@ movements:
|
||||
- データの可視化(統計グラフ、トレンド図等)
|
||||
- 関連する図解・インフォグラフィック
|
||||
|
||||
収集した画像はレポートの Markdown から相対パスで参照する: ``
|
||||
収集した画像はレポートの Markdown から相対パスで参照する: ``
|
||||
|
||||
## 終了 / 遷移方法
|
||||
- **次の analyze へ**: `transition({next_step: "analyze"})`
|
||||
@ -122,6 +137,13 @@ movements:
|
||||
必ず Write ツールで output/ にレポートファイルを書き出すこと。
|
||||
前のステップから指摘事項がある場合は、それに対応すること。
|
||||
|
||||
成果物の上書き防止: レポートのファイル名には dig で決めた実行キーを入れる
|
||||
(例: output/report-2026-07-08.md、同日再実行なら output/report-2026-07-08-2.md)。
|
||||
dig を経ていない等でキーが不明な場合のみ、同じルール(「現在日時」の日付、
|
||||
別タスクの同日成果物があれば空いている -2, -3 ...)で決め直す。
|
||||
**過去の実行が残したレポートは編集も削除もしない**。
|
||||
このタスク内での verify 差し戻しでは同じファイルを修正してよい。
|
||||
|
||||
## 検索の原則(必須)
|
||||
|
||||
- レポートに記載する事実・数値・主張は、dig で収集した検索結果に基づくこと
|
||||
@ -131,8 +153,9 @@ movements:
|
||||
|
||||
## 画像の活用(必須)
|
||||
|
||||
output/images/ に画像が保存されている場合は、必ずレポートの該当箇所に埋め込む:
|
||||
``
|
||||
output/images/{実行キー}/ にこのタスクで収集した画像がある場合は、
|
||||
必ずレポートの該当箇所に実際の保存パスで埋め込む:
|
||||
``(例: `./images/2026-07-08/graph.png`)
|
||||
画像があるのにテキストだけのレポートにしないこと。
|
||||
レポート作成中に追加で必要な図・グラフを見つけた場合も DownloadFile で収集して埋め込む。
|
||||
|
||||
@ -147,6 +170,13 @@ movements:
|
||||
persona: reviewer
|
||||
instruction: |
|
||||
output/ のレポートを確認する。
|
||||
審査対象は**このタスクの実行で作られたレポート**。ここまでの会話で書き出したファイル
|
||||
パスをそのまま使う。パスが会話から分からない場合のみ Glob output/report-*.md で列挙し、
|
||||
GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
|
||||
それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ
|
||||
(-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは
|
||||
無印が -2 より後に並ぶため使わない)。
|
||||
過去の実行が残したレポートは審査対象外で、編集・削除もしない。
|
||||
|
||||
確認手順:
|
||||
1. まず Glob で output/ 内のファイル一覧を確認する
|
||||
@ -169,8 +199,9 @@ movements:
|
||||
- ユーザーの追加質問(前回タスクへの補足・深掘り)への回答が含まれる場合、その内容に WebSearch/WebFetch による検索の裏付けがあるか確認する。内部知識だけで回答している形跡がある場合は「追加質問への回答に検索根拠が不足」として analyze に差し戻す
|
||||
|
||||
追加チェック(画像):
|
||||
- output/images/ に画像があるのにレポートに ` で繋ぐ。
|
||||
- Glob で output/deepdive/{DATE}/*.md の件数を確認する。
|
||||
- 各サブの要約を束ね、全体傾向・横断テーマ・注目点を output/report-{DATE}.md に Write する。
|
||||
各投稿の詳細へは相対リンク [tweet-{連番}](./deepdive/{DATE}/tweet-{連番}.md) で繋ぐ。
|
||||
- 仕上がったら complete({status: "success", result: ...}) を呼ぶ。
|
||||
result は output/report.md の内容をベースに、ユーザー向けの最終回答として整形する。
|
||||
result は output/report-{DATE}.md の内容をベースに、ユーザー向けの最終回答として整形する。
|
||||
「✅ 完了」等のメタ説明は書かず、1行目から本題を書く。
|
||||
|
||||
### 原則
|
||||
|
||||
@ -12,6 +12,16 @@ movements:
|
||||
- name: gather
|
||||
persona: researcher
|
||||
instruction: |
|
||||
## 実行日 {DATE}(過去の成果物の上書き防止)
|
||||
システムプロンプトの「現在日時」の日付 (YYYY-MM-DD) を {DATE} とする。
|
||||
成果物は必ず {DATE} 入りのパスに書き、**過去の実行が残したファイルは編集も削除もしない**。
|
||||
{DATE} 入りの成果物が1つでも既に存在し(output/raw/{DATE}/ だけでなく、他の調査 piece が
|
||||
作った output/report-{DATE}.md 等も含めて確認する)、それが別タスクのものなら
|
||||
{DATE}-2, {DATE}-3 ... を全ファイル共通で使う
|
||||
(このタスク内で gather に戻ってきた場合は同じディレクトリに追記してよい)。
|
||||
{DATE} を決めたら、最初の収集結果をすぐ output/raw/{DATE}/ に書き出してキーを確保する
|
||||
(並行する別タスクとの衝突の余地を減らす)。
|
||||
|
||||
## 調査計画
|
||||
|
||||
着手前に調査計画を立てる:
|
||||
@ -19,7 +29,7 @@ movements:
|
||||
2. 検索クエリ案を複数考える(日本語・英語の両方を検討)
|
||||
3. verify からの差し戻しがある場合は、不足点を優先的に解消する
|
||||
|
||||
計画に従って SNS から情報を収集し、Write で output/raw/ にテキストファイルとして書き出す。
|
||||
計画に従って SNS から情報を収集し、Write で output/raw/{DATE}/ にテキストファイルとして書き出す。
|
||||
|
||||
## SNS 別の収集方針
|
||||
|
||||
@ -40,8 +50,8 @@ movements:
|
||||
- 記事詳細: `https://hn.algolia.com/api/v1/items/{id}`
|
||||
|
||||
## ファイル命名規則
|
||||
`output/raw/{platform}-{query-slug}.txt`
|
||||
例: reddit-ollama-vs-vllm.txt, x-ollama-review.txt, hn-local-llm.txt
|
||||
`output/raw/{DATE}/{platform}-{query-slug}.txt`
|
||||
例: output/raw/2026-07-08/reddit-ollama-vs-vllm.txt, output/raw/2026-07-08/x-ollama-review.txt
|
||||
|
||||
## SNS 調査の原則
|
||||
モデルの内部知識だけで情報を書かないこと。必ず実際の SNS データを収集する。
|
||||
@ -49,7 +59,7 @@ movements:
|
||||
|
||||
## 画像・スクリーンショットの収集
|
||||
SNS 投稿には画像・グラフが含まれることが多い。重要なビジュアルは DownloadFile で
|
||||
`output/images/{platform}-{slug}.png` に保存する。
|
||||
`output/images/{DATE}/{platform}-{slug}.png` に保存する。
|
||||
|
||||
## 終了 / 遷移方法
|
||||
- **次の analyze へ**: `transition({next_step: "analyze"})`
|
||||
@ -68,12 +78,20 @@ movements:
|
||||
instruction: |
|
||||
output/raw/ の収集データを読み込み、分析してレポートを作成する。
|
||||
|
||||
このタスクの gather が書き出した日付ディレクトリを {DATE} とする。
|
||||
会話から分からない場合のみ、Glob output/raw/*/* でファイルを列挙し
|
||||
(ディレクトリ末尾の output/raw/*/ はファイルにマッチせず常に空になるので使わない)、
|
||||
GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
|
||||
それでも特定できなければ親ディレクトリ名の日付と連番を数値として解釈して
|
||||
最新のキーを選ぶ(-2 は無印より新しく -10 は -9 より新しい)。
|
||||
過去の実行のファイルは編集・削除しない。
|
||||
|
||||
手順:
|
||||
1. Glob で output/raw/ 内のファイル一覧を確認
|
||||
1. Glob で output/raw/{DATE}/ 内のファイル一覧を確認
|
||||
2. 各ファイルを Read で読み込む
|
||||
3. 重要な意見・トレンド・共通見解を抽出
|
||||
4. ポジティブ/ネガティブな意見を分類
|
||||
5. output/report.md にレポートを書き出す
|
||||
5. output/report-{DATE}.md にレポートを書き出す
|
||||
|
||||
## レポートの構成
|
||||
- トピック概要
|
||||
@ -82,15 +100,15 @@ movements:
|
||||
- まとめ
|
||||
|
||||
## 画像の活用
|
||||
output/images/ に画像がある場合は必ずレポートに埋め込む:
|
||||
``
|
||||
output/images/{DATE}/ に画像がある場合は必ずレポートに埋め込む:
|
||||
``
|
||||
|
||||
情報が不足している場合は gather に戻る(追加の検索クエリを明示すること)。
|
||||
verify からの差し戻しがある場合は、指摘された不足点・期待する修正を優先的に解消すること。
|
||||
|
||||
default_next: verify
|
||||
rules:
|
||||
- condition: output/report.md にレポートを書き出した
|
||||
- condition: output/report-{DATE}.md にレポートを書き出した
|
||||
next: verify
|
||||
- condition: 情報が不十分で追加収集が必要
|
||||
next: gather
|
||||
@ -99,10 +117,17 @@ movements:
|
||||
persona: supervisor
|
||||
instruction: |
|
||||
output/ のレポートを確認する。
|
||||
審査対象はこのタスクの analyze が書いたレポート(output/report-{DATE}.md)。ここまでの
|
||||
会話で書き出したパスをそのまま使う。会話から分からない場合のみ Glob output/report-*.md で
|
||||
列挙し、GetFileProvenance で created_by_task_id がこのタスクのものを選ぶ。
|
||||
それでも特定できなければ日付と連番を数値として解釈して最新を選ぶ
|
||||
(-2 は無印より新しく -10 は -9 より新しい。拡張子込みの辞書順ソートは
|
||||
無印が -2 より後に並ぶため使わない)。
|
||||
過去の実行のレポートは対象外で、編集・削除もしない。
|
||||
|
||||
確認手順:
|
||||
1. Glob で output/ 内のファイル一覧を確認する
|
||||
2. output/report.md がなければ「不足がある」と判断し analyze に差し戻す
|
||||
2. output/report-{DATE}.md がなければ「不足がある」と判断し analyze に差し戻す
|
||||
3. ファイルがあれば Read で内容を確認し、網羅性・正確性・分かりやすさをチェックする
|
||||
4. 不足があれば、`transition({next_step: "analyze", summary: ...})` で差し戻す。summary は次の形式で書く:
|
||||
[判定] needs_fix
|
||||
@ -117,7 +142,7 @@ movements:
|
||||
5. summary は抽象論で終えず、具体的な不足点・期待する修正内容を必ず含める
|
||||
|
||||
追加チェック(画像):
|
||||
- output/images/ に画像があるのにレポートに `![` が一つもない場合、
|
||||
- output/images/{DATE}/ に画像があるのにレポートに `![` が一つもない場合、
|
||||
画像埋め込み漏れとして analyze に差し戻す
|
||||
|
||||
## チェックシート確認
|
||||
@ -126,7 +151,7 @@ movements:
|
||||
|
||||
## 合格時のユーザーへの返答(complete ツール)
|
||||
output/ の内容で合格と判断したら、`complete({status: "success", result: ...})` を呼ぶ。
|
||||
result はそのままユーザーに表示される最終回答。output/report.md を Read で読み、その内容をベースに整形する。
|
||||
result はそのままユーザーに表示される最終回答。output/report-{DATE}.md を Read で読み、その内容をベースに整形する。
|
||||
- 「output/xxx.md を確認してください」のようなファイル参照ではなく、内容そのものを回答として返すこと
|
||||
- 【厳守】「✅ 完了」「レポートを作成しました」「確認しました」等のステータス表示・メタ説明は一切書かない。1行目からいきなり本題の内容を書き始めること
|
||||
- 調査結果・発見・結論を会話調で分かりやすく伝える
|
||||
|
||||
@ -257,6 +257,26 @@ describe('GET /api/local/tasks/:id/delegate-runs/:delegateRunId/timeline', () =>
|
||||
expect(res.body.events.some((e: { correlationId?: string }) => e.correlationId === 'R1')).toBe(true);
|
||||
});
|
||||
|
||||
it('回帰: tool_call が独自 correlationId でも delegateRunId でタイムラインに含まれる', async () => {
|
||||
// 本番形状: tool_call は per-tool UUID で correlationId を上書きし、実行帰属は
|
||||
// delegateRunId タグで持つ。旧実装は correlationId フィルタだったため、展開時の
|
||||
// タイムラインからツールイベントが丸ごと落ちていた(サマリの「ツール N 回」と矛盾)。
|
||||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||||
tmpDirs.push(workspacePath);
|
||||
const REAL_TOOL = {
|
||||
v: 1, ts: '2026-06-25T00:00:02.000Z', seq: 2, eventId: 't-real', runId: 'r',
|
||||
kind: 'tool_call', correlationId: 'per-tool-uuid-xyz', delegateRunId: 'R1',
|
||||
movement: 'delegate:d', payload: { tool: 'Read' },
|
||||
};
|
||||
writeEventsJsonl(workspacePath, [START('R1'), REAL_TOOL, DONE('R1')]);
|
||||
|
||||
const res = await request(app).get(`/api/local/tasks/${taskId}/delegate-runs/R1/timeline`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// ツールイベントが(correlation 上書きされていても)返る
|
||||
expect(res.body.events.some((e: { kind: string }) => e.kind === 'tool_call')).toBe(true);
|
||||
});
|
||||
|
||||
it('timeline: events.jsonl 不在なら空配列', async () => {
|
||||
const { app, taskId, workspacePath } = await seedTaskWithWorkspace();
|
||||
tmpDirs.push(workspacePath);
|
||||
|
||||
@ -81,6 +81,10 @@ export function createDelegateRunsRouter(repo: Repository): Router {
|
||||
const taskId = Number(req.params.id);
|
||||
const runId = req.params.delegateRunId;
|
||||
const ownerJobId = typeof req.query.jobId === 'string' ? req.query.jobId : null;
|
||||
// reconstructDelegateRuns と同じ帰属判定。tool_call/tool_result は
|
||||
// ペアリング用に correlationId を per-tool UUID で上書きするため、実行への
|
||||
// 帰属は delegateRunId タグで見る(旧ログは correlationId フォールバック)。
|
||||
const belongsToRun = (e: EventBase): boolean => (e.delegateRunId ?? e.correlationId) === runId;
|
||||
const viewer = req.user as Express.User | undefined;
|
||||
const task = await repo.getLocalTask(taskId, viewer ? { viewer } : undefined);
|
||||
if (!canViewTask(req, res, task, resolveSpaceAccess(repo, task, viewer))) return;
|
||||
@ -96,16 +100,16 @@ export function createDelegateRunsRouter(repo: Repository): Router {
|
||||
}
|
||||
events = readJobEvents(job.worktreePath);
|
||||
} else {
|
||||
// jobId 未指定: 親 events を読み、correlationId 一致がなければサブジョブを線形探索
|
||||
// jobId 未指定: 親 events を読み、run 帰属イベントが無ければサブジョブを線形探索
|
||||
events = readEvents(task!);
|
||||
if (!events.some((e) => e.correlationId === runId)) {
|
||||
if (!events.some(belongsToRun)) {
|
||||
const latestJob = await repo.getLatestJobForIssue(localTaskRepoName(taskId), taskId);
|
||||
if (latestJob) {
|
||||
for (const { job } of (await collectAllSubJobs(repo, latestJob.id)).slice(0, MAX_SUBJOBS)) {
|
||||
if (!job.worktreePath) continue;
|
||||
if (!isJobWithinWorkspace(task!.workspacePath, job.worktreePath)) continue;
|
||||
const ev = readJobEvents(job.worktreePath);
|
||||
if (ev.some((e) => e.correlationId === runId)) {
|
||||
if (ev.some(belongsToRun)) {
|
||||
events = ev;
|
||||
break;
|
||||
}
|
||||
@ -114,7 +118,7 @@ export function createDelegateRunsRouter(repo: Repository): Router {
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ events: events.filter((e) => e.correlationId === runId) });
|
||||
res.json({ events: events.filter(belongsToRun) });
|
||||
} catch (err) {
|
||||
logger.error(`[delegate-runs] timeline error: ${err}`);
|
||||
res.status(500).json({ error: 'Failed to fetch delegate run timeline' });
|
||||
|
||||
@ -2179,8 +2179,13 @@ describe('buildGlobalPreamble / buildMovementGuidance split (Phase A)', () => {
|
||||
|
||||
it('preamble is byte-identical across two different movements (job-stable)', () => {
|
||||
const tools = [toolDef('Read'), toolDef('Bash')];
|
||||
const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }) });
|
||||
const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }) });
|
||||
// The clock is an explicit input (現在日時 section); pin it so the assertion
|
||||
// isolates the actual contract — the preamble must not depend on the movement.
|
||||
// At runtime the preamble is built once per conversation seed, so within a
|
||||
// job the clock is naturally fixed.
|
||||
const now = new Date(2026, 6, 8, 12, 0);
|
||||
const p1 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-1' }), now });
|
||||
const p2 = buildGlobalPreamble(tools, { userId: 'u1', workspacePath: '/ws', movementForConsole: mv({ name: 'step-2' }), now });
|
||||
expect(p1).toBe(p2);
|
||||
});
|
||||
|
||||
|
||||
@ -83,7 +83,11 @@ export async function runDelegateSubAgent(
|
||||
const subCache = new ToolResultCache();
|
||||
const subEvents = parentCtx.eventLogger?.child({
|
||||
movement: `delegate:${params.description.slice(0, 40)}`,
|
||||
// delegateRunId: 実行帰属タグ。tool_call が correlationId を上書きしても
|
||||
// 保持されるので、reconstructDelegateRuns がツールイベントを確実に束ねられる。
|
||||
// correlationId: 後方互換(correlationId 上書きしないイベントの既定値)。
|
||||
correlationId: delegateRunId,
|
||||
delegateRunId,
|
||||
});
|
||||
|
||||
const subCtx: ToolContext = {
|
||||
|
||||
@ -152,3 +152,28 @@ describe('buildGlobalPreamble — SSH console screen scoped to allowed connectio
|
||||
expect(out).toContain('connOnly-line-3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGlobalPreamble — current datetime injection', () => {
|
||||
it('renders the given clock as a 現在日時 section with weekday, so agents can name dated files', () => {
|
||||
// Local-time constructor: 2026-07-08 is a Wednesday regardless of server TZ.
|
||||
const out = buildGlobalPreamble([], { now: new Date(2026, 6, 8, 9, 30) });
|
||||
expect(out).toContain('## 現在日時');
|
||||
// The rendered clock is server-local, so the timezone offset must be explicit
|
||||
// (e.g. "2026-07-08 (水) 09:30 (UTC+9)") to disambiguate for users elsewhere.
|
||||
expect(out).toMatch(/2026-07-08 \(水\) 09:30 \(UTC[+-]\d{1,2}(?::\d{2})?\)/);
|
||||
// Guidance so pieces can rely on it for dated output filenames.
|
||||
expect(out).toContain('report-2026-07-08.md');
|
||||
});
|
||||
|
||||
it('falls back to the real clock when now is not passed', () => {
|
||||
const fmt = (d: Date) =>
|
||||
`${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||
// Sample the clock on both sides of the call so a midnight rollover
|
||||
// between the internal new Date() and ours cannot flake the test.
|
||||
const before = new Date();
|
||||
const out = buildGlobalPreamble([]);
|
||||
const after = new Date();
|
||||
expect(out).toContain('## 現在日時');
|
||||
expect(out.includes(fmt(before)) || out.includes(fmt(after))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@ -176,6 +176,25 @@ export interface GlobalPreambleOpts {
|
||||
skillIndex?: string;
|
||||
folderContext?: { rootDir: string; leafId: string };
|
||||
movementForConsole?: ConsoleMovement;
|
||||
/** テスト用の時刻注入。未指定なら実時刻 */
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
const WEEKDAYS_JA = ['日', '月', '火', '水', '木', '金', '土'];
|
||||
|
||||
function formatPromptDatetime(d: Date): { full: string; date: string } {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
// Server-local clock: make the zone explicit so readers in other timezones
|
||||
// (and the LLM) know which zone the date belongs to.
|
||||
const offsetMin = -d.getTimezoneOffset();
|
||||
const sign = offsetMin >= 0 ? '+' : '-';
|
||||
const absMin = Math.abs(offsetMin);
|
||||
const tz = `UTC${sign}${Math.floor(absMin / 60)}${absMin % 60 ? `:${pad(absMin % 60)}` : ''}`;
|
||||
return {
|
||||
date,
|
||||
full: `${date} (${WEEKDAYS_JA[d.getDay()]}) ${pad(d.getHours())}:${pad(d.getMinutes())} (${tz})`,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleOpts = {}): string {
|
||||
@ -191,6 +210,11 @@ export function buildGlobalPreamble(tools: ToolDef[] = [], opts: GlobalPreambleO
|
||||
movementForConsole,
|
||||
} = opts;
|
||||
|
||||
const clock = formatPromptDatetime(opts.now ?? new Date());
|
||||
const datetimeSection = `## 現在日時
|
||||
${clock.full}
|
||||
- 「今日」「最新」等の相対表現や、レポート・ファイル名に入れる日付はこの日時を基準にすること (例: \`output/report-${clock.date}.md\`)`;
|
||||
|
||||
const missionBlock = renderMissionBrief(missionBrief);
|
||||
const hasBash = tools.some((t) => t.function.name === 'Bash');
|
||||
const scriptGuidanceSection = hasBash
|
||||
@ -290,7 +314,9 @@ workspace の input/ output/ logs/ には前 piece の成果物が残ってい
|
||||
新規作業を始める前に Glob / Read で既存ファイルを確認してください。`;
|
||||
}
|
||||
|
||||
const preamble = `${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection}
|
||||
const preamble = `${datetimeSection}
|
||||
|
||||
${missionSection}${workingDirectorySection}${existingFilesSection}${handoffStaticSection}${handoffDynamicSection}
|
||||
|
||||
## アプローチの考え方 (全タスク共通)
|
||||
- 依頼に着手する前に、想定アプローチを **2-3 個** 浮かべて比較してから動く。最初に思いついた手段で即着手しないこと
|
||||
|
||||
@ -38,6 +38,7 @@ vi.mock('./tools/index.js', () => ({
|
||||
// Import after mocking
|
||||
import { runPiece } from './piece-runner.js';
|
||||
import { Conversation } from './context/conversation.js';
|
||||
import { reconstructDelegateRuns } from '../progress/delegate-runs.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Unique sentinel strings — must survive the full test without false positives
|
||||
@ -454,15 +455,100 @@ describe('runPiece — delegate E2E (Phase B Task 4)', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((complete as any).payload.status).toBe('success');
|
||||
|
||||
// Sub-internal events (e.g. tool_call from Write) must share correlationId === delegateRunId
|
||||
// Sub-internal events must carry the delegateRunId attribution tag.
|
||||
// NOTE: correlationId は tool_call が per-tool UUID で上書きするため
|
||||
// 帰属キーには使えない。delegateRunId フィールドで束ねるのが正。
|
||||
const subEvent = events.find(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(e: any) =>
|
||||
e.correlationId === runId &&
|
||||
e.delegateRunId === runId &&
|
||||
e.kind !== 'delegate_start' &&
|
||||
e.kind !== 'delegate_complete',
|
||||
);
|
||||
expect(subEvent, 'at least one sub-internal event must carry correlationId === delegateRunId').toBeTruthy();
|
||||
expect(subEvent, 'at least one sub-internal event must carry delegateRunId === runId').toBeTruthy();
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
'回帰: sub-agent の通常ツール呼び出しが reconstructDelegateRuns で toolCalls に数えられる',
|
||||
async () => {
|
||||
// 「ツール 0 回」バグの真のエンドツーエンド再発防止。
|
||||
// sub-agent が通常ツール(ReadThing)を呼ぶと tool-execution が
|
||||
// per-tool correlationId を発行して上書きする。それでも scope の
|
||||
// delegateRunId で束ねられ、toolCalls が加算されねばならない。
|
||||
const tmp = makeTmpWorkspace();
|
||||
workspacePath = tmp.workspacePath;
|
||||
const { logsRoot } = tmp;
|
||||
|
||||
getToolDefsMock.mockResolvedValue(makeToolDefs(['delegate', 'ReadThing']));
|
||||
|
||||
executeToolMock.mockImplementation(
|
||||
async (name: string, input: Record<string, unknown>, ctx: ToolContext) => {
|
||||
if (name === 'delegate') {
|
||||
if (!ctx.runDelegate) {
|
||||
return { output: 'runDelegate not injected', isError: true };
|
||||
}
|
||||
const { result, status } = await ctx.runDelegate({
|
||||
description: String(input['description'] ?? 'sub-task'),
|
||||
prompt: String(input['prompt'] ?? ''),
|
||||
});
|
||||
if (status === 'aborted') {
|
||||
return { output: `[delegate aborted] ${result}`, isError: true };
|
||||
}
|
||||
return { output: result, isError: false };
|
||||
}
|
||||
return { output: 'ok', isError: false };
|
||||
},
|
||||
);
|
||||
|
||||
const client = new SpyFakeClient([
|
||||
// parent turn 1 — delegate
|
||||
[
|
||||
{ type: 'tool_use', id: 'del-tc-1', name: 'delegate', input: { description: 'read a thing', prompt: SUB_PROMPT_MARKER } },
|
||||
{ type: 'done', usage: { prompt_tokens: 100, completion_tokens: 10 } },
|
||||
],
|
||||
// sub turn 1 — regular tool call (ReadThing)
|
||||
[
|
||||
{ type: 'tool_use', id: 'sub-read-1', name: 'ReadThing', input: { file_path: 'x.txt' } },
|
||||
{ type: 'done', usage: { prompt_tokens: 80, completion_tokens: 5 } },
|
||||
],
|
||||
// sub turn 2 — complete
|
||||
[
|
||||
{ type: 'tool_use', id: 'sub-complete-1', name: 'complete', input: { status: 'success', result: SUB_RESULT_MARKER } },
|
||||
{ type: 'done', usage: { prompt_tokens: 60, completion_tokens: 5 } },
|
||||
],
|
||||
// parent turn 2 — complete
|
||||
[
|
||||
{ type: 'tool_use', id: 'par-complete-1', name: 'complete', input: { status: 'success', result: `parent: ${SUB_RESULT_MARKER}` } },
|
||||
{ type: 'done', usage: { prompt_tokens: 120, completion_tokens: 10 } },
|
||||
],
|
||||
]);
|
||||
|
||||
const piece = delegatePiece();
|
||||
await runPiece(
|
||||
piece,
|
||||
PARENT_TASK_INSTRUCTION,
|
||||
client as never,
|
||||
workspacePath,
|
||||
undefined,
|
||||
undefined,
|
||||
{ runtimeDir: logsRoot, workspaceTools: { allowedTools: ['delegate', 'ReadThing'], editAllowed: true } },
|
||||
);
|
||||
|
||||
const { parseEventLine: pel } = await import('../progress/event-log.js');
|
||||
const raw = readFileSync(join(logsRoot, 'events.jsonl'), 'utf-8');
|
||||
const events = raw
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((l) => pel(l))
|
||||
.filter((r) => r.kind === 'ok')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.map((r: any) => r.event);
|
||||
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
expect(runs, 'exactly one delegate run reconstructed').toHaveLength(1);
|
||||
expect(runs[0].toolCalls, 'ReadThing tool_call must be counted (not 0)').toBe(1);
|
||||
expect(runs[0].toolSummary).toEqual([{ tool: 'ReadThing', count: 1 }]);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -18,12 +18,16 @@ describe('sns-deep-sweep piece', () => {
|
||||
expect(mv.default_next).toBe('COMPLETE');
|
||||
});
|
||||
|
||||
it('instruction guides one-delegate-per-tweet and the deepdive/report file layout', () => {
|
||||
it('instruction guides one-delegate-per-tweet and the dated deepdive/report file layout', () => {
|
||||
const piece = loadPiece('sns-deep-sweep');
|
||||
const instr = piece.movements[0]!.instruction;
|
||||
expect(instr).toContain('output/deepdive/');
|
||||
expect(instr).toContain('output/report.md');
|
||||
expect(instr).toContain('output/triage.md');
|
||||
// Outputs are date-scoped so repeated runs in a persistent space accumulate
|
||||
// instead of overwriting the previous run's files.
|
||||
expect(instr).toContain('output/deepdive/{DATE}/');
|
||||
expect(instr).toContain('output/report-{DATE}.md');
|
||||
expect(instr).toContain('output/triage-{DATE}.md');
|
||||
expect(instr).toContain('現在日時');
|
||||
expect(instr).not.toContain('output/report.md ');
|
||||
// The sub must be told not to delegate further (leaf guard via prompt).
|
||||
expect(instr).toContain('delegate は呼ぶな');
|
||||
});
|
||||
|
||||
@ -8,6 +8,7 @@ function ev(partial: Partial<EventBase> & { kind: string }): EventBase {
|
||||
eventId: partial.eventId ?? `e${partial.seq ?? 0}`, runId: 'job-run',
|
||||
kind: partial.kind, payload: partial.payload ?? {},
|
||||
correlationId: partial.correlationId, movement: partial.movement,
|
||||
delegateRunId: partial.delegateRunId,
|
||||
} as EventBase;
|
||||
}
|
||||
|
||||
@ -28,6 +29,30 @@ describe('reconstructDelegateRuns', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('本番のイベント形状: tool_call は独自 correlationId を持つが delegateRunId で集計される', () => {
|
||||
// 実機では tool-execution.ts が tool_call/tool_result に per-tool の
|
||||
// correlationId を付与し、子スコープの delegateRunId フィールドで実行に紐づく。
|
||||
// 旧実装は correlationId 突き合わせだったため toolCalls が常に 0 になっていた。
|
||||
const events = [
|
||||
ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R1', parentRunId: null, description: 'x', depth: 1 } }),
|
||||
ev({ seq: 2, kind: 'tool_call', correlationId: 'tool-uuid-a', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'WebFetch' } }),
|
||||
ev({ seq: 3, kind: 'tool_result', correlationId: 'tool-uuid-a', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'WebFetch' } }),
|
||||
ev({ seq: 4, kind: 'tool_call', correlationId: 'tool-uuid-b', delegateRunId: 'R1', movement: 'delegate:x', payload: { tool: 'Write', args: { file_path: 'out.txt' } } }),
|
||||
ev({ seq: 5, kind: 'llm_call_end', delegateRunId: 'R1', movement: 'delegate:x', payload: { promptTokens: 10, completionTokens: 5 } }),
|
||||
ev({ seq: 6, kind: 'delegate_complete', payload: { delegateRunId: 'R1', parentRunId: null, description: 'x', depth: 1, next: 'COMPLETE', status: 'success' } }),
|
||||
];
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
expect(runs[0].toolCalls).toBe(2);
|
||||
expect(runs[0].toolSummary).toEqual([
|
||||
{ tool: 'WebFetch', count: 1 },
|
||||
{ tool: 'Write', count: 1 },
|
||||
]);
|
||||
expect(runs[0].filesChanged).toEqual(['out.txt']);
|
||||
expect(runs[0].totalTokens).toBe(15);
|
||||
// eventCount は correlationId ではなく delegateRunId 帰属で数える
|
||||
expect(runs[0].eventCount).toBe(4);
|
||||
});
|
||||
|
||||
it('complete 欠落 → status running', () => {
|
||||
const events = [ev({ seq: 1, kind: 'delegate_start', payload: { delegateRunId: 'R2', parentRunId: null, description: 'x', depth: 1 } })];
|
||||
const runs = reconstructDelegateRuns(events);
|
||||
|
||||
@ -68,9 +68,14 @@ export function reconstructDelegateRuns(events: EventBase[]): DelegateRun[] {
|
||||
continue;
|
||||
}
|
||||
if (e.kind === 'delegate_start') continue;
|
||||
// 内部イベント: correlationId が run の ID に一致するものを束ねる
|
||||
if (e.correlationId && byId.has(e.correlationId)) {
|
||||
const run = byId.get(e.correlationId)!;
|
||||
// 内部イベント: delegateRunId 帰属タグが run の ID に一致するものを束ねる。
|
||||
// correlationId は tool_call↔tool_result のペアリングで per-tool UUID に
|
||||
// 上書きされるため集計キーには使えない(delegateRunId は子スコープが刻印し
|
||||
// 上書きされない)。旧イベント(delegateRunId 欠落)は correlationId に
|
||||
// フォールバックして後方互換を保つ。
|
||||
const attributionId = e.delegateRunId ?? e.correlationId;
|
||||
if (attributionId && byId.has(attributionId)) {
|
||||
const run = byId.get(attributionId)!;
|
||||
run.eventCount++;
|
||||
if (e.kind === 'tool_call') {
|
||||
run.toolCalls++;
|
||||
|
||||
@ -148,6 +148,18 @@ describe('FileEventLogger', () => {
|
||||
expect(parsed.iteration).toBe(5);
|
||||
});
|
||||
|
||||
it('child() の delegateRunId は per-event correlationId 上書きでも保持される', () => {
|
||||
// delegate 実行の帰属タグ回帰: tool_call は自前の correlationId を付けるが、
|
||||
// scope の delegateRunId は残らねばならない(これが 0 回バグの再発防止)。
|
||||
const log = createFileEventLogger({ workspacePath: workspace, runId: 'run-1' });
|
||||
const child = log.child({ movement: 'delegate:x', delegateRunId: 'RUN-9' });
|
||||
const perToolCorr = child.startCorrelation();
|
||||
child.emit('tool_call', { tool: 'Read' }, { correlationId: perToolCorr, llmToolCallId: 'r-1' });
|
||||
const parsed = JSON.parse(readFileSync(join(workspace, EVENT_LOG_FILE), 'utf-8').trim()) as EventBase;
|
||||
expect(parsed.delegateRunId).toBe('RUN-9');
|
||||
expect(parsed.correlationId).toBe(perToolCorr); // ペアリング用は上書きされたまま
|
||||
});
|
||||
|
||||
// root ignores chmod-based write denial, so this only holds as non-root (e.g. CI running as root).
|
||||
it.skipIf(process.getuid?.() === 0)('does NOT throw when the workspace directory is not writable (failure isolation)', () => {
|
||||
// Make the workspace read-only so appendFileSync fails.
|
||||
|
||||
@ -56,6 +56,13 @@ export interface EventBase {
|
||||
parentEventId?: string;
|
||||
correlationId?: string;
|
||||
llmToolCallId?: string;
|
||||
/**
|
||||
* delegate サブ実行への帰属。子スコープが刻印し、個々の tool_call が
|
||||
* `correlationId` を上書きしても失われない。reconstructDelegateRuns が
|
||||
* 内部イベントを実行へ束ねるのに使う(correlationId は tool_call↔tool_result
|
||||
* のペアリング専用に残す)。
|
||||
*/
|
||||
delegateRunId?: string;
|
||||
movement?: string;
|
||||
iteration?: number;
|
||||
kind: string;
|
||||
@ -66,6 +73,7 @@ export interface EmitOptions {
|
||||
parentEventId?: string;
|
||||
correlationId?: string;
|
||||
llmToolCallId?: string;
|
||||
delegateRunId?: string;
|
||||
movement?: string;
|
||||
iteration?: number;
|
||||
}
|
||||
@ -162,7 +170,7 @@ function fitToEnvelope(eventLine: string, event: EventBase): string {
|
||||
export interface EventLogger {
|
||||
emit(kind: string, payload: unknown, opts?: EmitOptions): string;
|
||||
startCorrelation(): string;
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger;
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger;
|
||||
/** Ids and counters for diagnostics. */
|
||||
describe(): { runId: string; seq: number; degraded: boolean };
|
||||
}
|
||||
@ -176,7 +184,7 @@ export class NoopEventLogger implements EventLogger {
|
||||
startCorrelation(): string {
|
||||
return 'noop';
|
||||
}
|
||||
child(_scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger {
|
||||
child(_scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger {
|
||||
return this;
|
||||
}
|
||||
describe(): { runId: string; seq: number; degraded: boolean } {
|
||||
@ -198,7 +206,7 @@ interface FileLoggerCore {
|
||||
class ScopedEventLogger implements EventLogger {
|
||||
constructor(
|
||||
private readonly core: FileLoggerCore,
|
||||
private readonly scope: { movement?: string; iteration?: number; correlationId?: string },
|
||||
private readonly scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string },
|
||||
) {}
|
||||
|
||||
emit(kind: string, payload: unknown, opts?: EmitOptions): string {
|
||||
@ -213,6 +221,7 @@ class ScopedEventLogger implements EventLogger {
|
||||
parentEventId: opts?.parentEventId,
|
||||
correlationId: opts?.correlationId ?? this.scope.correlationId,
|
||||
llmToolCallId: opts?.llmToolCallId,
|
||||
delegateRunId: opts?.delegateRunId ?? this.scope.delegateRunId,
|
||||
movement: opts?.movement ?? this.scope.movement,
|
||||
iteration: opts?.iteration ?? this.scope.iteration,
|
||||
kind,
|
||||
@ -239,11 +248,12 @@ class ScopedEventLogger implements EventLogger {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string }): EventLogger {
|
||||
child(scope: { movement?: string; iteration?: number; correlationId?: string; delegateRunId?: string }): EventLogger {
|
||||
return new ScopedEventLogger(this.core, {
|
||||
movement: scope.movement ?? this.scope.movement,
|
||||
iteration: scope.iteration ?? this.scope.iteration,
|
||||
correlationId: scope.correlationId ?? this.scope.correlationId,
|
||||
delegateRunId: scope.delegateRunId ?? this.scope.delegateRunId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
74
ui/src/components/chat/ChatPane.drafts.test.tsx
Normal file
74
ui/src/components/chat/ChatPane.drafts.test.tsx
Normal file
@ -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);
|
||||
|
||||
61
ui/src/components/create/CreateTaskDialog.dismiss.test.tsx
Normal file
61
ui/src/components/create/CreateTaskDialog.dismiss.test.tsx
Normal file
@ -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());
|
||||
});
|
||||
});
|
||||
88
ui/src/components/create/CreateTaskDialog.drafts.test.tsx
Normal file
88
ui/src/components/create/CreateTaskDialog.drafts.test.tsx
Normal file
@ -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();
|
||||
});
|
||||
});
|
||||
67
ui/src/components/create/CreateTaskDialog.paste.test.tsx
Normal file
67
ui/src/components/create/CreateTaskDialog.paste.test.tsx
Normal file
@ -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
|
||||
|
||||
93
ui/src/hooks/useDraft.test.tsx
Normal file
93
ui/src/hooks/useDraft.test.tsx
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
119
ui/src/hooks/useDraft.ts
Normal file
119
ui/src/hooks/useDraft.ts
Normal file
@ -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": "スケジュール作成",
|
||||
|
||||
27
ui/src/lib/fileAttachments.test.ts
Normal file
27
ui/src/lib/fileAttachments.test.ts
Normal file
@ -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');
|
||||
});
|
||||
});
|
||||
43
ui/src/lib/fileAttachments.ts
Normal file
43
ui/src/lib/fileAttachments.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user