sync: update from private repo (9a86f49b)
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s

This commit is contained in:
oss-sync
2026-07-12 23:51:48 +00:00
parent a67c40d33c
commit ed3dc5529a
159 changed files with 11696 additions and 1600 deletions
@@ -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">&times;</button>
</span>
))}
</div>
);
}
+172
View File
@@ -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>
);
}
+8
View File
@@ -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();
});
});
+198
View File
@@ -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();
});
});
+13 -502
View File
@@ -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">&times;</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>
);
}
+215
View File
@@ -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>
);
}
+25 -4
View File
@@ -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
+151
View File
@@ -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();
});
});
+304
View File
@@ -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);
});
});
+151
View File
@@ -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();
});
});
+70 -328
View File
@@ -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} />;
}
+62 -29
View File
@@ -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}
/>
+129
View File
@@ -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%');
});
});
+100 -48
View File
@@ -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>
);
}
+161
View File
@@ -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();
}
});
});
+106 -20
View File
@@ -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)![0]({ ...form, scheduleType: 'cron' })).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)![0]({ ...form, visibility: 'org' })).toMatchObject({ visibilityScopeOrgId: 'org-2' });
fireEvent.change(selects[1], { target: { value: '4' } });
expect(setForm.mock.calls.at(-1)![0]({ ...form, visibility: 'org' })).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();
+77 -10
View File
@@ -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>
+11 -4
View File
@@ -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
+41 -8
View File
@@ -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">&gt;</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>
+4 -1
View File
@@ -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))}
+7 -6
View File
@@ -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>
+5 -3
View File
@@ -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
+9
View File
@@ -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, '-')}`;
+15 -2
View File
@@ -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();
});
});
+30 -6
View File
@@ -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>
+137 -1
View File
@@ -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();
}
});
});
+163 -60
View File
@@ -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>
);
}
+7 -1
View File
@@ -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}