sync: update from private repo (2336949a)
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
oss-sync
2026-09-17 11:29:31 +00:00
parent 28f74c4e37
commit 57702b4f34
2139 changed files with 321270 additions and 17494 deletions
+117 -23
View File
@@ -1,27 +1,86 @@
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
type ClipboardEvent,
type KeyboardEvent,
} from 'react';
import { useTranslation } from 'react-i18next';
import type { LocalTask } from '../../api';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { updateLocalTask, type LocalTask } from '../../api';
import { useDraft } from '../../hooks/useDraft';
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
import { toBase64 } from '../../lib/fileAttachments';
import { isComposerLocked } from '../../lib/composerLock';
import { readAttachments, type Attachment } from '../../lib/fileAttachments';
import { ChatComposerPanel } from './ChatComposerPanel';
import { appendFileSelectionToBody } from '../../lib/file-selection';
import { useFileSelection } from '../../lib/file-selection-context';
interface ChatComposerProps {
/**
* いま走っている LLM 呼び出しの実プロンプトサイズ(SSE の promptProgress.total)。
* 5 秒巡回の値は「直前の呼び出しの結果」なので、ツールコールが続く間ずっと
* 動かない。次の呼び出しが始まった瞬間にこの値へ切り替える。
*/
livePromptTokens?: number;
task: LocalTask;
commentsLength: number;
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>, opts?: { grounded?: boolean }) => Promise<void>;
onCancel?: () => Promise<void>;
/** 入力欄の下の行の左端に置く、このチャットそのものへの操作(issue #1433 原因 2 段 2)。 */
composerActions?: React.ReactNode;
}
export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatComposerProps) {
/** チャット画面へのドロップを入力欄へ届けるための窓口。 */
export interface ChatComposerHandle {
addFiles: (files: File[]) => Promise<void>;
}
export const ChatComposer = forwardRef<ChatComposerHandle, ChatComposerProps>(function ChatComposer(
{ task, commentsLength, onSubmit, onCancel, livePromptTokens, composerActions },
ref,
) {
const { t } = useTranslation('chat');
// 選んだ範囲(#1463 段 2)。送る瞬間に本文へ展開して、そのあと外す。
const { selection: fileSelection, clearSelection: clearFileSelection } = useFileSelection();
const { draft: restoredDraft, saveDraft, clearDraft } = useDraft(`chat:${task.id}`);
const [draft, setDraft] = useState(restoredDraft ?? '');
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
const [attachments, setAttachments] = useState<Attachment[]>([]);
// 非同期の読み込み中に来た次のドロップからも「今の添付一覧」が見えるようにする控え
const attachmentsRef = useRef<Attachment[]>([]);
const updateAttachments = useCallback((next: Attachment[]) => {
attachmentsRef.current = next;
setAttachments(next);
}, []);
const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [sendError, setSendError] = useState<string | null>(null);
const [showPromptCoach, setShowPromptCoach] = useState(false);
const [grounded, setGrounded] = useState(false);
// コンテキスト構成 (#1300)。タスクごとに覚える設定なので、画面の手元に控えを
// 持たずタスクの値をそのまま映す。切り替えたらその場で保存し、再取得で戻す。
const qc = useQueryClient();
const compactContext = task.options?.compactContext === true;
// 保存に失敗すると、画面のボタンは元の状態へ戻るだけになる。理由が出ないと
// 「押しても効かない」としか見えないので、失敗はその場に文字で出す。
const [contextModeError, setContextModeError] = useState<string | null>(null);
const compactContextMutation = useMutation({
mutationFn: (next: boolean) => updateLocalTask(task.id, { options: { compactContext: next } }),
onMutate: () => { setContextModeError(null); },
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['localTask', task.id] });
qc.invalidateQueries({ queryKey: ['localTasks'] });
},
onError: (e: unknown) => {
setContextModeError(
e instanceof Error && e.message ? e.message : t('pane.contextModeSaveFailed'),
);
},
});
const composerRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
@@ -42,7 +101,13 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
const inputLocked = jobStatus === 'dispatching';
const awaitingToolApproval =
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
const composerLocked = inputLocked || awaitingToolApproval;
const awaitingPolicyApproval =
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'policy_approval';
// 人の確認(CAPTCHA)待ち(#1199 段階 2)。カードで「解決した」か「スキップ」を
// 押すまで、普通のメッセージは送れない(サーバーも 409 で断る)。
const awaitingCaptcha =
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'captcha';
const composerLocked = isComposerLocked(task.latestJob);
const releaseSubmitting = () => {
if (submitTimeoutRef.current) {
@@ -68,16 +133,24 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
};
}, []);
// ファイル選択・貼り付け・ドロップの 3 経路をここに集約する。
// 命名規則(image.png の日時化・重複回避)は新規タスク作成画面と共通。
const addFiles = useCallback(async (files: ArrayLike<File>) => {
if (files.length === 0) return;
// 連続ドロップでも名前がぶつからないよう、最新の添付一覧は ref から読む
const { added, failed } = await readAttachments(files, attachmentsRef.current);
if (added.length > 0) updateAttachments([...attachmentsRef.current, ...added]);
if (failed > 0) setSendError(t('pane.attachFailed', { count: failed }));
}, [t, updateAttachments]);
const handleFiles = async (files: FileList | null) => {
if (!files || files.length === 0) return;
const converted = await Promise.all(
Array.from(files).map(async f => ({ name: f.name, contentBase64: await toBase64(f) })),
);
setAttachments(prev => [...prev, ...converted]);
if (files) await addFiles(files);
};
useImperativeHandle(ref, () => ({ addFiles }), [addFiles]);
const removeAttachment = (name: string) => {
setAttachments(prev => prev.filter(a => a.name !== name));
updateAttachments(attachmentsRef.current.filter(a => a.name !== name));
};
const handleSubmit = async () => {
@@ -86,9 +159,26 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
setSubmitting(true);
submitBaselineRef.current = commentsLength;
try {
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
const currentAttachments = attachments.length > 0 ? attachments : undefined;
/*
選んだ範囲は**送る瞬間に本文へ展開する**(#1463 段 2)。添付ファイルには
しない——添付は中身が必須で、送るとワークスペースの `input` に実ファイルが
落ちる。選ぶたびに断片ファイルが溜まることになる。サーバの受け口は
そのままで足りる。
*/
const body = fileSelection
? appendFileSelectionToBody(draft, fileSelection, (key, vars) => t(key, vars ?? {}) as string)
: draft;
if (grounded) {
await onSubmit(body, currentAttachments, { grounded: true });
} else {
await onSubmit(body, currentAttachments);
}
setDraft('');
setAttachments([]);
updateAttachments([]);
setGrounded(false);
// 送った札は外す。同じ範囲をもう一度添えたければ選び直す。
clearFileSelection();
clearDraft();
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
@@ -111,13 +201,7 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
}
if (files.length === 0) return;
e.preventDefault();
const converted = await Promise.all(
files.map(async f => {
const name = f.name === 'image.png' ? `paste-${Date.now()}.png` : f.name;
return { name, contentBase64: await toBase64(f) };
}),
);
setAttachments(prev => [...prev, ...converted]);
await addFiles(files);
};
const handleKeyDown = (e: KeyboardEvent) => {
@@ -140,6 +224,7 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
return (
<ChatComposerPanel
task={task}
livePromptTokens={livePromptTokens}
draft={draft}
attachments={attachments}
submitting={submitting}
@@ -150,6 +235,8 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
canInterject={canInterject}
inputLocked={inputLocked}
awaitingToolApproval={awaitingToolApproval}
awaitingPolicyApproval={awaitingPolicyApproval}
awaitingCaptcha={awaitingCaptcha}
composerLocked={composerLocked}
hasActiveJob={hasActiveJob}
jobStatus={jobStatus}
@@ -163,10 +250,17 @@ export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatC
onDraftChange={value => { setDraft(value); saveDraft(value); }}
showPromptCoach={showPromptCoach}
onTogglePromptCoach={() => setShowPromptCoach(value => !value)}
grounded={grounded}
onToggleGrounded={() => setGrounded(value => !value)}
compactContext={compactContext}
onToggleCompactContext={() => compactContextMutation.mutate(!compactContext)}
compactContextPending={compactContextMutation.isPending}
contextModeError={contextModeError}
onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }}
onResend={handleSubmit}
composerRef={composerRef}
fileInputRef={fileInputRef}
composerActions={composerActions}
/>
);
}
});