Files
maestro/ui/src/components/chat/AskUserCard.tsx
T
oss-sync 57702b4f34
CI / build-and-test (push) Canceled after 0s
sync: update from private repo (2336949a)
2026-09-17 11:29:31 +00:00

210 lines
9.1 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { postLocalTaskComment } from '../../api/tasks';
import { MarkdownText } from '../../lib/markdown-text';
/**
* AskUser の質問カード (issue #985)。
*
* kind='ask' コメントの本文が {type:'ask_questions'} JSON のとき、ChatMessage が
* このカードを描画する。選択(または自由記述)をまとめ、既存のコメント送信 API に
* 載せる — waiting_human のジョブはユーザーコメントで再開する既存経路をそのまま
* 使うため、専用の回答 API は無い。回答テキストは「見出し: 選択」の平文で組み、
* 会話履歴としてもエージェントの入力としてもそのまま読める形にする。
*/
export interface AskCardOption {
label: string;
description?: string;
}
export interface AskCardQuestion {
question: string;
header?: string;
options: AskCardOption[];
multiSelect?: boolean;
}
export interface AskQuestionsPayload {
type: 'ask_questions';
questions: AskCardQuestion[];
}
const OTHER = '__other__';
/** groupCommentsByMovement と同じユーザーコメント判定。 */
const isUserComment = (c: { author: string; kind: string }) =>
c.kind === 'request' || c.kind === 'comment' || c.kind === 'interjection';
/**
* いま回答を受け付けている ask コメントの id を返す(無ければ null)。
* 条件: ジョブが waiting_human で、最後の ask より後にユーザーコメントが無いこと。
* それ以外の ask カード(回答済み・タスク完了後の履歴)は読み取り専用で描画する —
* カードの phase はローカル state なのでリロードで戻り、放置すると古いカードの
* ワンタップで意図しない再実行が起こせてしまう。
*/
export function findInteractiveAskId(
comments: ReadonlyArray<{ id: number; author: string; kind: string }>,
jobStatus: string | null | undefined,
): number | null {
if (jobStatus !== 'waiting_human') return null;
for (let i = comments.length - 1; i >= 0; i--) {
const c = comments[i]!;
if (isUserComment(c)) return null;
if (c.kind === 'ask') return c.id;
}
return null;
}
export function tryParseAskQuestions(body: string): AskQuestionsPayload | null {
if (!body.startsWith('{')) return null;
try {
const data = JSON.parse(body) as AskQuestionsPayload;
if (data?.type !== 'ask_questions' || !Array.isArray(data.questions) || data.questions.length === 0) return null;
if (!data.questions.every(q => typeof q?.question === 'string' && Array.isArray(q.options) && q.options.length > 0)) return null;
return data;
} catch {
return null;
}
}
interface QuestionState {
selected: Set<string>;
otherText: string;
}
export function AskUserCard({ taskId, commentId, payload, readOnly = false }: { taskId: number; commentId?: number; payload: AskQuestionsPayload; readOnly?: boolean }) {
const { t } = useTranslation('chat');
const [states, setStates] = useState<QuestionState[]>(
() => payload.questions.map(() => ({ selected: new Set<string>(), otherText: '' })),
);
const [phase, setPhase] = useState<'idle' | 'sending' | 'done' | 'error'>('idle');
const toggle = (qi: number, label: string, multi: boolean | undefined) => {
setStates(prev => prev.map((s, i) => {
if (i !== qi) return s;
const selected = new Set(multi ? s.selected : []);
if (s.selected.has(label) && multi) selected.delete(label);
else selected.add(label);
return { ...s, selected };
}));
};
const answered = (qi: number): boolean => {
const s = states[qi]!;
if (s.selected.has(OTHER)) return s.otherText.trim().length > 0;
return s.selected.size > 0;
};
const allAnswered = payload.questions.every((_, qi) => answered(qi));
const submit = async () => {
if (phase === 'sending' || phase === 'done') return;
setPhase('sending');
const lines = payload.questions.map((q, qi) => {
const s = states[qi]!;
const picks = [...s.selected].filter(l => l !== OTHER);
if (s.selected.has(OTHER)) picks.push(s.otherText.trim());
return `${q.header ?? q.question}: ${picks.join(', ')}`;
});
try {
await postLocalTaskComment(taskId, lines.join('\n'));
setPhase('done');
} catch {
setPhase('error');
}
};
const disabled = readOnly || phase === 'sending' || phase === 'done';
return (
<div className="flex justify-start">
<div className="max-w-[82%] min-w-[280px] bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded-2xl rounded-bl-md px-4 py-3 shadow-sm space-y-3">
{payload.questions.map((q, qi) => (
<div key={qi}>
{q.header ? (
<div className="text-2xs font-semibold uppercase tracking-wide text-amber-600 dark:text-amber-300">{q.header}</div>
) : null}
{/* 質問文はエージェントが書く地の文で、箇条書き・強調・コード片が普通に
入ってくる (issue #1197)。自由文の ask 吹き出し・結果バブルと同じ
MarkdownText に相乗りする — 生 HTML のエスケープと DOMPurify も
そこで揃うので、ここだけ緩くならない。
選択肢のラベルと補足 (下の <button> の中) は素のままにしてある。
MarkdownText は <div>/<p> を吐くので、phrasing content しか置けない
<button> の中には入れられない。 */}
<div
data-dictionary-eligible={commentId === undefined ? undefined : `message-${commentId}-question-${qi}`}
className="mt-0.5 text-slate-900 dark:text-slate-100"
>
<MarkdownText text={q.question} />
</div>
<div className="mt-2 flex flex-col gap-1.5" role="group" aria-label={q.header ?? q.question}>
{q.options.map(option => {
const active = states[qi]!.selected.has(option.label);
return (
<button
key={option.label}
type="button"
disabled={disabled}
aria-pressed={active}
onClick={() => toggle(qi, option.label, q.multiSelect)}
className={`rounded-lg border px-3 py-2 text-left text-sm transition-colors disabled:opacity-60 ${
active
? 'border-accent bg-accent/10 text-slate-900 dark:text-slate-100'
: 'border-amber-200 dark:border-amber-500/30 bg-white/70 dark:bg-slate-900/30 text-slate-700 dark:text-slate-200 hover:bg-white'
}`}
>
<span className="font-medium">{option.label}</span>
{option.description ? (
<span className="block text-2xs text-slate-500 dark:text-slate-400">{option.description}</span>
) : null}
</button>
);
})}
<button
type="button"
disabled={disabled}
aria-pressed={states[qi]!.selected.has(OTHER)}
onClick={() => toggle(qi, OTHER, q.multiSelect)}
className={`rounded-lg border border-dashed px-3 py-2 text-left text-sm transition-colors disabled:opacity-60 ${
states[qi]!.selected.has(OTHER)
? 'border-accent bg-accent/10 text-slate-900 dark:text-slate-100'
: 'border-amber-300 dark:border-amber-500/40 text-slate-500 dark:text-slate-400 hover:bg-white/60'
}`}
>
{t('askCard.other')}
</button>
{states[qi]!.selected.has(OTHER) ? (
<input
type="text"
disabled={disabled}
value={states[qi]!.otherText}
onChange={e => setStates(prev => prev.map((s, i) => (i === qi ? { ...s, otherText: e.target.value } : s)))}
placeholder={t('askCard.otherPlaceholder')}
className="rounded-lg border border-amber-200 dark:border-amber-500/30 bg-white dark:bg-slate-900/40 px-3 py-2 text-sm"
/>
) : null}
</div>
</div>
))}
{readOnly ? (
<div className="pt-1 text-2xs text-slate-500 dark:text-slate-400">{t('askCard.closed')}</div>
) : (
<div className="flex items-center gap-2 pt-1">
<button
type="button"
onClick={() => void submit()}
disabled={disabled || !allAnswered}
className="rounded-md bg-accent px-3 py-1.5 text-xs font-semibold text-accent-fg disabled:opacity-50"
>
{phase === 'done' ? t('askCard.submitted') : phase === 'sending' ? t('askCard.sending') : t('askCard.submit')}
</button>
{phase === 'error' ? (
<span role="alert" className="text-xs text-red-600 dark:text-red-300">{t('askCard.failed')}</span>
) : null}
</div>
)}
</div>
</div>
);
}