sync: update from private repo (9a86f49b)
This commit is contained in:
@@ -0,0 +1,18 @@
|
||||
export interface ChatAttachment {
|
||||
name: string;
|
||||
contentBase64: string;
|
||||
}
|
||||
|
||||
export function ChatAttachmentList({ attachments, onRemove }: { attachments: ChatAttachment[]; onRemove: (name: string) => void }) {
|
||||
if (attachments.length === 0) return null;
|
||||
return (
|
||||
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
|
||||
{attachments.map(attachment => (
|
||||
<span key={attachment.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
|
||||
{attachment.name}
|
||||
<button type="button" onClick={() => onRemove(attachment.name)} aria-label={`${attachment.name}を削除`} className="ml-0.5 text-slate-400 hover:text-slate-700">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ClipboardEvent, type KeyboardEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { LocalTask } from '../../api';
|
||||
import { useDraft } from '../../hooks/useDraft';
|
||||
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
|
||||
import { toBase64 } from '../../lib/fileAttachments';
|
||||
import { ChatComposerPanel } from './ChatComposerPanel';
|
||||
|
||||
interface ChatComposerProps {
|
||||
task: LocalTask;
|
||||
commentsLength: number;
|
||||
onSubmit: (body: string, attachments?: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
onCancel?: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ChatComposer({ task, commentsLength, onSubmit, onCancel }: ChatComposerProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
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);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [showPromptCoach, setShowPromptCoach] = useState(false);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsAutosizeFallback) return;
|
||||
const el = composerRef.current;
|
||||
if (el) autosizeTextarea(el, 192);
|
||||
}, [draft, needsAutosizeFallback]);
|
||||
|
||||
const submitBaselineRef = useRef<number | null>(null);
|
||||
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
||||
const hasActiveJob = isBusy || isPending;
|
||||
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
||||
const inputLocked = jobStatus === 'dispatching';
|
||||
const awaitingToolApproval =
|
||||
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
|
||||
const composerLocked = inputLocked || awaitingToolApproval;
|
||||
|
||||
const releaseSubmitting = () => {
|
||||
if (submitTimeoutRef.current) {
|
||||
clearTimeout(submitTimeoutRef.current);
|
||||
submitTimeoutRef.current = null;
|
||||
}
|
||||
submitBaselineRef.current = null;
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!submitting) return;
|
||||
const baseline = submitBaselineRef.current;
|
||||
if (baseline === null) return;
|
||||
if (commentsLength > baseline && hasActiveJob) {
|
||||
releaseSubmitting();
|
||||
}
|
||||
}, [submitting, commentsLength, hasActiveJob]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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]);
|
||||
};
|
||||
|
||||
const removeAttachment = (name: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.name !== name));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if ((!draft.trim() && attachments.length === 0) || submitting) return;
|
||||
setSendError(null);
|
||||
setSubmitting(true);
|
||||
submitBaselineRef.current = commentsLength;
|
||||
try {
|
||||
await onSubmit(draft, attachments.length > 0 ? attachments : undefined);
|
||||
setDraft('');
|
||||
setAttachments([]);
|
||||
clearDraft();
|
||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
|
||||
} catch (e) {
|
||||
setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed'));
|
||||
releaseSubmitting();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!onCancel || cancelling) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await onCancel();
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ChatComposerPanel
|
||||
task={task}
|
||||
draft={draft}
|
||||
attachments={attachments}
|
||||
submitting={submitting}
|
||||
cancelling={cancelling}
|
||||
sendError={sendError}
|
||||
isBusy={isBusy}
|
||||
isPending={isPending}
|
||||
canInterject={canInterject}
|
||||
inputLocked={inputLocked}
|
||||
awaitingToolApproval={awaitingToolApproval}
|
||||
composerLocked={composerLocked}
|
||||
hasActiveJob={hasActiveJob}
|
||||
jobStatus={jobStatus}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onCancel ? handleCancel : undefined}
|
||||
onAttachClick={() => fileInputRef.current?.click()}
|
||||
onRemoveAttachment={removeAttachment}
|
||||
onFileChange={handleFiles}
|
||||
onPaste={handlePaste}
|
||||
onKeyDown={handleKeyDown}
|
||||
onDraftChange={value => { setDraft(value); saveDraft(value); }}
|
||||
showPromptCoach={showPromptCoach}
|
||||
onTogglePromptCoach={() => setShowPromptCoach(value => !value)}
|
||||
onApplyPromptRewrite={value => { setDraft(value); saveDraft(value); }}
|
||||
onResend={handleSubmit}
|
||||
composerRef={composerRef}
|
||||
fileInputRef={fileInputRef}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ClipboardEvent, KeyboardEvent, RefObject } from 'react';
|
||||
import type { LocalTask } from '../../api';
|
||||
import { PromptCoachPanel } from '../create/PromptCoachPanel';
|
||||
import { ToolRequestApproval } from './ToolRequestApproval';
|
||||
import { PackageRequestApproval } from './PackageRequestApproval';
|
||||
import { LlmSelectionControl } from './LlmSelectionControl';
|
||||
import { ChatAttachmentList } from './ChatAttachmentList';
|
||||
import { WaterContextGauge } from './WaterContextGauge';
|
||||
|
||||
interface ChatComposerPanelProps {
|
||||
task: LocalTask;
|
||||
draft: string;
|
||||
attachments: Array<{ name: string; contentBase64: string }>;
|
||||
submitting: boolean;
|
||||
cancelling: boolean;
|
||||
sendError: string | null;
|
||||
isBusy: boolean;
|
||||
isPending: boolean;
|
||||
canInterject: boolean;
|
||||
inputLocked: boolean;
|
||||
awaitingToolApproval: boolean;
|
||||
composerLocked: boolean;
|
||||
hasActiveJob: boolean;
|
||||
jobStatus: string | null | undefined;
|
||||
onSubmit: () => Promise<void>;
|
||||
onCancel?: () => Promise<void>;
|
||||
onAttachClick: () => void;
|
||||
onRemoveAttachment: (name: string) => void;
|
||||
onFileChange: (files: FileList | null) => Promise<void>;
|
||||
onPaste: (e: ClipboardEvent) => Promise<void>;
|
||||
onKeyDown: (e: KeyboardEvent) => void;
|
||||
onDraftChange: (value: string) => void;
|
||||
showPromptCoach: boolean;
|
||||
onTogglePromptCoach: () => void;
|
||||
onApplyPromptRewrite: (value: string) => void;
|
||||
onResend: () => Promise<void>;
|
||||
composerRef: RefObject<HTMLTextAreaElement>;
|
||||
fileInputRef: RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export function ChatComposerPanel({
|
||||
task,
|
||||
draft,
|
||||
attachments,
|
||||
submitting,
|
||||
cancelling,
|
||||
sendError,
|
||||
isBusy,
|
||||
isPending,
|
||||
canInterject,
|
||||
inputLocked,
|
||||
awaitingToolApproval,
|
||||
composerLocked,
|
||||
hasActiveJob,
|
||||
jobStatus,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
onAttachClick,
|
||||
onRemoveAttachment,
|
||||
onFileChange,
|
||||
onPaste,
|
||||
onKeyDown,
|
||||
onDraftChange,
|
||||
showPromptCoach,
|
||||
onTogglePromptCoach,
|
||||
onApplyPromptRewrite,
|
||||
onResend,
|
||||
composerRef,
|
||||
fileInputRef,
|
||||
}: ChatComposerPanelProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
return (
|
||||
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-2.5" style={{ paddingBottom: 'calc(10px + env(safe-area-inset-bottom, 0px))' }}>
|
||||
{hasActiveJob && (
|
||||
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
|
||||
canInterject
|
||||
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
|
||||
: isPending
|
||||
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
|
||||
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
|
||||
</div>
|
||||
)}
|
||||
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||||
<PackageRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||||
{sendError && !isBusy && (
|
||||
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
|
||||
<span className="truncate">⚠ {sendError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onResend()}
|
||||
disabled={submitting}
|
||||
className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||
>
|
||||
{t('pane.resend')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`relative overflow-hidden rounded-xl border transition-shadow ${composerLocked ? 'border-hairline bg-surface' : 'border-hairline bg-canvas focus-within:border-accent focus-within:ring-2 focus-within:ring-accent-ring'}`}>
|
||||
<WaterContextGauge
|
||||
variant="fill"
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
limitTokens={task.latestJob?.contextLimitTokens}
|
||||
/>
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={e => onDraftChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onPaste={e => void onPaste(e)}
|
||||
rows={1}
|
||||
disabled={composerLocked}
|
||||
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
||||
className="relative z-10 block w-full resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 text-sm leading-6 text-slate-900 outline-none [field-sizing:content] min-h-6 max-h-48 overflow-y-auto disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="relative z-10 flex flex-wrap items-center gap-1.5 px-2 pb-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { void onFileChange(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<button
|
||||
onClick={onAttachClick}
|
||||
disabled={composerLocked || submitting}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
title={t('pane.attachFile')}
|
||||
aria-label={t('pane.attachFile')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTogglePromptCoach}
|
||||
disabled={composerLocked || !draft.trim()}
|
||||
aria-expanded={showPromptCoach}
|
||||
title={t('pane.evaluatePrompt')}
|
||||
aria-label={t('pane.evaluatePrompt')}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="m12 3 1.5 4.5L18 9l-4.5 1.5L12 15l-1.5-4.5L6 9l4.5-1.5L12 3Z" />
|
||||
<path d="m19 15 .75 2.25L22 18l-2.25.75L19 21l-.75-2.25L16 18l2.25-.75L19 15Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<ChatAttachmentList attachments={attachments} onRemove={onRemoveAttachment} />
|
||||
<LlmSelectionControl task={task} busy={isBusy} />
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||
<WaterContextGauge
|
||||
variant="label"
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
limitTokens={task.latestJob?.contextLimitTokens}
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
<div className="flex gap-1.5">
|
||||
{canInterject && (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={onSubmit}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
{t('pane.interject')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={cancelling}
|
||||
onClick={() => void onCancel()}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||
title={t('pane.stopAgent')}
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
|
||||
</svg>
|
||||
{cancelling ? t('pane.stopping') : t('pane.stop')}
|
||||
</button>
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={onSubmit}
|
||||
title={t('pane.addToQueuedHint')}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
{t('pane.addToQueued')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={onSubmit}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M22 2 11 13" />
|
||||
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
|
||||
</svg>
|
||||
{t('pane.send')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showPromptCoach && (
|
||||
<div className="relative z-10 border-t border-hairline bg-canvas/90 px-3 py-2 backdrop-blur-sm" data-testid="chat-prompt-coach">
|
||||
<PromptCoachPanel body={draft} piece={task.pieceName} onApplyRewrite={onApplyPromptRewrite} compact />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { LocalTaskComment, getLocalFileRawUrl } from '../../api';
|
||||
import { MarkdownPreview } from '../files/FilePreview';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { ToolCallsSection, parseToolCallComment } from './ToolCallsSection';
|
||||
import { parseJobRetry } from './movementHistory';
|
||||
|
||||
// We delegate spacing to MarkdownText's built-in COMPACT_PROSE default
|
||||
// (4px-ish paragraph margins, leading-snug, `!important` to beat the
|
||||
@@ -283,6 +284,13 @@ function ProgressPill({ icon, children, variant = 'inline' }: { icon: React.Reac
|
||||
|
||||
function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment; isStaleThinking?: boolean }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const retry = parseJobRetry(comment.body);
|
||||
if (retry) {
|
||||
const text = retry.disposition === 'requeued_unhealthy'
|
||||
? t('movementMap.workerRetry')
|
||||
: t('movementMap.jobRetry', { attempt: retry.nextAttempt, max: retry.maxAttempts });
|
||||
return <ProgressPill icon={<span className="text-amber-600">{'↻'}</span>}>{text}</ProgressPill>;
|
||||
}
|
||||
// Interjection ack → minimal centered confirmation
|
||||
const ackData = tryParseInterjectionAck(comment.body);
|
||||
if (ackData) {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, screen } from '@testing-library/react';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import type { LocalTask, LocalTaskComment } from '../../api';
|
||||
import type { JobStreamState } from '../../hooks/useJobStream';
|
||||
import { ChatMessageFeed } from './ChatMessageFeed';
|
||||
|
||||
const task = { id: 42, title: 'Task', pieceName: 'chat' } as LocalTask;
|
||||
const jobStream = {
|
||||
promptProgress: null,
|
||||
streamingText: '',
|
||||
toolCallStream: {},
|
||||
connected: false,
|
||||
delegateStreams: {},
|
||||
llmState: null,
|
||||
} as unknown as JobStreamState;
|
||||
|
||||
const comment = (id: number): LocalTaskComment => ({
|
||||
id,
|
||||
taskId: 42,
|
||||
author: 'agent',
|
||||
kind: 'comment',
|
||||
body: `message ${id}`,
|
||||
createdAt: new Date().toISOString(),
|
||||
injectedAt: null,
|
||||
} as LocalTaskComment);
|
||||
|
||||
describe('ChatMessageFeed scroll-to-latest integration', () => {
|
||||
it('keeps the positioner outside the scrolling element', () => {
|
||||
renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||
const scroll = screen.getByTestId('chat-message-scroll');
|
||||
Object.defineProperties(scroll, {
|
||||
scrollHeight: { configurable: true, value: 1000 },
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollTop: { configurable: true, writable: true, value: 100 },
|
||||
});
|
||||
fireEvent.scroll(scroll);
|
||||
|
||||
const positioner = screen.getByTestId('scroll-to-latest-positioner');
|
||||
expect(scroll.contains(positioner)).toBe(false);
|
||||
expect(positioner.parentElement).toBe(scroll.parentElement);
|
||||
});
|
||||
|
||||
it('clears a stale new-message count when manually reaching the bottom', () => {
|
||||
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||
const scroll = screen.getByTestId('chat-message-scroll');
|
||||
Object.defineProperties(scroll, {
|
||||
scrollHeight: { configurable: true, value: 1000 },
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollTop: { configurable: true, writable: true, value: 100 },
|
||||
});
|
||||
fireEvent.scroll(scroll);
|
||||
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2)]} jobStream={jobStream} />);
|
||||
expect(screen.getByTestId('scroll-to-latest-positioner')).toHaveTextContent(/1/);
|
||||
|
||||
act(() => { scroll.scrollTop = 700; });
|
||||
fireEvent.scroll(scroll);
|
||||
expect(screen.queryByTestId('scroll-to-latest-positioner')).not.toBeInTheDocument();
|
||||
|
||||
act(() => { scroll.scrollTop = 100; });
|
||||
fireEvent.scroll(scroll);
|
||||
expect(screen.getByTestId('scroll-to-latest-positioner')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('scroll-to-latest-positioner')).not.toHaveTextContent(/1/);
|
||||
});
|
||||
|
||||
it('cancels a pending auto-scroll when the user scrolls away', () => {
|
||||
let pendingFrame: FrameRequestCallback | null = null;
|
||||
const cancelFrame = vi.fn();
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn((callback: FrameRequestCallback) => {
|
||||
pendingFrame = callback;
|
||||
return 17;
|
||||
}));
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelFrame);
|
||||
|
||||
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||
const scroll = screen.getByTestId('chat-message-scroll');
|
||||
Object.defineProperties(scroll, {
|
||||
scrollHeight: { configurable: true, value: 1000 },
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||
});
|
||||
fireEvent.scroll(scroll);
|
||||
|
||||
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2)]} jobStream={jobStream} />);
|
||||
expect(pendingFrame).not.toBeNull();
|
||||
|
||||
act(() => { scroll.scrollTop = 100; });
|
||||
fireEvent.scroll(scroll);
|
||||
expect(cancelFrame).toHaveBeenCalledWith(17);
|
||||
|
||||
act(() => { pendingFrame?.(0); });
|
||||
expect(scroll.scrollTop).toBe(100);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('keeps only one pending auto-scroll across consecutive updates', () => {
|
||||
let nextFrameId = 20;
|
||||
const cancelFrame = vi.fn();
|
||||
vi.stubGlobal('requestAnimationFrame', vi.fn(() => ++nextFrameId));
|
||||
vi.stubGlobal('cancelAnimationFrame', cancelFrame);
|
||||
|
||||
const rendered = renderWithProviders(<ChatMessageFeed task={task} comments={[comment(1)]} jobStream={jobStream} />);
|
||||
const scroll = screen.getByTestId('chat-message-scroll');
|
||||
Object.defineProperties(scroll, {
|
||||
scrollHeight: { configurable: true, value: 1000 },
|
||||
clientHeight: { configurable: true, value: 300 },
|
||||
scrollTop: { configurable: true, writable: true, value: 700 },
|
||||
});
|
||||
|
||||
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2)]} jobStream={jobStream} />);
|
||||
rendered.rerender(<ChatMessageFeed task={task} comments={[comment(1), comment(2), comment(3)]} jobStream={jobStream} />);
|
||||
expect(cancelFrame).toHaveBeenCalledWith(21);
|
||||
|
||||
rendered.unmount();
|
||||
expect(cancelFrame).toHaveBeenCalledWith(22);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { LocalTask, LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||
import type { JobStreamState } from '../../hooks/useJobStream';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||
import { ChatMessageFeedPanel } from './ChatMessageFeedPanel';
|
||||
import { assignRetryEvents } from './movementHistory';
|
||||
import { ScrollToLatestButton } from './ScrollToLatestButton';
|
||||
|
||||
interface ChatMessageFeedProps {
|
||||
task: LocalTask;
|
||||
comments: LocalTaskComment[];
|
||||
jobStream: JobStreamState;
|
||||
historyEvents?: MovementHistoryEvent[];
|
||||
}
|
||||
|
||||
export function ChatMessageFeed({ task, comments, jobStream, historyEvents = [] }: ChatMessageFeedProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = jobStream;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isAtBottomRef = useRef(true);
|
||||
const autoScrollFrameRef = useRef<number | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||
const prevCommentCountRef = useRef(comments.length);
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||
|
||||
const checkIfAtBottom = useMemo(() => {
|
||||
return () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useMemo(() => {
|
||||
return () => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
isAtBottomRef.current = true;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => {
|
||||
const atBottom = checkIfAtBottom();
|
||||
isAtBottomRef.current = atBottom;
|
||||
if (!atBottom && autoScrollFrameRef.current !== null) {
|
||||
cancelAnimationFrame(autoScrollFrameRef.current);
|
||||
autoScrollFrameRef.current = null;
|
||||
}
|
||||
setIsAtBottom(atBottom);
|
||||
if (atBottom) setNewMessageCount(0);
|
||||
};
|
||||
el.addEventListener('scroll', handler, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handler);
|
||||
}, [checkIfAtBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const delta = comments.length - prevCommentCountRef.current;
|
||||
prevCommentCountRef.current = comments.length;
|
||||
if (delta <= 0) return;
|
||||
if (isAtBottom) {
|
||||
if (autoScrollFrameRef.current !== null) {
|
||||
cancelAnimationFrame(autoScrollFrameRef.current);
|
||||
}
|
||||
autoScrollFrameRef.current = requestAnimationFrame(() => {
|
||||
autoScrollFrameRef.current = null;
|
||||
if (isAtBottomRef.current && scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setNewMessageCount(prev => prev + delta);
|
||||
}
|
||||
}, [comments.length, isAtBottom]);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (autoScrollFrameRef.current !== null) cancelAnimationFrame(autoScrollFrameRef.current);
|
||||
}, []);
|
||||
|
||||
const visibleComments = useMemo(() => {
|
||||
if (!isBusy) return comments;
|
||||
if (!connected) return comments;
|
||||
if (!hasTrailingThinking(comments)) return comments;
|
||||
return comments.slice(0, -1);
|
||||
}, [comments, isBusy, connected]);
|
||||
|
||||
const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]);
|
||||
const retryAssignments = useMemo(() => assignRetryEvents(
|
||||
historyEvents,
|
||||
groupedItems
|
||||
.filter((item): item is Extract<typeof item, { type: 'movement' }> => item.type === 'movement')
|
||||
.map(item => ({ id: item.completionComment.id, movement: item.movementName })),
|
||||
), [historyEvents, groupedItems]);
|
||||
const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1;
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
||||
<div ref={scrollRef} data-testid="chat-message-scroll" className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
||||
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-[13px] py-8">
|
||||
{t('pane.empty')}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
let commentIdx = 0;
|
||||
return groupedItems.map((item, gi) => {
|
||||
if (item.type === 'movement') {
|
||||
const startIdx = commentIdx;
|
||||
commentIdx += item.inner.length;
|
||||
return (
|
||||
<MovementGroupExpanded
|
||||
key={`mg-${gi}`}
|
||||
item={item}
|
||||
taskId={task.id}
|
||||
isLast={gi === groupedItems.length - 1}
|
||||
isRunning={isBusy}
|
||||
animatingIdx={animatingIdx}
|
||||
startIdx={startIdx}
|
||||
historyEvents={retryAssignments.byCompletionId.get(item.completionComment.id) ?? []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (item.type === 'diagnostic') {
|
||||
commentIdx++;
|
||||
return (
|
||||
<div id={`comment-${item.comment.id}`} key={`diagnostic-${item.comment.id}`} tabIndex={-1} className="rounded outline-none transition-[background-color,box-shadow]">
|
||||
<ChatMessage comment={item.comment} taskId={task.id} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const idx = commentIdx;
|
||||
commentIdx++;
|
||||
return (
|
||||
<div id={`comment-${item.comment.id}`} key={item.comment.id} tabIndex={-1} className="rounded outline-none transition-[background-color,box-shadow]">
|
||||
<ChatMessage
|
||||
comment={item.comment}
|
||||
taskId={task.id}
|
||||
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{retryAssignments.unassigned.map(event => (
|
||||
<div
|
||||
key={event.eventId}
|
||||
id={`trace-event-${event.eventId}`}
|
||||
tabIndex={-1}
|
||||
className="rounded border border-amber-200/70 bg-amber-50/70 px-2 py-1 text-[11px] text-amber-800 outline-none dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
|
||||
>
|
||||
{t('movementMap.llmRetry', { attempt: event.payload.attempt ?? '?', max: event.payload.maxAttempts ?? '?' })}
|
||||
{event.movement ? ` · ${event.movement}` : ''}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
||||
<SubtaskInlineCard
|
||||
subtasks={task.subtasks}
|
||||
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||||
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||||
/>
|
||||
)}
|
||||
<ChatMessageFeedPanel
|
||||
jobStream={jobStream}
|
||||
isBusy={isBusy}
|
||||
isWaitingSubtasks={isWaitingSubtasks}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isAtBottom && (
|
||||
<ScrollToLatestButton
|
||||
newMessageCount={newMessageCount}
|
||||
onClick={scrollToBottom}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { JobStreamState } from '../../hooks/useJobStream';
|
||||
import RotatingTips from './RotatingTips';
|
||||
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
||||
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||
|
||||
interface ChatMessageFeedPanelProps {
|
||||
jobStream: JobStreamState;
|
||||
isBusy: boolean;
|
||||
isWaitingSubtasks: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessageFeedPanel({
|
||||
jobStream,
|
||||
isBusy,
|
||||
isWaitingSubtasks,
|
||||
}: ChatMessageFeedPanelProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const { promptProgress, streamingText, toolCallStream, delegateStreams, llmState } = jobStream;
|
||||
const liveToolContent = useMemo(() => {
|
||||
const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD);
|
||||
for (let k = entries.length - 1; k >= 0; k--) {
|
||||
const text = extractStreamingField(entries[k].name, entries[k].rawArgs);
|
||||
if (text) return { name: entries[k].name, text };
|
||||
}
|
||||
return null;
|
||||
}, [toolCallStream]);
|
||||
const liveToolRef = useRef<HTMLPreElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight;
|
||||
}, [liveToolContent?.text]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isBusy && !isWaitingSubtasks && (
|
||||
<div className="flex justify-start">
|
||||
{promptProgress ? (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span>{t('pane.processing')}</span>
|
||||
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1">
|
||||
<div
|
||||
className="bg-emerald-500 h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${promptProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : streamingText ? (
|
||||
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-canvas border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
||||
{streamingText}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</div>
|
||||
) : liveToolContent ? (
|
||||
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
||||
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
|
||||
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
||||
{liveToolContent.text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
|
||||
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
|
||||
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
|
||||
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
|
||||
: t('pane.agentResponding')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
||||
<div className="flex justify-start mt-1.5">
|
||||
<DelegateLiveConsole streams={delegateStreams} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && <RotatingTips />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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';
|
||||
import type { LocalTask, LocalTaskComment } from '../../api';
|
||||
|
||||
const DRAFT_KEY = 'maestro:draft:v1:chat:42';
|
||||
|
||||
@@ -71,4 +71,56 @@ describe('ChatPane drafts', () => {
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
expect(localStorage.getItem(DRAFT_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('queued のままでもコメントが反映されたら送信ロックを外す', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn(async () => {});
|
||||
const queuedTask = {
|
||||
...task,
|
||||
latestJob: { status: 'queued' },
|
||||
} as LocalTask;
|
||||
const rendered = renderWithProviders(<ChatPane task={queuedTask} comments={[]} onSubmit={onSubmit} />);
|
||||
const textbox = screen.getByRole('textbox');
|
||||
await user.click(textbox);
|
||||
await user.type(textbox, '追記');
|
||||
|
||||
const queuedButton = screen.getByRole('button', { name: /Add to task|追加/ });
|
||||
await user.click(queuedButton);
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('追記', undefined));
|
||||
expect(queuedButton).toBeDisabled();
|
||||
|
||||
rendered.rerender(
|
||||
<ChatPane
|
||||
task={queuedTask}
|
||||
comments={[
|
||||
{
|
||||
id: 1,
|
||||
taskId: 42,
|
||||
author: 'agent',
|
||||
kind: 'comment',
|
||||
body: '追記',
|
||||
createdAt: new Date().toISOString(),
|
||||
injectedAt: null,
|
||||
} as LocalTaskComment,
|
||||
]}
|
||||
onSubmit={onSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textboxAfter = screen.getByRole('textbox');
|
||||
await user.click(textboxAfter);
|
||||
await user.type(textboxAfter, '続き');
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: /Add to task|追加/ })).toBeEnabled());
|
||||
});
|
||||
|
||||
it('入力中のプロンプトコーチを常設ボタンから開閉できる', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<ChatPane task={task} comments={[]} onSubmit={vi.fn(async () => {})} />);
|
||||
await user.type(screen.getByRole('textbox'), '評価してほしい依頼');
|
||||
const coachButton = screen.getByRole('button', { name: /Evaluate prompt|プロンプトを評価/i });
|
||||
await user.click(coachButton);
|
||||
expect(screen.getByTestId('chat-prompt-coach')).toBeInTheDocument();
|
||||
await user.click(coachButton);
|
||||
expect(screen.queryByTestId('chat-prompt-coach')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
// @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 type { LocalTask } from '../../api';
|
||||
|
||||
// useJobStream が EventSource を張るため jsdom にスタブを入れる(ChatPane.drafts.test.tsx と同じ)
|
||||
class FakeEventSource {
|
||||
onmessage: ((e: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
addEventListener() {}
|
||||
removeEventListener() {}
|
||||
close() {}
|
||||
}
|
||||
|
||||
const WORKERS = [
|
||||
{ id: 'w1', model: 'm1', roles: ['auto'], reasoningEfforts: ['low', 'high'], vlm: false, enabled: true },
|
||||
{ id: 'w2', model: 'm2', roles: ['auto'], reasoningEfforts: [], vlm: false, enabled: true },
|
||||
];
|
||||
|
||||
let patchCalls: Array<{ url: string; body: unknown }> = [];
|
||||
|
||||
function baseTask(overrides: Partial<LocalTask> = {}): LocalTask {
|
||||
return {
|
||||
id: 42,
|
||||
title: 'テストタスク',
|
||||
pieceName: 'chat',
|
||||
llmWorkerId: null,
|
||||
llmEffort: null,
|
||||
latestJob: undefined,
|
||||
...overrides,
|
||||
} as unknown as LocalTask;
|
||||
}
|
||||
|
||||
function stubFetch() {
|
||||
patchCalls = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes('/api/llm/workers')) {
|
||||
return new Response(JSON.stringify({ workers: WORKERS }), { status: 200 });
|
||||
}
|
||||
if (url.includes('/api/local/tasks/42') && init?.method === 'PATCH') {
|
||||
const body = init.body ? JSON.parse(init.body as string) : {};
|
||||
patchCalls.push({ url, body });
|
||||
return new Response(JSON.stringify({ task: baseTask(body as Partial<LocalTask>) }), { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 404 });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('EventSource', FakeEventSource);
|
||||
vi.stubGlobal('matchMedia', (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
}));
|
||||
stubFetch();
|
||||
});
|
||||
|
||||
async function openSelector(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByTestId('llm-select-trigger'));
|
||||
}
|
||||
|
||||
describe('ChatPane — LLM 選択セレクター(コンポーザー)', () => {
|
||||
it('ポップオーバーをbodyへPortalし、コンポーザーのoverflowで切り取らない', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
const popover = await screen.findByTestId('llm-select-popover');
|
||||
const trigger = screen.getByTestId('llm-select-trigger');
|
||||
expect(popover.parentElement).toBe(document.body);
|
||||
expect(trigger.parentElement?.contains(popover)).toBe(false);
|
||||
expect(popover).toHaveClass('fixed', 'overflow-y-auto', 'z-[80]');
|
||||
expect(Number.parseFloat(popover.style.width)).toBeGreaterThan(0);
|
||||
expect(popover).toHaveAttribute('role', 'dialog');
|
||||
expect(trigger).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(trigger).toHaveAttribute('aria-controls', 'llm-select-popover');
|
||||
});
|
||||
|
||||
it('Portal内クリックでは閉じず、Escapeで閉じてトリガーへフォーカスを戻す', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
await user.click(await screen.findByTestId('llm-select-worker'));
|
||||
expect(screen.getByTestId('llm-select-popover')).toBeInTheDocument();
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-select-trigger')).toHaveFocus();
|
||||
});
|
||||
|
||||
it('開いたパネルへフォーカスを移し、Tab境界で閉じてコンポーザーへ戻る', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'w1' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
const worker = await screen.findByTestId('llm-select-worker');
|
||||
await waitFor(() => expect(worker).toHaveFocus());
|
||||
await user.tab({ shift: true });
|
||||
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-select-trigger')).toHaveFocus();
|
||||
|
||||
await openSelector(user);
|
||||
const reopenedWorker = await screen.findByTestId('llm-select-worker');
|
||||
const reopenedEffort = screen.getByTestId('llm-select-effort');
|
||||
await waitFor(() => expect(reopenedWorker).toHaveFocus());
|
||||
await user.tab();
|
||||
expect(reopenedEffort).toHaveFocus();
|
||||
await user.tab();
|
||||
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-select-trigger')).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('操作要素が1個だけでもTabでポップオーバーから抜けられる', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
const worker = await screen.findByTestId('llm-select-worker');
|
||||
await waitFor(() => expect(worker).toHaveFocus());
|
||||
await user.tab();
|
||||
expect(screen.queryByTestId('llm-select-popover')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('llm-select-trigger')).not.toHaveFocus();
|
||||
});
|
||||
|
||||
it('sticky 未設定なら「自動」表示、設定済みなら model @ id + effort を表示する', async () => {
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
expect(screen.getByTestId('llm-select-trigger')).toHaveTextContent(/モデル: 自動|Model: auto/);
|
||||
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: 'high' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTestId('llm-select-trigger')[1]).toHaveTextContent('m1 @ w1');
|
||||
});
|
||||
expect(screen.getAllByTestId('llm-select-trigger')[1]).toHaveTextContent('high');
|
||||
});
|
||||
|
||||
it('ワーカーを選ぶと PATCH が {llmWorkerId, llmEffort:null} で発火する', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
const workerSelect = await screen.findByTestId('llm-select-worker');
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
await user.selectOptions(workerSelect, 'w1');
|
||||
|
||||
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||
expect(patchCalls[0].body).toEqual({ llmWorkerId: 'w1', llmEffort: null });
|
||||
});
|
||||
|
||||
it('effort を変えると現在のワーカーを保ったまま PATCH される', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
const effortSelect = await screen.findByTestId('llm-select-effort');
|
||||
await waitFor(() => expect(effortSelect).not.toBeDisabled());
|
||||
await user.selectOptions(effortSelect, 'low');
|
||||
|
||||
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||
expect(patchCalls[0].body).toEqual({ llmWorkerId: 'w1', llmEffort: 'low' });
|
||||
});
|
||||
|
||||
it('自動に戻すと llmWorkerId/llmEffort が両方 null で PATCH される', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: 'high' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
const workerSelect = await screen.findByTestId('llm-select-worker');
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
await user.selectOptions(workerSelect, '');
|
||||
|
||||
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||
expect(patchCalls[0].body).toEqual({ llmWorkerId: null, llmEffort: null });
|
||||
});
|
||||
|
||||
it('実行中ジョブがあるときは、モデル切替ポップオーバーを開くと「次のジョブから有効」の注記を表示する', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane
|
||||
task={baseTask({ latestJob: { id: 'j1', status: 'running' } as LocalTask['latestJob'] })}
|
||||
comments={[]}
|
||||
onSubmit={vi.fn(async () => {})}
|
||||
/>,
|
||||
);
|
||||
// 常時の横テキストは廃止。開く前は出ない。
|
||||
expect(screen.queryByTestId('llm-applies-next-job')).not.toBeInTheDocument();
|
||||
await openSelector(user);
|
||||
expect(await screen.findByTestId('llm-applies-next-job')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('実行中でなければ、ポップオーバーを開いても注記は表示しない', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask()} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await openSelector(user);
|
||||
await screen.findByTestId('llm-select-worker');
|
||||
expect(screen.queryByTestId('llm-applies-next-job')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ピン留め先が workers 一覧の enabled に無い場合は停滞バッジを表示する', async () => {
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'ghost-worker', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
expect(await screen.findByTestId('llm-stalled-badge')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('停滞ワーカー + effort 設定済みでも effort select は無効化され、別の有効ワーカー選択と自動復帰は動く', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'ghost-worker', llmEffort: 'high' })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await user.click(await screen.findByTestId('llm-select-trigger'));
|
||||
|
||||
// 停滞中は effort select を無効化(ゴースト id 由来の 400 を防ぐ)
|
||||
const effortSelect = await screen.findByTestId('llm-select-effort');
|
||||
expect(effortSelect).toBeDisabled();
|
||||
|
||||
// 別の有効なワーカーを選べば復帰できる → {newId, effort:null}
|
||||
const workerSelect = screen.getByTestId('llm-select-worker');
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
await user.selectOptions(workerSelect, 'w1');
|
||||
await waitFor(() => expect(patchCalls.length).toBe(1));
|
||||
expect(patchCalls[0].body).toEqual({ llmWorkerId: 'w1', llmEffort: null });
|
||||
|
||||
// 自動に戻すボタンでも復帰できる → {null, null}
|
||||
await user.click(screen.getByTestId('llm-select-clear-stalled'));
|
||||
await waitFor(() => expect(patchCalls.length).toBe(2));
|
||||
expect(patchCalls[1].body).toEqual({ llmWorkerId: null, llmEffort: null });
|
||||
});
|
||||
|
||||
it('停滞していないワーカーには停滞バッジを表示しない', async () => {
|
||||
renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId('llm-select-trigger')).toHaveTextContent('m1 @ w1'));
|
||||
expect(screen.queryByTestId('llm-stalled-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('/api/llm/workers が非 OK(一時的な 500)を返しても停滞バッジは出さない(false-stalled 防止)', async () => {
|
||||
// Given: fetchLlmWorkers が [] ではなく throw する(本 PR の修正)ので、
|
||||
// react-query は error 状態になり isSuccess が false のまま。stalled 判定は
|
||||
// isSuccess ゲート付きなので、健全なピン留めを誤って停滞扱いしない。
|
||||
//
|
||||
// 注意: トリガーのラベルはクエリ解決前から pinnedWorkerId をそのまま表示する
|
||||
// (`pinnedWorker?.model ?? pinnedWorkerId`)ため、「トリガーに w1 が出る」を
|
||||
// waitFor 条件にすると初回レンダーで即座に満たされてしまい、まだ設定中の
|
||||
// クエリの結果を待たずにバッジ不在を早合点する(false green)。react-query の
|
||||
// クエリ状態が pending を抜けるのを直接待ってから判定する。
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes('/api/llm/workers')) {
|
||||
return new Response('server error', { status: 500 });
|
||||
}
|
||||
return new Response('{}', { status: 404 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { queryClient } = renderWithProviders(
|
||||
<ChatPane task={baseTask({ llmWorkerId: 'w1', llmEffort: null })} comments={[]} onSubmit={vi.fn(async () => {})} />,
|
||||
);
|
||||
|
||||
// クエリが pending を抜ける(このケースでは error に落ちる)まで待つ
|
||||
await waitFor(() => {
|
||||
expect(queryClient.getQueryState(['llm-workers'])?.status).not.toBe('pending');
|
||||
});
|
||||
expect(queryClient.getQueryState(['llm-workers'])?.status).toBe('error');
|
||||
|
||||
// トリガーは「w1」というピン留め id そのままを表示し(model 解決はできない)、
|
||||
// かつ停滞バッジは表示されない。
|
||||
expect(screen.getByTestId('llm-select-trigger')).toHaveTextContent('w1');
|
||||
expect(screen.queryByTestId('llm-stalled-badge')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,21 +1,12 @@
|
||||
import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, type CSSProperties } from 'react';
|
||||
import { type CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalTask, LocalTaskComment } from '../../api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||
import { ChatPetOverlay } from '../pets/ChatPetOverlay';
|
||||
import { ContextUsageGauge } from '../detail/ContextUsageGauge';
|
||||
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||
import RotatingTips from './RotatingTips';
|
||||
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';
|
||||
import { toBase64 } from '../../lib/fileAttachments';
|
||||
import { ChatComposer } from './ChatComposer';
|
||||
import { ChatMessageFeed } from './ChatMessageFeed';
|
||||
import { MovementMap } from './MovementMap';
|
||||
import { useMovementHistory } from '../../hooks/useTaskDetail';
|
||||
|
||||
interface ChatPaneProps {
|
||||
task: LocalTask;
|
||||
@@ -32,234 +23,12 @@ interface ChatPaneProps {
|
||||
export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activeDetailTab, onSelectDetailTab }: ChatPaneProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const { t: dt } = useTranslation('detail');
|
||||
// 下書き保存: 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);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
// Firefox など field-sizing 未対応環境だけ JS で高さ追従(判定は初回のみ)。
|
||||
const needsAutosizeFallback = useMemo(() => !supportsFieldSizing(), []);
|
||||
useEffect(() => {
|
||||
if (!needsAutosizeFallback) return;
|
||||
const el = composerRef.current;
|
||||
if (el) autosizeTextarea(el, 192); // 192px ≈ 8行 (max-h-48 と一致させる)
|
||||
}, [draft, needsAutosizeFallback]);
|
||||
// Snapshot of comments.length at submit start. We hold "submitting" until
|
||||
// (a) the new user comment is reflected in the list AND (b) the job is
|
||||
// visibly busy (= picked up by a worker). Without this, the gap between the
|
||||
// POST resolving and the worker dispatching the job lets the user fire
|
||||
// multiple sends in a row.
|
||||
const submitBaselineRef = useRef<number | null>(null);
|
||||
const submitTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||
const prevCommentCountRef = useRef(comments.length);
|
||||
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setIsAtBottom(checkIfAtBottom());
|
||||
el.addEventListener('scroll', handler, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handler);
|
||||
}, [checkIfAtBottom]);
|
||||
|
||||
// タスクを開いたとき(ChatPane はチャット切替で remount される)は、最新の
|
||||
// メッセージが見える位置で開けるよう、初回描画前に最下部へジャンプする。
|
||||
// useLayoutEffect にすることで一番上→最下部のちらつきを避ける。以降の追従は
|
||||
// 上の comments.length 監視エフェクトが担う。
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const delta = comments.length - prevCommentCountRef.current;
|
||||
prevCommentCountRef.current = comments.length;
|
||||
if (delta <= 0) return;
|
||||
if (isAtBottom) {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setNewMessageCount(prev => prev + delta);
|
||||
}
|
||||
}, [comments.length, isAtBottom]);
|
||||
|
||||
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]);
|
||||
};
|
||||
|
||||
const removeAttachment = (name: string) => {
|
||||
setAttachments(prev => prev.filter(a => a.name !== name));
|
||||
};
|
||||
|
||||
const releaseSubmitting = () => {
|
||||
if (submitTimeoutRef.current) {
|
||||
clearTimeout(submitTimeoutRef.current);
|
||||
submitTimeoutRef.current = null;
|
||||
}
|
||||
submitBaselineRef.current = null;
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if ((!draft.trim() && attachments.length === 0) || submitting) return;
|
||||
setSendError(null);
|
||||
setSubmitting(true);
|
||||
submitBaselineRef.current = comments.length;
|
||||
try {
|
||||
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.
|
||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||
submitTimeoutRef.current = setTimeout(releaseSubmitting, 10000);
|
||||
} catch (e) {
|
||||
setSendError(e instanceof Error && e.message ? e.message : t('pane.sendFailed'));
|
||||
releaseSubmitting();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
const files: File[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (item.kind === 'file') {
|
||||
const file = item.getAsFile();
|
||||
if (file) files.push(file);
|
||||
}
|
||||
}
|
||||
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]);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async () => {
|
||||
if (!onCancel || cancelling) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
await onCancel();
|
||||
} finally {
|
||||
setCancelling(false);
|
||||
}
|
||||
};
|
||||
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus);
|
||||
|
||||
// Most-recent content-field tool with decoded content to show live.
|
||||
const liveToolContent = useMemo(() => {
|
||||
const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD);
|
||||
for (let k = entries.length - 1; k >= 0; k--) {
|
||||
const text = extractStreamingField(entries[k].name, entries[k].rawArgs);
|
||||
if (text) return { name: entries[k].name, text };
|
||||
}
|
||||
return null;
|
||||
}, [toolCallStream]);
|
||||
const liveToolRef = useRef<HTMLPreElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight;
|
||||
}, [liveToolContent?.text]);
|
||||
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||
const canInterject = jobStatus === 'running' || jobStatus === 'waiting_subtasks';
|
||||
const inputLocked = jobStatus === 'dispatching';
|
||||
// A job is already accepted but not yet running. The task is NOT idle: a new
|
||||
// message must fold into this pending job (server reuses it, no duplicate),
|
||||
// so the composer presents it as an addition rather than a fresh request.
|
||||
const isPending = jobStatus === 'queued' || jobStatus === 'retry';
|
||||
const hasActiveJob = isBusy || isPending;
|
||||
|
||||
// Tool-request mechanism: while the agent is paused waiting for the user to
|
||||
// approve/deny a requested tool, lock the normal composer. Sending a regular
|
||||
// message here would create a SECOND job (a waiting_human reply) that runs in
|
||||
// parallel with the original once it's resumed by the approval → duplicate
|
||||
// execution. Gate on the JOB's waitReason (available immediately from the
|
||||
// task data) rather than the async tool-requests fetch, so there is no
|
||||
// unlocked gap right after the task flips to waiting_human. A disabled
|
||||
// textarea also blocks the Ctrl+Enter submit path.
|
||||
const awaitingToolApproval =
|
||||
jobStatus === 'waiting_human' && task.latestJob?.waitReason === 'tool_request';
|
||||
const composerLocked = inputLocked || awaitingToolApproval;
|
||||
|
||||
// Release the submit lock once the agent is visibly responding: the new user
|
||||
// comment is reflected in the list AND the job has been picked up by a worker
|
||||
// (isBusy=true). This bridges the queued->dispatching gap where a stale
|
||||
// re-enabled send button would otherwise allow a double submit.
|
||||
useEffect(() => {
|
||||
if (!submitting) return;
|
||||
const baseline = submitBaselineRef.current;
|
||||
if (baseline === null) return;
|
||||
if (comments.length > baseline && hasActiveJob) {
|
||||
releaseSubmitting();
|
||||
}
|
||||
}, [submitting, comments.length, hasActiveJob]);
|
||||
|
||||
// Clear the safety-net timeout on unmount.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (submitTimeoutRef.current) clearTimeout(submitTimeoutRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// During an active run, suppress the trailing thinking comment so the
|
||||
// live SSE preview is the single source of truth for in-flight text.
|
||||
// We keep the comment in history (MovementGroup will render it once the
|
||||
// movement completes). On SSE disconnect we keep it visible as fallback.
|
||||
const visibleComments = useMemo(() => {
|
||||
if (!isBusy) return comments;
|
||||
if (!connected) return comments; // SSE disconnect fallback
|
||||
if (!hasTrailingThinking(comments)) return comments;
|
||||
return comments.slice(0, -1);
|
||||
}, [comments, isBusy, connected]);
|
||||
|
||||
const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]);
|
||||
const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1;
|
||||
const jobStream = useJobStream(task.id, jobStatus);
|
||||
const movementHistory = useMovementHistory(task.id, isBusy);
|
||||
const historyEvents = movementHistory.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full overflow-hidden">
|
||||
@@ -269,7 +38,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
<ChatPetOverlay
|
||||
taskId={task.id}
|
||||
taskStatus={task.latestJob?.status ?? null}
|
||||
currentActivity={task.latestJob?.currentActivity ?? null}
|
||||
lastToolEvent={jobStream.lastToolEvent}
|
||||
workerId={task.latestJob?.workerId ?? null}
|
||||
lastBackendId={task.latestJob?.lastBackendId ?? null}
|
||||
/>
|
||||
@@ -339,269 +108,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
||||
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-[13px] py-8">
|
||||
{t('pane.empty')}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
let commentIdx = 0;
|
||||
return groupedItems.map((item, gi) => {
|
||||
if (item.type === 'movement') {
|
||||
const startIdx = commentIdx;
|
||||
commentIdx += item.inner.length;
|
||||
return (
|
||||
<MovementGroupExpanded
|
||||
key={`mg-${gi}`}
|
||||
item={item}
|
||||
taskId={task.id}
|
||||
isLast={gi === groupedItems.length - 1}
|
||||
isRunning={isBusy}
|
||||
animatingIdx={animatingIdx}
|
||||
startIdx={startIdx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const idx = commentIdx;
|
||||
commentIdx++;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={item.comment.id}
|
||||
comment={item.comment}
|
||||
taskId={task.id}
|
||||
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
||||
<SubtaskInlineCard
|
||||
subtasks={task.subtasks}
|
||||
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||||
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBusy && !isWaitingSubtasks && (
|
||||
<div className="flex justify-start">
|
||||
{promptProgress ? (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span>{t('pane.processing')}</span>
|
||||
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1">
|
||||
<div
|
||||
className="bg-emerald-500 h-1 rounded-full transition-all duration-300"
|
||||
style={{ width: `${promptProgress.percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : streamingText ? (
|
||||
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-canvas border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
||||
{streamingText}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</div>
|
||||
) : liveToolContent ? (
|
||||
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
||||
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
|
||||
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
||||
{liveToolContent.text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
|
||||
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
|
||||
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
|
||||
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
|
||||
: t('pane.agentResponding')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
||||
<div className="flex justify-start mt-1.5">
|
||||
<DelegateLiveConsole streams={delegateStreams} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* General rotating tips to make the wait useful (running only). */}
|
||||
{isBusy && <RotatingTips />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scroll-to-bottom button */}
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-3 py-1.5 bg-canvas border border-slate-200 rounded-full shadow-md text-xs text-slate-600 hover:bg-slate-50 transition-colors z-10"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
{newMessageCount > 0 ? (
|
||||
<span className="text-blue-600 font-medium">{t('pane.newMessages', { count: newMessageCount })}</span>
|
||||
) : (
|
||||
<span>{t('pane.toLatest')}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer — カード+ツールバー型。上段 textarea(自動拡張)、下段に
|
||||
添付・コンテキスト残量・送信系を集約。旧: 独立ゲージ行+2行 textarea で
|
||||
約114px → 待機時約85px。 */}
|
||||
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-2.5" style={{ paddingBottom: 'calc(10px + env(safe-area-inset-bottom, 0px))' }}>
|
||||
{hasActiveJob && (
|
||||
<div className={`flex items-center gap-2 mb-2 px-2.5 py-1 rounded-md text-2xs ${
|
||||
canInterject
|
||||
? 'bg-amber-50 dark:bg-amber-500/15 border border-amber-100 dark:border-amber-500/30 text-amber-700 dark:text-amber-300'
|
||||
: isPending
|
||||
? 'bg-surface-2 border border-hairline text-slate-600 dark:text-slate-300'
|
||||
: 'bg-blue-50 dark:bg-blue-500/15 border border-blue-100 dark:border-blue-500/30 text-blue-700 dark:text-blue-300'
|
||||
}`}>
|
||||
<svg className="w-3 h-3 animate-spin flex-shrink-0" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
<span>{canInterject ? t('pane.interjectHint') : isPending ? t('pane.queuedHint') : t('pane.agentRunningWait')}</span>
|
||||
</div>
|
||||
)}
|
||||
<ToolRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||||
<PackageRequestApproval taskId={task.id} poll={jobStatus === 'waiting_human' || isBusy} />
|
||||
{sendError && !isBusy && (
|
||||
<div className="flex items-center justify-between gap-2 mb-2 px-2.5 py-1 bg-red-50 dark:bg-red-500/15 border border-red-100 dark:border-red-500/30 rounded-md text-2xs text-red-700 dark:text-red-300">
|
||||
<span className="truncate">⚠ {sendError}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSubmit()}
|
||||
disabled={submitting}
|
||||
className="flex-shrink-0 px-2 h-6 bg-canvas border border-red-200 rounded text-[10px] font-medium text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||
>
|
||||
{t('pane.resend')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className={`rounded-xl border transition-shadow ${composerLocked ? 'border-hairline bg-surface' : 'border-hairline bg-canvas focus-within:border-accent focus-within:ring-2 focus-within:ring-accent-ring'}`}>
|
||||
<textarea
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={e => { setDraft(e.target.value); saveDraft(e.target.value); }}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={e => void handlePaste(e)}
|
||||
rows={1}
|
||||
disabled={composerLocked}
|
||||
placeholder={awaitingToolApproval ? t('toolRequest.composerLocked') : inputLocked ? t('pane.placeholder.dispatching') : canInterject ? t('pane.placeholder.interject') : isPending ? t('pane.placeholder.queued') : t('pane.placeholder.default')}
|
||||
className="block w-full resize-none border-0 bg-transparent px-3 pt-2.5 pb-1 text-sm leading-6 text-slate-900 outline-none [field-sizing:content] min-h-6 max-h-48 overflow-y-auto disabled:text-slate-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-2 pb-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { void handleFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={composerLocked || submitting}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
title={t('pane.attachFile')}
|
||||
aria-label={t('pane.attachFile')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
|
||||
{attachments.map(a => (
|
||||
<span key={a.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
|
||||
{a.name}
|
||||
<button onClick={() => removeAttachment(a.name)} className="ml-0.5 text-slate-400 hover:text-slate-700">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||
{/* コンテキスト残量: 入力しながら「あとどれくらい書けるか」を把握
|
||||
できる位置に常時表示(issue #009 の趣旨を維持)。 */}
|
||||
<ContextUsageGauge
|
||||
inline
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
limitTokens={task.latestJob?.contextLimitTokens}
|
||||
jobStatus={task.latestJob?.status}
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
<div className="flex gap-1.5">
|
||||
{canInterject && (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
{t('pane.interject')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={cancelling}
|
||||
onClick={() => void handleCancel()}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||
title={t('pane.stopAgent')}
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
|
||||
</svg>
|
||||
{cancelling ? t('pane.stopping') : t('pane.stop')}
|
||||
</button>
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
title={t('pane.addToQueuedHint')}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
{t('pane.addToQueued')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
disabled={submitting || composerLocked || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep flex-shrink-0 transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M22 2 11 13" />
|
||||
<path d="M22 2 15 22 11 13 2 9 22 2Z" />
|
||||
</svg>
|
||||
{t('pane.send')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 flex-1">
|
||||
<ChatMessageFeed task={task} comments={comments} jobStream={jobStream} historyEvents={historyEvents} />
|
||||
<MovementMap task={task} comments={comments} historyEvents={historyEvents} />
|
||||
</div>
|
||||
<ChatComposer task={task} commentsLength={comments.length} onSubmit={onSubmit} onCancel={onCancel} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalTask, LocalTaskComment } from '../../api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { isThinkingComment, hasTrailingThinking } from './thinkingUtils';
|
||||
import { groupCommentsByMovement, MovementGroupExpanded } from './MovementGroup';
|
||||
import { SubtaskInlineCard } from './SubtaskInlineCard';
|
||||
import RotatingTips from './RotatingTips';
|
||||
import { DelegateLiveConsole } from './DelegateLiveConsole';
|
||||
import { useJobStream } from '../../hooks/useJobStream';
|
||||
import { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||
|
||||
interface ChatTimelineProps {
|
||||
task: LocalTask;
|
||||
comments: LocalTaskComment[];
|
||||
jobStatus: string | null | undefined;
|
||||
}
|
||||
|
||||
export function ChatTimeline({ task, comments, jobStatus }: ChatTimelineProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [newMessageCount, setNewMessageCount] = useState(0);
|
||||
const prevCommentCountRef = useRef(comments.length);
|
||||
|
||||
const { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState } = useJobStream(task.id, jobStatus);
|
||||
const isBusy = jobStatus === 'running' || jobStatus === 'dispatching' || jobStatus === 'waiting_subtasks';
|
||||
const isWaitingSubtasks = jobStatus === 'waiting_subtasks';
|
||||
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return true;
|
||||
return el.scrollHeight - el.scrollTop - el.clientHeight < 80;
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
setIsAtBottom(true);
|
||||
setNewMessageCount(0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const handler = () => setIsAtBottom(checkIfAtBottom());
|
||||
el.addEventListener('scroll', handler, { passive: true });
|
||||
return () => el.removeEventListener('scroll', handler);
|
||||
}, [checkIfAtBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const delta = comments.length - prevCommentCountRef.current;
|
||||
prevCommentCountRef.current = comments.length;
|
||||
if (delta <= 0) return;
|
||||
if (isAtBottom) {
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setNewMessageCount(prev => prev + delta);
|
||||
}
|
||||
}, [comments.length, isAtBottom]);
|
||||
|
||||
const visibleComments = useMemo(() => {
|
||||
if (!isBusy) return comments;
|
||||
if (!connected) return comments;
|
||||
if (!hasTrailingThinking(comments)) return comments;
|
||||
return comments.slice(0, -1);
|
||||
}, [comments, isBusy, connected]);
|
||||
|
||||
const groupedItems = useMemo(() => groupCommentsByMovement(visibleComments), [visibleComments]);
|
||||
const animatingIdx = isBusy && hasTrailingThinking(visibleComments) ? visibleComments.length - 1 : -1;
|
||||
|
||||
const liveToolContent = useMemo(() => {
|
||||
const entries = Object.values(toolCallStream).filter(e => e.name in CONTENT_FIELD);
|
||||
for (let k = entries.length - 1; k >= 0; k--) {
|
||||
const text = extractStreamingField(entries[k].name, entries[k].rawArgs);
|
||||
if (text) return { name: entries[k].name, text };
|
||||
}
|
||||
return null;
|
||||
}, [toolCallStream]);
|
||||
const liveToolRef = useRef<HTMLPreElement | null>(null);
|
||||
useEffect(() => {
|
||||
if (liveToolRef.current) liveToolRef.current.scrollTop = liveToolRef.current.scrollHeight;
|
||||
}, [liveToolContent?.text]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 relative min-h-0 overflow-x-hidden">
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto overflow-x-hidden p-4">
|
||||
<div className="max-w-3xl mx-auto min-w-0 flex flex-col gap-3">
|
||||
{comments.length === 0 && (
|
||||
<div className="text-center text-slate-400 text-[13px] py-8">
|
||||
{t('pane.empty')}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
let commentIdx = 0;
|
||||
return groupedItems.map((item, gi) => {
|
||||
if (item.type === 'movement') {
|
||||
const startIdx = commentIdx;
|
||||
commentIdx += item.inner.length;
|
||||
return (
|
||||
<MovementGroupExpanded
|
||||
key={`mg-${gi}`}
|
||||
item={item}
|
||||
taskId={task.id}
|
||||
isLast={gi === groupedItems.length - 1}
|
||||
isRunning={isBusy}
|
||||
animatingIdx={animatingIdx}
|
||||
startIdx={startIdx}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const idx = commentIdx;
|
||||
commentIdx++;
|
||||
return (
|
||||
<ChatMessage
|
||||
key={item.comment.id}
|
||||
comment={item.comment}
|
||||
taskId={task.id}
|
||||
isStaleThinking={isThinkingComment(item.comment) && idx !== animatingIdx}
|
||||
/>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
|
||||
{isWaitingSubtasks && task.subtasks && task.subtasks.length > 0 && (
|
||||
<SubtaskInlineCard
|
||||
subtasks={task.subtasks}
|
||||
subtaskCount={task.subtaskCount ?? task.subtasks.length}
|
||||
subtaskCompleted={task.subtaskCompleted ?? 0}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isBusy && !isWaitingSubtasks && (
|
||||
<div className="flex justify-start">
|
||||
{promptProgress ? (
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-surface border border-hairline rounded-md text-2xs text-slate-600 min-w-[180px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span>{t('pane.processing')}</span>
|
||||
<span className="font-mono tabular-nums">{promptProgress.percent}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-200 rounded-full h-1">
|
||||
<div className="bg-emerald-500 h-1 rounded-full transition-all duration-300" style={{ width: `${promptProgress.percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : streamingText ? (
|
||||
<div className="max-w-[80%] min-w-0 px-3 py-2 bg-canvas border border-hairline rounded-lg text-[13px] text-slate-800 leading-relaxed whitespace-pre-wrap break-words [overflow-wrap:anywhere] opacity-70">
|
||||
{streamingText}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</div>
|
||||
) : liveToolContent ? (
|
||||
<div className="max-w-[80%] min-w-0 w-full px-3 py-2 bg-slate-50 border border-hairline rounded-lg">
|
||||
<div className="text-2xs text-slate-500 mb-1 font-mono">{t('pane.generating', { name: liveToolContent.name })}</div>
|
||||
<pre ref={liveToolRef} className="max-h-64 overflow-auto text-[12px] text-slate-800 whitespace-pre-wrap break-words [overflow-wrap:anywhere] m-0">
|
||||
{liveToolContent.text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-slate-400 animate-pulse ml-0.5 align-text-bottom" />
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 px-2.5 py-1 bg-surface border border-hairline rounded-md text-2xs text-slate-600">
|
||||
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
{llmState?.phase === 'thinking' ? t('pane.llmThinking', { chars: (llmState.chars ?? 0).toLocaleString() })
|
||||
: llmState?.phase === 'retrying' ? t('pane.llmRetrying', { attempt: llmState.attempt ?? 0, max: llmState.maxAttempts ?? 0, reason: llmState.reason ?? llmState.errorClass ?? '' })
|
||||
: llmState?.phase === 'recovering' ? t('pane.llmRecovering', { stage: llmState.stage ?? '' })
|
||||
: llmState?.phase === 'waiting' ? t('pane.llmWaiting')
|
||||
: t('pane.agentResponding')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && Object.keys(delegateStreams).length > 0 && (
|
||||
<div className="flex justify-start mt-1.5">
|
||||
<DelegateLiveConsole streams={delegateStreams} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isBusy && <RotatingTips />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-3 left-1/2 -translate-x-1/2 flex items-center gap-1.5 px-3 py-1.5 bg-canvas border border-slate-200 rounded-full shadow-md text-xs text-slate-600 hover:bg-slate-50 transition-colors z-10"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
{newMessageCount > 0 ? (
|
||||
<span className="text-blue-600 font-medium">{t('pane.newMessages', { count: newMessageCount })}</span>
|
||||
) : (
|
||||
<span>{t('pane.toLatest')}</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 会話コンポーザーの LLM 選択セレクター(Phase 1 Task 14)。
|
||||
*
|
||||
* タスクの現在の実効設定(sticky worker + reasoning effort)を表示し、
|
||||
* クリックでポップオーバーを開いて変更できる。変更は即座に
|
||||
* `PATCH /api/local/tasks/:id` で永続化され(sticky)、既に実行中の
|
||||
* ジョブには影響しない — 次にディスパッチされるジョブから有効になる。
|
||||
*
|
||||
* 停滞バッジ: task.llmWorkerId が設定済みなのに、その id が
|
||||
* `/api/llm/workers` の enabled ワーカー一覧に見つからない場合
|
||||
* (無効化 or 設定から削除された)は、次のジョブが永久にそのワーカーの
|
||||
* 空きを待ち続けてしまう。警告色のバッジで知らせ、ポップオーバーから
|
||||
* 「自動」に戻せることを案内する。
|
||||
*/
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { LocalTask, fetchLlmWorkers, updateLocalTask } from '../../api';
|
||||
import { computeModelSelectorPosition, type ModelSelectorPosition } from './modelSelectorPosition';
|
||||
|
||||
interface LlmSelectionControlProps {
|
||||
task: LocalTask;
|
||||
/** true when the task currently has a running/dispatching/waiting_subtasks job. */
|
||||
busy?: boolean;
|
||||
}
|
||||
|
||||
export function LlmSelectionControl({ task, busy = false }: LlmSelectionControlProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const focusFrameRef = useRef<number | null>(null);
|
||||
const [panelPosition, setPanelPosition] = useState<ModelSelectorPosition | null>(null);
|
||||
|
||||
const workersQuery = useQuery({ queryKey: ['llm-workers'], queryFn: fetchLlmWorkers });
|
||||
const workers = workersQuery.data ?? [];
|
||||
const enabledWorkers = workers.filter(w => w.enabled !== false);
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: (sel: { llmWorkerId: string | null; llmEffort: string | null }) =>
|
||||
updateLocalTask(task.id, sel),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['localTask', task.id] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleMouseDown = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (!containerRef.current?.contains(target) && !panelRef.current?.contains(target)) setOpen(false);
|
||||
};
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') { setOpen(false); triggerRef.current?.focus(); }
|
||||
if (e.key === 'Tab' && panelRef.current) {
|
||||
const focusable = Array.from(panelRef.current.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), select:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
));
|
||||
if (focusable.length === 0) return;
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) {
|
||||
e.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
} else if (!e.shiftKey && document.activeElement === last) {
|
||||
e.preventDefault();
|
||||
const documentFocusable = Array.from(document.querySelectorAll<HTMLElement>(
|
||||
'button:not(:disabled), select:not(:disabled), input:not(:disabled), textarea:not(:disabled), [href], [tabindex]:not([tabindex="-1"])',
|
||||
)).filter(element => !panelRef.current?.contains(element));
|
||||
const triggerIndex = triggerRef.current ? documentFocusable.indexOf(triggerRef.current) : -1;
|
||||
const next = triggerIndex >= 0 ? documentFocusable[triggerIndex + 1] : null;
|
||||
setOpen(false);
|
||||
next?.focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleMouseDown);
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleMouseDown);
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const updatePanelPosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const panel = panelRef.current;
|
||||
if (!trigger || !panel) return;
|
||||
const visualViewport = window.visualViewport;
|
||||
setPanelPosition(computeModelSelectorPosition({
|
||||
trigger: trigger.getBoundingClientRect(),
|
||||
// Use the component's desired width instead of offsetWidth. During the
|
||||
// first hidden Portal render the inline measured width is not available
|
||||
// yet; feeding that zero back into state would permanently collapse it.
|
||||
panelWidth: 260,
|
||||
panelHeight: panel.scrollHeight,
|
||||
viewportWidth: visualViewport?.width ?? window.innerWidth,
|
||||
viewportHeight: visualViewport?.height ?? window.innerHeight,
|
||||
viewportLeft: visualViewport?.offsetLeft ?? 0,
|
||||
viewportTop: visualViewport?.offsetTop ?? 0,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!open) {
|
||||
setPanelPosition(null);
|
||||
return;
|
||||
}
|
||||
updatePanelPosition();
|
||||
focusFrameRef.current = requestAnimationFrame(() => {
|
||||
focusFrameRef.current = null;
|
||||
panelRef.current?.querySelector<HTMLElement>('select:not(:disabled), button:not(:disabled)')?.focus();
|
||||
});
|
||||
window.addEventListener('resize', updatePanelPosition);
|
||||
window.addEventListener('scroll', updatePanelPosition, true);
|
||||
window.visualViewport?.addEventListener('resize', updatePanelPosition);
|
||||
window.visualViewport?.addEventListener('scroll', updatePanelPosition);
|
||||
const resizeObserver = typeof ResizeObserver === 'undefined'
|
||||
? null
|
||||
: new ResizeObserver(updatePanelPosition);
|
||||
if (panelRef.current) resizeObserver?.observe(panelRef.current);
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePanelPosition);
|
||||
window.removeEventListener('scroll', updatePanelPosition, true);
|
||||
window.visualViewport?.removeEventListener('resize', updatePanelPosition);
|
||||
window.visualViewport?.removeEventListener('scroll', updatePanelPosition);
|
||||
resizeObserver?.disconnect();
|
||||
if (focusFrameRef.current !== null) cancelAnimationFrame(focusFrameRef.current);
|
||||
};
|
||||
}, [open, updatePanelPosition]);
|
||||
|
||||
const pinnedWorkerId = task.llmWorkerId ?? null;
|
||||
const pinnedWorker = pinnedWorkerId ? workers.find(w => w.id === pinnedWorkerId) : undefined;
|
||||
// ワーカー一覧の取得が終わっている(isSuccess)のに enabled 一覧に見当たらない
|
||||
// = 無効化 or 設定から削除済み。ロード中は誤検知を避けるため判定しない。
|
||||
const stalled = !!pinnedWorkerId && workersQuery.isSuccess && !enabledWorkers.some(w => w.id === pinnedWorkerId);
|
||||
|
||||
const triggerLabel = !pinnedWorkerId
|
||||
? t('llmSelect.auto')
|
||||
: `${pinnedWorker?.model ?? pinnedWorkerId} @ ${pinnedWorkerId}${task.llmEffort ? ` · ${task.llmEffort}` : ''}`;
|
||||
|
||||
const handleWorkerChange = (value: string) => {
|
||||
// 切替時は必ず effort もリセット(新ワーカーが旧 effort に非対応だと API が 400 を返すため)。
|
||||
mut.mutate({ llmWorkerId: value || null, llmEffort: null });
|
||||
};
|
||||
const handleEffortChange = (value: string) => {
|
||||
mut.mutate({ llmWorkerId: pinnedWorkerId, llmEffort: value || null });
|
||||
};
|
||||
const clearToAuto = () => {
|
||||
// 停滞解除: llmWorkerId/llmEffort を必ず両方 null で送る(片方だけ null だと
|
||||
// 「effort はワーカー指定とセットのみ」400 になり得るため)。
|
||||
mut.mutate({ llmWorkerId: null, llmEffort: null });
|
||||
};
|
||||
const initialPanelWidth = typeof window === 'undefined'
|
||||
? 260
|
||||
: Math.max(0, Math.min(260, (window.visualViewport?.width ?? window.innerWidth) - 24));
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative flex flex-shrink-0 items-center gap-1.5">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
data-testid="llm-select-trigger"
|
||||
onClick={() => setOpen(v => !v)}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-controls={open ? 'llm-select-popover' : undefined}
|
||||
title={t('llmSelect.title')}
|
||||
aria-label={t('llmSelect.title')}
|
||||
className={`inline-flex h-7 max-w-[220px] items-center gap-1 rounded-md border px-2 text-2xs font-medium transition-colors ${
|
||||
stalled
|
||||
? 'border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-500/40 dark:bg-amber-500/15 dark:text-amber-300'
|
||||
: open
|
||||
? 'border-accent bg-accent-soft text-accent'
|
||||
: 'border-hairline bg-canvas text-slate-600 hover:bg-surface-2 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<svg className="h-3 w-3 flex-shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M12 2a4.5 4.5 0 0 0-4.5 4.5c0 1.6.8 2.98 2 3.82V13a2.5 2.5 0 0 0 5 0v-2.68a4.48 4.48 0 0 0 2-3.82A4.5 4.5 0 0 0 12 2Z" />
|
||||
<path d="M9.5 17.5h5M10 21h4" />
|
||||
</svg>
|
||||
<span className="truncate">{triggerLabel}</span>
|
||||
</button>
|
||||
{stalled && (
|
||||
<span
|
||||
data-testid="llm-stalled-badge"
|
||||
title={t('llmSelect.stalledWorker', { id: pinnedWorkerId })}
|
||||
className="inline-flex items-center gap-1 rounded border border-amber-200 bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300"
|
||||
>
|
||||
{t('llmSelect.stalledWorker', { id: pinnedWorkerId })}
|
||||
</span>
|
||||
)}
|
||||
{open && createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
id="llm-select-popover"
|
||||
data-testid="llm-select-popover"
|
||||
data-placement={panelPosition?.placement ?? 'top'}
|
||||
style={{
|
||||
left: panelPosition?.left ?? 0,
|
||||
top: panelPosition?.top ?? 0,
|
||||
width: panelPosition?.width ?? initialPanelWidth,
|
||||
maxHeight: panelPosition?.maxHeight,
|
||||
visibility: panelPosition ? 'visible' : 'hidden',
|
||||
}}
|
||||
role="dialog"
|
||||
aria-label={t('llmSelect.title')}
|
||||
className="fixed z-[80] flex min-w-0 flex-col gap-2 overflow-y-auto rounded-md border border-hairline bg-canvas p-2.5 shadow-xl"
|
||||
>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-slate-500">{t('llmSelect.workerLabel')}</label>
|
||||
<select
|
||||
data-testid="llm-select-worker"
|
||||
value={pinnedWorkerId ?? ''}
|
||||
onChange={e => handleWorkerChange(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-200 px-2 py-1.5 text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">{t('llmSelect.autoOption')}</option>
|
||||
{enabledWorkers.map(w => (
|
||||
<option key={w.id} value={w.id}>{`${w.model} @ ${w.id}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-2xs text-slate-500">{t('llmSelect.effortLabel')}</label>
|
||||
<select
|
||||
data-testid="llm-select-effort"
|
||||
value={task.llmEffort ?? ''}
|
||||
// 停滞(ゴースト id)の場合は effort をいじれないようにする。
|
||||
// effort 変更は現在のワーカー id を保ったまま PATCH するため、
|
||||
// 存在しないワーカー id で送ると server が 400 を返す。復旧は
|
||||
// 「自動に戻す」または別の有効なワーカー選択のみに限定する。
|
||||
disabled={!pinnedWorkerId || stalled}
|
||||
onChange={e => handleEffortChange(e.target.value)}
|
||||
className="w-full rounded-md border border-slate-200 px-2 py-1.5 text-xs outline-none focus:border-accent disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<option value="">{t('llmSelect.effortNone')}</option>
|
||||
{(enabledWorkers.find(w => w.id === pinnedWorkerId)?.reasoningEfforts ?? []).map(effort => (
|
||||
<option key={effort} value={effort}>{effort}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{stalled && (
|
||||
<div className="flex items-start justify-between gap-2 rounded border border-amber-200 bg-amber-50 p-1.5 text-2xs text-amber-800 dark:border-amber-500/30 dark:bg-amber-500/15 dark:text-amber-300">
|
||||
<span>{t('llmSelect.stalledWorker', { id: pinnedWorkerId })}</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="llm-select-clear-stalled"
|
||||
onClick={clearToAuto}
|
||||
className="flex-shrink-0 font-semibold underline hover:no-underline"
|
||||
>
|
||||
{t('llmSelect.autoOption')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{busy && (
|
||||
<p data-testid="llm-applies-next-job" className="border-t border-hairline pt-1.5 text-[10px] text-slate-400">
|
||||
{t('llmSelect.appliesNextJob')}
|
||||
</p>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LocalTaskComment } from '../../api';
|
||||
import { LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||
import { ChatMessage } from './ChatMessage';
|
||||
import { isThinkingComment } from './thinkingUtils';
|
||||
import { MarkdownText } from '../../lib/markdown-text';
|
||||
import { ToolCallsSection, parseToolCallComment, type ToolCallData } from './ToolCallsSection';
|
||||
import { parseJobRetry } from './movementHistory';
|
||||
|
||||
interface ProgressData {
|
||||
movement: string;
|
||||
@@ -43,6 +44,7 @@ function getThinkingText(c: LocalTaskComment): string | null {
|
||||
|
||||
export type ChatItem =
|
||||
| { type: 'comment'; comment: LocalTaskComment }
|
||||
| { type: 'diagnostic'; comment: LocalTaskComment }
|
||||
| { type: 'movement'; movementName: string; summary: ProgressData; inner: LocalTaskComment[]; completionComment: LocalTaskComment };
|
||||
|
||||
export function groupCommentsByMovement(comments: LocalTaskComment[]): ChatItem[] {
|
||||
@@ -53,7 +55,13 @@ export function groupCommentsByMovement(comments: LocalTaskComment[]): ChatItem[
|
||||
c.kind === 'request' || c.kind === 'comment' || c.kind === 'interjection';
|
||||
|
||||
for (const c of comments) {
|
||||
if (isMovementCompleteComment(c)) {
|
||||
if (c.kind === 'progress' && c.author === 'system' && parseJobRetry(c.body)) {
|
||||
if (pendingInner.length > 0) {
|
||||
for (const p of pendingInner) items.push({ type: 'comment', comment: p });
|
||||
pendingInner = [];
|
||||
}
|
||||
items.push({ type: 'diagnostic', comment: c });
|
||||
} else if (isMovementCompleteComment(c)) {
|
||||
const summary = tryParseMovementComplete(c.body)!;
|
||||
items.push({
|
||||
type: 'movement',
|
||||
@@ -114,16 +122,18 @@ interface MovementGroupExpandedProps {
|
||||
imageBaseUrl?: string;
|
||||
/** 共有ビューで添付チップ(input/ 配下・共有 API 非配信)を隠す。ChatMessage に委譲。 */
|
||||
hideAttachments?: boolean;
|
||||
historyEvents?: MovementHistoryEvent[];
|
||||
}
|
||||
|
||||
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, imageBaseUrl, hideAttachments }: MovementGroupExpandedProps) {
|
||||
export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, imageBaseUrl, hideAttachments, historyEvents = [] }: MovementGroupExpandedProps) {
|
||||
const { t } = useTranslation('chat');
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const { movementName, summary, inner } = item;
|
||||
const previewText = getPreviewText(item);
|
||||
const retryEvents = historyEvents;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div id={`movement-completion-${item.completionComment.id}`} tabIndex={-1} className="flex flex-col rounded outline-none transition-[background-color,box-shadow]">
|
||||
{/* Header — always visible */}
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
@@ -154,6 +164,17 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, im
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{retryEvents.map(event => (
|
||||
<div
|
||||
key={event.eventId}
|
||||
id={`trace-event-${event.eventId}`}
|
||||
tabIndex={-1}
|
||||
className="ml-5 rounded border border-amber-200/70 bg-amber-50/70 px-2 py-1 text-[11px] text-amber-800 outline-none dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-200"
|
||||
>
|
||||
{t('movementMap.llmRetry', { attempt: event.payload.attempt ?? '?', max: event.payload.maxAttempts ?? '?' })}
|
||||
{event.payload.errorClass ? ` · ${event.payload.errorClass}` : ''}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Expanded: render inner comments in chronological order. Consecutive
|
||||
tool_call comments are merged into one ToolCallsSection so the
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import type { LocalTask, MovementHistoryEvent } from '../../api';
|
||||
import { MovementMap, summarizeMovementInstruction } from './MovementMap';
|
||||
|
||||
const longInstruction = `Build the approved change with the smallest possible UI. ${'Keep the original behavior and verify accessibility. '.repeat(6)}`;
|
||||
const activePetMock = vi.hoisted(() => ({ data: null as any }));
|
||||
const pieceMock = vi.hoisted(() => ({ movements: [
|
||||
{ name: 'investigate', instruction: 'Inspect the current behavior.' },
|
||||
{ name: 'implement', instruction: '' },
|
||||
] as Array<{ name: string; instruction: string }> }));
|
||||
pieceMock.movements[1]!.instruction = longInstruction;
|
||||
|
||||
vi.mock('../../hooks/usePieces', () => ({
|
||||
usePiece: () => ({ data: { piece: { movements: pieceMock.movements } } }),
|
||||
}));
|
||||
vi.mock('../../hooks/useActivePet', () => ({ useActivePet: () => ({ data: activePetMock.data }) }));
|
||||
|
||||
function task(status: string, currentMovement: string): LocalTask {
|
||||
return {
|
||||
id: 8,
|
||||
title: 'UI work',
|
||||
body: 'Improve UI',
|
||||
pieceName: 'build',
|
||||
profile: 'auto',
|
||||
outputFormat: 'markdown',
|
||||
askPolicy: 'low',
|
||||
priority: 'medium',
|
||||
state: status,
|
||||
workspacePath: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
latestJob: { id: 'j8', status, currentMovement },
|
||||
};
|
||||
}
|
||||
|
||||
describe('MovementMap', () => {
|
||||
beforeEach(() => {
|
||||
activePetMock.data = null;
|
||||
pieceMock.movements = [
|
||||
{ name: 'investigate', instruction: 'Inspect the current behavior.' },
|
||||
{ name: 'implement', instruction: longInstruction },
|
||||
];
|
||||
});
|
||||
it('renders as a compact overlay rail without showing the instruction on focus', () => {
|
||||
render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||
const rail = screen.getByTestId('movement-rail');
|
||||
expect(rail).toHaveClass('absolute', 'right-1.5');
|
||||
const trigger = screen.getByRole('button', { name: /implement/ });
|
||||
fireEvent.focus(trigger);
|
||||
expect(screen.queryByText(longInstruction)).not.toBeInTheDocument();
|
||||
expect(screen.getByText('implement')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a short purpose, manages focus, and expands the full instruction explicitly', async () => {
|
||||
render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||
const trigger = screen.getByRole('button', { name: /implement/ });
|
||||
fireEvent.click(trigger);
|
||||
const details = screen.getByRole('region', { name: 'implement' });
|
||||
const closeButton = screen.getByRole('button', { name: /movementMap\.closeInstruction|Close movement|詳細を閉じる/ });
|
||||
await waitFor(() => expect(closeButton).toHaveFocus());
|
||||
expect(details).toHaveTextContent('Build the approved change with the smallest possible UI.');
|
||||
expect(details).not.toHaveTextContent(longInstruction);
|
||||
fireEvent.click(screen.getByRole('button', { name: /movementMap\.showFullInstruction|Show full instruction|全文を見る/ }));
|
||||
expect(details.querySelector('p')?.textContent).toBe(longInstruction.trim());
|
||||
fireEvent.keyDown(window, { key: 'Escape' });
|
||||
expect(screen.queryByRole('region', { name: 'implement' })).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
|
||||
fireEvent.click(trigger);
|
||||
const reopenedClose = await screen.findByRole('button', { name: /movementMap\.closeInstruction|Close movement|詳細を閉じる/ });
|
||||
fireEvent.click(reopenedClose);
|
||||
await waitFor(() => expect(trigger).toHaveFocus());
|
||||
});
|
||||
|
||||
it('shows the instruction for a completed history point and offers navigation', () => {
|
||||
const historyEvents: MovementHistoryEvent[] = [{
|
||||
eventId: 'start-1', ts: '2026-07-12T00:00:00.000Z', seq: 1, line: 1, runId: 'run-1',
|
||||
kind: 'movement_start', movement: 'investigate', iteration: 0, payload: {},
|
||||
}, {
|
||||
eventId: 'complete-1', ts: '2026-07-12T00:00:01.000Z', seq: 2, line: 2, runId: 'run-1',
|
||||
kind: 'movement_complete', movement: 'investigate', iteration: 0, payload: {},
|
||||
}];
|
||||
const comments = [{
|
||||
id: 41, taskId: 8, author: 'agent', kind: 'progress' as const,
|
||||
body: JSON.stringify({ movement: 'investigate', durationMs: 1000, tools: {} }),
|
||||
createdAt: '2026-07-12T00:00:01.000Z', injectedAt: null,
|
||||
}];
|
||||
render(<MovementMap task={task('succeeded', 'investigate')} comments={comments} historyEvents={historyEvents} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'investigate' }));
|
||||
expect(screen.getByRole('region', { name: 'investigate' })).toHaveTextContent('Inspect the current behavior.');
|
||||
expect(screen.getByRole('button', { name: /movementMap\.goToMovement|Go to this movement|このMovementへ移動/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('tracks expanded state independently for repeated history movements', () => {
|
||||
const historyEvents: MovementHistoryEvent[] = [
|
||||
{ eventId: 's1', ts: '2026-07-12T00:00:00.000Z', seq: 1, line: 1, runId: 'r1', kind: 'movement_start', movement: 'investigate', iteration: 0, payload: {} },
|
||||
{ eventId: 'c1', ts: '2026-07-12T00:00:01.000Z', seq: 2, line: 2, runId: 'r1', kind: 'movement_complete', movement: 'investigate', iteration: 0, payload: {} },
|
||||
{ eventId: 's2', ts: '2026-07-12T00:00:02.000Z', seq: 3, line: 3, runId: 'r1', kind: 'movement_start', movement: 'investigate', iteration: 1, payload: {} },
|
||||
{ eventId: 'c2', ts: '2026-07-12T00:00:03.000Z', seq: 4, line: 4, runId: 'r1', kind: 'movement_complete', movement: 'investigate', iteration: 1, payload: {} },
|
||||
];
|
||||
const comments = [41, 42].map((id, index) => ({
|
||||
id, taskId: 8, author: 'agent', kind: 'progress' as const,
|
||||
body: JSON.stringify({ movement: 'investigate', durationMs: 1000, tools: {} }),
|
||||
createdAt: `2026-07-12T00:00:0${index * 2 + 1}.000Z`, injectedAt: null,
|
||||
}));
|
||||
render(<MovementMap task={task('succeeded', 'investigate')} comments={comments} historyEvents={historyEvents} />);
|
||||
const triggers = screen.getAllByRole('button', { name: 'investigate' });
|
||||
fireEvent.click(triggers[0]!);
|
||||
expect(triggers[0]).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(triggers[1]).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
|
||||
it('distinguishes a failed movement from pending movements', () => {
|
||||
render(<MovementMap task={task('failed', 'implement')} comments={[]} />);
|
||||
expect(screen.getByLabelText(/Failed|失敗/)).toBeInTheDocument();
|
||||
expect(screen.getAllByLabelText(/Pending|待機中/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('summarizes deterministically without inventing text', () => {
|
||||
expect(summarizeMovementInstruction(' First sentence.\nSecond sentence. ')).toBe('First sentence.');
|
||||
expect(summarizeMovementInstruction('目的を確認する。次に実装する。')).toBe('目的を確認する。');
|
||||
expect(summarizeMovementInstruction('abcdef', 4)).toBe('abc…');
|
||||
});
|
||||
|
||||
it('closes details when the task changes', () => {
|
||||
const rendered = render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: /implement/ }));
|
||||
expect(screen.getByRole('region', { name: 'implement' })).toBeInTheDocument();
|
||||
rendered.rerender(<MovementMap task={{ ...task('running', 'implement'), id: 9 }} comments={[]} />);
|
||||
expect(screen.queryByRole('region', { name: 'implement' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('places the active Pet in a separate lane beside the dot', () => {
|
||||
activePetMock.data = {
|
||||
pet: { name: 'Test pet' }, imageUrl: null, frameWidth: null, frameHeight: null,
|
||||
gridCols: null, gridRows: null, settings: { enabled: true, reducedMotion: true },
|
||||
};
|
||||
render(<MovementMap task={task('running', 'implement')} comments={[]} />);
|
||||
expect(screen.getByTestId('movement-active-pet')).toHaveClass('right-full');
|
||||
});
|
||||
|
||||
it('keeps a long custom Piece reachable with a scrollable rail', () => {
|
||||
pieceMock.movements = Array.from({ length: 20 }, (_, index) => ({ name: `step-${index + 1}`, instruction: `Do step ${index + 1}.` }));
|
||||
render(<MovementMap task={task('running', 'step-10')} comments={[]} />);
|
||||
expect(screen.getByTestId('movement-rail')).toHaveClass('max-h-[calc(100%-1rem)]', 'overflow-y-auto');
|
||||
expect(screen.getByRole('button', { name: /step-20/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { LocalTask, LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||
import { usePiece } from '../../hooks/usePieces';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { PetSprite } from '../pets/PetSprite';
|
||||
import { groupCommentsByMovement } from './MovementGroup';
|
||||
import { buildMovementRailItems, type MovementRailItem } from './movementHistory';
|
||||
|
||||
interface MovementDefinition {
|
||||
name?: string;
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
type MovementStatus = 'completed' | 'active' | 'pending' | 'failed' | 'cancelled';
|
||||
|
||||
const formatDuration = (ms: number): string => {
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
const seconds = Math.round(ms / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||
};
|
||||
|
||||
export const summarizeMovementInstruction = (instruction: string, maxLength = 160): string => {
|
||||
const normalized = instruction.replace(/\s+/g, ' ').trim();
|
||||
const firstSentence = normalized.match(/^.*?(?:[。!?]|[.!?](?=\s|$)|$)/)?.[0] ?? normalized;
|
||||
if (firstSentence.length <= maxLength) return firstSentence;
|
||||
return `${firstSentence.slice(0, Math.max(1, maxLength - 1)).trimEnd()}…`;
|
||||
};
|
||||
|
||||
const dotTone: Record<MovementStatus, string> = {
|
||||
completed: 'border-emerald-500/70 bg-emerald-500/70',
|
||||
active: 'border-blue-500 bg-blue-500 ring-2 ring-blue-300/50',
|
||||
pending: 'border-slate-400/60 bg-canvas/80',
|
||||
failed: 'border-red-500 bg-red-500',
|
||||
cancelled: 'border-slate-500 bg-slate-400',
|
||||
};
|
||||
|
||||
export function MovementMap({ task, comments, historyEvents = [] }: { task: LocalTask; comments: LocalTaskComment[]; historyEvents?: MovementHistoryEvent[] }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const pieceQuery = usePiece(task.pieceName, undefined, task.spaceId ?? undefined);
|
||||
const { data: activePet } = useActivePet(task.latestJob?.workerId, task.latestJob?.lastBackendId);
|
||||
const [hoveredName, setHoveredName] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<{ key: string; name: string; body: string; trigger: HTMLButtonElement; targetId?: string } | null>(null);
|
||||
const [showFull, setShowFull] = useState(false);
|
||||
const detailCloseRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const closeDetails = (restoreFocus: boolean) => {
|
||||
const trigger = selected?.trigger;
|
||||
setSelected(null);
|
||||
setShowFull(false);
|
||||
if (restoreFocus) window.requestAnimationFrame(() => trigger?.focus());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(null);
|
||||
setShowFull(false);
|
||||
}, [task.id, task.pieceName]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selected) window.requestAnimationFrame(() => detailCloseRef.current?.focus());
|
||||
}, [selected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selected) return;
|
||||
const close = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeDetails(true);
|
||||
}
|
||||
};
|
||||
const dismiss = (event: PointerEvent) => {
|
||||
const target = event.target as Element | null;
|
||||
if (target?.closest('[data-movement-trigger], [data-movement-detail]')) return;
|
||||
closeDetails(false);
|
||||
};
|
||||
window.addEventListener('keydown', close);
|
||||
window.addEventListener('pointerdown', dismiss);
|
||||
return () => {
|
||||
window.removeEventListener('keydown', close);
|
||||
window.removeEventListener('pointerdown', dismiss);
|
||||
};
|
||||
}, [selected]);
|
||||
|
||||
const completed = useMemo(() => {
|
||||
const result = new Map<string, number>();
|
||||
for (const item of groupCommentsByMovement(comments)) {
|
||||
if (item.type === 'movement') result.set(item.movementName, item.summary.durationMs);
|
||||
}
|
||||
return result;
|
||||
}, [comments]);
|
||||
|
||||
const definitions = (pieceQuery.data?.piece.movements ?? []) as MovementDefinition[];
|
||||
const definitionsByName = useMemo(
|
||||
() => new Map(definitions.filter(item => item.name).map(item => [item.name!, item])),
|
||||
[definitions],
|
||||
);
|
||||
const movements = definitions.length > 0 ? [...definitions, { name: 'COMPLETE' }] : [];
|
||||
const historyItems = useMemo(
|
||||
() => buildMovementRailItems(historyEvents, comments, movements.map(item => item.name ?? '')),
|
||||
[historyEvents, comments, movements.map(item => item.name).join('\u0000')],
|
||||
);
|
||||
const executedNames = useMemo(
|
||||
() => new Set(historyItems.filter(item => item.type === 'movement').map(item => item.name)),
|
||||
[historyItems],
|
||||
);
|
||||
const plannedMovements = historyEvents.length > 0
|
||||
? movements.filter(item => !executedNames.has(item.name ?? ''))
|
||||
: movements;
|
||||
if (movements.length === 0) return null;
|
||||
|
||||
const current = task.latestJob?.currentMovement;
|
||||
const jobStatus = task.latestJob?.status;
|
||||
const terminal = ['succeeded', 'failed', 'cancelled'].includes(jobStatus ?? '');
|
||||
|
||||
const navigateTo = (targetId: string) => {
|
||||
const target = document.getElementById(targetId);
|
||||
if (!target) return;
|
||||
const reducedMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
target.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'center' });
|
||||
target.focus({ preventScroll: true });
|
||||
target.classList.add('ring-2', 'ring-accent-ring', 'bg-accent/10');
|
||||
window.setTimeout(() => target.classList.remove('ring-2', 'ring-accent-ring', 'bg-accent/10'), 2200);
|
||||
};
|
||||
|
||||
const iconFor = (item: MovementRailItem): string => {
|
||||
if (item.type === 'return') return '↩';
|
||||
if (item.type === 'llm_retry') return '↻';
|
||||
if (item.type === 'job_retry') return '⇄';
|
||||
if (item.type === 'user_input') return '●';
|
||||
return '';
|
||||
};
|
||||
|
||||
const toneFor = (item: MovementRailItem): string => {
|
||||
if (item.type === 'return') return 'border-orange-500 bg-orange-100 text-orange-700';
|
||||
if (item.type === 'llm_retry') return 'border-amber-500 bg-amber-100 text-amber-700';
|
||||
if (item.type === 'job_retry') return 'border-orange-600 bg-orange-100 text-orange-800';
|
||||
if (item.type === 'user_input') return 'border-blue-500 bg-blue-100 text-blue-700';
|
||||
return item.status === 'completed' ? dotTone.completed : dotTone.active;
|
||||
};
|
||||
|
||||
const statusFor = (name: string, duration?: number): MovementStatus => {
|
||||
if (jobStatus === 'succeeded' && name === 'COMPLETE') return 'completed';
|
||||
if ((jobStatus === 'failed' || jobStatus === 'cancelled') && name === current) return jobStatus;
|
||||
if (!terminal && name === current) return 'active';
|
||||
if (duration != null) return 'completed';
|
||||
return 'pending';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside
|
||||
className="pointer-events-none absolute right-1.5 top-1/2 z-20 max-h-[calc(100%-1rem)] -translate-y-1/2 overflow-y-auto [scrollbar-width:thin]"
|
||||
aria-label={t('movementMap.label')}
|
||||
data-testid="movement-rail"
|
||||
>
|
||||
<ol className="pointer-events-auto flex flex-col items-center py-1">
|
||||
{historyItems.map((item) => {
|
||||
const hovered = hoveredName === item.id;
|
||||
const label = item.type === 'movement' ? item.name : `${item.name}: ${item.type.replace('_', ' ')}`;
|
||||
const definition = item.type === 'movement' ? definitionsByName.get(item.name) : undefined;
|
||||
const instruction = definition?.instruction?.trim();
|
||||
return (
|
||||
<li key={item.id} className="relative flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
data-movement-trigger
|
||||
aria-label={label}
|
||||
className="group relative grid h-5 w-5 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
onMouseEnter={() => setHoveredName(item.id)}
|
||||
onMouseLeave={() => setHoveredName(null)}
|
||||
onFocus={() => setHoveredName(item.id)}
|
||||
onBlur={() => setHoveredName(null)}
|
||||
aria-expanded={instruction ? selected?.key === item.id : undefined}
|
||||
aria-controls={instruction ? 'movement-detail-popover' : undefined}
|
||||
onClick={event => {
|
||||
if (!instruction) {
|
||||
if (item.targetId) navigateTo(item.targetId);
|
||||
return;
|
||||
}
|
||||
if (selected?.key === item.id) {
|
||||
closeDetails(true);
|
||||
return;
|
||||
}
|
||||
setSelected({ key: item.id, name: item.name, body: instruction, trigger: event.currentTarget, targetId: item.targetId });
|
||||
setShowFull(false);
|
||||
}}
|
||||
>
|
||||
<span className={`grid h-3 w-3 place-items-center rounded-full border text-[8px] font-bold leading-none ${toneFor(item)}`} aria-hidden="true">
|
||||
{iconFor(item)}
|
||||
</span>
|
||||
{hovered && (
|
||||
<span className="absolute right-full mr-1.5 whitespace-nowrap rounded-md bg-slate-900/80 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm backdrop-blur-sm">
|
||||
{label}{'detail' in item && item.detail ? <span className="ml-1 text-white/65">{item.detail}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<span className="h-1.5 w-px bg-slate-400/25" aria-hidden="true" />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{plannedMovements.map((movement, index) => {
|
||||
const name = movement.name || t('movementMap.unnamed');
|
||||
const duration = completed.get(name);
|
||||
const status = statusFor(name, duration);
|
||||
const active = status === 'active';
|
||||
const hovered = hoveredName === name;
|
||||
const hasInstruction = !!movement.instruction?.trim();
|
||||
const selectionKey = `planned-${name}-${index}`;
|
||||
return (
|
||||
<li key={`${name}-${index}`} className="relative flex flex-col items-center">
|
||||
<button
|
||||
type="button"
|
||||
data-movement-trigger
|
||||
aria-label={`${name}: ${t(`movementMap.${status}`)}`}
|
||||
aria-expanded={hasInstruction ? selected?.key === selectionKey : undefined}
|
||||
aria-controls={hasInstruction ? 'movement-detail-popover' : undefined}
|
||||
className="group relative grid h-6 w-6 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
|
||||
onMouseEnter={() => setHoveredName(name)}
|
||||
onMouseLeave={() => setHoveredName(null)}
|
||||
onFocus={() => setHoveredName(name)}
|
||||
onBlur={() => setHoveredName(null)}
|
||||
onClick={event => {
|
||||
if (!hasInstruction) return;
|
||||
if (selected?.key === selectionKey) {
|
||||
closeDetails(true);
|
||||
return;
|
||||
}
|
||||
setSelected({ key: selectionKey, name, body: movement.instruction!.trim(), trigger: event.currentTarget });
|
||||
setShowFull(false);
|
||||
}}
|
||||
>
|
||||
<span className={`h-2 w-2 rounded-full border ${dotTone[status]}`} aria-hidden="true" />
|
||||
{active && activePet?.settings.enabled && (
|
||||
<span data-testid="movement-active-pet" className="absolute right-full top-1/2 mr-0.5 -translate-y-1/2">
|
||||
<PetSprite
|
||||
name={activePet.pet?.name ?? 'Pet'}
|
||||
imageUrl={activePet.imageUrl}
|
||||
frameWidth={activePet.frameWidth}
|
||||
frameHeight={activePet.frameHeight}
|
||||
gridCols={activePet.gridCols}
|
||||
gridRows={activePet.gridRows}
|
||||
framesPerRow={null}
|
||||
state="running"
|
||||
size={18}
|
||||
reducedMotion={activePet.settings.reducedMotion}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{hovered && (
|
||||
<span className={`absolute right-full whitespace-nowrap rounded-md bg-slate-900/75 px-1.5 py-0.5 text-[10px] font-medium text-white shadow-sm backdrop-blur-sm ${activePet?.settings.enabled && active ? 'mr-6' : 'mr-1.5'}`}>
|
||||
<span className="inline-block max-w-28 truncate align-bottom">{name}</span>
|
||||
{hovered && <span className="ml-1 text-white/65">{duration != null ? formatDuration(duration) : t(`movementMap.${status}`)}</span>}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{index < plannedMovements.length - 1 && (
|
||||
<span className={`h-2.5 w-px ${status === 'completed' ? 'bg-emerald-400/45' : 'bg-slate-400/25'}`} aria-hidden="true" />
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
{selected && createPortal(
|
||||
<div
|
||||
data-movement-detail
|
||||
id="movement-detail-popover"
|
||||
className="fixed right-10 top-1/2 z-[70] max-h-[calc(100dvh-2rem)] w-[min(18rem,calc(100vw-4rem))] -translate-y-1/2 overflow-y-auto rounded-lg border border-white/40 bg-canvas/90 p-3 text-xs text-slate-700 shadow-xl backdrop-blur-md dark:border-slate-700/60 dark:text-slate-200"
|
||||
role="region"
|
||||
aria-label={selected.name}
|
||||
>
|
||||
<div className="mb-1 flex items-start justify-between gap-2">
|
||||
<strong className="truncate">{selected.name}</strong>
|
||||
<button ref={detailCloseRef} type="button" onClick={() => closeDetails(true)} className="grid h-6 w-6 place-items-center rounded text-slate-400 hover:bg-surface hover:text-slate-700" aria-label={t('movementMap.closeInstruction')}>×</button>
|
||||
</div>
|
||||
<p className={showFull ? 'max-h-60 overflow-y-auto whitespace-pre-wrap leading-relaxed' : 'line-clamp-2 leading-relaxed'}>
|
||||
{showFull ? selected.body : summarizeMovementInstruction(selected.body)}
|
||||
</p>
|
||||
{selected.body !== summarizeMovementInstruction(selected.body) && (
|
||||
<button type="button" onClick={() => setShowFull(value => !value)} className="mt-2 text-2xs font-semibold text-accent hover:underline">
|
||||
{showFull ? t('movementMap.collapseInstruction') : t('movementMap.showFullInstruction')}
|
||||
</button>
|
||||
)}
|
||||
{selected.targetId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const targetId = selected.targetId;
|
||||
closeDetails(false);
|
||||
if (targetId) window.requestAnimationFrame(() => navigateTo(targetId));
|
||||
}}
|
||||
className="mt-2 ml-3 text-2xs font-semibold text-accent hover:underline"
|
||||
>
|
||||
{t('movementMap.goToMovement')}
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import i18n from '../../i18n';
|
||||
import { ScrollToLatestButton } from './ScrollToLatestButton';
|
||||
|
||||
describe('ScrollToLatestButton', () => {
|
||||
it('stays in the centered message column and only the button accepts clicks', async () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ScrollToLatestButton newMessageCount={0} onClick={onClick} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
const positioner = screen.getByTestId('scroll-to-latest-positioner');
|
||||
expect(positioner).toHaveClass('absolute', 'inset-x-24', 'bottom-3', 'pointer-events-none', 'z-30');
|
||||
expect(positioner).not.toHaveClass('left-3', 'right-24');
|
||||
expect(positioner.firstElementChild).toHaveClass('mx-auto', 'max-w-3xl', 'justify-center');
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('pointer-events-auto', 'min-w-0', 'max-w-full');
|
||||
await userEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('shows the new-message count without changing its positioner', () => {
|
||||
render(
|
||||
<I18nextProvider i18n={i18n}>
|
||||
<ScrollToLatestButton newMessageCount={3} onClick={() => {}} />
|
||||
</I18nextProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('scroll-to-latest-positioner')).toHaveClass('absolute', 'inset-x-24', 'bottom-3');
|
||||
expect(screen.getByText(/3/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ScrollToLatestButton({
|
||||
newMessageCount,
|
||||
onClick,
|
||||
}: {
|
||||
newMessageCount: number;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('chat');
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="scroll-to-latest-positioner"
|
||||
className="pointer-events-none absolute inset-x-24 bottom-3 z-30"
|
||||
>
|
||||
<div className="mx-auto flex max-w-3xl justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className="pointer-events-auto flex min-w-0 max-w-full items-center gap-1.5 rounded-full border border-slate-200 bg-canvas px-3 py-1.5 text-xs text-slate-600 shadow-md transition-colors hover:bg-slate-50"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
{newMessageCount > 0 ? (
|
||||
<span className="min-w-0 truncate font-medium text-blue-600">{t('pane.newMessages', { count: newMessageCount })}</span>
|
||||
) : (
|
||||
<span className="min-w-0 truncate">{t('pane.toLatest')}</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { WaterContextGauge, getWaterTone } from './WaterContextGauge';
|
||||
|
||||
describe('WaterContextGauge', () => {
|
||||
it('uses the four context pressure tones', () => {
|
||||
expect(getWaterTone(0.2)).toBe('water-context--low');
|
||||
expect(getWaterTone(0.5)).toBe('water-context--mid');
|
||||
expect(getWaterTone(0.7)).toBe('water-context--high');
|
||||
expect(getWaterTone(0.9)).toBe('water-context--critical');
|
||||
});
|
||||
|
||||
it('fills to the clamped token percentage', () => {
|
||||
const { container } = render(<WaterContextGauge variant="fill" promptTokens={750} limitTokens={1000} />);
|
||||
const fill = screen.getByTestId('water-context-fill');
|
||||
expect(fill).toHaveStyle({ '--water-level': '75%' });
|
||||
expect(fill).toHaveClass('water-context');
|
||||
expect(container.querySelector('.water-context__ambient-edge')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.water-context__meter')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.water-context__wave')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.water-context__bubbles')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the exact token count in label mode', () => {
|
||||
render(<WaterContextGauge variant="label" promptTokens={234} limitTokens={1000} />);
|
||||
expect(screen.getByText('234 / 1,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render water before the first token measurement', () => {
|
||||
const { rerender } = render(<WaterContextGauge variant="fill" limitTokens={1000} />);
|
||||
expect(screen.queryByTestId('water-context-fill')).not.toBeInTheDocument();
|
||||
rerender(<WaterContextGauge variant="label" limitTokens={1000} />);
|
||||
expect(screen.getByText(/context\.awaiting|Awaiting first LLM call/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WaterContextGaugeProps {
|
||||
promptTokens?: number | null;
|
||||
limitTokens?: number | null;
|
||||
variant: 'fill' | 'label';
|
||||
}
|
||||
|
||||
function formatNumber(value: number): string {
|
||||
return value.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function getWaterTone(ratio: number): string {
|
||||
if (ratio >= 0.9) return 'water-context--critical';
|
||||
if (ratio >= 0.7) return 'water-context--high';
|
||||
if (ratio >= 0.5) return 'water-context--mid';
|
||||
return 'water-context--low';
|
||||
}
|
||||
|
||||
export function WaterContextGauge({ promptTokens, limitTokens, variant }: WaterContextGaugeProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
if (!limitTokens || limitTokens <= 0) return null;
|
||||
|
||||
const awaiting = typeof promptTokens !== 'number';
|
||||
const tokens = awaiting ? 0 : promptTokens;
|
||||
const ratio = Math.min(1, Math.max(0, tokens / limitTokens));
|
||||
const percent = Math.round(ratio * 100);
|
||||
const remaining = Math.max(0, limitTokens - tokens);
|
||||
const style = { '--water-level': `${percent}%` } as CSSProperties;
|
||||
|
||||
if (variant === 'fill') {
|
||||
if (awaiting) return null;
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-testid="water-context-fill"
|
||||
className={`water-context ${getWaterTone(ratio)}`}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className="relative z-10 shrink-0 text-2xs tabular-nums text-slate-500 dark:text-slate-300"
|
||||
title={`${formatNumber(tokens)} / ${formatNumber(limitTokens)} tokens`}
|
||||
aria-label={t('context.ariaLabel', { remaining: formatNumber(remaining), percent })}
|
||||
>
|
||||
{awaiting ? t('context.awaiting') : `${formatNumber(tokens)} / ${formatNumber(limitTokens)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { computeModelSelectorPosition } from './modelSelectorPosition';
|
||||
|
||||
describe('computeModelSelectorPosition', () => {
|
||||
it('places the panel above the trigger when it fits', () => {
|
||||
expect(computeModelSelectorPosition({
|
||||
trigger: { left: 100, right: 200, top: 500, bottom: 530 },
|
||||
panelWidth: 260,
|
||||
panelHeight: 200,
|
||||
viewportWidth: 1000,
|
||||
viewportHeight: 800,
|
||||
})).toEqual({ left: 100, top: 294, width: 260, maxHeight: 482, placement: 'top' });
|
||||
});
|
||||
|
||||
it('flips below and clamps to the viewport edges', () => {
|
||||
expect(computeModelSelectorPosition({
|
||||
trigger: { left: 290, right: 310, top: 20, bottom: 50 },
|
||||
panelWidth: 260,
|
||||
panelHeight: 180,
|
||||
viewportWidth: 320,
|
||||
viewportHeight: 400,
|
||||
})).toEqual({ left: 48, top: 56, width: 260, maxHeight: 332, placement: 'bottom' });
|
||||
});
|
||||
|
||||
it('never returns a negative width for an extremely narrow viewport', () => {
|
||||
const position = computeModelSelectorPosition({
|
||||
trigger: { left: 0, right: 10, top: 50, bottom: 70 },
|
||||
panelWidth: 260,
|
||||
panelHeight: 100,
|
||||
viewportWidth: 20,
|
||||
viewportHeight: 200,
|
||||
});
|
||||
expect(position.width).toBe(0);
|
||||
expect(position.left).toBe(12);
|
||||
});
|
||||
|
||||
it('limits an oversized panel to the viewport height', () => {
|
||||
const position = computeModelSelectorPosition({
|
||||
trigger: { left: 20, right: 80, top: 300, bottom: 330 },
|
||||
panelWidth: 260,
|
||||
panelHeight: 900,
|
||||
viewportWidth: 600,
|
||||
viewportHeight: 500,
|
||||
});
|
||||
expect(position.maxHeight).toBe(282);
|
||||
expect(position.top).toBe(12);
|
||||
});
|
||||
|
||||
it('uses the larger side without covering the trigger when neither side fits', () => {
|
||||
const position = computeModelSelectorPosition({
|
||||
trigger: { left: 100, right: 180, top: 200, bottom: 230 },
|
||||
panelWidth: 260,
|
||||
panelHeight: 300,
|
||||
viewportWidth: 500,
|
||||
viewportHeight: 500,
|
||||
});
|
||||
expect(position.placement).toBe('bottom');
|
||||
expect(position.top).toBe(236);
|
||||
expect(position.maxHeight).toBe(252);
|
||||
});
|
||||
|
||||
it('respects a visual viewport offset', () => {
|
||||
const position = computeModelSelectorPosition({
|
||||
trigger: { left: 150, right: 230, top: 250, bottom: 280 },
|
||||
panelWidth: 260,
|
||||
panelHeight: 160,
|
||||
viewportWidth: 320,
|
||||
viewportHeight: 300,
|
||||
viewportLeft: 100,
|
||||
viewportTop: 200,
|
||||
});
|
||||
expect(position.left).toBe(148);
|
||||
expect(position.top).toBe(286);
|
||||
expect(position.maxHeight).toBe(202);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
export interface ModelSelectorPosition {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
maxHeight: number;
|
||||
placement: 'top' | 'bottom';
|
||||
}
|
||||
|
||||
const VIEWPORT_MARGIN = 12;
|
||||
const TRIGGER_GAP = 6;
|
||||
|
||||
export function computeModelSelectorPosition({
|
||||
trigger,
|
||||
panelWidth,
|
||||
panelHeight,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
viewportLeft = 0,
|
||||
viewportTop = 0,
|
||||
}: {
|
||||
trigger: Pick<DOMRect, 'left' | 'top' | 'right' | 'bottom'>;
|
||||
panelWidth: number;
|
||||
panelHeight: number;
|
||||
viewportWidth: number;
|
||||
viewportHeight: number;
|
||||
viewportLeft?: number;
|
||||
viewportTop?: number;
|
||||
}): ModelSelectorPosition {
|
||||
const viewportRight = viewportLeft + viewportWidth;
|
||||
const viewportBottom = viewportTop + viewportHeight;
|
||||
const availableAbove = Math.max(0, trigger.top - TRIGGER_GAP - viewportTop - VIEWPORT_MARGIN);
|
||||
const availableBelow = Math.max(0, viewportBottom - VIEWPORT_MARGIN - trigger.bottom - TRIGGER_GAP);
|
||||
const placement = panelHeight <= availableAbove || availableAbove >= availableBelow ? 'top' : 'bottom';
|
||||
const maxHeight = placement === 'top' ? availableAbove : availableBelow;
|
||||
const effectiveHeight = Math.min(panelHeight, maxHeight);
|
||||
const top = placement === 'top'
|
||||
? trigger.top - TRIGGER_GAP - effectiveHeight
|
||||
: trigger.bottom + TRIGGER_GAP;
|
||||
const effectiveWidth = Math.max(0, Math.min(panelWidth, viewportWidth - VIEWPORT_MARGIN * 2));
|
||||
const left = Math.min(
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, trigger.left),
|
||||
Math.max(viewportLeft + VIEWPORT_MARGIN, viewportRight - VIEWPORT_MARGIN - effectiveWidth),
|
||||
);
|
||||
|
||||
return { left, top, width: effectiveWidth, maxHeight, placement };
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||
import { assignRetryEvents, buildMovementRailItems, parseJobRetry } from './movementHistory';
|
||||
|
||||
const history = (eventId: string, kind: MovementHistoryEvent['kind'], movement: string, ts: string, seq: number): MovementHistoryEvent => ({
|
||||
eventId, kind, movement, ts, seq, line: seq, runId: 'run-1', iteration: null, payload: {},
|
||||
});
|
||||
|
||||
const comment = (id: number, kind: LocalTaskComment['kind'], body: string, author = 'user'): LocalTaskComment => ({
|
||||
id, taskId: 1, kind, body, author, attachments: [], createdAt: `2026-07-12T00:00:${id}.000Z`, injectedAt: null,
|
||||
});
|
||||
|
||||
describe('buildMovementRailItems', () => {
|
||||
it('keeps repeated movements and marks a backwards transition', () => {
|
||||
const events = [
|
||||
history('s1', 'movement_start', 'implement', '2026-07-12T00:00:01.000Z', 1),
|
||||
history('s2', 'movement_start', 'verify', '2026-07-12T00:00:02.000Z', 2),
|
||||
history('s3', 'movement_start', 'implement', '2026-07-12T00:00:03.000Z', 3),
|
||||
];
|
||||
const items = buildMovementRailItems(events, [], ['investigate', 'implement', 'verify']);
|
||||
expect(items.filter(item => item.type === 'movement').map(item => item.name)).toEqual(['implement', 'verify', 'implement']);
|
||||
expect(items.some(item => item.type === 'return' && item.name === 'implement')).toBe(true);
|
||||
});
|
||||
|
||||
it('matches retries to the same run occurrence and leaves unfinished retries unassigned', () => {
|
||||
const events = [
|
||||
history('s1', 'movement_start', 'implement', '2026-07-12T00:00:01.000Z', 1),
|
||||
{ ...history('r1', 'llm_call_retry', 'implement', '2026-07-12T00:00:02.000Z', 2), payload: { attempt: 2 } },
|
||||
history('c1', 'movement_complete', 'implement', '2026-07-12T00:00:03.000Z', 3),
|
||||
{ ...history('s2', 'movement_start', 'implement', '2026-07-12T00:00:04.000Z', 1), runId: 'run-2' },
|
||||
{ ...history('r2', 'llm_call_retry', 'implement', '2026-07-12T00:00:05.000Z', 2), runId: 'run-2' },
|
||||
];
|
||||
const assigned = assignRetryEvents(events, [{ id: 10, movement: 'implement' }]);
|
||||
expect(assigned.byCompletionId.get(10)?.map(item => item.eventId)).toEqual(['r1']);
|
||||
expect(assigned.unassigned.map(item => item.eventId)).toEqual(['r2']);
|
||||
});
|
||||
|
||||
it('adds user input and job retry while excluding system comments', () => {
|
||||
const retry = JSON.stringify({ type: 'job_retry', disposition: 'retry', attempt: 1, nextAttempt: 2, maxAttempts: 3 });
|
||||
const items = buildMovementRailItems([], [
|
||||
comment(1, 'request', 'Initial request'),
|
||||
comment(2, 'progress', retry, 'system'),
|
||||
comment(3, 'comment', 'internal', 'system'),
|
||||
], ['implement']);
|
||||
expect(items.map(item => item.type)).toEqual(['user_input', 'job_retry']);
|
||||
expect(parseJobRetry(retry)?.nextAttempt).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { LocalTaskComment, MovementHistoryEvent } from '../../api';
|
||||
|
||||
export type MovementRailItem =
|
||||
| { id: string; type: 'movement'; name: string; status: 'completed' | 'active'; ts: string; targetId?: string }
|
||||
| { id: string; type: 'return'; name: string; ts: string; targetId?: string }
|
||||
| { id: string; type: 'llm_retry'; name: string; ts: string; targetId: string; detail: string }
|
||||
| { id: string; type: 'job_retry'; name: string; ts: string; targetId: string; detail: string }
|
||||
| { id: string; type: 'user_input'; name: string; ts: string; targetId: string; detail: string };
|
||||
|
||||
interface JobRetryData {
|
||||
type: 'job_retry';
|
||||
disposition: 'retry' | 'requeued_unhealthy';
|
||||
attempt: number;
|
||||
nextAttempt: number;
|
||||
maxAttempts: number;
|
||||
}
|
||||
|
||||
export const parseJobRetry = (body: string): JobRetryData | null => {
|
||||
try {
|
||||
const data = JSON.parse(body) as Partial<JobRetryData>;
|
||||
if (data.type !== 'job_retry'
|
||||
|| (data.disposition !== 'retry' && data.disposition !== 'requeued_unhealthy')
|
||||
|| typeof data.attempt !== 'number' || !Number.isFinite(data.attempt)
|
||||
|| typeof data.nextAttempt !== 'number' || !Number.isFinite(data.nextAttempt)
|
||||
|| typeof data.maxAttempts !== 'number' || !Number.isFinite(data.maxAttempts)) return null;
|
||||
return data as JobRetryData;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const userComment = (comment: LocalTaskComment): boolean =>
|
||||
['request', 'comment', 'interjection'].includes(comment.kind)
|
||||
&& comment.author !== 'agent'
|
||||
&& comment.author !== 'system';
|
||||
|
||||
export const buildMovementRailItems = (
|
||||
events: MovementHistoryEvent[],
|
||||
comments: LocalTaskComment[],
|
||||
movementNames: string[],
|
||||
): MovementRailItem[] => {
|
||||
const order = new Map(movementNames.map((name, index) => [name, index]));
|
||||
const items: MovementRailItem[] = [];
|
||||
let previousMovement: string | null = null;
|
||||
const completionTargets = new Map<string, string[]>();
|
||||
for (const comment of comments) {
|
||||
try {
|
||||
const data = JSON.parse(comment.body) as { movement?: unknown; durationMs?: unknown };
|
||||
if (comment.kind !== 'progress' || typeof data.movement !== 'string' || typeof data.durationMs !== 'number') continue;
|
||||
const targets = completionTargets.get(data.movement) ?? [];
|
||||
targets.push(`movement-completion-${comment.id}`);
|
||||
completionTargets.set(data.movement, targets);
|
||||
} catch { /* not a movement completion */ }
|
||||
}
|
||||
const occurrence = new Map<string, number>();
|
||||
const openMovements = new Map<string, number[]>();
|
||||
|
||||
const sortedEvents = [...events].sort((a, b) =>
|
||||
a.ts.localeCompare(b.ts) || (a.runId === b.runId ? a.seq - b.seq : a.line - b.line));
|
||||
for (const event of sortedEvents) {
|
||||
const name = event.movement ?? 'unknown';
|
||||
if (event.kind === 'movement_start') {
|
||||
const index = occurrence.get(name) ?? 0;
|
||||
occurrence.set(name, index + 1);
|
||||
const movementTarget = completionTargets.get(name)?.[index];
|
||||
const previousIndex = previousMovement ? order.get(previousMovement) : undefined;
|
||||
const nextIndex = order.get(name);
|
||||
if (previousIndex !== undefined && nextIndex !== undefined && nextIndex < previousIndex) {
|
||||
items.push({ id: `return-${event.eventId}`, type: 'return', name, ts: event.ts, targetId: movementTarget });
|
||||
}
|
||||
const itemIndex = items.length;
|
||||
items.push({ id: event.eventId, type: 'movement', name, status: 'active', ts: event.ts, targetId: movementTarget });
|
||||
const runMovement = `${event.runId}:${name}`;
|
||||
const open = openMovements.get(runMovement) ?? [];
|
||||
open.push(itemIndex);
|
||||
openMovements.set(runMovement, open);
|
||||
previousMovement = name;
|
||||
} else if (event.kind === 'movement_complete') {
|
||||
const index = Math.max(0, (occurrence.get(name) ?? 1) - 1);
|
||||
const open = openMovements.get(`${event.runId}:${name}`);
|
||||
const itemIndex = open?.shift();
|
||||
if (itemIndex !== undefined) {
|
||||
const started = items[itemIndex];
|
||||
if (started?.type === 'movement') {
|
||||
items[itemIndex] = { ...started, status: 'completed', targetId: completionTargets.get(name)?.[index] ?? started.targetId };
|
||||
}
|
||||
} else {
|
||||
items.push({ id: event.eventId, type: 'movement', name, status: 'completed', ts: event.ts, targetId: completionTargets.get(name)?.[index] });
|
||||
}
|
||||
} else {
|
||||
const attempt = event.payload.attempt ?? '?';
|
||||
const max = event.payload.maxAttempts ?? '?';
|
||||
const error = event.payload.errorClass ?? (event.payload.httpStatus ? `HTTP ${event.payload.httpStatus}` : 'LLM error');
|
||||
items.push({ id: event.eventId, type: 'llm_retry', name, ts: event.ts, targetId: `trace-event-${event.eventId}`, detail: `${attempt}/${max} · ${error}` });
|
||||
}
|
||||
}
|
||||
|
||||
for (const comment of comments) {
|
||||
const retry = comment.kind === 'progress' && comment.author === 'system' ? parseJobRetry(comment.body) : null;
|
||||
if (retry) {
|
||||
const detail = retry.disposition === 'requeued_unhealthy'
|
||||
? 'Worker connection retry'
|
||||
: `${retry.nextAttempt}/${retry.maxAttempts}`;
|
||||
items.push({ id: `comment-${comment.id}`, type: 'job_retry', name: 'Retry', ts: comment.createdAt, targetId: `comment-${comment.id}`, detail });
|
||||
} else if (userComment(comment)) {
|
||||
items.push({
|
||||
id: `comment-${comment.id}`,
|
||||
type: 'user_input',
|
||||
name: comment.kind === 'request' ? 'Request' : 'Comment',
|
||||
ts: comment.createdAt,
|
||||
targetId: `comment-${comment.id}`,
|
||||
detail: comment.body.replace(/\s+/g, ' ').trim().slice(0, 80),
|
||||
});
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => a.ts.localeCompare(b.ts));
|
||||
};
|
||||
|
||||
export const assignRetryEvents = (
|
||||
events: MovementHistoryEvent[],
|
||||
completions: Array<{ id: number; movement: string }>,
|
||||
): { byCompletionId: Map<number, MovementHistoryEvent[]>; unassigned: MovementHistoryEvent[] } => {
|
||||
const targets = new Map<string, number[]>();
|
||||
for (const completion of completions) {
|
||||
const ids = targets.get(completion.movement) ?? [];
|
||||
ids.push(completion.id);
|
||||
targets.set(completion.movement, ids);
|
||||
}
|
||||
const byCompletionId = new Map<number, MovementHistoryEvent[]>();
|
||||
const active = new Map<string, MovementHistoryEvent[]>();
|
||||
const unassigned: MovementHistoryEvent[] = [];
|
||||
const sorted = [...events].sort((a, b) => a.ts.localeCompare(b.ts) || a.line - b.line);
|
||||
for (const event of sorted) {
|
||||
const key = `${event.runId}:${event.movement ?? 'unknown'}`;
|
||||
if (event.kind === 'movement_start') {
|
||||
active.set(key, []);
|
||||
} else if (event.kind === 'llm_call_retry') {
|
||||
const retries = active.get(key);
|
||||
if (retries) retries.push(event);
|
||||
else unassigned.push(event);
|
||||
} else if (event.kind === 'movement_complete') {
|
||||
const completionId = targets.get(event.movement ?? 'unknown')?.shift();
|
||||
const retries = active.get(key) ?? [];
|
||||
if (completionId !== undefined) byCompletionId.set(completionId, retries);
|
||||
else unassigned.push(...retries);
|
||||
active.delete(key);
|
||||
}
|
||||
}
|
||||
for (const retries of active.values()) unassigned.push(...retries);
|
||||
return { byCompletionId, unassigned };
|
||||
};
|
||||
@@ -58,4 +58,16 @@ describe('CreateTaskDialog outside-click safety', () => {
|
||||
await user.click(screen.getByLabelText(/閉じる|close/i));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it('指定したコンテナ内ではオーバーレイなしのインラインフォームになる', async () => {
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const onClose = vi.fn();
|
||||
renderWithProviders(<CreateTaskDialog inlineContainer={host} onClose={onClose} onSubmit={vi.fn(async () => {})} />);
|
||||
await waitFor(() => expect(host.querySelector('[data-testid="create-task-body"]')).not.toBeNull());
|
||||
expect(document.querySelector('.fixed.inset-0')).toBeNull();
|
||||
await userEvent.setup().click(screen.getByText(/キャンセル|cancel/i));
|
||||
await waitFor(() => expect(onClose).toHaveBeenCalled());
|
||||
host.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// @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 type { CreateLocalTaskInput } from '../../api';
|
||||
|
||||
const WORKERS = [
|
||||
{ id: 'w1', model: 'm1', roles: ['auto'], reasoningEfforts: ['low', 'high'], vlm: false, enabled: true },
|
||||
{ id: 'w2', model: 'm2', roles: ['auto'], reasoningEfforts: [], vlm: false, enabled: true },
|
||||
];
|
||||
|
||||
// CreateTaskDialog fetches auth/me・pieces・spaces・orgs・browser-session-profiles・
|
||||
// mcp/connections 等。全部 404 で落としても各 hook は空データで動く(retry:false)。
|
||||
// /api/llm/workers だけ実データを返す。
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes('/api/llm/workers')) {
|
||||
return new Response(JSON.stringify({ workers: WORKERS }), { status: 200 });
|
||||
}
|
||||
return 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 };
|
||||
}
|
||||
|
||||
async function openAdvanced(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: /詳細設定を開く|show advanced settings/i }));
|
||||
}
|
||||
|
||||
describe('CreateTaskDialog — LLM worker override + reasoning effort', () => {
|
||||
it('effort select is disabled until a worker is chosen, then shows that worker\'s efforts', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await openAdvanced(user);
|
||||
|
||||
const effortSelect = await screen.findByTestId('create-task-llm-effort');
|
||||
expect(effortSelect).toBeDisabled();
|
||||
|
||||
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
await user.selectOptions(workerSelect, 'w1');
|
||||
|
||||
expect(effortSelect).not.toBeDisabled();
|
||||
const optionLabels = Array.from((effortSelect as HTMLSelectElement).options).map(o => o.value);
|
||||
expect(optionLabels).toEqual(['', 'low', 'high']);
|
||||
});
|
||||
|
||||
it('switching workers resets the effort (w2 has no declared efforts)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await openAdvanced(user);
|
||||
|
||||
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||
const effortSelect = screen.getByTestId('create-task-llm-effort');
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
|
||||
await user.selectOptions(workerSelect, 'w1');
|
||||
await user.selectOptions(effortSelect, 'high');
|
||||
expect((effortSelect as HTMLSelectElement).value).toBe('high');
|
||||
|
||||
await user.selectOptions(workerSelect, 'w2');
|
||||
expect((effortSelect as HTMLSelectElement).value).toBe('');
|
||||
const optionLabels = Array.from((effortSelect as HTMLSelectElement).options).map(o => o.value);
|
||||
expect(optionLabels).toEqual(['']);
|
||||
});
|
||||
|
||||
it('clearing the worker back to auto resets both llmWorkerId and llmEffort to null in the submitted payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderDialog();
|
||||
await openAdvanced(user);
|
||||
|
||||
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||
const effortSelect = screen.getByTestId('create-task-llm-effort');
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
|
||||
await user.selectOptions(workerSelect, 'w1');
|
||||
await user.selectOptions(effortSelect, 'high');
|
||||
// back to auto
|
||||
await user.selectOptions(workerSelect, '');
|
||||
|
||||
await user.type(screen.getByTestId('create-task-body'), '本文');
|
||||
await user.click(screen.getByTestId('create-task-submit'));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const [submittedInput] = onSubmit.mock.calls[0] as unknown as [CreateLocalTaskInput, unknown];
|
||||
expect(submittedInput.llmWorkerId).toBeNull();
|
||||
expect(submittedInput.llmEffort).toBeNull();
|
||||
});
|
||||
|
||||
it('a chosen worker sends llmWorkerId/llmEffort, disables the profile select, and shows the override hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSubmit } = renderDialog();
|
||||
await openAdvanced(user);
|
||||
|
||||
const workerSelect = screen.getByTestId('create-task-llm-worker');
|
||||
const effortSelect = screen.getByTestId('create-task-llm-effort');
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
|
||||
await user.selectOptions(workerSelect, 'w1');
|
||||
await user.selectOptions(effortSelect, 'low');
|
||||
|
||||
const profileSelect = screen.getByDisplayValue('auto') as HTMLSelectElement;
|
||||
expect(profileSelect).toBeDisabled();
|
||||
expect(screen.getByText(/ワーカーを指定するとプロファイルは使われません|Pinning a worker overrides the profile setting/)).toBeInTheDocument();
|
||||
|
||||
await user.type(screen.getByTestId('create-task-body'), '本文');
|
||||
await user.click(screen.getByTestId('create-task-submit'));
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
|
||||
const [submittedInput] = onSubmit.mock.calls[0] as unknown as [CreateLocalTaskInput, unknown];
|
||||
expect(submittedInput.llmWorkerId).toBe('w1');
|
||||
expect(submittedInput.llmEffort).toBe('low');
|
||||
});
|
||||
|
||||
// Fix C (P2, codex round3): scheduled-task POST doesn't send llmWorkerId/
|
||||
// llmEffort and the scheduler doesn't persist/inherit them, so a chosen
|
||||
// worker/effort was silently dropped on scheduled runs. Disable both
|
||||
// selects (and clear any prior selection) once "定期実行" is toggled on,
|
||||
// and surface a hint explaining why.
|
||||
it('toggling scheduled mode disables the worker+effort selects, clears a prior selection, and shows the hint', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderDialog();
|
||||
await openAdvanced(user);
|
||||
|
||||
const workerSelect = screen.getByTestId('create-task-llm-worker') as HTMLSelectElement;
|
||||
const effortSelect = screen.getByTestId('create-task-llm-effort') as HTMLSelectElement;
|
||||
await waitFor(() => expect(screen.getByText('m1 @ w1')).toBeInTheDocument());
|
||||
|
||||
await user.selectOptions(workerSelect, 'w1');
|
||||
await user.selectOptions(effortSelect, 'low');
|
||||
expect(workerSelect.value).toBe('w1');
|
||||
expect(effortSelect.value).toBe('low');
|
||||
|
||||
expect(screen.queryByTestId('create-task-llm-scheduled-hint')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('checkbox', { name: /定期実行|Run on a schedule/i }));
|
||||
|
||||
expect(workerSelect).toBeDisabled();
|
||||
expect(effortSelect).toBeDisabled();
|
||||
expect(workerSelect.value).toBe('');
|
||||
expect(effortSelect.value).toBe('');
|
||||
expect(screen.getByTestId('create-task-llm-scheduled-hint')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -2,10 +2,9 @@ import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles } from '../../api';
|
||||
import { AttachmentDropzone } from './AttachmentDropzone';
|
||||
import { PromptCoachPanel } from './PromptCoachPanel';
|
||||
import { ScheduleFields } from './ScheduleFields';
|
||||
import { CreateLocalTaskInput, fetchMyOrgs, Visibility, listBrowserSessionProfiles, fetchLlmWorkers } from '../../api';
|
||||
import { CreateTaskDialogAdvancedSection } from './CreateTaskDialogAdvancedSection';
|
||||
import { CreateTaskDialogCoreSection } from './CreateTaskDialogCoreSection';
|
||||
import { usePieceList } from '../../hooks/usePieces';
|
||||
import { useSpaces } from '../../hooks/useSpaces';
|
||||
import { sortSpacesForRail } from '../../lib/spaceSort';
|
||||
@@ -31,9 +30,10 @@ interface CreateTaskDialogProps {
|
||||
* 未指定(グローバルのタスク一覧から開いた)時は個人スペースが既定。
|
||||
*/
|
||||
initialSpaceId?: string;
|
||||
/** Optional in-page portal target. When set, the form is non-modal. */
|
||||
inlineContainer?: HTMLElement | null;
|
||||
}
|
||||
|
||||
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder, initialSpaceId }: CreateTaskDialogProps) {
|
||||
export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody, placeholder, initialSpaceId, inlineContainer }: CreateTaskDialogProps) {
|
||||
const { t } = useTranslation('create');
|
||||
const { data: pieces } = usePieceList();
|
||||
const { data: spacesData } = useSpaces();
|
||||
@@ -46,6 +46,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
const activeSessionProfiles = sessionProfiles.filter(p => p.status === 'active');
|
||||
const { data: llmWorkers = [] } = useQuery({ queryKey: ['llm-workers'], queryFn: fetchLlmWorkers });
|
||||
const enabledLlmWorkers = llmWorkers.filter(w => w.enabled !== false);
|
||||
interface ConnectionRow { serverId: string; serverName: string; connected: boolean }
|
||||
const { data: connections } = useQuery({
|
||||
queryKey: ['mcp-connections'],
|
||||
@@ -85,6 +87,8 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
askPolicy: 'low',
|
||||
priority: 'medium',
|
||||
workspaceMode: 'persistent',
|
||||
llmWorkerId: null,
|
||||
llmEffort: null,
|
||||
});
|
||||
// スペース起点で開いた場合は固定。それ以外はユーザーが任意で選択(既定=個人)。
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string | undefined>(initialSpaceId);
|
||||
@@ -92,8 +96,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
const fixedSpace = initialSpaceId ? sortedSpaces.find(s => s.id === initialSpaceId) : undefined;
|
||||
const [attachments, setAttachments] = useState<Array<{ name: string; contentBase64: string }>>([]);
|
||||
const [browserSessionProfileId, setBrowserSessionProfileId] = useState<number | null>(null);
|
||||
const [mcpDisabled, setMcpDisabled] = useState(false);
|
||||
const [skillsDisabled, setSkillsDisabled] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
@@ -169,9 +171,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
const options: Record<string, boolean> = {};
|
||||
if (mcpDisabled) options.mcpDisabled = true;
|
||||
if (skillsDisabled) options.skillsDisabled = true;
|
||||
const submitForm = {
|
||||
...form,
|
||||
// initialPiece が指定されているヘルプアシスタント等は piece を固定。
|
||||
@@ -184,7 +183,6 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
browserSessionProfileId: browserSessionProfileId ?? undefined,
|
||||
// 固定スペースを最優先、無ければ選択値。未指定なら個人スペースに解決される。
|
||||
spaceId: initialSpaceId ?? selectedSpaceId ?? undefined,
|
||||
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||
};
|
||||
await onSubmit(submitForm, attachments);
|
||||
clearDraft();
|
||||
@@ -199,12 +197,14 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
const dirty = form.body.trim().length > 0 || attachments.length > 0;
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />
|
||||
<Dialog.Root open modal={!inlineContainer} onOpenChange={(open) => { if (!open) onClose(); }}>
|
||||
<Dialog.Portal container={inlineContainer ?? undefined}>
|
||||
{!inlineContainer && <Dialog.Overlay className="fixed inset-0 bg-slate-900/50 z-30" />}
|
||||
<Dialog.Content
|
||||
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none"
|
||||
style={{ maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
|
||||
className={inlineContainer
|
||||
? 'h-full w-full overflow-auto bg-surface focus:outline-none'
|
||||
: 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 bg-surface rounded-2xl shadow-2xl w-full overflow-auto z-40 focus:outline-none'}
|
||||
style={inlineContainer ? undefined : { maxWidth: 'min(860px, 92vw)', maxHeight: '88dvh' }}
|
||||
onOpenAutoFocus={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
@@ -234,318 +234,60 @@ export function CreateTaskDialog({ onClose, onSubmit, initialPiece, initialBody,
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Textarea */}
|
||||
<div>
|
||||
<label className="block text-[13px] text-slate-600 mb-1.5">{t('body.label')}</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
data-testid="create-task-body"
|
||||
value={form.body}
|
||||
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();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
|
||||
placeholder={placeholder ?? (initialPiece === 'help' ? t('body.placeholderHelp') : t('body.placeholder'))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Workspace mode (永続/一時的) + スペース選択 */}
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.modeLabel')}</label>
|
||||
<div className="inline-flex rounded-lg border border-slate-200 p-0.5">
|
||||
{([['persistent', t('workspace.persistent')], ['ephemeral', t('workspace.ephemeral')]] as const).map(([mode, label]) => {
|
||||
const active = (form.workspaceMode ?? 'persistent') === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setForm(prev => ({ ...prev, workspaceMode: mode }))}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
active ? 'bg-accent text-accent-fg' : 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
{t('workspace.modeHint')}
|
||||
</p>
|
||||
{(form.workspaceMode ?? 'persistent') === 'ephemeral' && (
|
||||
<p className="text-2xs text-amber-600 mt-1" data-testid="ephemeral-warning">
|
||||
{t('workspace.ephemeralWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* スペース: 固定 or ピッカー(既定=個人) */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.spaceLabel')}</label>
|
||||
{initialSpaceId ? (
|
||||
<div className="px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs text-slate-700 bg-slate-50 truncate">
|
||||
{fixedSpace?.title ?? t('workspace.thisWorkspace')}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedSpaceId ?? ''}
|
||||
onChange={e => setSelectedSpaceId(e.target.value || undefined)}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">{t('workspace.personalDefault')}</option>
|
||||
{sortedSpaces
|
||||
.filter(s => s.kind === 'case')
|
||||
.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.title}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Attachments */}
|
||||
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
|
||||
|
||||
{/* Prompt coach (on-demand draft evaluation) */}
|
||||
<PromptCoachPanel
|
||||
body={form.body}
|
||||
piece={initialPiece ?? form.piece}
|
||||
onApplyRewrite={(text) => {
|
||||
setForm(prev => ({ ...prev, body: text }));
|
||||
saveDraft(text);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* MCP warnings (always visible when applicable) */}
|
||||
{missingMcp.length > 0 && (
|
||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
|
||||
<div>
|
||||
<strong>{t('mcp.required')}</strong> {missingMcp.join(', ')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{missingMcp.map((id) => (
|
||||
<a
|
||||
key={id}
|
||||
className="px-2 py-0.5 rounded bg-yellow-600 text-white hover:bg-yellow-700 text-2xs font-semibold"
|
||||
href={`/auth/mcp/${encodeURIComponent(id)}/start`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('mcp.connect', { id })}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-2xs text-yellow-700 dark:text-yellow-300">
|
||||
{t('mcp.note')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings toggle + content */}
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(prev => !prev)}
|
||||
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{showAdvanced ? t('advanced.hide') : t('advanced.show')}
|
||||
<CreateTaskDialogCoreSection
|
||||
initialPiece={initialPiece}
|
||||
placeholder={placeholder}
|
||||
initialSpaceId={initialSpaceId}
|
||||
fixedSpace={fixedSpace}
|
||||
selectedSpaceId={selectedSpaceId}
|
||||
setSelectedSpaceId={setSelectedSpaceId}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
attachments={attachments}
|
||||
setAttachments={setAttachments}
|
||||
resolvedPieces={resolvedPieces}
|
||||
missingMcp={missingMcp}
|
||||
onSubmit={handleSubmit}
|
||||
saveDraft={saveDraft}
|
||||
sortedSpaces={sortedSpaces}
|
||||
orgs={orgs}
|
||||
visibility={visibility}
|
||||
/>
|
||||
<CreateTaskDialogAdvancedSection
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
resolvedPieces={resolvedPieces}
|
||||
activeSessionProfiles={activeSessionProfiles}
|
||||
enabledLlmWorkers={enabledLlmWorkers}
|
||||
isScheduled={isScheduled}
|
||||
setIsScheduled={setIsScheduled}
|
||||
schedule={schedule}
|
||||
setSchedule={setSchedule}
|
||||
browserSessionProfileId={browserSessionProfileId}
|
||||
setBrowserSessionProfileId={setBrowserSessionProfileId}
|
||||
visibility={visibility}
|
||||
setVisibility={setVisibility}
|
||||
visibilityScopeOrgId={visibilityScopeOrgId}
|
||||
setVisibilityScopeOrgId={setVisibilityScopeOrgId}
|
||||
orgs={orgs}
|
||||
showAdvanced={showAdvanced}
|
||||
setShowAdvanced={setShowAdvanced}
|
||||
/>
|
||||
{error && <div className="mt-4 text-[13px] text-red-600">{error}</div>}
|
||||
<div className="mt-4 flex justify-end items-center gap-2 pt-1">
|
||||
<Dialog.Close asChild>
|
||||
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
|
||||
{t('cancel')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
|
||||
{/* Row 1: Piece, Profile, Priority */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.taskType')}</label>
|
||||
<select
|
||||
value={form.piece}
|
||||
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="auto">{t('advanced.auto')}</option>
|
||||
{resolvedPieces.map(p => (
|
||||
<option key={p.name} value={p.name}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.profile')}</label>
|
||||
<select
|
||||
value={form.profile}
|
||||
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value as CreateLocalTaskInput['profile'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['auto', 'auto'], ['fast', 'fast'], ['quality', 'quality']].map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.priority')}</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value as CreateLocalTaskInput['priority'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['low', 'low'], ['medium', 'medium'], ['high', 'high']].map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Output Format, Ask Policy */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.outputFormat')}</label>
|
||||
<select
|
||||
value={form.outputFormat}
|
||||
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value as CreateLocalTaskInput['outputFormat'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{[['markdown', 'markdown'], ['text', 'text'], ['json', 'json']].map(([v, l]) => (
|
||||
<option key={v} value={v}>{l}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.askPolicy')}</label>
|
||||
<select
|
||||
value={form.askPolicy}
|
||||
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value as CreateLocalTaskInput['askPolicy'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="low">{t('advanced.askLow')}</option>
|
||||
<option value="high">{t('advanced.askHigh')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: MCP disable, Skills disable checkboxes */}
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2">
|
||||
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={mcpDisabled}
|
||||
onChange={e => setMcpDisabled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{t('advanced.disableMcp')}
|
||||
</label>
|
||||
<label className="flex items-center gap-2 text-xs text-slate-600 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={skillsDisabled}
|
||||
onChange={e => setSkillsDisabled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
{t('advanced.disableSkills')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Browser Session (only if active profiles exist) */}
|
||||
{activeSessionProfiles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.browserSession')}</label>
|
||||
<select
|
||||
value={browserSessionProfileId ?? ''}
|
||||
onChange={e =>
|
||||
setBrowserSessionProfileId(e.target.value ? Number(e.target.value) : null)
|
||||
}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">{t('advanced.none')}</option>
|
||||
{activeSessionProfiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-2xs text-slate-400 mt-1">
|
||||
{t('advanced.browserSessionHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Visibility */}
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('visibility.label')}</label>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
|
||||
{t('visibility.private')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
|
||||
{t('visibility.org')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
|
||||
{t('visibility.public')}
|
||||
</label>
|
||||
</div>
|
||||
{visibility === 'org' && orgs.length > 1 && (
|
||||
<select
|
||||
className="mt-1 px-2 py-1 border border-slate-200 rounded text-xs"
|
||||
value={visibilityScopeOrgId ?? ''}
|
||||
onChange={e => setVisibilityScopeOrgId(e.target.value)}
|
||||
>
|
||||
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{visibility === 'org' && orgs.length === 1 && (
|
||||
<div className="mt-1 text-2xs text-slate-500">{t('visibility.sharedWith', { org: orgs[0].orgName })}</div>
|
||||
)}
|
||||
{visibility === 'org' && orgs.length === 0 && (
|
||||
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Schedule toggle + sub-form */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="schedule-toggle"
|
||||
checked={isScheduled}
|
||||
onChange={e => setIsScheduled(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer">{t('schedule.enable')}</label>
|
||||
</div>
|
||||
{isScheduled && (
|
||||
<ScheduleFields schedule={schedule} onChange={setSchedule} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="text-[13px] text-red-600">{error}</div>}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-end items-center gap-2 pt-1">
|
||||
<Dialog.Close asChild>
|
||||
<button className="px-4 py-2 border border-slate-200 rounded-xl text-[13px] text-slate-600 hover:bg-slate-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">
|
||||
{t('cancel')}
|
||||
</button>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
disabled={submitting}
|
||||
data-testid="create-task-submit"
|
||||
onClick={() => void handleSubmit()}
|
||||
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
|
||||
>
|
||||
{submitting ? t('submitting') : isScheduled ? t('submitSchedule') : t('submit')}
|
||||
</button>
|
||||
</div>
|
||||
</Dialog.Close>
|
||||
<button
|
||||
disabled={submitting}
|
||||
data-testid="create-task-submit"
|
||||
onClick={() => void handleSubmit()}
|
||||
className="px-4 py-2 bg-accent text-accent-fg rounded-xl text-[13px] font-bold disabled:opacity-50 hover:bg-accent-deep"
|
||||
>
|
||||
{submitting ? t('submitting') : isScheduled ? t('submitSchedule') : t('submit')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CreateLocalTaskInput, Visibility } from '../../api';
|
||||
import { ScheduleFields } from './ScheduleFields';
|
||||
|
||||
interface OrgOption {
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
interface LlmWorkerOption {
|
||||
id: string;
|
||||
model: string;
|
||||
enabled?: boolean;
|
||||
reasoningEfforts?: string[];
|
||||
}
|
||||
|
||||
interface CreateTaskDialogAdvancedSectionProps {
|
||||
form: CreateLocalTaskInput;
|
||||
setForm: (updater: (previous: CreateLocalTaskInput) => CreateLocalTaskInput) => void;
|
||||
resolvedPieces: Array<{ name: string }>;
|
||||
activeSessionProfiles: Array<{ id: number; label: string }>;
|
||||
enabledLlmWorkers: LlmWorkerOption[];
|
||||
isScheduled: boolean;
|
||||
setIsScheduled: (value: boolean) => void;
|
||||
schedule: {
|
||||
scheduleType: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
dayOfWeek: number;
|
||||
dayOfMonth: number;
|
||||
cronExpression: string;
|
||||
scheduledAt: string;
|
||||
};
|
||||
setSchedule: (updater: (previous: CreateTaskDialogAdvancedSectionProps['schedule']) => CreateTaskDialogAdvancedSectionProps['schedule']) => void;
|
||||
browserSessionProfileId: number | null;
|
||||
setBrowserSessionProfileId: (value: number | null) => void;
|
||||
visibility: Visibility;
|
||||
setVisibility: (value: Visibility) => void;
|
||||
visibilityScopeOrgId: string | null;
|
||||
setVisibilityScopeOrgId: (value: string) => void;
|
||||
orgs: OrgOption[];
|
||||
showAdvanced: boolean;
|
||||
setShowAdvanced: (value: (previous: boolean) => boolean) => void;
|
||||
}
|
||||
|
||||
export function CreateTaskDialogAdvancedSection({
|
||||
form,
|
||||
setForm,
|
||||
resolvedPieces,
|
||||
activeSessionProfiles,
|
||||
enabledLlmWorkers,
|
||||
isScheduled,
|
||||
setIsScheduled,
|
||||
schedule,
|
||||
setSchedule,
|
||||
browserSessionProfileId,
|
||||
setBrowserSessionProfileId,
|
||||
visibility,
|
||||
setVisibility,
|
||||
visibilityScopeOrgId,
|
||||
setVisibilityScopeOrgId,
|
||||
orgs,
|
||||
showAdvanced,
|
||||
setShowAdvanced,
|
||||
}: CreateTaskDialogAdvancedSectionProps) {
|
||||
const { t } = useTranslation('create');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowAdvanced(prev => !prev)}
|
||||
className="px-3 py-1.5 border border-slate-200 rounded-xl text-xs font-bold text-slate-600 hover:bg-slate-50"
|
||||
>
|
||||
{showAdvanced ? t('advanced.hide') : t('advanced.show')}
|
||||
</button>
|
||||
{showAdvanced && (
|
||||
<div className="mt-3 space-y-4 border border-slate-100 rounded-xl p-4 bg-slate-50/50">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.taskType')}</label>
|
||||
<select
|
||||
value={form.piece}
|
||||
onChange={e => setForm(prev => ({ ...prev, piece: e.target.value }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="auto">{t('advanced.auto')}</option>
|
||||
{resolvedPieces.map(p => (
|
||||
<option key={p.name} value={p.name}>{p.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.profile')}</label>
|
||||
<select
|
||||
value={form.profile}
|
||||
disabled={!!form.llmWorkerId}
|
||||
onChange={e => setForm(prev => ({ ...prev, profile: e.target.value as CreateLocalTaskInput['profile'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{['auto', 'fast', 'quality'].map(v => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
{!!form.llmWorkerId && (
|
||||
<p className="text-2xs text-slate-400 mt-1">{t('advanced.workerOverridesProfile')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.priority')}</label>
|
||||
<select
|
||||
value={form.priority}
|
||||
onChange={e => setForm(prev => ({ ...prev, priority: e.target.value as CreateLocalTaskInput['priority'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{['low', 'medium', 'high'].map(v => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.workerLabel')}</label>
|
||||
<select
|
||||
data-testid="create-task-llm-worker"
|
||||
value={form.llmWorkerId ?? ''}
|
||||
disabled={isScheduled}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setForm(prev => ({ ...prev, llmWorkerId: value || null, llmEffort: null }));
|
||||
}}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">{t('advanced.workerAuto')}</option>
|
||||
{enabledLlmWorkers.map(w => (
|
||||
<option key={w.id} value={w.id}>{`${w.model} @ ${w.id}`}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.effortLabel')}</label>
|
||||
<select
|
||||
data-testid="create-task-llm-effort"
|
||||
value={form.llmEffort ?? ''}
|
||||
disabled={!form.llmWorkerId || isScheduled}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setForm(prev => ({ ...prev, llmEffort: value || null }));
|
||||
}}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<option value="">{t('advanced.effortNone')}</option>
|
||||
{(enabledLlmWorkers.find(w => w.id === form.llmWorkerId)?.reasoningEfforts ?? []).map(effort => (
|
||||
<option key={effort} value={effort}>{effort}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{isScheduled && (
|
||||
<p className="text-2xs text-slate-400 sm:col-span-2" data-testid="create-task-llm-scheduled-hint">
|
||||
{t('advanced.llmNotForScheduled')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.outputFormat')}</label>
|
||||
<select
|
||||
value={form.outputFormat}
|
||||
onChange={e => setForm(prev => ({ ...prev, outputFormat: e.target.value as CreateLocalTaskInput['outputFormat'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
{['markdown', 'text', 'json'].map(v => (
|
||||
<option key={v} value={v}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.askPolicy')}</label>
|
||||
<select
|
||||
value={form.askPolicy}
|
||||
onChange={e => setForm(prev => ({ ...prev, askPolicy: e.target.value as CreateLocalTaskInput['askPolicy'] }))}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="low">{t('advanced.askLow')}</option>
|
||||
<option value="high">{t('advanced.askHigh')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeSessionProfiles.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('advanced.browserSession')}</label>
|
||||
<select
|
||||
value={browserSessionProfileId ?? ''}
|
||||
onChange={e => setBrowserSessionProfileId(e.target.value ? Number(e.target.value) : null)}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">{t('advanced.none')}</option>
|
||||
{activeSessionProfiles.map(p => (
|
||||
<option key={p.id} value={p.id}>{p.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-2xs text-slate-400 mt-1">{t('advanced.browserSessionHint')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('visibility.label')}</label>
|
||||
<div className="flex gap-3 text-xs">
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'private'} onChange={() => setVisibility('private')} />
|
||||
{t('visibility.private')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'org'} onChange={() => setVisibility('org')} disabled={orgs.length === 0} />
|
||||
{t('visibility.org')}
|
||||
</label>
|
||||
<label className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" checked={visibility === 'public'} onChange={() => setVisibility('public')} />
|
||||
{t('visibility.public')}
|
||||
</label>
|
||||
</div>
|
||||
{visibility === 'org' && orgs.length > 1 && (
|
||||
<select
|
||||
className="mt-1 px-2 py-1 border border-slate-200 rounded text-xs"
|
||||
value={visibilityScopeOrgId ?? ''}
|
||||
onChange={e => setVisibilityScopeOrgId(e.target.value)}
|
||||
>
|
||||
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
|
||||
</select>
|
||||
)}
|
||||
{visibility === 'org' && orgs.length === 1 && (
|
||||
<div className="mt-1 text-2xs text-slate-500">{t('visibility.sharedWith', { org: orgs[0].orgName })}</div>
|
||||
)}
|
||||
{visibility === 'org' && orgs.length === 0 && (
|
||||
<div className="mt-1 text-2xs text-slate-400">{t('visibility.orgLoginHint')}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="schedule-toggle"
|
||||
checked={isScheduled}
|
||||
onChange={e => {
|
||||
const checked = e.target.checked;
|
||||
setIsScheduled(checked);
|
||||
if (checked) {
|
||||
setForm(prev => ({ ...prev, llmWorkerId: null, llmEffort: null }));
|
||||
}
|
||||
}}
|
||||
className="rounded"
|
||||
/>
|
||||
<label htmlFor="schedule-toggle" className="text-xs text-slate-600 cursor-pointer">{t('schedule.enable')}</label>
|
||||
</div>
|
||||
{isScheduled && (
|
||||
<ScheduleFields schedule={schedule} onChange={setSchedule} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { CreateLocalTaskInput } from '../../api';
|
||||
import { AttachmentDropzone } from './AttachmentDropzone';
|
||||
import { PromptCoachPanel } from './PromptCoachPanel';
|
||||
|
||||
interface SpaceOption {
|
||||
id: string;
|
||||
title: string;
|
||||
kind?: string;
|
||||
}
|
||||
|
||||
interface OrgOption {
|
||||
orgId: string;
|
||||
orgName: string;
|
||||
}
|
||||
|
||||
interface CreateTaskDialogCoreSectionProps {
|
||||
initialPiece?: string;
|
||||
placeholder?: string;
|
||||
initialSpaceId?: string;
|
||||
fixedSpace?: { title: string };
|
||||
selectedSpaceId?: string;
|
||||
setSelectedSpaceId: (value: string | undefined) => void;
|
||||
form: CreateLocalTaskInput;
|
||||
setForm: (updater: (previous: CreateLocalTaskInput) => CreateLocalTaskInput) => void;
|
||||
attachments: Array<{ name: string; contentBase64: string }>;
|
||||
setAttachments: (value: Array<{ name: string; contentBase64: string }>) => void;
|
||||
resolvedPieces: Array<{ name: string }>;
|
||||
missingMcp: string[];
|
||||
onSubmit: () => Promise<void>;
|
||||
saveDraft: (value: string) => void;
|
||||
sortedSpaces: SpaceOption[];
|
||||
orgs: OrgOption[];
|
||||
visibility: 'private' | 'org' | 'public';
|
||||
}
|
||||
|
||||
export function CreateTaskDialogCoreSection({
|
||||
initialPiece,
|
||||
placeholder,
|
||||
initialSpaceId,
|
||||
fixedSpace,
|
||||
selectedSpaceId,
|
||||
setSelectedSpaceId,
|
||||
form,
|
||||
setForm,
|
||||
attachments,
|
||||
setAttachments,
|
||||
resolvedPieces,
|
||||
missingMcp,
|
||||
onSubmit,
|
||||
saveDraft,
|
||||
sortedSpaces,
|
||||
orgs,
|
||||
visibility,
|
||||
}: CreateTaskDialogCoreSectionProps) {
|
||||
const { t } = useTranslation('create');
|
||||
const selectedPiece = resolvedPieces.find(p => p.name === form.piece);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="block text-[13px] text-slate-600 mb-1.5">{t('body.label')}</label>
|
||||
<textarea
|
||||
autoFocus
|
||||
data-testid="create-task-body"
|
||||
value={form.body}
|
||||
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();
|
||||
void onSubmit();
|
||||
}
|
||||
}}
|
||||
rows={8}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-xl text-sm outline-none focus:border-accent resize-y leading-relaxed"
|
||||
placeholder={placeholder ?? (initialPiece === 'help' ? t('body.placeholderHelp') : t('body.placeholder'))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end sm:gap-4">
|
||||
<div>
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.modeLabel')}</label>
|
||||
<div className="inline-flex rounded-lg border border-slate-200 p-0.5">
|
||||
{([['persistent', t('workspace.persistent')], ['ephemeral', t('workspace.ephemeral')]] as const).map(([mode, label]) => {
|
||||
const active = (form.workspaceMode ?? 'persistent') === mode;
|
||||
return (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => setForm(prev => ({ ...prev, workspaceMode: mode }))}
|
||||
className={`px-3 py-1.5 rounded-md text-xs font-semibold transition-colors ${
|
||||
active ? 'bg-accent text-accent-fg' : 'text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<p className="text-2xs text-slate-400 mt-1">{t('workspace.modeHint')}</p>
|
||||
{(form.workspaceMode ?? 'persistent') === 'ephemeral' && (
|
||||
<p className="text-2xs text-amber-600 mt-1" data-testid="ephemeral-warning">
|
||||
{t('workspace.ephemeralWarning')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<label className="block text-2xs text-slate-500 mb-1">{t('workspace.spaceLabel')}</label>
|
||||
{initialSpaceId ? (
|
||||
<div className="px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs text-slate-700 bg-slate-50 truncate">
|
||||
{fixedSpace?.title ?? t('workspace.thisWorkspace')}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
value={selectedSpaceId ?? ''}
|
||||
onChange={e => setSelectedSpaceId(e.target.value || undefined)}
|
||||
className="w-full px-2.5 py-1.5 border border-slate-200 rounded-lg text-xs outline-none focus:border-accent"
|
||||
>
|
||||
<option value="">{t('workspace.personalDefault')}</option>
|
||||
{sortedSpaces
|
||||
.filter(s => s.kind === 'case')
|
||||
.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.title}</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AttachmentDropzone attachments={attachments} onFilesChange={setAttachments} />
|
||||
|
||||
<PromptCoachPanel
|
||||
body={form.body}
|
||||
piece={initialPiece ?? form.piece}
|
||||
onApplyRewrite={(text) => {
|
||||
setForm(prev => ({ ...prev, body: text }));
|
||||
saveDraft(text);
|
||||
}}
|
||||
/>
|
||||
|
||||
{missingMcp.length > 0 && (
|
||||
<div className="p-3 bg-yellow-50 dark:bg-yellow-500/15 border border-yellow-300 dark:border-yellow-500/30 rounded text-xs text-yellow-900 dark:text-yellow-300 space-y-2">
|
||||
<div>
|
||||
<strong>{t('mcp.required')}</strong> {missingMcp.join(', ')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{missingMcp.map((id) => (
|
||||
<a
|
||||
key={id}
|
||||
className="px-2 py-0.5 rounded bg-yellow-600 text-white hover:bg-yellow-700 text-2xs font-semibold"
|
||||
href={`/auth/mcp/${encodeURIComponent(id)}/start`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('mcp.connect', { id })}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-2xs text-yellow-700 dark:text-yellow-300">
|
||||
{t('mcp.note')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ interface PromptCoachPanelProps {
|
||||
piece?: string;
|
||||
/** Inject the rewrite suggestion back into the textarea. */
|
||||
onApplyRewrite: (text: string) => void;
|
||||
/** Compact spacing for the chat composer popover. */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
function scoreColor(score: number, max: number): string {
|
||||
@@ -19,7 +21,7 @@ function scoreColor(score: number, max: number): string {
|
||||
return 'text-rose-600 dark:text-rose-400';
|
||||
}
|
||||
|
||||
export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPanelProps) {
|
||||
export function PromptCoachPanel({ body, piece, onApplyRewrite, compact = false }: PromptCoachPanelProps) {
|
||||
const { t } = useTranslation('create');
|
||||
const mutation = useMutation<PromptCoachResult, Error, void>({
|
||||
mutationFn: () => evaluatePrompt({ instruction: body.trim(), piece }),
|
||||
@@ -48,7 +50,7 @@ export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPan
|
||||
)}
|
||||
{mutation.isPending ? t('coach.evaluating') : t('coach.evaluate')}
|
||||
</button>
|
||||
<span className="text-2xs text-slate-400">{t('coach.hint')}</span>
|
||||
{!compact && <span className="text-2xs text-slate-400">{t('coach.hint')}</span>}
|
||||
</div>
|
||||
|
||||
{mutation.isError && (
|
||||
@@ -56,7 +58,7 @@ export function PromptCoachPanel({ body, piece, onApplyRewrite }: PromptCoachPan
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl p-4 bg-slate-50/60 dark:bg-slate-800/40">
|
||||
<div className={`mt-3 space-y-3 border border-slate-200 dark:border-slate-700 rounded-xl bg-slate-50/60 dark:bg-slate-800/40 ${compact ? 'p-3 max-h-56 overflow-y-auto' : 'p-4'}`}>
|
||||
{/* Overall score */}
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xs font-bold text-slate-500">{t('coach.overall')}</span>
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ToastHost } from './ToastHost';
|
||||
|
||||
describe('ToastHost', () => {
|
||||
it('stacks notifications and dismisses one by its close button', async () => {
|
||||
const onDismiss = vi.fn();
|
||||
render(<ToastHost onDismiss={onDismiss} toasts={[
|
||||
{ id: 'first', message: '最初の通知', variant: 'info' },
|
||||
{ id: 'second', title: '完了', message: '次の通知', variant: 'success' },
|
||||
]} />);
|
||||
|
||||
expect(screen.getByText('最初の通知')).toBeInTheDocument();
|
||||
expect(screen.getByText('次の通知')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: '完了を閉じる' }));
|
||||
expect(onDismiss).toHaveBeenCalledWith('second');
|
||||
});
|
||||
|
||||
it('invokes an action and dismisses the toast', async () => {
|
||||
const onDismiss = vi.fn();
|
||||
const onAction = vi.fn();
|
||||
render(<ToastHost onDismiss={onDismiss} toasts={[{ id: 'task-1', title: 'タスク完了', message: '確認できます', variant: 'success', actionLabel: 'タスクを開く', onAction }]} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'タスクを開く' }));
|
||||
expect(onAction).toHaveBeenCalledOnce();
|
||||
expect(onDismiss).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
it('renders an optional completion visual beside the notification', () => {
|
||||
render(<ToastHost onDismiss={vi.fn()} toasts={[{ id: 'task-1', message: '完了', variant: 'success', visual: <span data-testid="pet-visual">pet</span> }]} />);
|
||||
expect(screen.getByTestId('pet-visual')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ToastState } from '../../hooks/useToast';
|
||||
|
||||
interface ToastHostProps {
|
||||
toasts: ToastState[];
|
||||
onDismiss: (id: string) => void;
|
||||
}
|
||||
|
||||
const variantClass = {
|
||||
success: 'border-emerald-200 bg-emerald-50 text-emerald-950',
|
||||
error: 'border-red-200 bg-red-50 text-red-950',
|
||||
info: 'border-sky-200 bg-sky-50 text-sky-950',
|
||||
};
|
||||
|
||||
/** 右下に積み上がる、OS 通知権限に依存しないアプリ内通知。 */
|
||||
export function ToastHost({ toasts, onDismiss }: ToastHostProps) {
|
||||
if (toasts.length === 0) return null;
|
||||
return (
|
||||
<section aria-label="アプリ内通知" className="pointer-events-none fixed inset-x-3 bottom-[max(0.75rem,env(safe-area-inset-bottom))] z-[70] flex max-w-sm flex-col-reverse gap-2 sm:left-auto sm:right-4">
|
||||
{toasts.map(toast => (
|
||||
<article
|
||||
key={toast.id}
|
||||
role={toast.variant === 'error' ? 'alert' : 'status'}
|
||||
className={`pointer-events-auto rounded-xl border px-3 py-2.5 shadow-lg motion-safe:animate-[toast-enter_160ms_ease-out] ${variantClass[toast.variant]}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
{toast.visual && <div className="shrink-0" aria-hidden="true">{toast.visual}</div>}
|
||||
<div className="min-w-0 flex-1">
|
||||
{toast.title && <p className="text-xs font-semibold">{toast.title}</p>}
|
||||
<p className="text-sm">{toast.message}</p>
|
||||
{toast.actionLabel && toast.onAction && (
|
||||
<button type="button" onClick={() => { toast.onAction?.(); onDismiss(toast.id); }} className="mt-1 text-xs font-semibold underline underline-offset-2">
|
||||
{toast.actionLabel}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="button" onClick={() => onDismiss(toast.id)} aria-label={`${toast.title ?? toast.message}を閉じる`} className="rounded p-1 text-current/70 hover:bg-black/10 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2">
|
||||
<span aria-hidden>×</span>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
import { PetSprite } from '../pets/PetSprite';
|
||||
|
||||
export function ToastPet() {
|
||||
const { data } = useActivePet();
|
||||
const framesPerRow = usePetFrameAnalysis(data?.spriteUrl ?? null, data?.gridCols ?? null, data?.gridRows ?? null);
|
||||
const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const media = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const update = () => setPrefersReducedMotion(media.matches);
|
||||
update();
|
||||
media.addEventListener('change', update);
|
||||
return () => media.removeEventListener('change', update);
|
||||
}, []);
|
||||
|
||||
if (!data?.settings.enabled || !data.pet) return null;
|
||||
return <PetSprite name={data.pet.name} imageUrl={data.imageUrl} frameWidth={data.frameWidth} frameHeight={data.frameHeight} gridCols={data.gridCols} gridRows={data.gridRows} framesPerRow={framesPerRow} state="done" size={48} reducedMotion={data.settings.reducedMotion || prefersReducedMotion} />;
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useActivePet } from '../../hooks/useActivePet';
|
||||
import { useNodeAnimationState } from '../../hooks/useNodeAnimationState';
|
||||
import { useStableNodePromotion } from '../../hooks/useStableNodePromotion';
|
||||
import { usePetFrameAnalysis } from '../../hooks/usePetFrameAnalysis';
|
||||
import { extractLatestToolName, petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
|
||||
import type { LastToolEvent } from '../../hooks/useJobStream';
|
||||
import { petStateFromJobStatus, type PetRuntimeState } from '../../lib/pets/petState';
|
||||
import { PetSprite } from './PetSprite';
|
||||
import { ToolSpark } from './ToolSpark';
|
||||
|
||||
const JUMP_DURATION_MS = 1500;
|
||||
// A whole number of petJump cycles (3 × 0.55s) so the hop ends near
|
||||
// translateY(0) instead of snapping down from mid-arc when it stops
|
||||
// (Fable review #2).
|
||||
const JUMP_DURATION_MS = 1650;
|
||||
const DONE_FLOURISH_MS = 1000;
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
@@ -26,14 +31,17 @@ function usePrefersReducedMotion(): boolean {
|
||||
export function ChatPetOverlay({
|
||||
taskId,
|
||||
taskStatus,
|
||||
currentActivity,
|
||||
lastToolEvent,
|
||||
workerId,
|
||||
lastBackendId,
|
||||
className,
|
||||
}: {
|
||||
taskId: number | null;
|
||||
taskStatus: string | null;
|
||||
currentActivity: string | null;
|
||||
/** Most recent SSE tool_use/tool_result, keyed by callId (Pets Phase 1,
|
||||
* U0). Drives the jump + spark triggers — see
|
||||
* docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md */
|
||||
lastToolEvent: LastToolEvent | null;
|
||||
workerId: string | null;
|
||||
/**
|
||||
* Physical backend id when the worker is a proxy (LiteLLM deployment
|
||||
@@ -60,56 +68,81 @@ export function ChatPetOverlay({
|
||||
// anyway. Prefer the proxy-backend mapping over the worker mapping
|
||||
// — same precedence as useActivePet uses for sprite selection.
|
||||
const nodeAnimState = useNodeAnimationState(lastBackendId ?? workerId ?? null);
|
||||
// U3: hold the promotion for ~2 idle polls before letting it lapse, so a
|
||||
// single missed busy sample on the shared node doesn't flap the pet back
|
||||
// to idle and immediately back up.
|
||||
const stableNodeAnimState = useStableNodePromotion(nodeAnimState);
|
||||
|
||||
const taskBaseState = petStateFromJobStatus(taskStatus, taskId);
|
||||
// Promote an 'idle' base state to 'running' when the backing node is
|
||||
// actively processing. Don't override informative states like
|
||||
// 'dispatching', 'waiting', 'done', or 'error' — those carry signal
|
||||
// that node.busy doesn't.
|
||||
const baseState: PetRuntimeState = taskBaseState === 'idle' && nodeAnimState === 'running'
|
||||
// that node.busy doesn't. Real task-status states stay immediate; only
|
||||
// this node-derived promotion goes through the hysteresis above.
|
||||
const baseState: PetRuntimeState = taskBaseState === 'idle' && stableNodeAnimState === 'running'
|
||||
? 'running'
|
||||
: taskBaseState;
|
||||
const baseStateRef = useRef(baseState);
|
||||
baseStateRef.current = baseState;
|
||||
|
||||
const reducedMotion = (data?.settings.reducedMotion ?? false) || prefersReducedMotion;
|
||||
|
||||
const [displayState, setDisplayState] = useState<PetRuntimeState>('idle');
|
||||
|
||||
// U1/U0: jump is a one-shot overlay on the outer sprite wrapper, kept
|
||||
// entirely separate from `displayState` (the pose fed to PetSprite's
|
||||
// frame-cycle) so re-triggering it never restarts the inner animation.
|
||||
// Triggered by SSE `tool_use` (callId change) instead of the 5s-polled
|
||||
// `currentActivity`, which also carried `LLM: …` status text and caused
|
||||
// false jumps/sparks.
|
||||
const [jumping, setJumping] = useState(false);
|
||||
const jumpTimerRef = useRef<number | null>(null);
|
||||
const lastJumpCallIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (jumpTimerRef.current != null) window.clearTimeout(jumpTimerRef.current);
|
||||
}, []);
|
||||
|
||||
// Reset display to base state whenever it changes; brief 'done' flourish then
|
||||
// settle to idle so the wave doesn't loop forever.
|
||||
useEffect(() => {
|
||||
setDisplayState(baseState);
|
||||
// Base state changed out from under an in-flight jump (e.g. the task
|
||||
// just finished) — stop the hop rather than let it bounce over a
|
||||
// done/error/waiting pose it no longer applies to.
|
||||
if (baseState !== 'running' && baseState !== 'runningAlt' && baseState !== 'dispatching') {
|
||||
if (jumpTimerRef.current != null) {
|
||||
window.clearTimeout(jumpTimerRef.current);
|
||||
jumpTimerRef.current = null;
|
||||
}
|
||||
setJumping(false);
|
||||
}
|
||||
if (baseState !== 'done') return;
|
||||
const timer = window.setTimeout(() => setDisplayState('idle'), DONE_FLOURISH_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [baseState]);
|
||||
|
||||
// When the current activity changes (= a new tool fired) during active
|
||||
// execution, jump for ~1.5s and revert to whatever the base state is by then.
|
||||
useEffect(() => {
|
||||
if (!currentActivity) return;
|
||||
if (reducedMotion) return;
|
||||
if (!lastToolEvent || !lastToolEvent.callId) return;
|
||||
if (lastToolEvent.callId === lastJumpCallIdRef.current) return;
|
||||
lastJumpCallIdRef.current = lastToolEvent.callId;
|
||||
const active = baseStateRef.current;
|
||||
if (active !== 'running' && active !== 'runningAlt' && active !== 'dispatching') return;
|
||||
setDisplayState('jumping');
|
||||
const timer = window.setTimeout(() => {
|
||||
const current = baseStateRef.current;
|
||||
if (current === 'running' || current === 'runningAlt' || current === 'dispatching') {
|
||||
setDisplayState(current);
|
||||
}
|
||||
// For other base states (done / error / idle / waiting) the dedicated
|
||||
// effects above will have taken over; don't fight them here.
|
||||
setJumping(true);
|
||||
// U5: a later tool_use before this fires just pushes the end time out
|
||||
// (new callId -> this effect re-runs, clears + reschedules) — it never
|
||||
// toggles `jumping` off and back on, so the CSS animation on the outer
|
||||
// wrapper never restarts.
|
||||
if (jumpTimerRef.current != null) window.clearTimeout(jumpTimerRef.current);
|
||||
jumpTimerRef.current = window.setTimeout(() => {
|
||||
setJumping(false);
|
||||
jumpTimerRef.current = null;
|
||||
}, JUMP_DURATION_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [currentActivity]);
|
||||
|
||||
const toolName = useMemo(
|
||||
() => extractLatestToolName(currentActivity),
|
||||
[currentActivity],
|
||||
);
|
||||
}, [lastToolEvent, reducedMotion]);
|
||||
|
||||
if (isLoading || !data?.settings.enabled || !data.pet) return null;
|
||||
|
||||
const reducedMotion = data.settings.reducedMotion || prefersReducedMotion;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className ? `chat-pet-overlay ${className}` : 'chat-pet-overlay'}
|
||||
@@ -117,8 +150,7 @@ export function ChatPetOverlay({
|
||||
aria-hidden="true"
|
||||
>
|
||||
<ToolSpark
|
||||
toolName={toolName}
|
||||
activityKey={currentActivity}
|
||||
event={lastToolEvent}
|
||||
enabled={data.settings.toolSparkEnabled}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
@@ -131,6 +163,7 @@ export function ChatPetOverlay({
|
||||
gridRows={data.gridRows}
|
||||
framesPerRow={framesPerRow}
|
||||
state={displayState}
|
||||
jumping={jumping}
|
||||
size={data.settings.size}
|
||||
reducedMotion={reducedMotion}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { PetSprite } from './PetSprite';
|
||||
|
||||
describe('PetSprite', () => {
|
||||
it('falls back when the configured pet image cannot be loaded', async () => {
|
||||
const OriginalImage = globalThis.Image;
|
||||
class FailedImage {
|
||||
onerror: (() => void) | null = null;
|
||||
set src(_: string) { queueMicrotask(() => this.onerror?.()); }
|
||||
}
|
||||
vi.stubGlobal('Image', FailedImage);
|
||||
try {
|
||||
const { container } = render(<PetSprite name="Toast pet" imageUrl="/broken.png" frameWidth={null} frameHeight={null} gridCols={null} gridRows={null} framesPerRow={null} state="done" size={48} reducedMotion />);
|
||||
await waitFor(() => expect(container.querySelector('.pet-sprite-fallback')).toBeInTheDocument());
|
||||
// U1: the outer (titled) wrapper only carries the jump overlay now —
|
||||
// the base-state class lives on the nested `.pet-sprite-pose` layer.
|
||||
expect(screen.getByTitle('Toast pet')).toHaveClass('pet-sprite');
|
||||
expect(container.querySelector('.pet-sprite-pose')).toHaveClass('pet-sprite-done');
|
||||
} finally {
|
||||
vi.stubGlobal('Image', OriginalImage);
|
||||
}
|
||||
});
|
||||
|
||||
it('U1: jumping never changes the inner frame-cycle animation name', () => {
|
||||
const { container, rerender } = render(
|
||||
<PetSprite
|
||||
name="Toast pet"
|
||||
imageUrl="/sprite.png"
|
||||
frameWidth={null}
|
||||
frameHeight={null}
|
||||
gridCols={8}
|
||||
gridRows={9}
|
||||
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||
state="running"
|
||||
jumping={false}
|
||||
size={48}
|
||||
reducedMotion={false}
|
||||
/>,
|
||||
);
|
||||
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||
expect(grid).toBeInTheDocument();
|
||||
const before = grid.style.animation;
|
||||
expect(before).toContain('petFrameCycle8');
|
||||
|
||||
rerender(
|
||||
<PetSprite
|
||||
name="Toast pet"
|
||||
imageUrl="/sprite.png"
|
||||
frameWidth={null}
|
||||
frameHeight={null}
|
||||
gridCols={8}
|
||||
gridRows={9}
|
||||
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||
state="running"
|
||||
jumping={true}
|
||||
size={48}
|
||||
reducedMotion={false}
|
||||
/>,
|
||||
);
|
||||
const gridAfter = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||
expect(gridAfter.style.animation).toBe(before);
|
||||
|
||||
// The jump overlay lands on the outer (titled) wrapper, not the pose/grid layers.
|
||||
expect(screen.getByTitle('Toast pet')).toHaveClass('pet-sprite-jumping');
|
||||
expect(container.querySelector('.pet-sprite-pose')).not.toHaveClass('pet-sprite-jumping');
|
||||
});
|
||||
|
||||
it('U4: does not start the frame-cycle animation while framesPerRow is unresolved', () => {
|
||||
const { container, rerender } = render(
|
||||
<PetSprite
|
||||
name="Toast pet"
|
||||
imageUrl="/sprite.png"
|
||||
frameWidth={null}
|
||||
frameHeight={null}
|
||||
gridCols={8}
|
||||
gridRows={9}
|
||||
framesPerRow={null}
|
||||
state="running"
|
||||
size={48}
|
||||
reducedMotion={false}
|
||||
/>,
|
||||
);
|
||||
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||
expect(grid).toBeInTheDocument();
|
||||
expect(grid.style.animation).toBe('');
|
||||
|
||||
rerender(
|
||||
<PetSprite
|
||||
name="Toast pet"
|
||||
imageUrl="/sprite.png"
|
||||
frameWidth={null}
|
||||
frameHeight={null}
|
||||
gridCols={8}
|
||||
gridRows={9}
|
||||
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||
state="running"
|
||||
size={48}
|
||||
reducedMotion={false}
|
||||
/>,
|
||||
);
|
||||
const gridAfter = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||
expect(gridAfter.style.animation).toContain('petFrameCycle8');
|
||||
});
|
||||
|
||||
it('positions sprite frames from the full grid without wrapping', () => {
|
||||
const { container } = render(
|
||||
<PetSprite
|
||||
name="Toast pet"
|
||||
imageUrl="/sprite.png"
|
||||
frameWidth={null}
|
||||
frameHeight={null}
|
||||
gridCols={8}
|
||||
gridRows={9}
|
||||
framesPerRow={[8, 8, 8, 8, 8, 8, 8, 8, 8]}
|
||||
state="running"
|
||||
size={48}
|
||||
reducedMotion={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const grid = container.querySelector('.pet-sprite-grid') as HTMLElement;
|
||||
expect(grid.style.backgroundRepeat).toBe('no-repeat');
|
||||
expect(Number.parseFloat(grid.style.getPropertyValue('--pet-frame-1-position'))).toBeCloseTo(100 / 7);
|
||||
expect(grid.style.getPropertyValue('--pet-frame-7-position')).toBe('100%');
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { rowIndexForState, type PetRuntimeState } from '../../lib/pets/petState';
|
||||
|
||||
const STATE_FRAME_DURATION: Record<PetRuntimeState, string> = {
|
||||
@@ -20,6 +21,7 @@ export function PetSprite({
|
||||
gridRows,
|
||||
framesPerRow,
|
||||
state,
|
||||
jumping = false,
|
||||
size,
|
||||
reducedMotion,
|
||||
}: {
|
||||
@@ -31,79 +33,129 @@ export function PetSprite({
|
||||
gridRows: number | null;
|
||||
framesPerRow: number[] | null;
|
||||
state: PetRuntimeState;
|
||||
/** One-shot hop, layered on the outer wrapper only (U1). Never changes
|
||||
* `state`, so the inner frame-cycle animation below never restarts
|
||||
* when a tool fires mid-run. See
|
||||
* docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md */
|
||||
jumping?: boolean;
|
||||
size: number;
|
||||
reducedMotion: boolean;
|
||||
}) {
|
||||
const className = [
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setImageFailed(false);
|
||||
if (!imageUrl) return;
|
||||
const image = new Image();
|
||||
image.onerror = () => setImageFailed(true);
|
||||
image.src = imageUrl;
|
||||
}, [imageUrl]);
|
||||
const usableImageUrl = imageFailed ? null : imageUrl;
|
||||
|
||||
// Outer layer: the jump hop only. Deliberately carries no pose/state
|
||||
// class so toggling it never disturbs the pose layer's continuous
|
||||
// wobble animation or the inner frame-cycle (U1). Nested transforms
|
||||
// compose, so a hop + the base-state wobble render together.
|
||||
const outerClassName = [
|
||||
'pet-sprite',
|
||||
jumping && !reducedMotion ? 'pet-sprite-jumping' : '',
|
||||
reducedMotion ? 'pet-sprite-reduced' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
// Pose layer: the continuous base-state wobble (idle/run/wait/done/
|
||||
// error) and the sprite-sheet row/clip selection. Always reflects
|
||||
// `state` as-is — jumping never reaches this layer.
|
||||
const poseClassName = [
|
||||
'pet-sprite-pose',
|
||||
`pet-sprite-${state}`,
|
||||
reducedMotion ? 'pet-sprite-reduced' : '',
|
||||
].filter(Boolean).join(' ');
|
||||
|
||||
const useGridCrop = !!(imageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
|
||||
const useFrameCrop = !useGridCrop && !!(imageUrl && frameWidth && frameHeight);
|
||||
const useGridCrop = !!(usableImageUrl && gridCols && gridRows && gridCols > 0 && gridRows > 0);
|
||||
const useFrameCrop = !useGridCrop && !!(usableImageUrl && frameWidth && frameHeight);
|
||||
|
||||
const stateRow = useGridCrop ? rowIndexForState(state, gridRows!) : 0;
|
||||
const bgPosY = useGridCrop && gridRows! > 1
|
||||
? `${(stateRow / (gridRows! - 1)) * 100}%`
|
||||
: '0%';
|
||||
|
||||
// U4: framesPerRow starts null and resolves asynchronously (canvas
|
||||
// analysis of the spritesheet). Before it resolves, don't start the
|
||||
// frame-cycle at all — cycling through the gridCols fallback walks
|
||||
// past columns that may be transparent on rows with fewer filled
|
||||
// frames, producing a flash right after mount. Stay on frame 0 (the
|
||||
// default background-position) until analysis resolves.
|
||||
const analysisResolved = framesPerRow !== null;
|
||||
const detectedFrames = framesPerRow?.[stateRow];
|
||||
const rowFrameCount = Math.max(1, Math.min(8, detectedFrames ?? gridCols ?? 1));
|
||||
const cycleAnimation = useGridCrop && !reducedMotion && rowFrameCount > 1
|
||||
const cycleAnimation = useGridCrop && !reducedMotion && analysisResolved && rowFrameCount > 1
|
||||
? `petFrameCycle${rowFrameCount} ${STATE_FRAME_DURATION[state]} linear infinite`
|
||||
: undefined;
|
||||
const framePositions = useGridCrop
|
||||
? Array.from({ length: 8 }, (_, index) => {
|
||||
const clampedIndex = Math.min(index, gridCols! - 1);
|
||||
return gridCols! > 1 ? `${(clampedIndex / (gridCols! - 1)) * 100}%` : '0%';
|
||||
})
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
|
||||
}}
|
||||
className={outerClassName}
|
||||
style={{ width: size, height: size }}
|
||||
aria-hidden="true"
|
||||
title={name}
|
||||
>
|
||||
{imageUrl ? (
|
||||
useGridCrop ? (
|
||||
<div
|
||||
className="pet-sprite-grid"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundImage: `url(${imageUrl})`,
|
||||
backgroundRepeat: 'repeat-x',
|
||||
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
|
||||
backgroundPositionY: bgPosY,
|
||||
animation: cycleAnimation,
|
||||
imageRendering: 'auto',
|
||||
}}
|
||||
/>
|
||||
) : useFrameCrop ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
objectFit: 'none',
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${size / frameWidth!})`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className={poseClassName}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
overflow: useGridCrop || useFrameCrop ? 'hidden' : undefined,
|
||||
}}
|
||||
>
|
||||
{usableImageUrl ? (
|
||||
useGridCrop ? (
|
||||
<div
|
||||
className="pet-sprite-grid"
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
backgroundImage: `url(${usableImageUrl})`,
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundSize: `${gridCols! * 100}% ${gridRows! * 100}%`,
|
||||
backgroundPositionY: bgPosY,
|
||||
animation: cycleAnimation,
|
||||
imageRendering: 'auto',
|
||||
...Object.fromEntries(framePositions.map((position, index) => [
|
||||
`--pet-frame-${index}-position`,
|
||||
position,
|
||||
])),
|
||||
}}
|
||||
/>
|
||||
) : useFrameCrop ? (
|
||||
<img
|
||||
src={usableImageUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
style={{
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
maxWidth: 'none',
|
||||
maxHeight: 'none',
|
||||
objectFit: 'none',
|
||||
transformOrigin: '0 0',
|
||||
transform: `scale(${size / frameWidth!})`,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<img src={usableImageUrl} alt="" draggable={false} />
|
||||
)
|
||||
) : (
|
||||
<img src={imageUrl} alt="" draggable={false} />
|
||||
)
|
||||
) : (
|
||||
<div className="pet-sprite-fallback">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
<div className="pet-sprite-fallback">
|
||||
<span />
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { ToolSpark } from './ToolSpark';
|
||||
import type { LastToolEvent } from '../../hooks/useJobStream';
|
||||
|
||||
function toolEvent(name: string, isError: boolean | null, callId: string): LastToolEvent {
|
||||
return { name, isError, callId, ts: Date.now() };
|
||||
}
|
||||
|
||||
describe('ToolSpark', () => {
|
||||
it('U7: does not render a spark for the tool_use moment (isError === null)', () => {
|
||||
const { container } = render(
|
||||
<ToolSpark event={toolEvent('WebSearch', null, 'c0')} enabled reducedMotion={false} />,
|
||||
);
|
||||
expect(container.querySelector('.tool-spark-burst')).toBeNull();
|
||||
});
|
||||
|
||||
it('U6: renders a search-ripple spark once the result resolves', () => {
|
||||
const { container, rerender } = render(
|
||||
<ToolSpark event={toolEvent('WebSearch', null, 'c1')} enabled reducedMotion={false} />,
|
||||
);
|
||||
expect(container.querySelector('.tool-spark-burst')).toBeNull();
|
||||
|
||||
rerender(<ToolSpark event={toolEvent('WebSearch', false, 'c1')} enabled reducedMotion={false} />);
|
||||
const burst = container.querySelector('.tool-spark-burst');
|
||||
expect(burst).toBeInTheDocument();
|
||||
expect(burst).toHaveAttribute('data-tool-kind', 'search');
|
||||
expect(burst).toHaveClass('tool-spark-mode-ripple');
|
||||
expect(burst).toHaveClass('tool-spark-success');
|
||||
});
|
||||
|
||||
it('U6: renders a terminal-blink spark for Bash', () => {
|
||||
const { container } = render(
|
||||
<ToolSpark event={toolEvent('Bash', false, 'c2')} enabled reducedMotion={false} />,
|
||||
);
|
||||
const burst = container.querySelector('.tool-spark-burst');
|
||||
expect(burst).toHaveAttribute('data-tool-kind', 'terminal');
|
||||
expect(burst).toHaveClass('tool-spark-mode-blink');
|
||||
});
|
||||
|
||||
it('U6: falls back to the default star spark for uncategorized tools', () => {
|
||||
const { container } = render(
|
||||
<ToolSpark event={toolEvent('SomeCustomTool', false, 'c3')} enabled reducedMotion={false} />,
|
||||
);
|
||||
const burst = container.querySelector('.tool-spark-burst');
|
||||
expect(burst).toHaveAttribute('data-tool-kind', 'spark');
|
||||
expect(burst).toHaveClass('tool-spark-mode-star');
|
||||
expect(burst).not.toHaveClass('tool-spark-mode-ripple');
|
||||
});
|
||||
|
||||
it('U7: distinguishes success vs failure by color class and the "!" mark', () => {
|
||||
const { container: successContainer } = render(
|
||||
<ToolSpark event={toolEvent('Read', false, 'ok-1')} enabled reducedMotion={false} />,
|
||||
);
|
||||
expect(successContainer.querySelector('.tool-spark-burst')).toHaveClass('tool-spark-success');
|
||||
expect(successContainer.querySelector('.tool-spark-burst')).not.toHaveClass('tool-spark-error');
|
||||
expect(successContainer.querySelector('.tool-spark-error-mark')).toBeNull();
|
||||
|
||||
const { container: errorContainer } = render(
|
||||
<ToolSpark event={toolEvent('Read', true, 'err-1')} enabled reducedMotion={false} />,
|
||||
);
|
||||
expect(errorContainer.querySelector('.tool-spark-burst')).toHaveClass('tool-spark-error');
|
||||
expect(errorContainer.querySelector('.tool-spark-burst')).not.toHaveClass('tool-spark-success');
|
||||
expect(errorContainer.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
|
||||
});
|
||||
|
||||
it('U7: reducedMotion keeps only the color/mark — no particles, no per-kind motion class', () => {
|
||||
const { container } = render(
|
||||
<ToolSpark event={toolEvent('Bash', true, 'rm-1')} enabled reducedMotion={true} />,
|
||||
);
|
||||
const burst = container.querySelector('.tool-spark-burst');
|
||||
expect(burst).toHaveClass('tool-spark-error');
|
||||
expect(burst?.className ?? '').not.toMatch(/tool-spark-mode-/);
|
||||
expect(container.querySelector('.tool-spark-particle')).toBeNull();
|
||||
expect(container.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
|
||||
});
|
||||
|
||||
it('U8: coalesces rapid results within the window into a ×N combo badge', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ToolSpark event={toolEvent('Read', false, 'a1')} enabled reducedMotion={false} />,
|
||||
);
|
||||
const initialBurst = container.querySelector('.tool-spark-burst');
|
||||
const initialParticle = container.querySelector('.tool-spark-particle');
|
||||
// A single result never shows a combo count.
|
||||
expect(container.querySelector('.tool-spark-combo')).toBeNull();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
rerender(<ToolSpark event={toolEvent('Read', false, 'a2')} enabled reducedMotion={false} />);
|
||||
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×2');
|
||||
expect(container.querySelector('.tool-spark-burst')).toBe(initialBurst);
|
||||
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
|
||||
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
rerender(<ToolSpark event={toolEvent('Grep', false, 'a3')} enabled reducedMotion={false} />);
|
||||
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×3');
|
||||
expect(container.querySelector('.tool-spark-burst')).toBe(initialBurst);
|
||||
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('U8: resets the combo once the coalesce window elapses without a new result', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ToolSpark event={toolEvent('Read', false, 'b1')} enabled reducedMotion={false} />,
|
||||
);
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
rerender(<ToolSpark event={toolEvent('Read', false, 'b2')} enabled reducedMotion={false} />);
|
||||
expect(container.querySelector('.tool-spark-combo')).toHaveTextContent('×2');
|
||||
|
||||
// Let the 600ms coalesce window lapse before the next result arrives.
|
||||
act(() => { vi.advanceTimersByTime(700); });
|
||||
rerender(<ToolSpark event={toolEvent('Read', false, 'b3')} enabled reducedMotion={false} />);
|
||||
expect(container.querySelector('.tool-spark-combo')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('U8: promotes a coalesced combo to error without restarting its particles', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ToolSpark event={toolEvent('Read', false, 'error-combo-1')} enabled reducedMotion={false} />,
|
||||
);
|
||||
const initialBurst = container.querySelector('.tool-spark-burst');
|
||||
const initialParticle = container.querySelector('.tool-spark-particle');
|
||||
|
||||
act(() => { vi.advanceTimersByTime(200); });
|
||||
rerender(<ToolSpark event={toolEvent('Grep', true, 'error-combo-2')} enabled reducedMotion={false} />);
|
||||
|
||||
const burst = container.querySelector('.tool-spark-burst');
|
||||
expect(burst).toBe(initialBurst);
|
||||
expect(container.querySelector('.tool-spark-particle')).toBe(initialParticle);
|
||||
expect(burst).toHaveClass('tool-spark-error');
|
||||
expect(container.querySelector('.tool-spark-error-mark')).toHaveTextContent('!');
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the spark after the hold duration with no further results', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { container } = render(
|
||||
<ToolSpark event={toolEvent('Read', false, 'hold-1')} enabled reducedMotion={false} />,
|
||||
);
|
||||
expect(container.querySelector('.tool-spark-burst')).toBeInTheDocument();
|
||||
act(() => { vi.advanceTimersByTime(3300); });
|
||||
expect(container.querySelector('.tool-spark-burst')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { iconKindForTool, type ToolIconKind } from '../../lib/pets/toolIconMap';
|
||||
import type { LastToolEvent } from '../../hooks/useJobStream';
|
||||
|
||||
function ToolIcon({ kind }: { kind: ToolIconKind }) {
|
||||
if (kind === 'search') {
|
||||
@@ -45,30 +46,97 @@ function randomLaunchVx(): number {
|
||||
return Math.cos((angleDeg * Math.PI) / 180);
|
||||
}
|
||||
|
||||
// U8: a tool_result landing within this many ms of the previous spark
|
||||
// coalesces into the held spark (bumps the combo badge) instead of firing a
|
||||
// second overlapping burst. See docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md
|
||||
const COALESCE_WINDOW_MS = 600;
|
||||
// Total time a spark (and its combo badge) stays visible after the most
|
||||
// recent qualifying result — matches the bubble/particle CSS duration
|
||||
// (3000ms) plus the particles' staggered delay.
|
||||
const HOLD_MS = 3300;
|
||||
|
||||
// U6: CSS-only visual treatment per tool kind. `ToolIcon` (the bubble
|
||||
// glyph) is reused unchanged — only the particle/bubble motion differs,
|
||||
// via the `.tool-spark-mode-*` rules in index.css.
|
||||
const SPARK_MODE: Record<ToolIconKind, string> = {
|
||||
search: 'ripple',
|
||||
terminal: 'blink',
|
||||
file: 'paper',
|
||||
edit: 'paper',
|
||||
browser: 'window',
|
||||
issue: 'marker',
|
||||
plug: 'bolt',
|
||||
spark: 'star',
|
||||
};
|
||||
|
||||
interface SparkVisual {
|
||||
kind: ToolIconKind;
|
||||
isError: boolean;
|
||||
}
|
||||
|
||||
export function ToolSpark({
|
||||
toolName,
|
||||
activityKey,
|
||||
event,
|
||||
enabled,
|
||||
reducedMotion,
|
||||
}: {
|
||||
toolName: string | null;
|
||||
activityKey: string | null;
|
||||
/** Most recent SSE tool_use/tool_result (Pets Phase 1, U0). The spark
|
||||
* fires only once `isError` is confirmed (U7, Phase 2) — the initial
|
||||
* `tool_use` (isError === null) is the jump's cue, handled separately by
|
||||
* ChatPetOverlay, and is deliberately ignored here. */
|
||||
event: LastToolEvent | null;
|
||||
enabled: boolean;
|
||||
reducedMotion: boolean;
|
||||
}) {
|
||||
const [visibleTool, setVisibleTool] = useState<string | null>(null);
|
||||
const [visible, setVisible] = useState<SparkVisual | null>(null);
|
||||
const [comboCount, setComboCount] = useState(0);
|
||||
const [animationToken, setAnimationToken] = useState(0);
|
||||
|
||||
const lastHandledCallIdRef = useRef<string | null>(null);
|
||||
const lastFireAtRef = useRef<number>(-Infinity);
|
||||
const comboCountRef = useRef(0);
|
||||
const hideTimerRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (hideTimerRef.current != null) window.clearTimeout(hideTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !toolName) return;
|
||||
setVisibleTool(toolName);
|
||||
setAnimationToken(t => t + 1);
|
||||
// Bubble: 3000ms animation. Particles: 3000ms animation + up to 270ms
|
||||
// staggered delay. Hold ~30ms past the latest particle's fade-out so we
|
||||
// don't snap-cut the last one.
|
||||
const timer = window.setTimeout(() => setVisibleTool(null), 3300);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [enabled, toolName, activityKey]);
|
||||
if (!enabled) return;
|
||||
// U7: only fire once the result is confirmed. `isError === null` means
|
||||
// this is still the `tool_use` moment (the jump's trigger, not ours).
|
||||
if (!event || event.isError === null || !event.callId) return;
|
||||
// Same callId already handled (e.g. a re-render didn't produce a new
|
||||
// tool_result) — don't re-fire.
|
||||
if (event.callId === lastHandledCallIdRef.current) return;
|
||||
lastHandledCallIdRef.current = event.callId;
|
||||
|
||||
const now = Date.now();
|
||||
// U8: a result landing within the coalesce window bumps the combo count
|
||||
// on the held spark; otherwise it starts a fresh one.
|
||||
const coalesced = now - lastFireAtRef.current <= COALESCE_WINDOW_MS;
|
||||
lastFireAtRef.current = now;
|
||||
comboCountRef.current = coalesced ? comboCountRef.current + 1 : 1;
|
||||
setComboCount(comboCountRef.current);
|
||||
// Keep an in-flight burst intact when rapid results coalesce. Replacing
|
||||
// its visual kind or React key would restart every particle at frame 0,
|
||||
// which reads as a teleport rather than a continuous fall.
|
||||
if (!coalesced) {
|
||||
setVisible({ kind: iconKindForTool(event.name), isError: event.isError });
|
||||
setAnimationToken(t => t + 1);
|
||||
} else if (event.isError) {
|
||||
// Preserve the original kind/particle DOM, but never hide a failure
|
||||
// that arrives later in the same combo group.
|
||||
setVisible(current => current ? { ...current, isError: true } : current);
|
||||
}
|
||||
|
||||
if (hideTimerRef.current != null) window.clearTimeout(hideTimerRef.current);
|
||||
hideTimerRef.current = window.setTimeout(() => {
|
||||
setVisible(null);
|
||||
comboCountRef.current = 0;
|
||||
setComboCount(0);
|
||||
hideTimerRef.current = null;
|
||||
}, HOLD_MS);
|
||||
}, [event, enabled]);
|
||||
|
||||
const launchVelocities = useMemo(
|
||||
() => PARTICLE_BASE.map(() => randomLaunchVx()),
|
||||
@@ -77,15 +145,28 @@ export function ToolSpark({
|
||||
[animationToken],
|
||||
);
|
||||
|
||||
if (!enabled || !visibleTool) return null;
|
||||
if (!enabled || !visible) return null;
|
||||
|
||||
const mode = SPARK_MODE[visible.kind];
|
||||
const statusClass = visible.isError ? 'tool-spark-error' : 'tool-spark-success';
|
||||
// reduced-motion: no per-kind motion, no particles — just the color/mark.
|
||||
const modeClass = !reducedMotion ? `tool-spark-mode-${mode}` : '';
|
||||
|
||||
const kind = iconKindForTool(visibleTool);
|
||||
return (
|
||||
<div className="tool-spark-burst" key={animationToken} aria-hidden="true">
|
||||
<div className={`tool-spark-bubble ${reducedMotion ? 'tool-spark-reduced' : ''}`}>
|
||||
<div
|
||||
className={`tool-spark-burst ${statusClass} ${modeClass}`.trim()}
|
||||
key={animationToken}
|
||||
aria-hidden="true"
|
||||
data-tool-kind={visible.kind}
|
||||
data-tool-error={visible.isError}
|
||||
>
|
||||
<div className={`tool-spark-bubble ${reducedMotion ? 'tool-spark-reduced' : ''}`.trim()}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ToolIcon kind={kind} />
|
||||
<ToolIcon kind={visible.kind} />
|
||||
</svg>
|
||||
{visible.isError && (
|
||||
<span className="tool-spark-error-mark" aria-hidden="true">!</span>
|
||||
)}
|
||||
</div>
|
||||
{!reducedMotion && PARTICLE_BASE.map((p, i) => (
|
||||
<span
|
||||
@@ -104,6 +185,11 @@ export function ToolSpark({
|
||||
<svg viewBox="0 0 24 24"><path d={STAR_PATH} /></svg>
|
||||
</span>
|
||||
))}
|
||||
{comboCount > 1 && (
|
||||
<span className={`tool-spark-combo ${reducedMotion ? 'tool-spark-combo-reduced' : ''}`.trim()}>
|
||||
×{comboCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { fireEvent, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import { ScheduleAccessSections, ScheduleTaskConfigurationSection, ScheduleTimingSection } from './ScheduleEditorSections';
|
||||
import type { ScheduleFormState } from './scheduleEditorTypes';
|
||||
|
||||
const t = ((key: string) => key) as unknown as TFunction;
|
||||
const form: ScheduleFormState = { title: '', body: 'prompt', piece: 'auto', scheduleType: 'daily', hour: 9, minute: 0, dayOfWeek: 1, dayOfMonth: 1, cronExpression: '', scheduledAt: '', outputFormat: 'markdown', visibility: 'private', visibilityScopeOrgId: null, browserSessionProfileId: null, taskKind: 'agent', scriptName: '', scriptParams: '' };
|
||||
|
||||
describe('ScheduleEditorSections', () => {
|
||||
it('switches the task configuration to script without owning form state', async () => {
|
||||
const setForm = vi.fn();
|
||||
renderWithProviders(<ScheduleTaskConfigurationSection form={form} setForm={setForm} titleRef={{ current: null }} pieceOptions={[{ value: 'auto', label: 'auto', description: '' }]} t={t} />);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'editor.kindScript' }));
|
||||
expect(setForm).toHaveBeenCalledOnce();
|
||||
expect(setForm.mock.calls[0][0](form)).toMatchObject({ taskKind: 'script' });
|
||||
});
|
||||
|
||||
it('updates the cron expression through the parent setter', async () => {
|
||||
const setForm = vi.fn();
|
||||
renderWithProviders(<ScheduleTimingSection form={{ ...form, scheduleType: 'cron' }} setForm={setForm} preview="" t={t} />);
|
||||
fireEvent.change(screen.getByPlaceholderText('0 9 * * 1'), { target: { value: '0 8 * * 1' } });
|
||||
expect(setForm.mock.calls.at(-1)).toMatchObject({ cronExpression: '0 8 * * 1' });
|
||||
});
|
||||
|
||||
it('keeps organization and browser profile selection in the parent state', async () => {
|
||||
const setForm = vi.fn();
|
||||
renderWithProviders(<ScheduleAccessSections form={{ ...form, visibility: 'org' }} setForm={setForm} authenticated orgs={[{ orgId: 'org-1', orgName: 'Team' }, { orgId: 'org-2', orgName: 'Other' }]} profiles={[{ id: 4, label: 'Work' }]} t={t} />);
|
||||
const selects = screen.getAllByRole('combobox');
|
||||
fireEvent.change(selects[0], { target: { value: 'org-2' } });
|
||||
expect(setForm.mock.calls.at(-1)).toMatchObject({ visibilityScopeOrgId: 'org-2' });
|
||||
fireEvent.change(selects[1], { target: { value: '4' } });
|
||||
expect(setForm.mock.calls.at(-1)).toMatchObject({ browserSessionProfileId: 4 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Visibility } from '../../api';
|
||||
import type { ScheduleFormState, TaskKind } from './scheduleEditorTypes';
|
||||
|
||||
const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS: Array<{ value: string; labelKey: string | null; label?: string; hintKey: string }> = [
|
||||
{ value: 'daily', labelKey: 'scheduleType.daily.label', hintKey: 'scheduleType.daily.hint' },
|
||||
{ value: 'weekly', labelKey: 'scheduleType.weekly.label', hintKey: 'scheduleType.weekly.hint' },
|
||||
{ value: 'monthly', labelKey: 'scheduleType.monthly.label', hintKey: 'scheduleType.monthly.hint' },
|
||||
{ value: 'cron', labelKey: null, label: 'Cron', hintKey: 'scheduleType.cron.hint' },
|
||||
{ value: 'once', labelKey: 'scheduleType.once.label', hintKey: 'scheduleType.once.hint' },
|
||||
];
|
||||
|
||||
const OUTPUT_FORMAT_OPTIONS = [
|
||||
{ value: 'markdown', label: 'markdown' },
|
||||
{ value: 'plain', label: 'plain' },
|
||||
{ value: 'json', label: 'json' },
|
||||
];
|
||||
|
||||
const INPUT_CLASS = 'w-full px-3 py-2 border border-hairline rounded-md text-[13px] outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring transition-colors';
|
||||
|
||||
type SetForm = (updater: (previous: ScheduleFormState) => ScheduleFormState) => void;
|
||||
|
||||
function FormRow({ label, help, children }: { label: string; help?: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<div className="text-2xs font-semibold text-slate-600 mb-1">{label}</div>
|
||||
{children}
|
||||
{help && <div className="text-[10px] text-slate-400 mt-1">{help}</div>}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleTaskConfigurationSection({ form, setForm, titleRef, pieceOptions, t }: {
|
||||
form: ScheduleFormState;
|
||||
setForm: SetForm;
|
||||
titleRef: React.RefObject<HTMLInputElement>;
|
||||
pieceOptions: Array<{ value: string; label: string; description: string }>;
|
||||
t: TFunction;
|
||||
}) {
|
||||
return (
|
||||
<section className="bg-canvas border border-hairline rounded-md p-5">
|
||||
<div className="section-label mb-3.5">{t('editor.basicInfo')}</div>
|
||||
<div className="space-y-3">
|
||||
<FormRow label={t('editor.kind')} help={t('editor.kindHelp')}>
|
||||
<div className="flex gap-1.5">
|
||||
{(['agent', 'script'] as const).map((kind: TaskKind) => {
|
||||
const selected = form.taskKind === kind;
|
||||
return <button key={kind} type="button" onClick={() => setForm(p => ({ ...p, taskKind: kind }))} aria-pressed={selected} className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors ${selected ? 'border-accent bg-accent-soft text-accent' : 'border-hairline bg-canvas text-slate-600'}`}>{kind === 'agent' ? t('editor.kindAgent') : t('editor.kindScript')}</button>;
|
||||
})}
|
||||
</div>
|
||||
</FormRow>
|
||||
<FormRow label={t('editor.title')}>
|
||||
<input ref={titleRef} value={form.title} onChange={e => { const title = e.target.value; setForm(p => ({ ...p, title })); }} className={INPUT_CLASS} placeholder={form.taskKind === 'script' ? t('editor.titlePlaceholderScript') : t('editor.titlePlaceholderAgent')} />
|
||||
</FormRow>
|
||||
{form.taskKind === 'agent' ? <>
|
||||
<FormRow label={t('editor.prompt')} help={t('editor.promptHelp')}>
|
||||
<textarea value={form.body} onChange={e => { const body = e.target.value; setForm(p => ({ ...p, body })); }} rows={5} className={`${INPUT_CLASS} resize-y leading-relaxed`} placeholder={t('editor.promptPlaceholder')} />
|
||||
</FormRow>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormRow label="Piece" help={pieceOptions.find(o => o.value === form.piece)?.description || undefined}>
|
||||
<select value={form.piece} onChange={e => { const piece = e.target.value; setForm(p => ({ ...p, piece })); }} className={`${INPUT_CLASS} font-mono`}>{pieceOptions.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
|
||||
</FormRow>
|
||||
<FormRow label={t('editor.outputFormat')}>
|
||||
<select value={form.outputFormat} onChange={e => { const outputFormat = e.target.value; setForm(p => ({ ...p, outputFormat })); }} className={`${INPUT_CLASS} font-mono`}>{OUTPUT_FORMAT_OPTIONS.map(o => <option key={o.value} value={o.value}>{o.label}</option>)}</select>
|
||||
</FormRow>
|
||||
</div>
|
||||
</> : <>
|
||||
<FormRow label={t('editor.scriptName')} help={t('editor.scriptNameHelp')}><input value={form.scriptName} onChange={e => { const scriptName = e.target.value; setForm(p => ({ ...p, scriptName })); }} className={`${INPUT_CLASS} font-mono`} placeholder="weekly-report" /></FormRow>
|
||||
<FormRow label="params (JSON)" help={t('editor.scriptParamsHelp')}><textarea value={form.scriptParams} onChange={e => { const scriptParams = e.target.value; setForm(p => ({ ...p, scriptParams })); }} rows={4} className={`${INPUT_CLASS} font-mono resize-y leading-relaxed`} placeholder={'{"date":"2026-05-11"}'} /></FormRow>
|
||||
</>}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleTimingSection({ form, setForm, preview, t }: { form: ScheduleFormState; setForm: SetForm; preview: string; t: TFunction }) {
|
||||
return (
|
||||
<section className="bg-canvas border border-hairline rounded-md p-5">
|
||||
<div className="section-label mb-3.5">{t('editor.scheduleSection')}</div>
|
||||
<FormRow label={t('editor.type')}><div className="flex flex-wrap gap-1.5">{SCHEDULE_TYPE_OPTIONS.map(opt => { const selected = form.scheduleType === opt.value; return <button key={opt.value} type="button" onClick={() => setForm(p => ({ ...p, scheduleType: opt.value }))} aria-pressed={selected} title={t(opt.hintKey)} className={`px-3 py-1.5 rounded-full text-xs font-semibold border transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${selected ? 'border-accent bg-accent-soft text-accent' : 'border-hairline bg-canvas text-slate-600 hover:border-hairline'}`}>{opt.labelKey ? t(opt.labelKey) : opt.label}</button>; })}</div></FormRow>
|
||||
{form.scheduleType !== 'cron' && form.scheduleType !== 'once' && <div className="grid grid-cols-2 gap-3 mt-3"><FormRow label={t('editor.time')}><div className="flex items-center gap-1"><input type="number" min={0} max={23} value={form.hour} onChange={e => { const hour = Number(e.target.value); setForm(p => ({ ...p, hour })); }} className={`${INPUT_CLASS} w-16 text-center font-mono`} /><span className="text-slate-400">:</span><input type="number" min={0} max={59} value={form.minute} onChange={e => { const minute = Number(e.target.value); setForm(p => ({ ...p, minute })); }} className={`${INPUT_CLASS} w-16 text-center font-mono`} /></div></FormRow>{form.scheduleType === 'weekly' && <FormRow label={t('editor.dayOfWeek')}><select value={form.dayOfWeek} onChange={e => { const dayOfWeek = Number(e.target.value); setForm(p => ({ ...p, dayOfWeek })); }} className={INPUT_CLASS}>{DAY_KEYS.map((day, index) => <option key={index} value={index}>{t(`dayOptions.${day}`)}</option>)}</select></FormRow>}{form.scheduleType === 'monthly' && <FormRow label={t('editor.dayOfMonth')}><input type="number" min={1} max={31} value={form.dayOfMonth} onChange={e => { const dayOfMonth = Number(e.target.value); setForm(p => ({ ...p, dayOfMonth })); }} className={INPUT_CLASS} /></FormRow>}</div>}
|
||||
{form.scheduleType === 'cron' && <div className="mt-3"><FormRow label={t('editor.cronExpression')} help={t('editor.cronHelp')}><input value={form.cronExpression} onChange={e => { const cronExpression = e.target.value; setForm(p => ({ ...p, cronExpression })); }} className={`${INPUT_CLASS} font-mono`} placeholder="0 9 * * 1" /></FormRow></div>}
|
||||
{form.scheduleType === 'once' && <div className="mt-3"><FormRow label={t('editor.runAt')}><input type="datetime-local" value={form.scheduledAt} onChange={e => { const scheduledAt = e.target.value; setForm(p => ({ ...p, scheduledAt })); }} className={INPUT_CLASS} /></FormRow></div>}
|
||||
{preview && form.scheduleType !== 'once' && <div className="mt-3 px-3 py-2.5 bg-surface border border-hairline rounded-md text-xs text-slate-600"><span className="text-[10px] font-bold text-slate-500 uppercase tracking-wide mr-2">{t('editor.preview')}</span><b className="font-semibold text-slate-900">{preview}</b></div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleAccessSections({ form, setForm, authenticated, orgs, profiles, t }: { form: ScheduleFormState; setForm: SetForm; authenticated: boolean; orgs: Array<{ orgId: string; orgName: string }>; profiles: Array<{ id: number; label: string }>; t: TFunction }) {
|
||||
return <>{authenticated && <section className="bg-canvas border border-hairline rounded-md p-5"><div className="section-label mb-3.5">{t('editor.visibilitySection')}</div><div className="flex flex-col gap-2 text-[13px]">{(['private', 'org', 'public'] as const).map((visibility: Visibility) => <label key={visibility} className="inline-flex items-center gap-2"><input type="radio" checked={form.visibility === visibility} onChange={() => setForm(p => ({ ...p, visibility }))} disabled={visibility === 'org' && orgs.length === 0} /><span>{t(`editor.vis${visibility[0].toUpperCase()}${visibility.slice(1)}`)}</span></label>)}{form.visibility === 'org' && orgs.length > 1 && <select className={`${INPUT_CLASS} mt-1`} value={form.visibilityScopeOrgId ?? ''} onChange={e => { const visibilityScopeOrgId = e.target.value || null; setForm(p => ({ ...p, visibilityScopeOrgId })); }}>{orgs.map(org => <option key={org.orgId} value={org.orgId}>{org.orgName}</option>)}</select>}{form.visibility === 'org' && orgs.length === 1 && <div className="text-2xs text-slate-500 mt-1">→ {orgs[0].orgName}</div>}{form.visibility === 'org' && orgs.length === 0 && <div className="text-2xs text-amber-700 dark:text-amber-300 mt-1">{t('editor.noOrg')}</div>}</div></section>}{profiles.length > 0 && <section className="bg-canvas border border-hairline rounded-md p-5"><div className="section-label mb-3.5">{t('editor.browserSession')}</div><select value={form.browserSessionProfileId ?? ''} onChange={e => { const browserSessionProfileId = e.target.value ? Number(e.target.value) : null; setForm(p => ({ ...p, browserSessionProfileId })); }} className={INPUT_CLASS}><option value="">{t('editor.none')}</option>{profiles.map(profile => <option key={profile.id} value={profile.id}>{profile.label}</option>)}</select><p className="text-2xs text-slate-500 mt-1">{t('editor.browserSessionHelp')}</p></section>}</>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Visibility } from '../../api';
|
||||
|
||||
export type TaskKind = 'agent' | 'script';
|
||||
|
||||
export interface ScheduleFormState {
|
||||
title: string;
|
||||
body: string;
|
||||
piece: string;
|
||||
scheduleType: string;
|
||||
hour: number;
|
||||
minute: number;
|
||||
dayOfWeek: number;
|
||||
dayOfMonth: number;
|
||||
cronExpression: string;
|
||||
scheduledAt: string;
|
||||
outputFormat: string;
|
||||
visibility: Visibility;
|
||||
visibilityScopeOrgId: string | null;
|
||||
browserSessionProfileId: number | null;
|
||||
taskKind: TaskKind;
|
||||
scriptName: string;
|
||||
scriptParams: string;
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for ChatConnectorsForm (Settings → Chat Connectors, issue #801
|
||||
* Phase 2a). Verifies: binding list rendering with resolved space name, the
|
||||
* add-binding form only offering delegations that are live + single-space, and
|
||||
* that bot credentials never appear as pre-filled values (write-only).
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { afterEach, beforeAll, describe, it, expect, vi } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
import { ChatConnectorsForm } from './ChatConnectorsForm';
|
||||
|
||||
beforeAll(async () => {
|
||||
await i18n.changeLanguage('en');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const BINDING = {
|
||||
id: 'bind-1',
|
||||
platform: 'slack',
|
||||
externalWorkspaceId: 'T123',
|
||||
externalChannelId: 'C456',
|
||||
spaceId: 'space-1',
|
||||
a2aClientId: 'client-abc',
|
||||
a2aDelegationId: 'del-1',
|
||||
status: 'active',
|
||||
createdBy: 'admin-1',
|
||||
createdAt: '2026-07-10T00:00:00.000Z',
|
||||
updatedAt: '2026-07-10T00:00:00.000Z',
|
||||
};
|
||||
|
||||
const LIVE_SINGLE_SPACE_DELEGATION = {
|
||||
id: 'del-1', userId: 'u1', clientId: 'client-abc', clientName: 'MyApp',
|
||||
grantedSpaceIds: ['space-1'], grantedSkills: ['search'],
|
||||
expiresAt: null, revokedAt: null, createdAt: '2026-01-01T00:00:00.000Z', live: true,
|
||||
};
|
||||
const MULTI_SPACE_DELEGATION = {
|
||||
id: 'del-2', userId: 'u1', clientId: 'client-xyz', clientName: 'WideApp',
|
||||
grantedSpaceIds: ['space-1', 'space-2'], grantedSkills: [],
|
||||
expiresAt: null, revokedAt: null, createdAt: '2026-01-01T00:00:00.000Z', live: true,
|
||||
};
|
||||
const SPACES = [{ id: 'space-1', title: 'Design Team' }];
|
||||
|
||||
function mockFetch({ bindings }: { bindings: unknown[] }) {
|
||||
return vi.spyOn(global, 'fetch').mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
let body: unknown = {};
|
||||
if (url.includes('/chat/bindings')) body = { bindings };
|
||||
else if (url.includes('/a2a/delegations')) body = { delegations: [LIVE_SINGLE_SPACE_DELEGATION, MULTI_SPACE_DELEGATION] };
|
||||
else if (url.includes('/local/spaces')) body = SPACES;
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(body) } as Response);
|
||||
});
|
||||
}
|
||||
|
||||
describe('ChatConnectorsForm', () => {
|
||||
it('shows the empty state when there are no bindings', async () => {
|
||||
mockFetch({ bindings: [] });
|
||||
renderWithProviders(<ChatConnectorsForm />);
|
||||
await waitFor(() => expect(screen.getByText(i18n.t('chatConnectors:empty'))).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('renders a binding row and resolves the space id to its title', async () => {
|
||||
mockFetch({ bindings: [BINDING] });
|
||||
renderWithProviders(<ChatConnectorsForm />);
|
||||
await waitFor(() => expect(screen.getByTestId('binding-bind-1')).toBeInTheDocument());
|
||||
// spaceId 'space-1' is displayed as the human-readable title, not the raw id.
|
||||
expect(screen.getByText('Design Team')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('the add form only offers live single-space delegations', async () => {
|
||||
mockFetch({ bindings: [] });
|
||||
renderWithProviders(<ChatConnectorsForm />);
|
||||
await waitFor(() => expect(screen.getByTestId('chat-connectors-add')).toBeInTheDocument());
|
||||
await userEvent.click(screen.getByTestId('chat-connectors-add'));
|
||||
expect(screen.getByTestId('chat-connectors-create-form')).toBeInTheDocument();
|
||||
// The single-space delegation is offered; the multi-space one is filtered out.
|
||||
expect(screen.getByRole('option', { name: /MyApp/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('option', { name: /WideApp/ })).not.toBeInTheDocument();
|
||||
// Credential inputs are empty (write-only — never pre-filled from the server).
|
||||
const secret = screen.getByLabelText(i18n.t('chatConnectors:credentials.signingSecret')) as HTMLInputElement;
|
||||
expect(secret.value).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
// ChatConnectorsForm — admin UI for chat connector bindings (issue #801, Phase 2a).
|
||||
// Lists /api/admin/chat/bindings and lets an admin create/enable/disable/delete
|
||||
// bindings that link a Slack channel to a workspace. Bot credentials are
|
||||
// write-only: the server never returns them, so the create/rotate form always
|
||||
// asks for a fresh signing secret + bot token.
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
fetchChatConnectorBindings,
|
||||
createChatConnectorBinding,
|
||||
updateChatConnectorBinding,
|
||||
deleteChatConnectorBinding,
|
||||
fetchAdminA2aDelegations,
|
||||
eligibleDelegationsForChatBinding,
|
||||
type ChatConnectorBinding,
|
||||
} from '../../api/chat-connectors';
|
||||
import { fetchSpaces } from '../../api/spaces';
|
||||
|
||||
const BINDINGS_KEY = ['chat-connector-bindings'];
|
||||
|
||||
export function ChatConnectorsForm() {
|
||||
const { t } = useTranslation('chatConnectors');
|
||||
const qc = useQueryClient();
|
||||
|
||||
const bindingsQ = useQuery({ queryKey: BINDINGS_KEY, queryFn: fetchChatConnectorBindings });
|
||||
const delegationsQ = useQuery({ queryKey: ['admin-a2a-delegations'], queryFn: fetchAdminA2aDelegations });
|
||||
const spacesQ = useQuery({ queryKey: ['spaces'], queryFn: fetchSpaces });
|
||||
|
||||
const spaceName = (id: string) => spacesQ.data?.find(s => s.id === id)?.title ?? id;
|
||||
|
||||
const invalidate = () => qc.invalidateQueries({ queryKey: BINDINGS_KEY });
|
||||
|
||||
const updateM = useMutation({
|
||||
mutationFn: (v: { id: string; status: 'active' | 'disabled' }) =>
|
||||
updateChatConnectorBinding(v.id, { status: v.status }),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
const deleteM = useMutation({
|
||||
mutationFn: (id: string) => deleteChatConnectorBinding(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="chat-connectors-form">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-slate-900">{t('title')}</h2>
|
||||
<p className="text-xs text-slate-500 mt-1">{t('subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{bindingsQ.isLoading && <p className="text-sm text-slate-500">{t('loading')}</p>}
|
||||
{bindingsQ.isError && <p className="text-sm text-red-600">{t('err.load')}</p>}
|
||||
|
||||
{bindingsQ.data && bindingsQ.data.length === 0 && (
|
||||
<div className="text-sm text-slate-500 border border-hairline rounded-md p-4">
|
||||
<p>{t('empty')}</p>
|
||||
<p className="text-xs mt-1">{t('emptyExplain')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{bindingsQ.data && bindingsQ.data.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{bindingsQ.data.map(b => (
|
||||
<li key={b.id} className="border border-hairline rounded-md p-3 text-sm" data-testid={`binding-${b.id}`}>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="font-mono text-xs text-slate-700 truncate">
|
||||
{b.platform} · #{b.externalChannelId} · {b.externalWorkspaceId}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1">
|
||||
{t('field.space')}: <span className="text-slate-700">{spaceName(b.spaceId)}</span>
|
||||
{' · '}
|
||||
<span className={b.status === 'active' ? 'text-green-600' : 'text-slate-400'}>
|
||||
{t(`status.${b.status}`)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1 rounded border border-hairline hover:bg-surface-2"
|
||||
disabled={updateM.isPending}
|
||||
onClick={() => updateM.mutate({ id: b.id, status: b.status === 'active' ? 'disabled' : 'active' })}
|
||||
>
|
||||
{t(b.status === 'active' ? 'action.disable' : 'action.enable')}
|
||||
</button>
|
||||
{confirmDeleteId === b.id ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1 rounded bg-red-600 text-white hover:bg-red-700"
|
||||
disabled={deleteM.isPending}
|
||||
onClick={() => { deleteM.mutate(b.id); setConfirmDeleteId(null); }}
|
||||
>
|
||||
{t('action.confirmDelete')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1 rounded border border-hairline hover:bg-surface-2"
|
||||
onClick={() => setConfirmDeleteId(null)}
|
||||
>
|
||||
{t('action.cancel')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs px-2 py-1 rounded border border-hairline text-red-600 hover:bg-red-50"
|
||||
title={t('confirmDeletePrompt')}
|
||||
onClick={() => setConfirmDeleteId(b.id)}
|
||||
>
|
||||
{t('action.delete')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{(updateM.isError || deleteM.isError) && (
|
||||
<p className="text-sm text-red-600">{t(updateM.isError ? 'err.update' : 'err.delete')}</p>
|
||||
)}
|
||||
|
||||
{showCreate ? (
|
||||
<CreateBindingForm
|
||||
delegations={eligibleDelegationsForChatBinding(delegationsQ.data ?? [])}
|
||||
spaceName={spaceName}
|
||||
onDone={() => { setShowCreate(false); invalidate(); }}
|
||||
onCancel={() => setShowCreate(false)}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-connectors-add"
|
||||
className="text-sm px-3 py-1.5 rounded-md border border-hairline hover:bg-surface-2"
|
||||
onClick={() => setShowCreate(true)}
|
||||
>
|
||||
{t('action.addBinding')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CreateBindingForm({
|
||||
delegations,
|
||||
spaceName,
|
||||
onDone,
|
||||
onCancel,
|
||||
}: {
|
||||
delegations: ReturnType<typeof eligibleDelegationsForChatBinding>;
|
||||
spaceName: (id: string) => string;
|
||||
onDone: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('chatConnectors');
|
||||
const [externalWorkspaceId, setTeamId] = useState('');
|
||||
const [externalChannelId, setChannelId] = useState('');
|
||||
const [a2aDelegationId, setDelegationId] = useState('');
|
||||
const [signingSecret, setSigningSecret] = useState('');
|
||||
const [botToken, setBotToken] = useState('');
|
||||
|
||||
const createM = useMutation({
|
||||
mutationFn: () =>
|
||||
createChatConnectorBinding({
|
||||
platform: 'slack',
|
||||
externalWorkspaceId: externalWorkspaceId.trim(),
|
||||
externalChannelId: externalChannelId.trim(),
|
||||
a2aDelegationId,
|
||||
botCredentials: { signingSecret, botToken },
|
||||
}),
|
||||
onSuccess: onDone,
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
externalWorkspaceId.trim() && externalChannelId.trim() && a2aDelegationId && signingSecret && botToken;
|
||||
|
||||
return (
|
||||
<form
|
||||
className="border border-hairline rounded-md p-4 space-y-3"
|
||||
data-testid="chat-connectors-create-form"
|
||||
onSubmit={e => { e.preventDefault(); createM.mutate(); }}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-slate-800">{t('createForm.title')}</h3>
|
||||
|
||||
<Field label={t('field.externalWorkspaceId')}>
|
||||
<input className="input" value={externalWorkspaceId} onChange={e => setTeamId(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('field.externalChannelId')}>
|
||||
<input className="input" value={externalChannelId} onChange={e => setChannelId(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
<Field label={t('field.delegation')}>
|
||||
{delegations.length === 0 ? (
|
||||
<p className="text-xs text-amber-600">{t('createForm.noDelegations')}</p>
|
||||
) : (
|
||||
<select className="input" value={a2aDelegationId} onChange={e => setDelegationId(e.target.value)}>
|
||||
<option value="">{t('createForm.delegationPlaceholder')}</option>
|
||||
{delegations.map(d => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.clientName} → {spaceName(d.grantedSpaceIds[0])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<p className="text-xs text-slate-500">{t('credentials.reenterHint')}</p>
|
||||
<Field label={t('credentials.signingSecret')}>
|
||||
<input className="input" type="password" autoComplete="off" value={signingSecret} onChange={e => setSigningSecret(e.target.value)} />
|
||||
</Field>
|
||||
<Field label={t('credentials.botToken')}>
|
||||
<input className="input" type="password" autoComplete="off" value={botToken} onChange={e => setBotToken(e.target.value)} />
|
||||
</Field>
|
||||
|
||||
{createM.isError && (
|
||||
<p className="text-sm text-red-600">{(createM.error as Error)?.message || t('err.create')}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="text-sm px-3 py-1.5 rounded-md bg-accent text-accent-fg disabled:opacity-50 hover:bg-accent-deep transition-colors"
|
||||
disabled={!canSubmit || createM.isPending}
|
||||
>
|
||||
{createM.isPending ? t('action.creating') : t('action.create')}
|
||||
</button>
|
||||
<button type="button" className="text-sm px-3 py-1.5 rounded-md border border-hairline hover:bg-surface-2" onClick={onCancel}>
|
||||
{t('action.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-xs font-medium text-slate-600 mb-1">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,7 @@
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { beforeAll, beforeEach, afterEach, describe, it, expect, vi } from 'vitest';
|
||||
import { screen } from '@testing-library/react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import i18n from '../../i18n';
|
||||
@@ -50,9 +50,9 @@ afterEach(() => {
|
||||
|
||||
const tSettings = (key: string) => i18n.t(key, { ns: 'settings' }) as string;
|
||||
|
||||
function renderConfigForm(config: any) {
|
||||
function renderConfigForm(config: any, onDraftStatusChange?: (status: { dirtySectionIds: ReadonlySet<string> }) => void) {
|
||||
mockConfigData = { config, etag: 'etag-1', overriddenByEnv: {} };
|
||||
renderWithProviders(<ConfigForm section="llm-workers" isAdmin={true} />);
|
||||
renderWithProviders(<ConfigForm section="llm-workers" isAdmin={true} onDraftStatusChange={onDraftStatusChange} />);
|
||||
}
|
||||
|
||||
const saveButton = () =>
|
||||
@@ -69,6 +69,17 @@ async function dirtyAnotherField() {
|
||||
}
|
||||
|
||||
describe('ConfigForm blocks Save & Apply while extra_body draft is invalid', () => {
|
||||
it('reports the section containing an unsaved admin config draft', async () => {
|
||||
const onDraftStatusChange = vi.fn();
|
||||
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } }, onDraftStatusChange);
|
||||
|
||||
await dirtyAnotherField();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onDraftStatusChange).toHaveBeenLastCalledWith({ dirtySectionIds: new Set(['llm-workers']) });
|
||||
});
|
||||
});
|
||||
|
||||
it('disables Save & Apply and shows a hint while extra_body is invalid, even with another dirty field', async () => {
|
||||
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
|
||||
await dirtyAnotherField();
|
||||
@@ -81,6 +92,26 @@ describe('ConfigForm blocks Save & Apply while extra_body draft is invalid', ()
|
||||
expect(screen.getByText(tSettings('configForm.invalidBlocked'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('moves focus to the invalid input from the save bar and exposes its error to assistive technology', async () => {
|
||||
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
|
||||
await dirtyAnotherField();
|
||||
|
||||
const textarea = screen.getByLabelText('extra_body');
|
||||
const scrollIntoView = vi.fn();
|
||||
Object.defineProperty(textarea, 'scrollIntoView', { configurable: true, value: scrollIntoView });
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.paste('{not valid json');
|
||||
|
||||
expect(textarea).toHaveAttribute('aria-invalid', 'true');
|
||||
expect(textarea).toHaveAttribute('aria-describedby', `${textarea.id}-error`);
|
||||
expect(document.getElementById(`${textarea.id}-error`)).toHaveAttribute('role', 'alert');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: tSettings('configForm.showInvalidField') }));
|
||||
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'center', behavior: 'smooth' });
|
||||
expect(textarea).toHaveFocus();
|
||||
});
|
||||
|
||||
it('re-enables Save & Apply once the JSON is fixed', async () => {
|
||||
renderConfigForm({ llm: { workers: [{ id: 'w1', endpoint: 'http://a/v1' }] } });
|
||||
await dirtyAnotherField();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useConfig } from '../../hooks/useConfig';
|
||||
@@ -33,13 +33,18 @@ import { AuthForm } from './AuthForm';
|
||||
import { OrgsForm } from './OrgsForm';
|
||||
import { PetsForm } from './PetsForm';
|
||||
import { A2aDelegationsForm } from './A2aDelegationsForm';
|
||||
import { ChatConnectorsForm } from './ChatConnectorsForm';
|
||||
import { useToast } from '../../hooks/useToast';
|
||||
import { settingsFieldId, type ConfigDraftStatus } from './types';
|
||||
|
||||
import { useAuthState } from '../../App';
|
||||
|
||||
interface ConfigFormProps {
|
||||
section: string;
|
||||
isAdmin: boolean;
|
||||
focusFieldKey?: string;
|
||||
focusFieldRequestId?: number;
|
||||
onDraftStatusChange?: (status: ConfigDraftStatus) => void;
|
||||
}
|
||||
|
||||
function PreferencesFormWrapper() {
|
||||
@@ -116,8 +121,14 @@ function countDiff(a: any, b: any): number {
|
||||
return norm(a) === norm(b) ? 0 : 1;
|
||||
}
|
||||
|
||||
export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
export function ConfigForm({ section, isAdmin, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const usesAdminDraft = isAdmin && !['preferences', 'notifications', 'memory-learning', 'pets', 'a2a-delegations', 'chat-connectors', 'organizations'].includes(section);
|
||||
|
||||
useEffect(() => {
|
||||
if (!usesAdminDraft) onDraftStatusChange?.({ dirtySectionIds: new Set() });
|
||||
}, [onDraftStatusChange, usesAdminDraft]);
|
||||
|
||||
// User-scoped sections use their own per-user APIs and should not load the
|
||||
// admin /api/config draft. Render them stand-alone without the global save bar.
|
||||
if (section === 'preferences') {
|
||||
@@ -143,14 +154,20 @@ export function ConfigForm({ section, isAdmin }: ConfigFormProps) {
|
||||
if (section === 'organizations') {
|
||||
return <OrgsForm />;
|
||||
}
|
||||
// Chat connectors: admin-managed via /api/admin/chat/bindings (not
|
||||
// config.yaml), so render stand-alone without the global save bar —
|
||||
// mirrors a2a-delegations/organizations above.
|
||||
if (section === 'chat-connectors') {
|
||||
return <div className="max-w-2xl"><ChatConnectorsForm /></div>;
|
||||
}
|
||||
// Step 8: 'gateway-keys' bookmarks are redirected to 'gateway-server'
|
||||
// by SettingsPage via LEGACY_SECTION_REDIRECT, so we no longer need a
|
||||
// dedicated branch here. The keys UI lives inside GatewayServerForm
|
||||
// as the Virtual Keys section.
|
||||
return <ConfigFormInner section={section} isAdmin={isAdmin} />;
|
||||
return <ConfigFormInner section={section} isAdmin={isAdmin} onDraftStatusChange={onDraftStatusChange} focusFieldKey={focusFieldKey} focusFieldRequestId={focusFieldRequestId} />;
|
||||
}
|
||||
|
||||
function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
function ConfigFormInner({ section, onDraftStatusChange, focusFieldKey, focusFieldRequestId }: ConfigFormProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const { data, isLoading, error, refetch } = useConfig();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -170,6 +187,8 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
// contract on SectionFormProps), so section switches / row removals cannot
|
||||
// leave Save bricked by a stale key.
|
||||
const [invalidKeys, setInvalidKeys] = useState<ReadonlySet<string>>(new Set());
|
||||
const [dirtySectionIds, setDirtySectionIds] = useState<ReadonlySet<string>>(new Set());
|
||||
const completedFocusRequestRef = useRef<number | undefined>();
|
||||
// Bumped whenever the draft is replaced wholesale out from under any
|
||||
// in-progress local field state (Discard Changes, or a fresh `data`
|
||||
// load/refetch below). Section forms with local "in-progress draft"
|
||||
@@ -194,6 +213,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
setOverriddenByEnv(data.overriddenByEnv);
|
||||
setIsDirty(false);
|
||||
setInvalidKeys(new Set());
|
||||
setDirtySectionIds(new Set());
|
||||
setResetToken(t => t + 1);
|
||||
}
|
||||
}, [data]);
|
||||
@@ -201,7 +221,8 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
const handleChange = useCallback((path: string, value: any) => {
|
||||
setDraft((prev: any) => setNestedValue(prev, path, value));
|
||||
setIsDirty(true);
|
||||
}, []);
|
||||
setDirtySectionIds(prev => prev.has(section) ? prev : new Set(prev).add(section));
|
||||
}, [section]);
|
||||
|
||||
const handleValidityChange = useCallback((fieldKey: string, valid: boolean) => {
|
||||
setInvalidKeys(prev => {
|
||||
@@ -213,6 +234,32 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleNavigateToInvalid = () => {
|
||||
const fieldKey = invalidKeys.values().next().value;
|
||||
if (!fieldKey) return;
|
||||
|
||||
const field = document.getElementById(settingsFieldId(fieldKey));
|
||||
if (!(field instanceof HTMLElement)) return;
|
||||
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||
field.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!focusFieldKey) return;
|
||||
if (completedFocusRequestRef.current === focusFieldRequestId) return;
|
||||
const field = document.getElementById(settingsFieldId(focusFieldKey));
|
||||
if (!(field instanceof HTMLElement)) return;
|
||||
completedFocusRequestRef.current = focusFieldRequestId;
|
||||
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
|
||||
field.scrollIntoView({ block: 'center', behavior: reduceMotion ? 'auto' : 'smooth' });
|
||||
field.focus({ preventScroll: true });
|
||||
field.classList.add('ring-2', 'ring-amber-400');
|
||||
const timer = window.setTimeout(() => field.classList.remove('ring-2', 'ring-amber-400'), 1600);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [focusFieldKey, focusFieldRequestId, draft, section]);
|
||||
|
||||
const handleDiscard = () => {
|
||||
if (data) {
|
||||
setDraft(data.config);
|
||||
@@ -223,6 +270,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
// in-progress textarea + JSON error) remounts from the reverted
|
||||
// value instead of getting stuck showing a stale error forever.
|
||||
setInvalidKeys(new Set());
|
||||
setDirtySectionIds(new Set());
|
||||
setResetToken(t => t + 1);
|
||||
}
|
||||
};
|
||||
@@ -240,6 +288,7 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: ['config'] });
|
||||
setIsDirty(false);
|
||||
setDirtySectionIds(new Set());
|
||||
setToastIsError(false);
|
||||
setToast(t('configForm.saved'));
|
||||
setTimeout(() => setToast(null), 2000);
|
||||
@@ -253,6 +302,15 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
};
|
||||
|
||||
const dirtyCount = isDirty && data ? countDiff(data.config, draft) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (dirtyCount === 0 && dirtySectionIds.size > 0) setDirtySectionIds(new Set());
|
||||
}, [dirtyCount, dirtySectionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
onDraftStatusChange?.({ dirtySectionIds: dirtyCount > 0 ? dirtySectionIds : new Set() });
|
||||
}, [dirtyCount, dirtySectionIds, onDraftStatusChange]);
|
||||
|
||||
// Arm beforeunload + register an in-app guard so navigating elsewhere
|
||||
// (e.g. clicking a TopBar tab) prompts when there are unsaved fields.
|
||||
useUnsavedGuard(dirtyCount > 0);
|
||||
@@ -354,15 +412,24 @@ function ConfigFormInner({ section }: ConfigFormProps) {
|
||||
{toast}
|
||||
</span>
|
||||
) : blockedByInvalid ? (
|
||||
<span className="text-xs mr-auto text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" aria-hidden />
|
||||
<span className="truncate">{t('configForm.invalidBlocked')}</span>
|
||||
</span>
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2">
|
||||
<span className="text-xs text-red-600 dark:text-red-400 flex items-center gap-1.5 font-medium min-w-0" aria-live="polite">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-red-500 flex-shrink-0" aria-hidden />
|
||||
<span className="truncate">{t('configForm.invalidBlocked')}</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNavigateToInvalid}
|
||||
className="h-8 flex-shrink-0 rounded-md border border-red-300 bg-canvas px-2 text-xs font-medium text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/10"
|
||||
>
|
||||
{t('configForm.showInvalidField')}
|
||||
</button>
|
||||
</div>
|
||||
) : dirty ? (
|
||||
<span className="text-xs mr-auto text-amber-800 dark:text-amber-300 flex items-center gap-1.5 font-medium min-w-0">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-amber-500 animate-pulse flex-shrink-0" aria-hidden />
|
||||
<span className="truncate">
|
||||
<span className="hidden sm:inline">{t('configForm.unsaved', { count: dirtyCount })}</span>
|
||||
<span className="hidden sm:inline">{t('configForm.unsavedAdmin', { count: dirtyCount })}</span>
|
||||
<span className="sm:hidden">{t('configForm.unsavedShort', { count: dirtyCount })}</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import { SecretInput } from './SecretInput';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
import { settingsFieldId, type SectionFormProps } from './types';
|
||||
|
||||
/**
|
||||
* Worker entry shape used by the v2 `llm.workers[]` config block. The
|
||||
@@ -127,6 +127,8 @@ function ExtraBodyField({ value, onChange, fieldKey, onValidityChange }: {
|
||||
const { t } = useTranslation('settings');
|
||||
const [text, setText] = useState(() => (value === undefined ? '' : JSON.stringify(value, null, 2)));
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const inputId = settingsFieldId(fieldKey);
|
||||
const errorId = `${inputId}-error`;
|
||||
// Last value WE pushed via onChange. If the prop diverges from it, the
|
||||
// change came from outside (discard / row shift) → re-sync the draft.
|
||||
const lastEmitted = useRef(value);
|
||||
@@ -175,7 +177,10 @@ function ExtraBodyField({ value, onChange, fieldKey, onValidityChange }: {
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>extra_body</FieldLabel>
|
||||
<textarea
|
||||
id={inputId}
|
||||
aria-label="extra_body"
|
||||
aria-invalid={error !== null}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
value={text}
|
||||
onChange={e => handleChange(e.target.value)}
|
||||
rows={4}
|
||||
@@ -184,7 +189,7 @@ function ExtraBodyField({ value, onChange, fieldKey, onValidityChange }: {
|
||||
error ? 'border-red-400 focus:border-red-400' : 'border-hairline focus:border-accent'
|
||||
}`}
|
||||
/>
|
||||
{error && <p className="text-2xs text-red-600 mt-1">{error}</p>}
|
||||
{error && <p id={errorId} role="alert" className="text-2xs text-red-600 mt-1">{error}</p>}
|
||||
<HelpText>{t('llmWorkers.extraBodyHelp')}</HelpText>
|
||||
</div>
|
||||
);
|
||||
@@ -554,8 +559,9 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv, onValidityCh
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Timeout (minutes)</FieldLabel>
|
||||
<FieldLabel htmlFor={settingsFieldId('llm.timeoutMinutes')}>Timeout (minutes)</FieldLabel>
|
||||
<FieldInput
|
||||
id={settingsFieldId('llm.timeoutMinutes')}
|
||||
type="number"
|
||||
value={llm.timeoutMinutes ?? 10}
|
||||
onChange={v => onChange('llm.timeoutMinutes', Number(v))}
|
||||
@@ -564,8 +570,9 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv, onValidityCh
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Stream (minutes)</FieldLabel>
|
||||
<FieldLabel htmlFor={settingsFieldId('llm.maxStreamMinutes')}>Max Stream (minutes)</FieldLabel>
|
||||
<FieldInput
|
||||
id={settingsFieldId('llm.maxStreamMinutes')}
|
||||
type="number"
|
||||
value={llm.maxStreamMinutes ?? ''}
|
||||
onChange={v => onChange('llm.maxStreamMinutes', v === '' ? undefined : Number(v))}
|
||||
|
||||
@@ -47,6 +47,27 @@ describe('SettingsSidebar search', () => {
|
||||
expect(screen.queryByTestId('settings-search-result-safety')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a field path and requests direct field navigation', async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onSelectField = vi.fn();
|
||||
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} onSelectField={onSelectField} />);
|
||||
|
||||
await userEvent.type(screen.getByTestId('settings-search'), 'searxng_url');
|
||||
const result = screen.getByTestId('settings-search-field-result-tools.searxngUrl');
|
||||
expect(result).toHaveTextContent('Web & Search > SearXNG URL');
|
||||
|
||||
await userEvent.click(result);
|
||||
expect(onSelectField).toHaveBeenCalledWith('tools-web', 'tools.searxngUrl');
|
||||
});
|
||||
|
||||
it('marks a section with unsaved admin configuration changes', () => {
|
||||
const onSelect = vi.fn();
|
||||
renderWithProviders(<SettingsSidebar isAdmin onSelectSection={onSelect} dirtySectionIds={new Set(['safety'])} />);
|
||||
|
||||
expect(screen.getByTestId('settings-nav-dirty-safety')).toHaveAttribute('aria-label', 'settingsSidebar.unsaved');
|
||||
expect(screen.queryByTestId('settings-nav-dirty-branding')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the A2A Delegations section when a2a is disabled (default fail-closed)', () => {
|
||||
const onSelect = vi.fn();
|
||||
// Omitting a2aEnabled must default to hidden — the delegations API route
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
|
||||
import { buildSettingsFieldSearchIndex, buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
|
||||
|
||||
interface SettingsSidebarProps {
|
||||
activeSection?: string;
|
||||
onSelectSection: (section: string) => void;
|
||||
onSelectField?: (section: string, fieldId: string) => void;
|
||||
isAdmin: boolean;
|
||||
/**
|
||||
* Whether A2A is enabled server-side. The A2A Delegations section is per-user
|
||||
@@ -12,6 +13,15 @@ interface SettingsSidebarProps {
|
||||
* so the section is hidden unless this is true (defaults false = fail-closed).
|
||||
*/
|
||||
a2aEnabled?: boolean;
|
||||
/**
|
||||
* Whether the chat connector subsystem is enabled server-side. The Chat
|
||||
* Connectors section is admin-only and its API route
|
||||
* (`/api/admin/chat/bindings`) only mounts when chat.enabled, so the
|
||||
* section is hidden unless this is true (defaults false = fail-closed).
|
||||
*/
|
||||
chatEnabled?: boolean;
|
||||
/** Admin config sections with draft changes not saved to config.yaml yet. */
|
||||
dirtySectionIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -93,6 +103,7 @@ export const CONFIG_GROUPS = [
|
||||
adminOnly: true,
|
||||
sections: [
|
||||
{ id: 'mcp', label: 'MCP Runtime' },
|
||||
{ id: 'chat-connectors', label: '💬 Chat Connectors', labelKey: 'chatConnectors.navLabel' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -142,17 +153,18 @@ export const USER_SECTIONS: string[] = CONFIG_GROUPS
|
||||
.flatMap(g => g.sections.map(s => s.id));
|
||||
|
||||
/** Sections gated on a runtime feature flag rather than admin role. */
|
||||
function isSectionAvailable(sectionId: string, a2aEnabled: boolean): boolean {
|
||||
function isSectionAvailable(sectionId: string, a2aEnabled: boolean, chatEnabled: boolean): boolean {
|
||||
if (sectionId === 'a2a-delegations') return a2aEnabled;
|
||||
if (sectionId === 'chat-connectors') return chatEnabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEnabled = false }: SettingsSidebarProps) {
|
||||
export function SettingsSidebar({ activeSection, onSelectSection, onSelectField, isAdmin, a2aEnabled = false, chatEnabled = false, dirtySectionIds }: SettingsSidebarProps) {
|
||||
const { t } = useTranslation('settings');
|
||||
const [query, setQuery] = useState('');
|
||||
const visibleGroups = CONFIG_GROUPS
|
||||
.filter(g => isAdmin || !('adminOnly' in g) || !g.adminOnly)
|
||||
.map(g => ({ ...g, sections: g.sections.filter(s => isSectionAvailable(s.id, a2aEnabled)) }))
|
||||
.map(g => ({ ...g, sections: g.sections.filter(s => isSectionAvailable(s.id, a2aEnabled, chatEnabled)) }))
|
||||
.filter(g => g.sections.length > 0);
|
||||
|
||||
// Only sections the current user can actually open are searchable.
|
||||
@@ -162,6 +174,10 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEn
|
||||
);
|
||||
const index = useMemo(() => buildSettingsSearchIndex().filter(e => visibleIds.has(e.sectionId)), [visibleIds]);
|
||||
const results = useMemo(() => searchSettings(query, index), [query, index]);
|
||||
const fieldResults = useMemo(
|
||||
() => searchSettings(query, buildSettingsFieldSearchIndex().filter(e => visibleIds.has(e.sectionId))),
|
||||
[query, visibleIds],
|
||||
);
|
||||
const searching = query.trim().length > 0;
|
||||
const labelFor = (id: string, fallback: string) => {
|
||||
for (const g of visibleGroups) {
|
||||
@@ -185,10 +201,18 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEn
|
||||
|
||||
{searching ? (
|
||||
<div data-testid="settings-search-results">
|
||||
{results.length === 0 ? (
|
||||
{results.length === 0 && fieldResults.length === 0 ? (
|
||||
<div className="px-2 py-1 text-xs text-slate-400">{t('search.noResults')}</div>
|
||||
) : (
|
||||
results.map(r => {
|
||||
<>
|
||||
{fieldResults.map(r => (
|
||||
<button key={`field-${r.fieldId}`} data-testid={`settings-search-field-result-${r.fieldId}`}
|
||||
onClick={() => { onSelectField?.(r.sectionId, r.fieldId); setQuery(''); }}
|
||||
className="block w-full text-left px-2 py-1 rounded text-xs mb-0.5 text-slate-700 hover:bg-surface">
|
||||
<span>{r.label} <span className="text-slate-400">></span> {r.fieldLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
{results.map(r => {
|
||||
const active = activeSection === r.sectionId;
|
||||
return (
|
||||
<button
|
||||
@@ -203,7 +227,8 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEn
|
||||
<span className="ml-1 text-2xs text-slate-400">· {r.group}</span>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
@@ -219,7 +244,15 @@ export function SettingsSidebar({ activeSection, onSelectSection, isAdmin, a2aEn
|
||||
? 'bg-accent-soft text-accent font-semibold'
|
||||
: 'text-slate-700 hover:bg-surface'
|
||||
}`}>
|
||||
{'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label}
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span>{'labelKey' in s && s.labelKey ? t(s.labelKey) : s.label}</span>
|
||||
{dirtySectionIds?.has(s.id) && (
|
||||
<span data-testid={`settings-nav-dirty-${s.id}`} className="inline-flex items-center gap-1" aria-label={t('settingsSidebar.unsaved')}>
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-amber-500" aria-hidden />
|
||||
<span className="sr-only">{t('settingsSidebar.unsaved')}</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import type { SectionFormProps } from './types';
|
||||
import { settingsFieldId } from './types';
|
||||
|
||||
/**
|
||||
* SSH global config editor (`ssh.*` + nested `ssh.console.*`).
|
||||
@@ -21,6 +22,7 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm text-slate-700">
|
||||
<input
|
||||
id={settingsFieldId('ssh.enabled')}
|
||||
type="checkbox"
|
||||
checked={ssh.enabled === true}
|
||||
onChange={e => onChange('ssh.enabled', e.target.checked)}
|
||||
@@ -62,8 +64,9 @@ export function SshConfigForm({ config, onChange, overriddenByEnv: _overriddenBy
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>{t('ssh.config.callTimeout')}</FieldLabel>
|
||||
<FieldLabel htmlFor={settingsFieldId('ssh.callTimeoutSeconds')}>{t('ssh.config.callTimeout')}</FieldLabel>
|
||||
<FieldInput
|
||||
id={settingsFieldId('ssh.callTimeoutSeconds')}
|
||||
type="number"
|
||||
value={ssh.callTimeoutSeconds ?? 30}
|
||||
onChange={v => onChange('ssh.callTimeoutSeconds', Number(v))}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { HelpText } from './HelpText';
|
||||
import { FieldLabel, FieldInput } from './formUtils';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import type { SectionFormProps } from './types';
|
||||
import { settingsFieldId } from './types';
|
||||
|
||||
/**
|
||||
* Web & Search settings.
|
||||
@@ -37,18 +38,18 @@ export function ToolsWebForm({ config, onChange }: SectionFormProps) {
|
||||
Web Fetch / Search
|
||||
</h3>
|
||||
<div>
|
||||
<FieldLabel>SearXNG URL</FieldLabel>
|
||||
<FieldInput value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
|
||||
<FieldLabel htmlFor={settingsFieldId('tools.searxngUrl')}>SearXNG URL</FieldLabel>
|
||||
<FieldInput id={settingsFieldId('tools.searxngUrl')} value={tools.searxngUrl ?? ''} onChange={v => onChange('tools.searxngUrl', v)} />
|
||||
<HelpText>{t('tools.web.searxngHelp')}</HelpText>
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>{t('tools.web.webfetchTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.webfetchTimeout ?? 30}
|
||||
<FieldLabel htmlFor={settingsFieldId('tools.webfetchTimeout')}>{t('tools.web.webfetchTimeout')}</FieldLabel>
|
||||
<FieldInput id={settingsFieldId('tools.webfetchTimeout')} type="number" value={tools.webfetchTimeout ?? 30}
|
||||
onChange={v => onChange('tools.webfetchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
<FieldLabel>{t('tools.web.websearchTimeout')}</FieldLabel>
|
||||
<FieldInput type="number" value={tools.websearchTimeout ?? 15}
|
||||
<FieldLabel htmlFor={settingsFieldId('tools.websearchTimeout')}>{t('tools.web.websearchTimeout')}</FieldLabel>
|
||||
<FieldInput id={settingsFieldId('tools.websearchTimeout')} type="number" value={tools.websearchTimeout ?? 15}
|
||||
onChange={v => onChange('tools.websearchTimeout', Number(v))} />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -9,11 +9,12 @@ export function EnvOverrideWarning() {
|
||||
);
|
||||
}
|
||||
|
||||
export function FieldLabel({ children }: { children: React.ReactNode }) {
|
||||
return <label className="block text-2xs font-medium text-slate-600 mb-1">{children}</label>;
|
||||
export function FieldLabel({ children, htmlFor }: { children: React.ReactNode; htmlFor?: string }) {
|
||||
return <label htmlFor={htmlFor} className="block text-2xs font-medium text-slate-600 mb-1">{children}</label>;
|
||||
}
|
||||
|
||||
interface FieldInputProps {
|
||||
id?: string;
|
||||
value: string | number;
|
||||
onChange: (value: string) => void;
|
||||
type?: 'text' | 'number' | 'password';
|
||||
@@ -26,9 +27,10 @@ interface FieldInputProps {
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
export function FieldInput({ value, onChange, type = 'text', placeholder, disabled, disabledReason, ...rest }: FieldInputProps) {
|
||||
export function FieldInput({ id, value, onChange, type = 'text', placeholder, disabled, disabledReason, ...rest }: FieldInputProps) {
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { CONFIG_GROUPS } from './SettingsSidebar';
|
||||
import { buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
|
||||
import { buildSettingsFieldSearchIndex, buildSettingsSearchIndex, searchSettings } from './settingsSearchIndex';
|
||||
|
||||
describe('searchSettings', () => {
|
||||
it('finds a section by a config-key keyword buried in a form', () => {
|
||||
@@ -37,6 +37,15 @@ describe('searchSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('field search index', () => {
|
||||
it('finds a fixed field by its config key and keeps the target section', () => {
|
||||
const results = searchSettings('searxng_url', buildSettingsFieldSearchIndex());
|
||||
expect(results).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ sectionId: 'tools-web', fieldId: 'tools.searxngUrl' }),
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('index integrity (drift guard)', () => {
|
||||
const allIds = CONFIG_GROUPS.flatMap(g => g.sections.map(s => s.id));
|
||||
|
||||
|
||||
@@ -26,6 +26,21 @@ export interface SettingsSearchEntry {
|
||||
keywords: string;
|
||||
}
|
||||
|
||||
export interface SettingsFieldSearchEntry extends SettingsSearchEntry {
|
||||
fieldId: string;
|
||||
fieldLabel: string;
|
||||
}
|
||||
|
||||
const FIELD_ENTRIES: SettingsFieldSearchEntry[] = [
|
||||
{ sectionId: 'llm-workers', group: 'LLM', label: 'Workers', fieldId: 'llm.timeoutMinutes', fieldLabel: 'Timeout (minutes)', keywords: 'timeout timeout_minutes request deadline タイムアウト' },
|
||||
{ sectionId: 'llm-workers', group: 'LLM', label: 'Workers', fieldId: 'llm.maxStreamMinutes', fieldLabel: 'Max Stream (minutes)', keywords: 'max_stream_minutes stream duration ストリーム 上限時間' },
|
||||
{ sectionId: 'tools-web', group: 'Tools', label: 'Web & Search', fieldId: 'tools.searxngUrl', fieldLabel: 'SearXNG URL', keywords: 'searxng_url search endpoint 検索 URL' },
|
||||
{ sectionId: 'tools-web', group: 'Tools', label: 'Web & Search', fieldId: 'tools.webfetchTimeout', fieldLabel: 'Web fetch timeout', keywords: 'webfetch_timeout fetch timeout ウェブ取得 タイムアウト' },
|
||||
{ sectionId: 'tools-web', group: 'Tools', label: 'Web & Search', fieldId: 'tools.websearchTimeout', fieldLabel: 'Web search timeout', keywords: 'websearch_timeout search timeout 検索 タイムアウト' },
|
||||
{ sectionId: 'ssh', group: 'SSH', label: 'Admin SSH', fieldId: 'ssh.enabled', fieldLabel: 'Enable SSH', keywords: 'ssh enabled enable 有効化' },
|
||||
{ sectionId: 'ssh', group: 'SSH', label: 'Admin SSH', fieldId: 'ssh.callTimeoutSeconds', fieldLabel: 'Call timeout', keywords: 'ssh call_timeout_seconds timeout 接続 タイムアウト' },
|
||||
];
|
||||
|
||||
/** sectionId → extra searchable keywords (config keys, concepts, JP terms). */
|
||||
const KEYWORDS: Record<string, string> = {
|
||||
// Preference
|
||||
@@ -59,6 +74,7 @@ const KEYWORDS: Record<string, string> = {
|
||||
'search-filter': 'search filter domain allow deny websearch ドメイン 許可 除外',
|
||||
// MCP & Connections
|
||||
mcp: 'mcp model context protocol runtime quota サーバー クォータ',
|
||||
'chat-connectors': 'chat connectors slack binding channel workspace delegation bot token signing secret チャット連携 スラック バインディング チャンネル 委任',
|
||||
// SSH
|
||||
ssh: 'ssh remote connection grant audit master key 接続 監査 鍵ローテーション',
|
||||
// Network
|
||||
@@ -81,6 +97,10 @@ export function buildSettingsSearchIndex(): SettingsSearchEntry[] {
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function buildSettingsFieldSearchIndex(): SettingsFieldSearchEntry[] {
|
||||
return FIELD_ENTRIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive AND search: every whitespace-separated term must appear in
|
||||
* the section's label, group, id, or keywords. Empty query → no results (the
|
||||
|
||||
@@ -30,3 +30,12 @@ export interface SectionFormProps {
|
||||
*/
|
||||
resetToken?: number;
|
||||
}
|
||||
|
||||
/** Unsaved state shared from the admin config draft to Settings navigation. */
|
||||
export interface ConfigDraftStatus {
|
||||
dirtySectionIds: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** Converts a stable form field key into a DOM id for error navigation. */
|
||||
export const settingsFieldId = (fieldKey: string) =>
|
||||
`settings-field-${fieldKey.replace(/[^a-zA-Z0-9_-]/g, '-')}`;
|
||||
|
||||
@@ -539,6 +539,7 @@ function EventForm({
|
||||
const [endDate, setEndDate] = useState(event?.endDate ?? '');
|
||||
const [time, setTime] = useState(event?.time ?? '');
|
||||
const [endTime, setEndTime] = useState(event?.endTime ?? '');
|
||||
const [reminderMinutes, setReminderMinutes] = useState(event?.reminderMinutes?.toString() ?? '');
|
||||
const [description, setDescription] = useState(event?.description ?? '');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -561,6 +562,7 @@ function EventForm({
|
||||
endDate: endDate && endDate > date ? endDate : null,
|
||||
time: time ? time : null,
|
||||
endTime: effEndTime,
|
||||
reminderMinutes: reminderMinutes === '' ? null : Number(reminderMinutes),
|
||||
title: title.trim(),
|
||||
description: description ? description : null,
|
||||
};
|
||||
@@ -572,7 +574,7 @@ function EventForm({
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [title, date, endDate, time, endTime, description, event, spaceId, onSaved]);
|
||||
}, [title, date, endDate, time, endTime, reminderMinutes, description, event, spaceId, onSaved]);
|
||||
|
||||
return (
|
||||
<div data-testid="space-cal-add-event" className="mb-2 space-y-2 rounded-md border border-hairline bg-surface p-2.5">
|
||||
@@ -597,10 +599,21 @@ function EventForm({
|
||||
type="time"
|
||||
data-testid="space-cal-event-time"
|
||||
value={time}
|
||||
onChange={e => setTime(e.target.value)}
|
||||
onChange={e => { setTime(e.target.value); if (!e.target.value) setReminderMinutes(''); }}
|
||||
className="w-28 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 focus:outline-none focus:ring-2 focus:ring-accent-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">通知</label>
|
||||
<select data-testid="space-cal-event-reminder" value={reminderMinutes} disabled={!time} onChange={e => setReminderMinutes(e.target.value)} className="flex-1 rounded-md border border-hairline bg-canvas px-2 py-1.5 text-xs text-slate-700 disabled:cursor-not-allowed disabled:opacity-50">
|
||||
<option value="">なし</option>
|
||||
<option value="0">開始時</option>
|
||||
<option value="5">5分前</option>
|
||||
<option value="10">10分前</option>
|
||||
<option value="30">30分前</option>
|
||||
<option value="60">1時間前</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-10 shrink-0 text-2xs font-medium text-slate-500">{t('calendar.form.end')}</label>
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
vi.mock('react-i18next', async importOriginal => ({
|
||||
...await importOriginal<typeof import('react-i18next')>(),
|
||||
useTranslation: () => ({ t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock('../../hooks/useSpaces', () => ({
|
||||
useSpaces: () => ({ data: [{ id: 'space-1', title: 'Project A', kind: 'personal' }] }),
|
||||
useArchiveSpace: () => ({ mutateAsync: vi.fn() }),
|
||||
}));
|
||||
vi.mock('../../hooks/useSpaceBranding', () => ({ useSpaceBranding: vi.fn() }));
|
||||
vi.mock('../../App', () => ({ useAuthState: () => ({ mode: 'disabled' }) }));
|
||||
vi.mock('./SpaceSettings', () => ({ SpaceSettings: ({ spaceId }: { spaceId: string }) => <div data-testid="space-settings">settings for {spaceId}</div> }));
|
||||
|
||||
import { SpaceDetail } from './SpaceDetail';
|
||||
|
||||
const props = {
|
||||
spaceId: 'space-1',
|
||||
spaceTaskId: undefined,
|
||||
onSelectSpaceTask: vi.fn(),
|
||||
onCreateTask: vi.fn().mockResolvedValue(undefined),
|
||||
onOpenTask: vi.fn(),
|
||||
chatFilter: { search: '', status: 'all' as const, sort: 'updated' as const, scope: 'mine' as const },
|
||||
onChatFilterChange: vi.fn(),
|
||||
};
|
||||
|
||||
describe('SpaceDetail initial settings tab', () => {
|
||||
it('opens workspace settings from the initial tab and returns to app settings', async () => {
|
||||
const onInitialTabApplied = vi.fn();
|
||||
const onOpenAppSettings = vi.fn();
|
||||
render(<SpaceDetail {...props} initialTab="settings" onInitialTabApplied={onInitialTabApplied} onOpenAppSettings={onOpenAppSettings} />);
|
||||
|
||||
expect(await screen.findByTestId('space-settings')).toHaveTextContent('settings for space-1');
|
||||
await waitFor(() => expect(onInitialTabApplied).toHaveBeenCalledOnce());
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: '個人・システム設定に戻る' }));
|
||||
expect(onOpenAppSettings).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,9 @@ export interface SpaceChatFilter {
|
||||
interface SpaceDetailProps {
|
||||
spaceId?: string;
|
||||
spaceTaskId?: number;
|
||||
initialTab?: SpaceTab;
|
||||
onInitialTabApplied?: () => void;
|
||||
onOpenAppSettings?: () => void;
|
||||
onSelectSpace?: (id: string | undefined) => void;
|
||||
onSelectSpaceTask: (id: number) => void;
|
||||
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
@@ -91,10 +94,15 @@ interface SpaceDetailProps {
|
||||
|
||||
type SpaceTab = 'chat' | 'files' | 'apps' | 'calendar' | 'schedules' | 'settings';
|
||||
|
||||
export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
|
||||
export function SpaceDetail({ spaceId, spaceTaskId, initialTab, onInitialTabApplied, onOpenAppSettings, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpaceDetailProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const { data: spaces } = useSpaces();
|
||||
const [tab, setTab] = useState<SpaceTab>('chat');
|
||||
const [tab, setTab] = useState<SpaceTab>(initialTab ?? 'chat');
|
||||
useEffect(() => {
|
||||
if (!initialTab) return;
|
||||
setTab(initialTab);
|
||||
onInitialTabApplied?.();
|
||||
}, [initialTab, onInitialTabApplied, spaceId]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const auth = useAuthState();
|
||||
|
||||
@@ -203,7 +211,19 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
/>
|
||||
)}
|
||||
{tab === 'schedules' && <SchedulesPage key={spaceId} spaceId={spaceId} />}
|
||||
{tab === 'settings' && <SpaceSettings key={spaceId} spaceId={spaceId} />}
|
||||
{tab === 'settings' && (
|
||||
<div className="flex h-full flex-col">
|
||||
{onOpenAppSettings && (
|
||||
<div className="shrink-0 border-b border-hairline px-3 py-2 text-xs text-slate-600">
|
||||
この設定は「{space.title}」だけに適用されます。{' '}
|
||||
<button type="button" onClick={onOpenAppSettings} className="font-medium text-blue-700 hover:underline">
|
||||
個人・システム設定に戻る
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1 overflow-hidden"><SpaceSettings key={spaceId} spaceId={spaceId} /></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -424,6 +444,7 @@ function SpaceChat({
|
||||
const { data: allTasks } = useLocalTaskList();
|
||||
const { data: spaces } = useSpaces();
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createHost, setCreateHost] = useState<HTMLDivElement | null>(null);
|
||||
const { search: searchQuery, status: selectedStatus, sort: sortMode, scope } = filter;
|
||||
const setScope = (val: TaskScope) => onFilterChange({ scope: val });
|
||||
const setSearchQuery = (val: string) => onFilterChange({ search: val });
|
||||
@@ -485,7 +506,7 @@ function SpaceChat({
|
||||
{/* 左: チャット一覧。会話を開いている狭幅では隠す(会話が一覧を置き換える)。 */}
|
||||
<div
|
||||
className={`w-full md:w-[280px] md:shrink-0 flex flex-col min-h-0 overflow-hidden border-r border-hairline ${
|
||||
spaceTaskId != null ? 'hidden md:flex' : 'flex'
|
||||
spaceTaskId != null || showCreate ? 'hidden md:flex' : 'flex'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-2.5 border-b border-hairline">
|
||||
@@ -575,7 +596,9 @@ function SpaceChat({
|
||||
|
||||
{/* 右: 選択したチャットの会話をインライン表示。 */}
|
||||
<div className="flex-1 min-w-0 min-h-0 overflow-hidden">
|
||||
{spaceTaskId != null ? (
|
||||
{showCreate ? (
|
||||
<div ref={setCreateHost} className="h-full w-full overflow-hidden" data-testid="space-inline-create" />
|
||||
) : spaceTaskId != null ? (
|
||||
// key={spaceTaskId}: チャット切替で会話サブツリー(ChatPane 含む)を remount し、
|
||||
// 入力中の下書き・添付が別チャットへ持ち越されないようにする。詳細タブの切替では
|
||||
// remount しないので、2ペイン内のチャット入力は保たれる。
|
||||
@@ -591,7 +614,7 @@ function SpaceChat({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showCreate && (
|
||||
{showCreate && createHost && (
|
||||
<CreateTaskDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
onSubmit={async (input, attachments) => {
|
||||
@@ -599,6 +622,7 @@ function SpaceChat({
|
||||
setShowCreate(false);
|
||||
}}
|
||||
initialSpaceId={spaceId}
|
||||
inlineContainer={createHost}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
|
||||
import type { Space } from '../../api';
|
||||
import i18n from '../../i18n';
|
||||
|
||||
@@ -225,4 +225,140 @@ describe('SpaceRail', () => {
|
||||
fireEvent.click(screen.getByTestId('space-display-undo'));
|
||||
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { favorite: false, hidden: false } }));
|
||||
});
|
||||
|
||||
it('opens the row menu on right-click (contextmenu)', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
|
||||
fireEvent.contextMenu(screen.getByTestId('space-row'));
|
||||
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
|
||||
expect(screen.getByText('非表示にする')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns focus to the row menu button after a right-click-opened menu closes', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
fireEvent.contextMenu(screen.getByTestId('space-row'));
|
||||
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
|
||||
// Esc で閉じたとき、フォーカスは body ではなく同じ行の … ボタンへ戻る。
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
|
||||
expect(document.activeElement).toBe(screen.getByTestId('space-row-menu'));
|
||||
});
|
||||
|
||||
it('closes the menu when clicking outside of it', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('space-row-menu'));
|
||||
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
|
||||
|
||||
// メニュー外(ここでは見出し)を押すと閉じる。
|
||||
fireEvent.pointerDown(screen.getByText('ワークスペース'));
|
||||
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the menu on Escape', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('space-row-menu'));
|
||||
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('closes the menu when the list scrolls (fixed portal would detach from its row)', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('space-row-menu'));
|
||||
expect(screen.getByTestId('space-row-menu-panel')).toBeInTheDocument();
|
||||
// 一覧スクロールで閉じる(capture フェーズで拾う)。
|
||||
fireEvent.scroll(window);
|
||||
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
|
||||
});
|
||||
|
||||
it('moves focus into the menu on open and back to the trigger on Escape', () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
const trigger = screen.getByTestId('space-row-menu');
|
||||
fireEvent.click(trigger);
|
||||
// 開いた直後は先頭のメニュー項目にフォーカスが移る。
|
||||
const firstItem = screen.getByText('お気に入りに追加');
|
||||
expect(document.activeElement).toBe(firstItem);
|
||||
|
||||
// Esc で閉じるとフォーカスはトリガー(… ボタン)へ戻る。
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
|
||||
it('returns focus to the trigger after executing a menu item', async () => {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
const trigger = screen.getByTestId('space-row-menu');
|
||||
fireEvent.click(trigger);
|
||||
fireEvent.click(screen.getByText('非表示にする'));
|
||||
// 実行後もメニューは閉じ、フォーカスはトリガーへ戻る(body へ失われない)。
|
||||
expect(screen.queryByTestId('space-row-menu-panel')).toBeNull();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
await waitFor(() => expect(updateDisplayMock.mutateAsync).toHaveBeenCalledWith({ id: 'c1', patch: { hidden: true } }));
|
||||
});
|
||||
|
||||
it('auto-dismisses the undo toast after the timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
useSpacesMock.mockReturnValue({
|
||||
data: [space({ id: 'c1', kind: 'case', title: 'Project A' })],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
render(<SpaceRail onSelect={() => {}} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('space-row-menu'));
|
||||
fireEvent.click(screen.getByText('非表示にする'));
|
||||
// changeDisplay は mutateAsync(解決済み)を await した後に undo を立てる。
|
||||
// フェイクタイマー下では waitFor が使えないのでマイクロタスクを直接フラッシュする。
|
||||
await act(async () => { await Promise.resolve(); await Promise.resolve(); });
|
||||
expect(screen.getByTestId('space-display-toast')).toBeInTheDocument();
|
||||
|
||||
act(() => { vi.advanceTimersByTime(5000); });
|
||||
expect(screen.queryByTestId('space-display-toast')).toBeNull();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthState } from '../../App';
|
||||
import { useSpaces, useUpdateSpaceDisplayPrefs } from '../../hooks/useSpaces';
|
||||
@@ -37,6 +38,22 @@ interface UndoState {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// カーソル位置に開くコンテキストメニューの状態(開いている行 + 表示座標 + フォーカス復帰先)。
|
||||
interface MenuState {
|
||||
space: Space;
|
||||
x: number;
|
||||
y: number;
|
||||
// 閉じたときにフォーカスを戻す要素(… ボタン起動時のみ。右クリック起動では null)。
|
||||
trigger: HTMLElement | null;
|
||||
}
|
||||
|
||||
// メニューの見積もりサイズ。画面端でのはみ出しクランプに使う。
|
||||
const MENU_WIDTH = 160;
|
||||
const MENU_HEIGHT = 84;
|
||||
const MENU_MARGIN = 8;
|
||||
// 取り消し通知が自動で消えるまでの時間(ミリ秒)。
|
||||
const UNDO_DISMISS_MS = 5000;
|
||||
|
||||
const normalize = (value: string) => value.trim().toLocaleLowerCase();
|
||||
const isFavorite = (space: Space) => space.favorite === true;
|
||||
const isHidden = (space: Space) => space.hidden === true;
|
||||
@@ -49,7 +66,8 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [menu, setMenu] = useState<MenuState | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [undo, setUndo] = useState<UndoState | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -99,8 +117,67 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
const runningCount = (space: Space) =>
|
||||
countRunningTasksForSpace(tasks ?? [], space, { viewerId: myUserId, isAdmin, caseSpaceIds });
|
||||
|
||||
// 指定座標にメニューを開く。画面右端・下端でのはみ出しはクランプする。
|
||||
// trigger は閉じたときのフォーカス復帰先(… ボタン / 右クリック時は同じ行の … ボタン)。
|
||||
const openMenu = (space: Space, x: number, y: number, trigger: HTMLElement | null) => {
|
||||
const maxX = window.innerWidth - MENU_WIDTH - MENU_MARGIN;
|
||||
const maxY = window.innerHeight - MENU_HEIGHT - MENU_MARGIN;
|
||||
setMenu({
|
||||
space,
|
||||
x: Math.max(MENU_MARGIN, Math.min(x, maxX)),
|
||||
y: Math.max(MENU_MARGIN, Math.min(y, maxY)),
|
||||
trigger,
|
||||
});
|
||||
};
|
||||
// restoreFocus=true のときだけトリガー(… ボタン)へフォーカスを戻す。
|
||||
// キーボード導線(Esc・項目実行)では戻し、ポインタ操作(外側クリック・スクロール)では
|
||||
// ユーザーの操作先を奪わないよう戻さない。
|
||||
const closeMenu = (restoreFocus = false) => {
|
||||
setMenu(prev => {
|
||||
if (restoreFocus) prev?.trigger?.focus();
|
||||
return null;
|
||||
});
|
||||
};
|
||||
|
||||
// メニュー外のクリック / Esc / 一覧スクロールで閉じる。開いている間だけ購読する。
|
||||
// スクロール時は fixed ポータルが行から切り離されて別スペースに重なるため必ず閉じる。
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
const el = e.target as Element | null;
|
||||
if (el && (el.closest('[data-testid="space-row-menu-panel"]') || el.closest('[data-menu-trigger]'))) return;
|
||||
closeMenu();
|
||||
};
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') closeMenu(true);
|
||||
};
|
||||
// capture フェーズなら入れ子のスクロールコンテナ(一覧の overflow-y-auto)も拾える。
|
||||
const onScroll = () => closeMenu();
|
||||
document.addEventListener('pointerdown', onPointerDown, true);
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
window.addEventListener('scroll', onScroll, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', onPointerDown, true);
|
||||
document.removeEventListener('keydown', onKeyDown);
|
||||
window.removeEventListener('scroll', onScroll, true);
|
||||
};
|
||||
}, [menu]);
|
||||
|
||||
// メニューを開いたら先頭項目へフォーカスを移す(キーボードで到達・選択できるように)。
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
menuRef.current?.querySelector<HTMLButtonElement>('button')?.focus();
|
||||
}, [menu]);
|
||||
|
||||
// 取り消し通知は一定時間で自動的に消す。
|
||||
useEffect(() => {
|
||||
if (!undo) return;
|
||||
const timer = window.setTimeout(() => setUndo(null), UNDO_DISMISS_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [undo]);
|
||||
|
||||
const changeDisplay = async (space: Space, patch: { favorite?: boolean; hidden?: boolean }, message: string) => {
|
||||
setOpenMenuId(null);
|
||||
closeMenu(true);
|
||||
setError(null);
|
||||
const prev = { favorite: isFavorite(space), hidden: isHidden(space) };
|
||||
try {
|
||||
@@ -122,6 +199,20 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const renderRow = (s: Space) => (
|
||||
<SpaceRow
|
||||
key={s.id}
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
menuOpen={menu?.space.id === s.id}
|
||||
onSelect={onSelect}
|
||||
runningCount={runningCount(s)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
onOpenMenu={openMenu}
|
||||
onCloseMenu={closeMenu}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden" data-testid="space-rail">
|
||||
<div className="flex items-center justify-between border-b border-hairline px-3 py-2.5">
|
||||
@@ -182,21 +273,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
style={{ borderLeftColor: g.band }}
|
||||
>
|
||||
<div className="mb-1 px-1 text-[10px] font-bold uppercase tracking-wider text-slate-400">{g.label}</div>
|
||||
{g.spaces.map(s => (
|
||||
<SpaceRow
|
||||
key={s.id}
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
menuOpen={openMenuId === s.id}
|
||||
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
|
||||
onSelect={onSelect}
|
||||
runningCount={runningCount(s)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
|
||||
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
|
||||
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
|
||||
/>
|
||||
))}
|
||||
{g.spaces.map(renderRow)}
|
||||
</section>
|
||||
))}
|
||||
|
||||
@@ -220,21 +297,7 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
<path d="M4 6l4 4 4-4" />
|
||||
</svg>
|
||||
</button>
|
||||
{showHidden && hiddenSpaces.map(s => (
|
||||
<SpaceRow
|
||||
key={s.id}
|
||||
space={s}
|
||||
active={s.id === selectedId}
|
||||
menuOpen={openMenuId === s.id}
|
||||
onToggleMenu={() => setOpenMenuId(openMenuId === s.id ? null : s.id)}
|
||||
onSelect={onSelect}
|
||||
runningCount={runningCount(s)}
|
||||
mine={hasOthersSpaces && myUserId != null && s.ownerId === myUserId}
|
||||
onFavorite={() => changeDisplay(s, { favorite: !isFavorite(s) }, isFavorite(s) ? t('rail.toast.favoriteRemoved', { title: s.title }) : t('rail.toast.favoriteAdded', { title: s.title }))}
|
||||
onHide={() => changeDisplay(s, { hidden: true }, t('rail.toast.hidden', { title: s.title }))}
|
||||
onRestore={() => changeDisplay(s, { hidden: false }, t('rail.toast.restored', { title: s.title }))}
|
||||
/>
|
||||
))}
|
||||
{showHidden && hiddenSpaces.map(renderRow)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
@@ -259,6 +322,56 @@ export function SpaceRail({ selectedId, onSelect }: SpaceRailProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{menu && createPortal(
|
||||
<div
|
||||
ref={menuRef}
|
||||
data-testid="space-row-menu-panel"
|
||||
role="menu"
|
||||
onKeyDown={(e) => {
|
||||
const items = Array.from(menuRef.current?.querySelectorAll<HTMLButtonElement>('button') ?? []);
|
||||
if (items.length === 0) return;
|
||||
const idx = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (e.key === 'ArrowDown') { e.preventDefault(); items[(idx + 1 + items.length) % items.length]?.focus(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); items[(idx - 1 + items.length) % items.length]?.focus(); }
|
||||
else if (e.key === 'Home') { e.preventDefault(); items[0]?.focus(); }
|
||||
else if (e.key === 'End') { e.preventDefault(); items[items.length - 1]?.focus(); }
|
||||
}}
|
||||
className="fixed z-50 w-40 rounded-md border border-hairline bg-white py-1 text-xs shadow-lg"
|
||||
style={{ left: menu.x, top: menu.y }}
|
||||
>
|
||||
{!isHidden(menu.space) && (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => changeDisplay(menu.space, { favorite: !isFavorite(menu.space) }, isFavorite(menu.space) ? t('rail.toast.favoriteRemoved', { title: menu.space.title }) : t('rail.toast.favoriteAdded', { title: menu.space.title }))}
|
||||
className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2"
|
||||
>
|
||||
{isFavorite(menu.space) ? t('rail.menuUnfavorite') : t('rail.menuFavorite')}
|
||||
</button>
|
||||
)}
|
||||
{isHidden(menu.space) ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => changeDisplay(menu.space, { hidden: false }, t('rail.toast.restored', { title: menu.space.title }))}
|
||||
className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2"
|
||||
>
|
||||
{t('rail.menuRestore')}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
onClick={() => changeDisplay(menu.space, { hidden: true }, t('rail.toast.hidden', { title: menu.space.title }))}
|
||||
className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2"
|
||||
>
|
||||
{t('rail.menuHide')}
|
||||
</button>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{showCreate && (
|
||||
<SpaceFormDialog
|
||||
onClose={() => setShowCreate(false)}
|
||||
@@ -276,24 +389,20 @@ function SpaceRow({
|
||||
space,
|
||||
active,
|
||||
menuOpen,
|
||||
onToggleMenu,
|
||||
onSelect,
|
||||
runningCount,
|
||||
mine,
|
||||
onFavorite,
|
||||
onHide,
|
||||
onRestore,
|
||||
onOpenMenu,
|
||||
onCloseMenu,
|
||||
}: {
|
||||
space: Space;
|
||||
active: boolean;
|
||||
menuOpen: boolean;
|
||||
onToggleMenu: () => void;
|
||||
onSelect: (id: string) => void;
|
||||
runningCount: number;
|
||||
mine?: boolean;
|
||||
onFavorite: () => void;
|
||||
onHide: () => void;
|
||||
onRestore: () => void;
|
||||
onOpenMenu: (space: Space, x: number, y: number, trigger: HTMLElement | null) => void;
|
||||
onCloseMenu: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const dot = space.brandColor ?? 'var(--brand-primary)';
|
||||
@@ -308,6 +417,12 @@ function SpaceRow({
|
||||
data-space-hidden={hidden ? '1' : undefined}
|
||||
data-space-favorite={favorite ? '1' : undefined}
|
||||
data-space-mine={mine ? '1' : undefined}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// 右クリック起動でもフォーカス復帰先を持たせる(閉じたとき body へ失わないよう、同じ行の … ボタンへ戻す)。
|
||||
const trigger = e.currentTarget.querySelector<HTMLElement>('[data-testid="space-row-menu"]');
|
||||
onOpenMenu(space, e.clientX, e.clientY, trigger);
|
||||
}}
|
||||
className={`group relative mb-0.5 flex w-full items-center rounded-md border transition-colors ${
|
||||
active
|
||||
? 'border-hairline bg-[var(--brand-primary-soft)]'
|
||||
@@ -364,7 +479,13 @@ function SpaceRow({
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-row-menu"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleMenu(); }}
|
||||
data-menu-trigger
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (menuOpen) { onCloseMenu(); return; }
|
||||
const r = e.currentTarget.getBoundingClientRect();
|
||||
onOpenMenu(space, r.right - MENU_WIDTH, r.bottom + 4, e.currentTarget);
|
||||
}}
|
||||
className="mr-1 shrink-0 rounded p-1 text-slate-400 opacity-100 hover:bg-white/70 hover:text-slate-700 md:opacity-0 md:group-hover:opacity-100 md:focus:opacity-100"
|
||||
aria-label={t('rail.menuLabel', { title: space.title })}
|
||||
title={t('rail.menu')}
|
||||
@@ -375,24 +496,6 @@ function SpaceRow({
|
||||
<circle cx="12" cy="8" r="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div data-testid="space-row-menu-panel" className="absolute right-1 top-8 z-20 w-40 rounded-md border border-hairline bg-white py-1 text-xs shadow-lg">
|
||||
{!hidden && (
|
||||
<button type="button" onClick={onFavorite} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
|
||||
{favorite ? t('rail.menuUnfavorite') : t('rail.menuFavorite')}
|
||||
</button>
|
||||
)}
|
||||
{hidden ? (
|
||||
<button type="button" onClick={onRestore} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
|
||||
{t('rail.menuRestore')}
|
||||
</button>
|
||||
) : (
|
||||
<button type="button" onClick={onHide} className="block w-full px-3 py-1.5 text-left text-slate-700 hover:bg-surface-2">
|
||||
{t('rail.menuHide')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,6 +10,9 @@ import type { CreateLocalTaskInput } from '../../api';
|
||||
interface SpacesPageProps {
|
||||
spaceId?: string;
|
||||
spaceTaskId?: number;
|
||||
initialTab?: 'settings';
|
||||
onInitialTabApplied?: () => void;
|
||||
onOpenAppSettings?: () => void;
|
||||
onSelectSpace: (id: string | undefined) => void;
|
||||
onSelectSpaceTask: (id: number) => void;
|
||||
onCreateTask: (input: CreateLocalTaskInput, attachments: Array<{ name: string; contentBase64: string }>) => Promise<void>;
|
||||
@@ -28,7 +31,7 @@ function clampRailWidth(px: number): number {
|
||||
return Math.max(RAIL_MIN_PX, Math.min(RAIL_MAX_PX, Math.round(px)));
|
||||
}
|
||||
|
||||
export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpacesPageProps) {
|
||||
export function SpacesPage({ spaceId, spaceTaskId, initialTab, onInitialTabApplied, onOpenAppSettings, onSelectSpace, onSelectSpaceTask, onCreateTask, onOpenTask, chatFilter, onChatFilterChange }: SpacesPageProps) {
|
||||
const { t } = useTranslation('spaces');
|
||||
const isMobile = useIsMobile();
|
||||
const [railWidth, setRailWidth] = useLocalStorageState<number>('maestro.spaceRailWidth', RAIL_DEFAULT_PX);
|
||||
@@ -116,6 +119,9 @@ export function SpacesPage({ spaceId, spaceTaskId, onSelectSpace, onSelectSpaceT
|
||||
<SpaceDetail
|
||||
spaceId={spaceId}
|
||||
spaceTaskId={spaceTaskId}
|
||||
initialTab={initialTab}
|
||||
onInitialTabApplied={onInitialTabApplied}
|
||||
onOpenAppSettings={onOpenAppSettings}
|
||||
onSelectSpace={onSelectSpace}
|
||||
onSelectSpaceTask={onSelectSpaceTask}
|
||||
onCreateTask={onCreateTask}
|
||||
|
||||
Reference in New Issue
Block a user