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
+41 -17
View File
@@ -4,8 +4,11 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useSetupState } from './hooks/useSetupState';
import { SetupWizard } from './components/setup/SetupWizard';
import { createLocalTask, fetchLocalTask, type CreateLocalTaskInput, type Visibility } from './api';
import { claimSpaceCalendarReminders } from './api/calendar';
import { useUrlState } from './hooks/useUrlState';
import { useToast } from './hooks/useToast';
import { ToastHost } from './components/notifications/ToastHost';
import { ToastPet } from './components/notifications/ToastPet';
import { useLocalTaskList } from './hooks/useTaskList';
import { useBranding } from './hooks/useBranding';
import { useLocalStorageState } from './hooks/useLocalStorageState';
@@ -182,11 +185,36 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
// 個人スペースを所有するので user=null でも従来通り解決する。
// 一覧が空/ローディング中なら personalSpaceId は nullgraceful skeleton)。
const { data: spaces } = useSpaces();
const { toasts, showToast, dismissToast } = useToast();
const [settingsSpaceId, setSettingsSpaceId] = useState<string | null>(null);
const personalSpaceId =
spaces?.find(
s => s.kind === 'personal' && (user == null || s.ownerId === user.id),
)?.id ?? null;
useEffect(() => {
if (!spaces?.length) return;
let cancelled = false;
const poll = async () => {
const events = await Promise.all(spaces.map(space => claimSpaceCalendarReminders(space.id).then(items => ({ space, items })).catch(() => null)));
if (cancelled) return;
for (const result of events) {
if (!result) continue;
for (const event of result.items) {
showToast(event.time ? `${event.date} ${event.time}` : event.date, 'info', {
id: `calendar-event-${event.id}`,
title: `予定: ${event.title}`,
actionLabel: 'カレンダーを開く',
onAction: () => setUrlState(prev => ({ ...prev, page: 'calendar', spaceId: result.space.id, spaceTaskId: undefined })),
});
}
}
};
void poll();
const timer = window.setInterval(() => void poll(), 30_000);
return () => { cancelled = true; window.clearInterval(timer); };
}, [spaces, showToast, setUrlState]);
// Legacy `?page=tasks` deep links (the Tasks tab was removed in M2) are
// normalized onto the workspace model once spaces have loaded. We wait for the
// personal workspace to resolve so the rewrite lands on the right rail, then
@@ -298,9 +326,6 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
*/
const [createInitialPiece, setCreateInitialPiece] = useState<string | null>(null);
// Toast
const { toast, showToast } = useToast();
// URL sync. While a legacy Tasks deep link is still pending normalization,
// skip the push: otherwise this would pushState the parsed fallback (page=tasks
// → spaces) and pollute history *before* the legacy redirect's replaceState
@@ -450,6 +475,16 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
onNotificationClick: (taskId) => {
handleOpenTaskInSpace(taskId);
},
onInAppNotification: (notification) => {
const taskId = notification.data.taskId;
showToast(notification.body, notification.title.startsWith('❌') ? 'error' : 'info', {
id: notification.tag,
title: notification.title,
actionLabel: 'タスクを開く',
onAction: () => handleOpenTaskInSpace(taskId),
visual: notification.tag.endsWith('-succeeded') ? <ToastPet /> : undefined,
});
},
});
// V2: SW posts `open-task` when the user clicks an OS notification and the
@@ -509,20 +544,10 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
onCompactChange={setCompactMode}
/>
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
{toast && (
<div className={
toast.variant === 'error'
? 'mx-4 mt-2 px-4 py-2.5 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded-xl text-[13px] text-red-800 dark:text-red-300'
: 'mx-4 mt-2 px-4 py-2.5 bg-green-50 dark:bg-green-500/15 border border-green-200 dark:border-green-500/30 rounded-xl text-[13px] text-green-800 dark:text-green-300'
}>
{toast.message}
</div>
)}
</div>
<ToastHost toasts={toasts} onDismiss={dismissToast} />
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} chatFilter={{ search: urlState.spaceSearch, status: urlState.spaceStatus, sort: urlState.spaceSort, scope: urlState.spaceScope }} onChatFilterChange={(next) => setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} /></div>}
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} urlState={urlState} setUrlState={setUrlState} workspaceName={spaces?.find(space => space.id === urlState.spaceId)?.title} onOpenWorkspaceSettings={urlState.spaceId ? () => { setSettingsSpaceId(urlState.spaceId!); setUrlState(prev => ({ ...prev, page: 'spaces', spaceTaskId: undefined })); } : undefined} /></div>}
{page === 'spaces' && <div className="flex-1 min-h-0 overflow-hidden"><SpacesPage spaceId={urlState.spaceId} spaceTaskId={urlState.spaceTaskId} initialTab={settingsSpaceId === urlState.spaceId ? 'settings' : undefined} onInitialTabApplied={() => setSettingsSpaceId(null)} onOpenAppSettings={() => setUrlState(prev => ({ ...prev, page: 'settings', spaceTaskId: undefined }))} chatFilter={{ search: urlState.spaceSearch, status: urlState.spaceStatus, sort: urlState.spaceSort, scope: urlState.spaceScope }} onChatFilterChange={(next) => setUrlState(prev => ({ ...prev, ...(next.search !== undefined ? { spaceSearch: next.search } : {}), ...(next.status !== undefined ? { spaceStatus: next.status } : {}), ...(next.sort !== undefined ? { spaceSort: next.sort } : {}), ...(next.scope !== undefined ? { spaceScope: next.scope } : {}) }))} onSelectSpace={(id) => setUrlState(prev => ({ ...prev, spaceId: id, spaceTaskId: undefined, spaceSearch: '', spaceStatus: 'all', spaceSort: 'updated', spaceScope: 'mine' }))} onSelectSpaceTask={(id) => setUrlState(prev => ({ ...prev, spaceTaskId: id > 0 ? id : undefined }))} onCreateTask={handleGlobalCreateTask} onOpenTask={handleOpenTaskInSpace} /></div>}
{page === 'calendar' && <div className="flex-1 min-h-0 overflow-hidden"><CrossSpaceCalendar onOpenSpace={(id) => setUrlState(prev => ({ ...prev, page: 'spaces', spaceId: id, spaceTaskId: undefined }))} onOpenTask={handleOpenTaskInSpace} /></div>}
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
@@ -558,4 +583,3 @@ function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnable
</div>
);
}
+1
View File
@@ -24,3 +24,4 @@ export * from './api/skills';
export * from './api/notifications';
export * from './api/usage';
export * from './api/delegate';
export * from './api/chat-connectors';
+19 -5
View File
@@ -12,6 +12,8 @@ export interface CalendarEvent {
endDate: string | null; // 終了日 YYYY-MM-DD。null = 単日
time: string | null; // 開始 HH:MM。null = 終日
endTime: string | null; // 終了 HH:MM。null = 終了時刻なし(time が null なら常に null
reminderMinutes: number | null;
reminderDeliveredAt: string | null;
title: string;
description: string | null;
createdBy: 'user' | 'agent';
@@ -102,13 +104,13 @@ export async function fetchSpaceCalendarDay(
export async function createCalendarEvent(
spaceId: string,
input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; title: string; description?: string | null },
input: { date: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, endTime, ...rest } = input;
const { endDate, endTime, reminderMinutes, ...rest } = input;
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null }),
body: JSON.stringify({ ...rest, end_date: endDate ?? null, end_time: endTime ?? null, reminder_minutes: reminderMinutes ?? null }),
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to create event');
@@ -118,12 +120,13 @@ export async function createCalendarEvent(
export async function updateCalendarEvent(
spaceId: string,
eventId: number,
patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; title?: string; description?: string | null },
patch: { date?: string; endDate?: string | null; time?: string | null; endTime?: string | null; reminderMinutes?: number | null; title?: string; description?: string | null },
): Promise<CalendarEvent> {
const { endDate, endTime, ...rest } = patch;
const { endDate, endTime, reminderMinutes, ...rest } = patch;
const body: Record<string, unknown> = { ...rest };
if (endDate !== undefined) body.end_date = endDate;
if (endTime !== undefined) body.end_time = endTime;
if (reminderMinutes !== undefined) body.reminder_minutes = reminderMinutes;
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/events/${eventId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
@@ -143,3 +146,14 @@ export async function deleteCalendarEvent(spaceId: string, eventId: number): Pro
throw new Error(err.error || res.statusText);
}
}
export async function claimSpaceCalendarReminders(spaceId: string): Promise<CalendarEvent[]> {
const res = await fetch(`${BASE}/local/spaces/${spaceId}/calendar/reminders/claim`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: '{}',
});
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to claim calendar reminders');
return data.events as CalendarEvent[];
}
+117
View File
@@ -0,0 +1,117 @@
// ui/src/api/chat-connectors.ts — admin CRUD for chat connector bindings
// (issue #801, Phase 2a). Talks to /api/admin/chat/bindings (requireAdmin)
// and, for the "which delegation backs this binding?" picker, the existing
// admin A2A delegations list at /api/admin/a2a/delegations.
//
// Mirrors the shape of ui/src/api/gateway.ts: plain fetch helpers, no
// react-query baked in (the form component owns useQuery/useMutation).
// --- Chat connector bindings ------------------------------------------------
export type ChatConnectorPlatform = 'slack';
export type ChatConnectorBindingStatus = 'active' | 'disabled';
/**
* Mirrors `toPublicBinding()` in src/bridge/chat/chat-connector-bindings-api.ts.
* Never includes bot credentials — those are write-only from the client's
* point of view.
*/
export interface ChatConnectorBinding {
id: string;
platform: ChatConnectorPlatform;
externalWorkspaceId: string;
externalChannelId: string;
spaceId: string;
a2aClientId: string;
a2aDelegationId: string;
status: ChatConnectorBindingStatus;
createdBy: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreateChatConnectorBindingInput {
platform: ChatConnectorPlatform;
externalWorkspaceId: string;
externalChannelId: string;
a2aDelegationId: string;
botCredentials: { signingSecret: string; botToken: string };
}
export interface UpdateChatConnectorBindingInput {
status?: ChatConnectorBindingStatus;
botCredentials?: { signingSecret: string; botToken: string };
}
async function readErrorMessage(res: Response): Promise<string> {
const body = await res.json().catch(() => ({} as { error?: string }));
return (body as { error?: string }).error ?? res.statusText ?? String(res.status);
}
export async function fetchChatConnectorBindings(): Promise<ChatConnectorBinding[]> {
const res = await fetch('/api/admin/chat/bindings');
if (!res.ok) throw new Error(await readErrorMessage(res));
const data = (await res.json()) as { bindings: ChatConnectorBinding[] };
return data.bindings;
}
export async function createChatConnectorBinding(
input: CreateChatConnectorBindingInput,
): Promise<ChatConnectorBinding> {
const res = await fetch('/api/admin/chat/bindings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error(await readErrorMessage(res));
return res.json();
}
export async function updateChatConnectorBinding(
id: string,
patch: UpdateChatConnectorBindingInput,
): Promise<ChatConnectorBinding> {
const res = await fetch(`/api/admin/chat/bindings/${encodeURIComponent(id)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
if (!res.ok) throw new Error(await readErrorMessage(res));
return res.json();
}
export async function deleteChatConnectorBinding(id: string): Promise<void> {
const res = await fetch(`/api/admin/chat/bindings/${encodeURIComponent(id)}`, { method: 'DELETE' });
if (!res.ok) throw new Error(await readErrorMessage(res));
}
// --- A2A delegations (admin, read-only) — used to populate the "which
// delegation backs this binding?" picker on the create form. A binding can
// only be backed by a delegation that is live AND grants exactly one space
// (chat-connector-bindings-api.ts enforces the same rule server-side; the
// client-side filter below just keeps invalid choices out of the picker). ---
export interface AdminA2aDelegation {
id: string;
userId: string;
clientId: string;
clientName: string;
grantedSpaceIds: string[];
grantedSkills: string[];
expiresAt: string | null;
revokedAt: string | null;
createdAt: string;
live: boolean;
}
export async function fetchAdminA2aDelegations(): Promise<AdminA2aDelegation[]> {
const res = await fetch('/api/admin/a2a/delegations');
if (!res.ok) throw new Error(await readErrorMessage(res));
const data = (await res.json()) as { delegations: AdminA2aDelegation[] };
return data.delegations;
}
/** Delegations eligible to back a new chat binding: live + exactly one granted space. */
export function eligibleDelegationsForChatBinding(delegations: AdminA2aDelegation[]): AdminA2aDelegation[] {
return delegations.filter(d => d.live && d.grantedSpaceIds.length === 1);
}
+56 -1
View File
@@ -52,6 +52,14 @@ export interface LocalTask {
visibilityScopeOrgName?: string | null;
/** Spaces foundation: which space this task belongs to (null = legacy/個人). */
spaceId?: string | null;
/**
* LLM 選択 Phase 1: sticky なワーカー直接指定。未設定 (null/undefined) は
* プロファイルベースの自動ルーティング。次にディスパッチされるジョブから
* 有効になる(実行中のジョブには影響しない)。
*/
llmWorkerId?: string | null;
/** llmWorkerId とセットの場合のみ有効な reasoning effort。 */
llmEffort?: string | null;
createdAt: string;
updatedAt: string;
latestJob?: {
@@ -211,6 +219,26 @@ export interface LocalTaskComment {
injectedAt: string | null;
}
export interface MovementHistoryEvent {
eventId: string;
ts: string;
seq: number;
line: number;
runId: string;
kind: 'movement_start' | 'movement_complete' | 'llm_call_retry';
movement: string | null;
iteration: number | null;
payload: {
next?: string | null;
waitReason?: string | null;
attempt?: number | null;
maxAttempts?: number | null;
errorClass?: string | null;
httpStatus?: number | null;
delayMs?: number | null;
};
}
export interface CreateLocalTaskInput {
title?: string;
body: string;
@@ -230,6 +258,13 @@ export interface CreateLocalTaskInput {
workspaceMode?: 'persistent' | 'ephemeral';
/** 紐付けるスペース。未指定なら owner の個人スペースに解決される。 */
spaceId?: string;
/**
* LLM 選択 Phase 1: タスクをピン留めする実行ワーカー。未指定/null は
* プロファイルルーティングに委ねる(従来どおり)。
*/
llmWorkerId?: string | null;
/** llmWorkerId とセットの場合のみ有効な reasoning effort。 */
llmEffort?: string | null;
options?: {
mcpDisabled?: boolean;
skillsDisabled?: boolean;
@@ -297,6 +332,13 @@ export async function fetchLocalTaskComments(taskId: number): Promise<LocalTaskC
return data.comments ?? [];
}
export async function fetchMovementHistory(taskId: number): Promise<MovementHistoryEvent[]> {
const res = await fetch(`${BASE}/local/tasks/${taskId}/movement-history`);
const data = await res.json();
if (!res.ok) throw new Error(data?.error ?? 'Failed to fetch movement history');
return data.events ?? [];
}
export async function postLocalTaskComment(taskId: number, body: string, author: string = 'user', attachments?: Array<{ name: string; contentBase64: string }>): Promise<void> {
const payload: Record<string, unknown> = { body, author };
if (attachments && attachments.length > 0) payload.attachments = attachments;
@@ -311,7 +353,20 @@ export async function postLocalTaskComment(taskId: number, body: string, author:
export async function updateLocalTask(
taskId: number,
updates: { title?: string; visibility?: Visibility; visibilityScopeOrgId?: string | null },
updates: {
title?: string;
visibility?: Visibility;
visibilityScopeOrgId?: string | null;
/**
* LLM 選択 Phase 1: sticky worker 直接指定。null で解除。effort を送るときは
* llmWorkerId も必ず一緒に送ること(サーバーは PATCH 済みフィールドだけ検証する
* ため、effort だけ null にすると「ワーカーは残っているのに effort だけ消える」
* のは OK だが、逆に worker を null にするときは effort も null で送らないと
* 「effort はワーカー指定とセットのみ」400 になり得る)。
*/
llmWorkerId?: string | null;
llmEffort?: string | null;
},
): Promise<LocalTask> {
const res = await fetch(`${BASE}/local/tasks/${taskId}`, {
method: 'PATCH',
+26
View File
@@ -44,6 +44,32 @@ export async function fetchWorkerBackends(workerId: string): Promise<WorkerBacke
return await res.json() as WorkerBackendsResponse;
}
// ── LLM 選択 Phase 1: タスク作成時のワーカー直接指定 ──────────────────────────
export interface LlmWorkerListItem {
id: string;
model: string;
roles: string[];
reasoningEfforts?: string[];
vlm?: boolean;
enabled?: boolean;
}
/**
* GET /api/llm/workers — タスクにピン留め可能な実行ワーカー一覧(title/reflection
* 専用ワーカーは含まない、サーバー側でフィルタ済み)。失敗時は空配列を返す。
*/
export async function fetchLlmWorkers(): Promise<LlmWorkerListItem[]> {
const res = await fetch('/api/llm/workers', { credentials: 'include' });
// Throw (not []) on a non-OK response so react-query surfaces an error
// state instead of a false "empty worker list". The composer's stalled
// badge is gated on `workersQuery.isSuccess`, so swallowing a transient
// 500/404 into [] made a healthy pinned worker look stalled.
if (!res.ok) throw new Error(`Failed to list LLM workers: ${res.status}`);
const data = await res.json() as { workers?: LlmWorkerListItem[] };
return data.workers ?? [];
}
// ── Side Info Panel ────────────────────────────────────────────────────────
export interface NodeStatus {
@@ -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}
+95
View File
@@ -12,6 +12,97 @@ MAESTRO に入った、ユーザーに関係する主な変更を新しい順に
> 機能に変更があるたび、このページを更新していきます。日付は変更が本番に入ったおおよその時期です。
## 2026-07-12 — 完了済みのMovementでも目的を確認できるようになりました
Movement Mapの緑色の完了点を押したときも、実行中・待機中の点と同じようにMovementの目的が表示されます。詳細内の「このMovementへ移動」から、対応する会話箇所へ移動できます。
## 2026-07-12 — Movement Mapで作業の差し戻しやRetryを追えるようになりました
会話右側のMovement Mapに、実際に通った工程、前の工程への差し戻し、LLM通信とジョブ全体のRetry、ユーザーのコメントや追加依頼が時系列で表示されます。履歴の印を押すと、会話内の対応する工程やメッセージへ移動して強調表示されるため、タスクがどのような経緯をたどったかを後から確認できます(→[実行中のタスクを見る・介入する](./03-running.md))。
## 2026-07-12 — モデル選択パネルが途中で切れないようにしました
チャット入力欄からモデルを選ぶパネルが、会話エリアの端で切れて操作しにくくなる問題を修正しました。画面の空きに合わせて上下を切り替え、狭い画面でもパネル全体をスクロールして操作できます。
## 2026-07-12 — 入力欄のコンテキスト表示を計器だけにしました
コンテキストの使用量は入力欄の下端にある細い計器だけで確認できるようになりました。入力欄全体を色付けしていた背景表示はなくなり、文章に集中しやすくなっています。
## 2026-07-12 — 「最新に移動」ボタンを会話の中央へ戻しました
過去のメッセージを読んでいるときに表示される「最新に移動」ボタンが、会話から離れた位置へずれる問題を修正しました。画面幅や詳細パネルの状態にかかわらず、入力欄のすぐ上で会話本文の中央に表示されます。
## 2026-07-10 — 入力欄のコンテキスト表示を静かにしました
入力欄を横切っていた波と気泡を、ごく薄い光と下端の細い計器に置き換えました。使用量と警告色は確認しやすいまま、入力中の文章に集中しやすくなっています。
## 2026-07-10 — ペットと星のアニメーションをさらに滑らかにしました
ペットのスプライトがコマを飛ばしてカクついて見える問題を修正しました。ツールが短時間に連続実行された場合も、落下中の星が出発点へ戻らず、同じ軌道を最後まで滑らかに進みます。
## 2026-07-10 — チャットのペットのツールエフェクトを強化しました
チャットのペットが出すツール実行時のエフェクトを、種別ごとの演出(検索=波紋、ターミナル=点滅、ファイル操作=紙片、ブラウザ操作=窓の開閉、issue操作=マーカー、MCP=電気火花、それ以外=星)に分けました。成功時は緑、失敗時は赤+「!」マークで結果が一目でわかり、短時間にツールが連続実行されたときはエフェクトを重ねず「×N」件数バッジでまとめて表示します。
## 2026-07-10 — 会話の進み具合とコンテキスト量を直感的に確認できるようになりました
会話の右中央に薄い Movement ミニマップを追加し、チャットの幅を狭めずに完了・実行中・待機中の工程を確認できるようにしました。長い工程指示は通常表示せず、点を押したときに目的を2行だけ表示し、必要な場合だけ全文を開けます。入力欄の背景にはコンテキスト使用量が水のように左から満ち、残量が少なくなるにつれて青から黄・橙・赤へ変わります。会話中も星のボタンからプロンプトコーチを使えます。ワークスペースの新規チャットは別画面を重ねず、会話ペイン内で作成できるようになりました。
## 2026-07-10 — チャットのペットのちらつきを直し、動きをなめらかにしました
会話中に表示されるペットが、ツールの実行タイミングや待機中にちらつく・コマ送りが頭出しに飛ぶ問題を修正しました。ツールが連続で動くときの反応もリアルタイムになり、走る・跳ねる動きの途中で止まって見えていた点も解消しています。
## 2026-07-10 — 会話コンポーザーのモデル選択まわりの表示を整理しました
タスク実行中に出ていた「実行中のジョブには適用されません」の注記を、モデル切り替えボタンの隣ではなく、ボタンを押して開くモデル選択パネルの中に移しました。狭い画面でコンポーザーの操作列が2行に折り返してしまう問題を解消しています。
## 2026-07-10 — Slack 連携の紐付けを管理画面から登録・管理できるようになりました(管理者)
これまで管理者 API を直接呼ぶ必要があった Slack チャンネルとワークスペースの紐付け(バインディング)を、設定の「💬 チャット連携」画面から登録・有効化/無効化・削除できるようになりました。委任(A2A Delegation)は「有効かつ付与スペースがちょうど1つ」のものだけが選べ、Slack の署名シークレットと Bot トークンは保存後に再表示されない書き込み専用の扱いです(→[チャット連携(Slack](./24-chat-connectors.md))。
## 2026-07-10 — AI にリマインダーの登録と取消を頼めるようにしました
AI に未来の時刻または指定分後の通知を頼むと、個人向けリマインダーとして保存されます。登録済みの通知は一覧で確認でき、ID を指定して取消できます。
## 2026-07-10 — Slack メンションの再送でタスクが重複しないようにしました
Slack 側から同じイベントが再送されても、紐付けたワークスペースのタスク作成と返信は1回だけ行われます。チャット連携は A2A が有効な構成でのみ受け付けるようになりました。
## 2026-07-10 — アプリ内でタスク通知を確認できるようにしました
タスクの状態が変わると、アプリ右下に通知が積み上がります。ブラウザ通知の許可がなくても表示され、通知から対象タスクを開いたり、個別に閉じたりできます。
タスク完了の通知では、設定済みの Pet が完了アニメーションでお知らせします。
カレンダー予定の作成・編集時に、開始何分前に通知するかを選べるようにしました。
## 2026-07-10 — 設定検索から目的の入力欄へ直接移動できるようにしました
Settings の検索結果で項目名や設定キーを選ぶと、長い **Workers**、**Web & Search**、**Admin SSH** フォームでも該当する入力欄へ移動して強調表示されます。
## 2026-07-10 — アプリ設定とワークスペース設定を区別しやすくしました
アプリ設定画面に、個人・システム全体へ適用される設定であることを表示しました。ワークスペースを開いているときは、そのワークスペースだけに保存される設定へ直接戻れます。
## 2026-07-10 — 未保存の管理設定をサイドバーで確認できるようにしました
管理設定を編集すると、まだ **Save & Apply** していないセクションに小さな印が付きます。別の設定を確認していても未保存の変更を見失わず、保存・破棄・再読み込みを行うと印は消えます。
## 2026-07-10 — モバイルの設定画面で戻る操作が正しく動くようにしました
モバイルで設定項目を開いた後、画面上部の戻る操作やブラウザの戻る/進むを使うと、設定一覧と編集画面が URL に合わせて正しく切り替わるようになりました。設定項目への直接リンクも、その項目の編集画面から開きます。
## 2026-07-10 — 設定の入力エラーを修正箇所まで案内するようにしました
LLM ワーカーの `extra_body` に不正な JSON を入力すると保存が止まる場面で、保存バーの **問題の入力へ** から該当の入力欄へ直接移動できるようになりました。長い設定画面でも、修正が必要な箇所を探し直さずに済みます。
## 2026-07-10 — タスク作成・会話画面からモデルと reasoning effort をタスクごとに直接指定できるように
タスク作成の詳細設定と、会話画面のコンポーザーに「モデル / ワーカー直接指定」と「推論の強さ (reasoning effort)」の選択欄を追加しました。これまではプロファイル(`auto` / `fast` / `quality`)による自動ルーティングしかできませんでしたが、特定のワーカーを名指しで固定でき、指定するとプロファイルの選択は使われなくなります。指定はタスクに紐づいて残り、続けて送るメッセージやサブタスクにも引き継がれます。コンポーザーからの変更は実行中のジョブには影響せず、次のジョブから反映されます。指定したワーカーが後から無効化・削除された場合は空き待ちのまま止まるため、`自動` に戻すと復旧します(→[タスクを作って実行する](./02-tasks.md)・[実行中のタスクを見る・介入する](./03-running.md))。
## 2026-07-10 — Slack でボットにメンションすると、紐付けたワークスペースでタスクを実行して結果を返信するようになりました(管理者設定)
Slack のチャンネルでボットにメンションすると、あらかじめ紐付けたワークスペースでタスクが実行され、結果の要約と詳細リンクが同じチャンネル(スレッド)に返信されるようになりました。現時点ではテキストの依頼のみに対応し、紐付け(バインディング)の登録は管理者による設定が必要です(→[チャット連携(Slack](./24-chat-connectors.md))。
## 2026-07-10 — 「A2A 委任」設定が無効環境でエラーにならないようにしました
A2A を有効にしていないサーバーでは、設定の「A2A 委任」項目を開くと読み込みエラーが表示されていました。この項目は A2A が有効なときだけ意味を持つため、無効なサーバーでは設定サイドバーに表示しないようにしました(→[外部エージェント連携(A2A](./23-a2a.md))。
@@ -44,6 +135,10 @@ delegate カードの変更ファイル一覧をクリックしてプレビュ
delegate の中断カードに「最後に失敗したツール」を表示するようにしました。
## 2026-07-09 — ワークスペースの表示メニューを右クリックで開けるように・取り消し通知を自動で消えるように
ワークスペース一覧の **お気に入り/非表示** メニューを、行を右クリックしても開けるようにしました。右クリックしたカーソルの位置にメニューが出ます(従来の **…** ボタンも使えます)。メニューは外側をクリックするか **Esc** キーで閉じられるようになり、開きっぱなしになりません。あわせて、操作直後に出る「取り消し」通知が約5秒で自動的に消えるようにしました。
## 2026-07-09 — カレンダーで単日の予定にも件数バッジ・横断ドットが付くように
単日(終了日を指定しない)の予定を追加しても、月グリッド上で分かりにくかった問題を直しました。ワークスペース別カレンダーの日付マスにタスク件数の💬バッジと並んで予定件数の📌バッジが表示されるようになり、単日の予定を追加したその場で気づけます。横断カレンダーの色ドットも、これまでタスクがある日にしか出ませんでしたが、予定だけの日にも表示されるようになりました(ドットにカーソルを合わせるとタスク・予定の内訳を確認できます)。
+11 -3
View File
@@ -3,7 +3,7 @@ id: tasks
title: タスクを作って実行する
category: basic
order: 20
keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask policy, ワークスペース, 共有, メンバー, 招待, 組織, サブタブ, ファイル, 設定, 個人ワークスペース]
keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask policy, ワークスペース, 共有, メンバー, 招待, 組織, サブタブ, ファイル, 設定, 個人ワークスペース, モデル選択, ワーカー直接指定, reasoning effort]
---
## タスクはワークスペースで作る
@@ -12,7 +12,7 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
## 新しいタスクを作る
ワークスペースのチャット一覧の上部にある **「新しい Task」ボタン** を押すと、作成ダイアログが開きます。最低限必要なのは「依頼内容」だけです。
ワークスペースのチャット一覧の上部にある **「新しい Task」ボタン** を押すと、右側の会話ペインに作成フォームが開きます。最低限必要なのは「依頼内容」だけです。
### 依頼内容
@@ -29,7 +29,7 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
### プロンプトコーチ(依頼文の採点・改善)
作成ダイアログには、書いた依頼文をその場で評価してくれる **プロンプトコーチ** があります。実行前に依頼文を採点し、何が足りないか(出力形式・観点・制約の不足など)を指摘したうえで、改善した文案を提示します。提案をそのまま入力欄に反映できるので、曖昧な依頼で `waiting_human` に止まる前に、依頼の質を上げられます。入力欄の近くに出る一般的な書き方のヒント(Tips)とは別の、あなたの依頼文に対する個別フィードバックです。
作成フォームには、書いた依頼文をその場で評価してくれる **プロンプトコーチ** があります。実行前に依頼文を採点し、何が足りないか(出力形式・観点・制約の不足など)を指摘したうえで、改善した文案を提示します。提案をそのまま入力欄に反映できます。チャットを始めた後も入力欄の星ボタンから同じ評価を使えます。
### 添付ファイル
@@ -60,6 +60,14 @@ keywords: [タスク作成, piece選択, 添付, 詳細設定, 可視性, ask po
- **プロファイル**: `auto` / `fast` / `quality` — どの種類のワーカーで実行するか
- **優先度**: `low` / `medium` / `high` — ワーカーが拾う順番に影響します
### モデル / ワーカー直接指定
プロファイルによる自動選択に任せず、使うモデル(ワーカー)を名指しで固定したいときは、「モデル / ワーカー直接指定」から選びます。`自動(プロファイルで選択)` のままなら従来どおりプロファイルでルーティングされますが、特定のワーカーを選ぶと **プロファイルは使われず**、以後そのワーカーだけで実行されます。
ワーカーを選ぶと「推論の強さ (reasoning effort)」も選べるようになります。選択肢は、そのワーカーが対応している effort だけが表示されます(設定 → LLM Workers 側で宣言されたものです)。
この指定はタスクに紐づいて残り(sticky)、続けて送るメッセージやそこから生まれるサブタスクにも引き継がれます。あとから変更したい場合は、会話画面のコンポーザーからも切り替えられます(→[実行中のタスクを見る・介入する](./03-running.md))。
### 出力形式
`markdown` / `text` / `json` から選べます。最終回答の形式の指針になります。
+16 -2
View File
@@ -3,7 +3,7 @@ id: running
title: 実行中のタスクを見る・介入する
category: basic
order: 30
keywords: [チャット, ストリーミング, ツールコール, 割り込み, interjection, ブラウザ, SSH, 進捗, タブ, 入場エフェクト]
keywords: [チャット, ストリーミング, ツールコール, 割り込み, interjection, ブラウザ, SSH, 進捗, タブ, 入場エフェクト, モデル選択, ワーカー直接指定, reasoning effort]
---
タスクが動き出すと、その様子をリアルタイムで観察でき、必要なら途中で口を出せます。
@@ -39,11 +39,25 @@ keywords: [チャット, ストリーミング, ツールコール, 割り込み
チャットが「会話の見え方」だとすれば、進捗タブは「作業ログの見え方」です。
過去のメッセージまでスクロールすると、入力欄のすぐ上に **最新に移動** ボタンが表示されます。選ぶと会話の末尾へ戻り、まだ見ていないメッセージがある場合は件数も表示されます。
### 入力欄とコンテキスト残量ゲージ
入力欄(コンポーザー)はカード型で、書いた内容に合わせて高さが自動で伸びます(1〜8 行程度)。長めの指示を書いても窮屈にならず、送る前に見直しやすくなっています。
ツールバー右下には、いまの会話が使っているコンテキスト量を示す **残量ゲージ** が常時表示されます(概要タブにも同じ情報のカード版があります)。使用率が 70% を超えるとバーの横にパーセント表示が出て、残量が少ないことがひと目で分かります。マウスを乗せると実際のトークン数を確認できます。上限に近づくと色も変わります。ゲージが詰まってくると、エージェントは古いやり取りを自動で要約して空きを作りながら作業を続けます(→[結果を受け取る](./04-results.md)・[トラブルシューティング](./08-troubleshooting.md))。
入力欄の下端にある細い計器で、いまの会話が使っているコンテキスト量を確認できます。使用率が増えるにつれて青から黄・橙・赤へ変わり、ツールバーには使用トークン数と上限も表示されます。ゲージが詰まってくると、エージェントは古いやり取りを自動で要約して空きを作りながら作業を続けます(→[結果を受け取る](./04-results.md)・[トラブルシューティング](./08-troubleshooting.md))。
会話の右中央には、Movement の現在地と実際の履歴を示す細い **ミニマップ型レール** が薄く重なります。緑は完了、青は実行中、灰色は待機中の工程です。途中で前の工程へ戻った場合は戻り矢印、LLM通信のRetryは黄色の回転矢印、ジョブ全体のRetryは橙色の矢印、ユーザーのコメントや追加依頼は青色の印で表示されます。同じMovementをやり直した場合も実行順に残るため、作業がどのように変遷したかを確認できます。
実行済み・実行中・待機中のMovement点を押すと、どの状態でも工程の目的が2行で表示されます。実行済みの点では、詳細内の **このMovementへ移動** を押すと対応する会話箇所までスクロールし、一時的に強調されます。Retryやコメントなどの記号は、押すと直接その箇所へ移動します。カーソルを合わせるかキーボードで選ぶと工程名・状態・Retry回数・コメント冒頭を確認できます。長い指示は **全文を見る** から開けます。Pet を有効にしている場合は、現在地の点の横に小さく表示されます。
### 使うモデルを会話の途中で切り替える
コンポーザーには、いま使われているモデルを表示するボタン(`モデル: 自動` または `モデル名 @ ワーカーID · effort`)があります。押すとポップオーバーが開き、「モデル / ワーカー直接指定」と「推論の強さ (reasoning effort)」をその場で選び直せます。パネルは画面の空きに合わせてボタンの上または下に表示され、狭い画面ではパネル内をスクロールできます。ワーカーを指定するとタスク作成時の詳細設定と同じくプロファイルは使われなくなり、`自動(プロファイルで選択)` に戻せばいつでも自動ルーティングに戻せます。
この変更もタスクに紐づいて残り(sticky)、以後の返信やサブタスクに引き継がれます。ただし **反映されるのは次に投入されるジョブから** です。実行中のジョブがあるときは「実行中のジョブには適用されません。次のジョブから有効」と添えて表示され、いま動いている処理を横から書き換えるわけではないことを示します。
指定していたワーカーが後から無効化・削除されて使えなくなると、ボタンとポップオーバーに警告色のバッジが出て空き待ちのまま止まってしまいます。その場合はポップオーバーから `自動` を選び直すと、自動ルーティングに戻って進行が再開します。
## 実行中に指示を追加する(割り込み / interjection
+4
View File
@@ -8,6 +8,10 @@ keywords: [通知, ブラウザ通知, Web Push, プッシュ通知, Service Wor
# 通知を受け取る
アプリを開いている間は、タスクの状態変更が画面右下の**アプリ内通知**にも表示されます。これはブラウザの通知権限とは別に動作します。通知を選ぶと対象タスクを開け、閉じる操作で個別に消せます。タスク完了時は、有効にしている Pet がいれば完了アニメーションも表示されます。Pet を設定していない場合や画像を読み込めない場合でも、通常の完了通知は表示されます。
スペースのカレンダー予定には、開始時・5分前・10分前・30分前・1時間前の通知時刻を設定できます。開始時刻のない終日予定には通知を設定できません。
タスクの開始・完了・失敗・回答待ちをブラウザ通知で受け取れます。通知は **自分が owner のタスクのみ** が対象です。設定は TopBar → 設定 → 通知 で行います。
通知の仕組みは 2 種類あります。
+2
View File
@@ -95,3 +95,5 @@ keywords: [User Folder, ワークスペース設定, AGENTS.md, browser-macros,
## Pets
チャット画面に表示するキャラクター(マスコット)です。**設定 → Pets** で ZIP のインポート・既定キャラクターの選択・worker / backend ごとの割り当てを行います。Pets はユーザー単位の設定なので、ワークスペースではなくグローバルの設定にあります。
会話中のペットは状態に合わせてコマ送りで動き、ツールが完了すると種別と成功・失敗に応じたエフェクトを表示します。短時間に複数のツールが完了した場合は、落下中の粒子を途中から再生し直さず、件数バッジにまとめて表示します。OSで「視差効果を減らす」を有効にすると、粒子や大きな動きは抑制されます。
+1 -1
View File
@@ -34,7 +34,7 @@ movement の開始時には、その movement で使えるツールの一覧と
| オーケストレーション | サブタスクの生成 | SpawnSubTask |
| 地図 | 場所検索・経路・逆ジオコーディング | SearchPlaces / GetDirections |
| メディア | 文字起こし・動画検索・字幕取得 | TranscribeAudio / SearchYouTube / GetYouTubeTranscript |
| カレンダー | ワークスペースのカレンダーへ予定を登録・参照 | AddCalendarEvent / ListCalendarEvents |
| カレンダー・リマインダー | ワークスペースの予定と個人向けリマインダーを登録・参照・取消 | AddCalendarEvent / ListCalendarEvents / CreateReminder / ListReminders / CancelReminder |
| その他 | X(旧Twitter)検索・Amazon 検索など | XSearch / SearchAmazon |
SSH 系の詳しい使い方は [SSH リモート操作](./14-ssh.md) を参照してください。
+8 -2
View File
@@ -10,6 +10,10 @@ keywords: [設定, Settings, 管理者, Workers, Tools, Save & Apply]
設定画面は MAESTRO の挙動を `config.yaml` に書き込むための UI です。TopBar → **設定** タブで開きます。左にセクションのサイドバー、右に選択中セクションの編集フォームという 2 ペイン構成です。
モバイルでは最初にセクション一覧が表示されます。項目を選ぶと編集画面が開き、上部の戻る操作で一覧へ戻れます。URL にセクション名を含めて開いた場合は、その編集画面から表示され、ブラウザの戻る/進むにも追従します。
この画面は**個人・システム設定**です。ワークスペースを開いた状態で設定へ移動した場合は、画面上部の「ワークスペース設定を開く」から、そのワークスペースだけに保存される設定へ移動できます。ワークスペース設定では、同じ説明と「個人・システム設定に戻る」導線が表示されます。
YAML キーは **スネークケース** (`max_concurrency`)、コード内は **キャメルケース** (`maxConcurrency`) で、`src/config.ts``transformKeys` が自動変換します。UI から保存しても YAML はスネークケースのまま保たれます。
## 画面の構成
@@ -18,9 +22,11 @@ YAML キーは **スネークケース** (`max_concurrency`)、コード内は *
- **右フォーム** — 選択したセクションの設定項目。ほとんどはその場で編集するインラインフォーム
- `User` グループ以外は **admin 専用**。一般ユーザーには表示されない (`adminOnly`)
管理設定を編集してまだ **Save & Apply** を押していないセクションには、サイドバーに小さな琥珀色の印が付きます。別のセクションへ移動しても未保存変更を見失わず、保存・変更の破棄・設定の再読み込みを行うと印は消えます。
## 設定を検索する
サイドバー上部の検索ボックスに、探している設定のキーワードを入れると、該当するセクションだけが一覧に絞り込まれます。項目名だけでなく、その設定の中身(`config.yaml` のキー名や概念)でも引けます。たとえば「デッドライン」「reserve_cap」で **Safety**、「python」で **Execution**、「TLS」「証明書」で **HTTPS / TLS** に飛べます。複数の語をスペースで区切ると、そのすべてを含むセクションに絞り込みます(AND 検索)。結果をクリックするとそのセクションが開きます。表示されるのは、あなたが開ける権限のあるセクションだけです。
サイドバー上部の検索ボックスに、探している設定のキーワードを入れると、該当するセクションだけが一覧に絞り込まれます。項目名だけでなく、その設定の中身(`config.yaml` のキー名や概念)でも引けます。たとえば「デッドライン」「reserve_cap」で **Safety**、「python」で **Execution**、「TLS」「証明書」で **HTTPS / TLS** に飛べます。複数の語をスペースで区切ると、そのすべてを含むセクションに絞り込みます(AND 検索)。`Workers > Timeout``Web & Search > SearXNG URL` のように項目まで表示される結果を選ぶと、該当入力欄へ移動して強調表示されます。表示されるのは、あなたが開ける権限のあるセクションだけです。
## セクション一覧
@@ -64,7 +70,7 @@ Gateway の運用は [LLM Gateway 連携](#llm-gateway) を参照。
各 worker の編集カードには、通常の接続設定の下に「詳細設定」欄があります。
- **extra_body** — OpenAI 互換のリクエストボディへそのまま浅くマージされる任意 JSON(例: `{"reasoning_effort": "high"}`)。JSON として解析できない内容(配列・文字列・数値を含む)を入力すると赤枠とエラーメッセージが出て、その内容は保存されません。**ここに API キーなどの秘密情報を書かないでください** — `/api/config` はこの値をマスクしないため、`config.yaml` に平文のまま残ります。`model` / `messages` / `stream` / `stream_options` / `tools` / `tool_choice` / `temperature` は AAO 自身が組み立てる予約キーなので、指定しても無視されます。空欄にするとフィールド自体を省略します(`{}` にはなりません)。
- **extra_body** — OpenAI 互換のリクエストボディへそのまま浅くマージされる任意 JSON(例: `{"reasoning_effort": "high"}`)。JSON として解析できない内容(配列・文字列・数値を含む)を入力すると赤枠とエラーメッセージが出て、その内容は保存されません。保存バーの **問題の入力へ** を押すと、修正が必要な入力欄へ移動できます。**ここに API キーなどの秘密情報を書かないでください** — `/api/config` はこの値をマスクしないため、`config.yaml` に平文のまま残ります。`model` / `messages` / `stream` / `stream_options` / `tools` / `tool_choice` / `temperature` は AAO 自身が組み立てる予約キーなので、指定しても無視されます。空欄にするとフィールド自体を省略します(`{}` にはなりません)。
- **reasoning_efforts** — このワーカーが対応する reasoning effort をカンマ区切りで宣言する欄です(例: `high, medium, low`)。後続フェーズでジョブ単位の effort 指定に使われます。
- **reasoning_effort_mode** — effort をリクエストボディへ注入する形式。`body`(既定)はトップレベルの `reasoning_effort` で vLLM 向け、`chat_template_kwargs``chat_template_kwargs.reasoning_effort` で llama-serverllama.cpp)向けです。**llama-server はトップレベルの `reasoning_effort` を黙って無視する**ため、llama-server を使う場合は `chat_template_kwargs` を明示してください。
+2 -2
View File
@@ -27,9 +27,9 @@ keywords: [ワークスペース, 個人ワークスペース, 案件ワーク
一覧では、いま実行中のタスクがあるワークスペースに緑の **「● N 実行中」** バッジが行の右端に付きます(実行中が 0 件のときは表示されません)。どのワークスペースで作業が動いているかを開かずに把握できます。件数は自動で更新されます。公開範囲は **組織 / 公開** のときだけ小さく表示し、既定の **非公開** は表示しません(その分ワークスペース名を広く見せています)。
一覧の検索欄にワークスペース名の一部を入れると、参加しているワークスペースをすばやく絞り込めます。行の **…** メニューから **お気に入り** にすると専用セクションにまとまり、普段見ないものは **非表示** にできます。非表示にしたワークスペースは通常一覧から外れますが、検索結果には「非表示」バッジ付きで出るため、その場で再表示できます。下部の **非表示 N** を開いて一覧から戻すこともできます。
一覧の検索欄にワークスペース名の一部を入れると、参加しているワークスペースをすばやく絞り込めます。行の **…** メニュー、または **行を右クリック** するとメニューが開き、**お気に入り** にすると専用セクションにまとまり、普段見ないものは **非表示** にできます。右クリックのときはカーソルの位置にメニューが出ます。メニューはメニューの外側をクリックするか **Esc** キーで閉じます。非表示にしたワークスペースは通常一覧から外れますが、検索結果には「非表示」バッジ付きで出るため、その場で再表示できます。下部の **非表示 N** を開いて一覧から戻すこともできます。
お気に入り・非表示はユーザーごとの表示整理です。他のメンバーには影響せず、権限・通知・共有・直接 URL からのアクセスも変わりません。非表示にするとお気に入りは解除され、再表示しても自動ではお気に入りに戻りません。操作直後通知から取り消せます。
お気に入り・非表示はユーザーごとの表示整理です。他のメンバーには影響せず、権限・通知・共有・直接 URL からのアクセスも変わりません。非表示にするとお気に入りは解除され、再表示しても自動ではお気に入りに戻りません。操作直後通知から取り消せます。この通知は約5秒で自動的に消えます。
## 名前・色・説明を編集する
+83
View File
@@ -0,0 +1,83 @@
---
id: chat-connectors
title: チャット連携(Slack
category: advanced
order: 240
keywords: [Slack, チャット連携, チャットボット, メンション, chat connector, app_mention, A2A]
---
# チャット連携(Slack
Slack でボットにメンションすると、あらかじめ紐付けたワークスペースでタスクを実行し、結果を同じチャンネル(スレッド)に返信する機能です。裏側では [外部エージェント連携(A2A](./23-a2a.md) の仕組みをそのまま再利用しています。
> **既定は無効です。** 利用するには管理者が `config.yaml` で `chat.enabled: true`Slack 経路を使うなら合わせて `a2a.enabled: true`)を設定する必要があります。
## 現在のバージョンでできること(Phase 1)
- Slack のチャンネルでボットにメンションすると、紐付けたワークスペースでタスクが実行されます。同じ Slack イベントが再送されても、タスクは1回だけ実行されます
- 実行結果の要約と詳細リンクが、同じチャンネル(メンションがスレッド内なら同じスレッド)に返信されます
- テキストによる依頼のみに対応しています。添付ファイルは今後のバージョンで対応予定です
- 設定画面からの登録 UI はまだありません。**バインディング(チャンネルとワークスペースの紐付け)の作成は管理者にご依頼ください。**
## 仕組み
1. あなたが Slack で `@ボット名 ○○をやって` のようにメンションします
2. サーバーは、そのチャンネルに紐付けられたワークスペースと、その紐付けの元になった [A2A 委任](./23-a2a.md#ユーザーの作業委任に同意取り消しをする) を確認します
3. 委任が有効であれば、委任した本人の代わりに対象ワークスペースでピースが起動します
4. 実行が終わると、結果の要約とタスク詳細ページへのリンクが Slack に返信されます
紐付けが存在しない、委任が取り消し済み・期限切れ、またはメッセージの署名検証に失敗した場合は、**何も起こりません**(安全側に倒して無反応になります)。
## 管理者の作業:チャンネルとワークスペースを紐付ける
Phase 1 では専用の設定画面がないため、次の準備を管理者が行います。
1. **Slack アプリを用意する** — [Slack API](https://api.slack.com/apps) で Bot を作成し、`app_mention` イベントを購読、`chat:write` スコープを付与します。Events API の Request URL には、このサーバーの `/api/chat/slack/events` を指定します。
2. **A2A 委任を用意する** — 実行時に使うユーザーが、[外部エージェント連携](./23-a2a.md) の同意フローで、**1 つのワークスペースだけ**を選んで委任を許可しておきます(1 つのチャットバインディングは、必ずちょうど 1 つのワークスペースを許可した委任にひも付きます)。
3. **バインディングを登録する** — 管理者 API`POST /api/admin/chat/bindings`)で、Slack のワークスペース ID・チャンネル ID・上記の委任 ID・Bot の署名シークレットとトークンを登録します。登録した署名シークレットとトークンは、以後 API レスポンスに一切表示されません(書き込み専用)。
| 項目 | 内容 |
|------|------|
| platform | `slack`Phase 1 はこのみ) |
| externalWorkspaceId | Slack のワークスペース(teamID |
| externalChannelId | メンションを受け付ける Slack チャンネル ID |
| a2aDelegationId | 実行に使う A2A 委任の ID(1 スペースのみを許可したもの) |
| botCredentials | Slack Bot の signing secret と bot token |
## セキュリティ
- 受信した Slack イベントは、`X-Slack-Signature` / `X-Slack-Request-Timestamp` による署名検証を必ず通過しないと処理されません。署名が古すぎる(5 分以上前)リクエストはリプレイ防止のため拒否されます。
- Bot の署名シークレットとトークンは暗号化して保存され、管理者 API のレスポンスにも表示されません。
- 実行時に使われる権限は、あなたが同意した A2A 委任のスコープ(ワークスペース・スキル)と、あなた自身が普段アクセスできる範囲の**両方を満たす範囲**に限られます。委任が取り消されれば、チャット経由の実行もすぐに効かなくなります。
- リクエスト数の上限・同時実行数の上限は、[A2A のリソース制限](./23-a2a.md#委任ごとのリソース制限) と共有されます。
## 有効化の設定
`config.yaml` に以下を追加してください(`a2a.enabled: true` も合わせて必要です)。
```yaml
a2a:
enabled: true
chat:
enabled: true
public_base_url: "https://maestro.example.com" # 返信メッセージ内のリンクに使う絶対URL(省略可)
slack:
signing_secret_max_age_sec: 300 # リプレイ防止の許容時間(省略可、既定300秒)
```
## 管理画面から登録する(推奨)
管理者は、設定の **「💬 チャット連携」**(サーバーで `chat.enabled: true` のときに表示)からバインディングを登録・管理できます。
1. 「+ バインディングを追加」を開く
2. Slack の Workspace(Team) ID・Channel ID を入力
3. 紐付ける **A2A 委任** を選ぶ(一覧には「有効かつ付与スペースがちょうど1つ」の委任だけが出ます。付与スペースがそのままタスクの実行先になります)
4. Slack の **署名シークレット****Bot トークン** を入力して作成
資格情報は保存後に画面へ再表示されません(書き込み専用)。変更したいときは、新しい値を入力して更新します。一覧からは各バインディングの有効/無効の切り替えと削除ができます。
## 今後の予定
- Discord・Microsoft Teams への対応
- 追加確認(input-required)の往復対応
- 添付ファイル(画像・PDF など)の選択的な受け取り
+41 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { reduceDelegateStreams, reduceLlmState } from './useJobStream';
import { reduceDelegateStreams, reduceLastToolEvent, reduceLlmState } from './useJobStream';
describe('reduceDelegateStreams originJobId contract', () => {
it('lifecycle の originJobId を run に紐づける', () => {
@@ -96,3 +96,43 @@ describe('reduceLlmState', () => {
expect(reduceLlmState({ type: 'text_delta' }, 0)).toBeNull();
});
});
// Pets Phase 1, U0: SSE tool_use/tool_result -> LastToolEvent. Drives the
// chat pet's jump + spark triggers instead of the 5s-polled currentActivity.
describe('reduceLastToolEvent', () => {
it('tool_use starts a new event with isError null (unconfirmed)', () => {
const entry = reduceLastToolEvent(null, { type: 'tool_use', toolName: 'ReadPdf', callId: 'c1' }, 100);
expect(entry).toEqual({ name: 'ReadPdf', isError: null, callId: 'c1', ts: 100 });
});
it('a later tool_use replaces the previous event outright', () => {
let entry = reduceLastToolEvent(null, { type: 'tool_use', toolName: 'ReadPdf', callId: 'c1' }, 100);
entry = reduceLastToolEvent(entry, { type: 'tool_use', toolName: 'WebSearch', callId: 'c2' }, 200);
expect(entry).toEqual({ name: 'WebSearch', isError: null, callId: 'c2', ts: 200 });
});
it('tool_result confirms isError when the callId matches the pending event', () => {
let entry = reduceLastToolEvent(null, { type: 'tool_use', toolName: 'ReadPdf', callId: 'c1' }, 100);
entry = reduceLastToolEvent(entry, { type: 'tool_result', callId: 'c1', toolIsError: false }, 150);
expect(entry).toEqual({ name: 'ReadPdf', isError: false, callId: 'c1', ts: 150 });
});
it('tool_result propagates toolIsError: true', () => {
let entry = reduceLastToolEvent(null, { type: 'tool_use', toolName: 'Bash', callId: 'c1' }, 100);
entry = reduceLastToolEvent(entry, { type: 'tool_result', callId: 'c1', toolIsError: true }, 150);
expect(entry?.isError).toBe(true);
});
it('a tool_result for a stale callId (superseded by a newer tool_use) is ignored', () => {
let entry = reduceLastToolEvent(null, { type: 'tool_use', toolName: 'ReadPdf', callId: 'c1' }, 100);
entry = reduceLastToolEvent(entry, { type: 'tool_use', toolName: 'WebSearch', callId: 'c2' }, 200);
const beforeStaleResult = entry;
entry = reduceLastToolEvent(entry, { type: 'tool_result', callId: 'c1', toolIsError: true }, 250);
expect(entry).toEqual(beforeStaleResult);
});
it('returns prev unchanged for unrelated event types', () => {
const entry = reduceLastToolEvent(null, { type: 'tool_use', toolName: 'ReadPdf', callId: 'c1' }, 100);
expect(reduceLastToolEvent(entry, { type: 'text_delta' }, 200)).toBe(entry);
});
});
+45 -1
View File
@@ -43,6 +43,20 @@ export interface LlmStateEntry {
receivedAt: number;
}
/**
* Most recent tool invocation observed over SSE (`tool_use` / `tool_result`),
* keyed by `callId`. Used to drive the chat pet's jump + spark effects
* (Pets Phase 1, U0) instead of the 5s-polled `currentActivity` string,
* which also carries non-tool `LLM: …` status text and caused false
* triggers. `isError` is null until the matching `tool_result` arrives.
*/
export interface LastToolEvent {
name: string;
isError: boolean | null;
callId: string;
ts: number;
}
export interface JobStreamState {
promptProgress: PromptProgressState | null;
streamingText: string;
@@ -50,6 +64,7 @@ export interface JobStreamState {
connected: boolean;
delegateStreams: Record<string, DelegateStreamEntry>;
llmState: LlmStateEntry | null;
lastToolEvent: LastToolEvent | null;
}
function emptyEntry(delegateRunId: string): DelegateStreamEntry {
@@ -82,6 +97,28 @@ export function reduceLlmState(
};
}
/**
* SSE `tool_use` / `tool_result` を直近ツールイベントへ畳み込む純関数。
* `tool_use` は新規イベント(callId 単位)として上書き、`tool_result` は
* callId が一致するときだけ `isError` を確定させる。callId が既に別の
* イベントに進んでいた場合(tool_result が遅延到達)は無視する。
*/
export function reduceLastToolEvent(
prev: LastToolEvent | null,
data: { type: string; toolName?: string; callId?: string; toolIsError?: boolean },
now: number,
): LastToolEvent | null {
if (data.type === 'tool_use') {
if (!data.toolName) return prev;
return { name: data.toolName, isError: null, callId: data.callId ?? '', ts: now };
}
if (data.type === 'tool_result') {
if (!prev || !data.callId || prev.callId !== data.callId) return prev;
return { ...prev, isError: data.toolIsError ?? false, ts: now };
}
return prev;
}
/** SSE 1 イベントを delegateStreams マップへ畳み込む純関数。 */
export function reduceDelegateStreams(
prev: Record<string, DelegateStreamEntry>,
@@ -118,6 +155,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
const [connected, setConnected] = useState(false);
const [delegateStreams, setDelegateStreams] = useState<Record<string, DelegateStreamEntry>>({});
const [llmState, setLlmState] = useState<LlmStateEntry | null>(null);
const [lastToolEvent, setLastToolEvent] = useState<LastToolEvent | null>(null);
const esRef = useRef<EventSource | null>(null);
const isActive = jobStatus === 'running' || jobStatus === 'dispatching';
@@ -138,6 +176,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
setToolCallStream({});
setDelegateStreams({});
setLlmState(null);
setLastToolEvent(null);
return;
}
@@ -198,6 +237,10 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
delete next[data.callId];
return next;
});
setLastToolEvent(prev => reduceLastToolEvent(prev, data, Date.now()));
break;
case 'tool_result':
setLastToolEvent(prev => reduceLastToolEvent(prev, data, Date.now()));
break;
case 'delegate_lifecycle':
case 'delegate_text_delta':
@@ -210,6 +253,7 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
setToolCallStream({});
setDelegateStreams({});
setLlmState(null);
setLastToolEvent(null);
cleanup();
break;
}
@@ -224,5 +268,5 @@ export function useJobStream(taskId: number | null, jobStatus: string | null | u
return cleanup;
}, [taskId, isActive, cleanup]);
return { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState };
return { promptProgress, streamingText, toolCallStream, connected, delegateStreams, llmState, lastToolEvent };
}
+6
View File
@@ -16,6 +16,12 @@ export interface SetupStatus {
* Delegations settings section is shown (its API route only exists when on).
*/
a2aEnabled?: boolean;
/**
* True when the chat connector subsystem is enabled server-side. Drives
* whether the admin Chat Connectors settings section is shown (its API
* route, `/api/admin/chat/bindings`, only exists when on).
*/
chatEnabled?: boolean;
}
/**
@@ -0,0 +1,71 @@
// @vitest-environment jsdom
import '../test/dom-setup';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, renderHook } from '@testing-library/react';
import { DEMOTE_HOLD_MS, useStableNodePromotion } from './useStableNodePromotion';
import type { NodeAnimationState } from './useNodeAnimationState';
// Pets Phase 1, U3: node-busy-derived promotion (idle -> running) stays
// immediate; the running -> idle demotion is held for DEMOTE_HOLD_MS so a
// single missed busy poll on the shared node doesn't flap the pet.
describe('useStableNodePromotion', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('promotes idle -> running immediately', () => {
const { result, rerender } = renderHook(({ s }: { s: NodeAnimationState }) => useStableNodePromotion(s), {
initialProps: { s: 'idle' },
});
expect(result.current).toBe('idle');
rerender({ s: 'running' });
expect(result.current).toBe('running');
});
it('does not demote on a single transient idle sample', () => {
const { result, rerender } = renderHook(({ s }: { s: NodeAnimationState }) => useStableNodePromotion(s), {
initialProps: { s: 'running' },
});
expect(result.current).toBe('running');
rerender({ s: 'idle' });
// A busy sample comes back before the hold window elapses.
act(() => {
vi.advanceTimersByTime(DEMOTE_HOLD_MS - 1000);
});
expect(result.current).toBe('running');
rerender({ s: 'running' });
act(() => {
vi.advanceTimersByTime(DEMOTE_HOLD_MS + 1000);
});
expect(result.current).toBe('running');
});
it('demotes running -> idle once idle has been held for the full window', () => {
const { result, rerender } = renderHook(({ s }: { s: NodeAnimationState }) => useStableNodePromotion(s), {
initialProps: { s: 'running' },
});
rerender({ s: 'idle' });
expect(result.current).toBe('running');
act(() => {
vi.advanceTimersByTime(DEMOTE_HOLD_MS);
});
expect(result.current).toBe('idle');
});
it('re-promotes immediately once idle actually demotes', () => {
const { result, rerender } = renderHook(({ s }: { s: NodeAnimationState }) => useStableNodePromotion(s), {
initialProps: { s: 'running' },
});
rerender({ s: 'idle' });
act(() => {
vi.advanceTimersByTime(DEMOTE_HOLD_MS);
});
expect(result.current).toBe('idle');
rerender({ s: 'running' });
expect(result.current).toBe('running');
});
});
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useRef, useState } from 'react';
import type { NodeAnimationState } from './useNodeAnimationState';
/**
* Hold period before a `running -> idle` demotion is allowed to take
* effect. `useNodeStatus` polls every 5s, so a single missed/flapping
* busy sample would otherwise cause the pet to drop back to idle for one
* cycle and pop back up — this holds the promoted state until the node
* has reported idle for roughly two consecutive polls (~10s) before
* believing it.
*/
const DEMOTE_HOLD_MS = 10_000;
/**
* Debounces the `running -> idle` transition of a `NodeAnimationState`
* feed, while letting `idle -> running` promotions through immediately
* (Pets Phase 1, U3 — see
* docs/superpowers/specs/2026-07-10-pets-flicker-and-tool-effects-design.md).
*
* This only smooths the *node-busy-derived* promotion signal consumed by
* `ChatPetOverlay`; real task-status transitions (running/dispatching/
* done/error) don't go through this hook and stay instantaneous.
*/
export function useStableNodePromotion(nodeAnimState: NodeAnimationState): NodeAnimationState {
const [stable, setStable] = useState<NodeAnimationState>(nodeAnimState);
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (nodeAnimState === 'running') {
if (timerRef.current != null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
setStable('running');
return;
}
// nodeAnimState === 'idle': only demote after holding idle for the
// full window. Don't restart an already-running countdown — the
// first idle sample starts the clock, subsequent idle samples before
// it fires are the "second consecutive sample" confirming it.
if (timerRef.current != null) return;
timerRef.current = window.setTimeout(() => {
setStable('idle');
timerRef.current = null;
}, DEMOTE_HOLD_MS);
}, [nodeAnimState]);
useEffect(() => () => {
if (timerRef.current != null) window.clearTimeout(timerRef.current);
}, []);
return stable;
}
export { DEMOTE_HOLD_MS };
+10
View File
@@ -2,6 +2,7 @@ import { useQuery, keepPreviousData } from '@tanstack/react-query';
import {
fetchLocalTask,
fetchLocalTaskComments,
fetchMovementHistory,
fetchLocalFiles,
fetchLocalFileContent,
} from '../api';
@@ -22,6 +23,15 @@ export function useLocalTask(taskId: number | null, enabled: boolean) {
});
}
export function useMovementHistory(taskId: number | null, polling: boolean) {
return useQuery({
queryKey: ['movementHistory', taskId],
queryFn: () => fetchMovementHistory(taskId!),
enabled: taskId !== null,
refetchInterval: polling ? POLLING.FAST : false,
});
}
export function useLocalTaskComments(taskId: number | null, enabled: boolean) {
return useQuery({
queryKey: ['localTaskComments', taskId],
@@ -0,0 +1,43 @@
// @vitest-environment jsdom
import '../test/dom-setup';
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useTaskNotifications } from './useTaskNotifications';
import { DEFAULT_NOTIFY_EVENTS } from '../lib/notifications';
const task = (status: string) => ({
id: 7,
title: '通知対象',
pieceName: 'default',
ownerId: 'user-1',
latestJob: { status },
}) as any;
describe('useTaskNotifications in-app delivery', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('delivers a debounced task transition even without Notification permission', () => {
const onInAppNotification = vi.fn();
const { rerender } = renderHook(
({ tasks }) => useTaskNotifications({
tasks,
currentUserId: 'user-1',
enabled: true,
events: DEFAULT_NOTIFY_EVENTS,
onNotificationClick: vi.fn(),
onInAppNotification,
debounceMs: 100,
}),
{ initialProps: { tasks: [task('running')] } },
);
rerender({ tasks: [task('succeeded')] });
act(() => vi.advanceTimersByTime(100));
expect(onInAppNotification).toHaveBeenCalledWith(expect.objectContaining({
tag: 'task-7-succeeded',
data: { taskId: 7 },
}));
});
});
+8
View File
@@ -20,6 +20,8 @@ interface UseTaskNotificationsArgs {
events: NotifyEventSettings;
/** 通知クリック時のコールバック (taskId を受け取る)。 */
onNotificationClick: (taskId: number) => void;
/** OS 通知とは独立して、表示中アプリへ同じイベントを渡す。 */
onInAppNotification?: (notification: ReturnType<typeof buildNotificationOptions>) => void;
/** デバウンス長 (ms)。テストやチューニング用。デフォルト 4000。 */
debounceMs?: number;
}
@@ -34,6 +36,7 @@ export function useTaskNotifications({
enabled,
events,
onNotificationClick,
onInAppNotification,
debounceMs = 4000,
}: UseTaskNotificationsArgs): void {
// 各タスクの前回観測 status。初回マウント時の snapshot 用に first-pass フラグを別管理。
@@ -43,9 +46,13 @@ export function useTaskNotifications({
// onNotificationClick が再生成されても debouncer を作り直さないよう ref に逃がす。
const onClickRef = useRef(onNotificationClick);
const onInAppNotificationRef = useRef(onInAppNotification);
useEffect(() => {
onClickRef.current = onNotificationClick;
}, [onNotificationClick]);
useEffect(() => {
onInAppNotificationRef.current = onInAppNotification;
}, [onInAppNotification]);
// tasks / events も ref に逃がす (debouncer 内部から最新を読むため)。
const latestTasksRef = useRef<LocalTask[] | undefined>(tasks);
@@ -71,6 +78,7 @@ export function useTaskNotifications({
const settings = eventsRef.current;
if (!settings[event]) return;
const opts = buildNotificationOptions(task, event);
onInAppNotificationRef.current?.(opts);
const n = createNotification(opts, tid => onClickRef.current(tid));
if (n) activeNotifications.current.add(n);
});
+11
View File
@@ -55,4 +55,15 @@ describe('useToast', () => {
act(() => vi.advanceTimersByTime(200));
expect(result.current.toast).toBeNull();
});
it('queues separate notifications while replacing a duplicate id', () => {
const { result } = renderHook(() => useToast());
act(() => {
result.current.showToast('first', 'info', { id: 'task-1' });
result.current.showToast('second', 'success', { id: 'task-2' });
result.current.showToast('updated', 'success', { id: 'task-1' });
});
expect(result.current.toasts).toHaveLength(2);
expect(result.current.toasts.map(toast => toast.message)).toEqual(['second', 'updated']);
});
});
+47 -12
View File
@@ -1,24 +1,59 @@
import { useState, useEffect, useCallback } from 'react';
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react';
export type ToastVariant = 'success' | 'error';
export type ToastVariant = 'success' | 'error' | 'info';
export interface ToastState {
id: string;
message: string;
variant: ToastVariant;
title?: string;
actionLabel?: string;
onAction?: () => void;
visual?: ReactNode;
}
export interface ShowToastOptions {
/** 同じ ID の通知は更新し、重複して積まない。 */
id?: string;
title?: string;
actionLabel?: string;
onAction?: () => void;
visual?: ReactNode;
}
let nextToastId = 0;
/**
* 画面内通知のキュー。既存の `toast` は後方互換のため最新1件を返す。
* 同じ ID は置き換えるため、ポーリング由来の重複通知を表示しない。
*/
export function useToast(durationMs = 3500) {
const [state, setState] = useState<ToastState | null>(null);
const [toasts, setToasts] = useState<ToastState[]>([]);
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>>());
useEffect(() => {
if (!state) return;
const id = setTimeout(() => setState(null), durationMs);
return () => clearTimeout(id);
}, [state, durationMs]);
const showToast = useCallback((message: string, variant: ToastVariant = 'success') => {
setState({ message, variant });
const dismissToast = useCallback((id: string) => {
const timer = timers.current.get(id);
if (timer) clearTimeout(timer);
timers.current.delete(id);
setToasts(current => current.filter(toast => toast.id !== id));
}, []);
return { toast: state, showToast };
const showToast = useCallback((message: string, variant: ToastVariant = 'success', options: ShowToastOptions = {}) => {
const id = options.id ?? `toast-${++nextToastId}`;
const timer = timers.current.get(id);
if (timer) clearTimeout(timer);
const toast: ToastState = { id, message, variant, title: options.title, actionLabel: options.actionLabel, onAction: options.onAction, visual: options.visual };
setToasts(current => [...current.filter(item => item.id !== id), toast]);
timers.current.set(id, setTimeout(() => dismissToast(id), durationMs));
}, [dismissToast, durationMs]);
useEffect(() => () => {
timers.current.forEach(clearTimeout);
timers.current.clear();
}, []);
const latest = toasts.at(-1);
// Legacy consumers only expect these two properties.
const toast = latest ? { message: latest.message, variant: latest.variant } : null;
return { toast, toasts, showToast, dismissToast };
}
+28
View File
@@ -16,6 +16,7 @@
"failed": "Action failed (you may not have permission, or the install failed)"
},
"pane": {
"evaluatePrompt": "Evaluate prompt",
"empty": "No messages yet",
"newMessages": "{{count}} new",
"toLatest": "Jump to latest",
@@ -88,8 +89,35 @@
"checklist": {
"showMore": "Show {{count}} more..."
},
"movementMap": {
"jobRetry": "Job retry {{attempt}}/{{max}}",
"llmRetry": "LLM retry {{attempt}}/{{max}}",
"goToMovement": "Go to this movement",
"workerRetry": "Worker connection failed; reassigned for retry",
"label": "Movement progress map",
"title": "Movements",
"completed": "Completed",
"active": "In progress",
"failed": "Failed",
"cancelled": "Cancelled",
"closeInstruction": "Close movement details",
"showFullInstruction": "Show full instruction",
"collapseInstruction": "Show less",
"pending": "Pending",
"unnamed": "Unnamed"
},
"movement": {
"complete": "{{movement}} complete",
"noIntermediateOutput": "No intermediate output"
},
"llmSelect": {
"title": "Choose model",
"auto": "Model: auto",
"autoOption": "Auto (profile-routed)",
"workerLabel": "Model / worker override",
"effortLabel": "Reasoning effort",
"effortNone": "None",
"appliesNextJob": "Won't affect the running job — takes effect from the next job",
"stalledWorker": "Waiting for pinned worker ({{id}}) — clear it to fall back to auto routing"
}
}
@@ -0,0 +1,51 @@
{
"title": "Chat Connectors",
"subtitle": "Bindings that link a Slack channel to a workspace. When someone mentions the bot in a bound channel, a task runs in that workspace under the linked A2A delegation and the result is posted back to the channel.",
"loading": "Loading bindings…",
"err": {
"load": "Failed to load chat connector bindings.",
"create": "Failed to create the binding.",
"update": "Failed to update the binding.",
"delete": "Failed to delete the binding."
},
"empty": "No chat connector bindings yet.",
"emptyExplain": "Add one below to link a Slack channel to a workspace.",
"field": {
"platform": "Platform",
"externalWorkspaceId": "Slack Workspace (Team) ID",
"externalChannelId": "Slack Channel ID",
"space": "Workspace",
"delegation": "A2A Delegation",
"status": "Status",
"createdBy": "Created by",
"createdAt": "Created"
},
"status": {
"active": "Active",
"disabled": "Disabled"
},
"action": {
"enable": "Enable",
"disable": "Disable",
"delete": "Delete",
"confirmDelete": "Confirm delete",
"cancel": "Cancel",
"rotateCredentials": "Rotate credentials",
"addBinding": "+ Add binding",
"create": "Create binding",
"creating": "Creating…",
"save": "Save",
"saving": "Saving…"
},
"confirmDeletePrompt": "Delete this binding? Mentions in this channel will stop working immediately.",
"credentials": {
"reenterHint": "Credentials are write-only and never shown again. Only re-enter them if you want to replace the stored values.",
"signingSecret": "Signing secret",
"botToken": "Bot token"
},
"createForm": {
"title": "Add a binding",
"noDelegations": "No eligible A2A delegations found. A delegation must be live and grant exactly one workspace before it can back a chat binding.",
"delegationPlaceholder": "Select a delegation…"
}
}
+6
View File
@@ -28,6 +28,12 @@
"taskType": "Task type",
"auto": "Auto-select",
"profile": "Profile",
"workerLabel": "Model / worker override",
"workerAuto": "Automatic (by profile)",
"effortLabel": "Reasoning effort",
"effortNone": "Default",
"workerOverridesProfile": "Pinning a worker overrides the profile setting",
"llmNotForScheduled": "Model/effort pinning is not available for scheduled tasks",
"priority": "Priority",
"outputFormat": "Output format",
"askPolicy": "Ask policy",
+1
View File
@@ -61,6 +61,7 @@
},
"timeline": { "empty": "No comments" },
"context": {
"awaiting": "Awaiting first LLM call",
"ariaLabel": "Context: {{remaining}} tokens remaining, {{percent}}% used"
},
"sharingPreview": { "notShared": "— not shared" },
+9 -1
View File
@@ -431,6 +431,9 @@
"delegations": {
"navLabel": "🔑 A2A Delegations"
},
"chatConnectors": {
"navLabel": "💬 Chat Connectors"
},
"reflection": {
"intro": "Every time a normal job completes, the LLM extracts the lessons learned from that job and automatically updates the user's memory (data/users/{userId}/memory/) and, when needed, a custom piece. All changes are saved as snapshots and can be reverted from the Memory & Learning tab.",
"enableLabel": "Enable Reflection (auto-apply)",
@@ -730,8 +733,10 @@
"saving": "Saving...",
"loadError": "Failed to load configuration",
"unsaved": "Unsaved: {{count}} items — changes don't apply until you press \"Save & Apply\"",
"unsavedAdmin": "Unsaved admin config: {{count}} items — changes don't apply until you press \"Save & Apply\"",
"unsavedShort": "Unsaved {{count}}",
"invalidBlocked": "Cannot save: fix (or empty) the invalid highlighted field first."
"invalidBlocked": "Cannot save: fix (or empty) the invalid highlighted field first.",
"showInvalidField": "Show invalid input"
},
"searchFilter": {
"blockedLabel": "Blocked Patterns",
@@ -800,6 +805,9 @@
"settingsPage": {
"sectionList": "Section list"
},
"settingsSidebar": {
"unsaved": "Unsaved changes"
},
"serverTls": {
"title": "HTTPS / TLS",
"port": "HTTP(S) port",
+28
View File
@@ -16,6 +16,7 @@
"failed": "操作に失敗しました(権限が無い、またはインストールに失敗した可能性があります)"
},
"pane": {
"evaluatePrompt": "プロンプトを評価",
"empty": "メッセージはまだありません",
"newMessages": "{{count}} 件の新着",
"toLatest": "最新へ",
@@ -88,8 +89,35 @@
"checklist": {
"showMore": "他 {{count}} 件を表示..."
},
"movementMap": {
"jobRetry": "ジョブを再試行 {{attempt}}/{{max}}",
"llmRetry": "LLM通信を再試行 {{attempt}}/{{max}}",
"goToMovement": "このMovementへ移動",
"workerRetry": "ワーカー接続に失敗したため、再割り当てして再試行",
"label": "Movement 進捗マップ",
"title": "Movement",
"completed": "完了",
"active": "実行中",
"failed": "失敗",
"cancelled": "キャンセル",
"closeInstruction": "Movement の詳細を閉じる",
"showFullInstruction": "全文を見る",
"collapseInstruction": "折りたたむ",
"pending": "待機中",
"unnamed": "名称未設定"
},
"movement": {
"complete": "{{movement}} 完了",
"noIntermediateOutput": "中間出力なし"
},
"llmSelect": {
"title": "使用モデルを選択",
"auto": "モデル: 自動",
"autoOption": "自動(プロファイルで選択)",
"workerLabel": "モデル / ワーカー直接指定",
"effortLabel": "推論の強さ (reasoning effort)",
"effortNone": "指定なし",
"appliesNextJob": "実行中のジョブには適用されません。次のジョブから有効",
"stalledWorker": "指定ワーカー({{id}})の空き待ち — 解除で自動ルーティングに戻せます"
}
}
@@ -0,0 +1,51 @@
{
"title": "チャット連携",
"subtitle": "Slack のチャンネルとワークスペースを紐付けるバインディング一覧です。紐付けたチャンネルでボットにメンションすると、対応する A2A 委任の権限でそのワークスペースのタスクが実行され、結果が同じチャンネルに返信されます。",
"loading": "バインディングを読み込んでいます…",
"err": {
"load": "バインディングの読み込みに失敗しました。",
"create": "バインディングの作成に失敗しました。",
"update": "バインディングの更新に失敗しました。",
"delete": "バインディングの削除に失敗しました。"
},
"empty": "バインディングはまだありません。",
"emptyExplain": "下のフォームから、Slack チャンネルとワークスペースを紐付けられます。",
"field": {
"platform": "プラットフォーム",
"externalWorkspaceId": "Slack ワークスペース(teamID",
"externalChannelId": "Slack チャンネル ID",
"space": "紐付くワークスペース",
"delegation": "A2A 委任",
"status": "状態",
"createdBy": "作成者",
"createdAt": "作成日時"
},
"status": {
"active": "有効",
"disabled": "無効"
},
"action": {
"enable": "有効化",
"disable": "無効化",
"delete": "削除",
"confirmDelete": "削除を確定",
"cancel": "キャンセル",
"rotateCredentials": "認証情報を更新",
"addBinding": "+ バインディングを追加",
"create": "バインディングを作成",
"creating": "作成中…",
"save": "保存",
"saving": "保存中…"
},
"confirmDeletePrompt": "このバインディングを削除しますか?削除すると、このチャンネルでのメンション実行はすぐに反応しなくなります。",
"credentials": {
"reenterHint": "認証情報は書き込み専用で、以後表示されません。保存済みの値を差し替えたい場合のみ再入力してください。",
"signingSecret": "Signing secret",
"botToken": "Bot token"
},
"createForm": {
"title": "バインディングを追加",
"noDelegations": "利用できる A2A 委任がありません。チャットバインディングに使うには、有効(live)かつ、ちょうど 1 つのワークスペースだけを許可した委任である必要があります。",
"delegationPlaceholder": "委任を選択…"
}
}
+6
View File
@@ -28,6 +28,12 @@
"taskType": "タスクタイプ",
"auto": "自動選択",
"profile": "プロファイル",
"workerLabel": "モデル / ワーカー直接指定",
"workerAuto": "自動(プロファイルで選択)",
"effortLabel": "推論の強さ (reasoning effort)",
"effortNone": "指定なし",
"workerOverridesProfile": "ワーカーを指定するとプロファイルは使われません",
"llmNotForScheduled": "モデル/effort の直接指定は定期実行では使えません",
"priority": "優先度",
"outputFormat": "出力形式",
"askPolicy": "質問ポリシー",
+1
View File
@@ -60,6 +60,7 @@
},
"timeline": { "empty": "コメントなし" },
"context": {
"awaiting": "最初の LLM 呼び出しを待機中",
"ariaLabel": "コンテキスト残り {{remaining}} tokens、{{percent}}% 使用"
},
"sharingPreview": { "notShared": "— 共有されません" },
+9 -1
View File
@@ -431,6 +431,9 @@
"delegations": {
"navLabel": "🔑 A2A 委任"
},
"chatConnectors": {
"navLabel": "💬 チャット連携"
},
"reflection": {
"intro": "通常ジョブが完了するたびに LLM がそのジョブから学んだ教訓を抽出し、ユーザーの memory (data/users/{userId}/memory/) と必要に応じて custom piece を自動更新します。全変更は snapshot として保存され、Memory & Learning タブから revert 可能です。",
"enableLabel": "Reflection を有効化(自動適用)",
@@ -730,8 +733,10 @@
"saving": "Saving...",
"loadError": "設定の読み込みに失敗しました",
"unsaved": "未保存: {{count}} 項目 — 「Save & Apply」を押すまで反映されません",
"unsavedAdmin": "管理設定で未保存: {{count}} 項目 — 「Save & Apply」を押すまで反映されません",
"unsavedShort": "未保存 {{count}}",
"invalidBlocked": "入力エラーがあるため保存できません。赤枠のフィールドを修正するか空にしてください。"
"invalidBlocked": "入力エラーがあるため保存できません。赤枠のフィールドを修正するか空にしてください。",
"showInvalidField": "問題の入力へ"
},
"searchFilter": {
"blockedLabel": "Blocked Patterns (ブロックパターン)",
@@ -800,6 +805,9 @@
"settingsPage": {
"sectionList": "セクション一覧"
},
"settingsSidebar": {
"unsaved": "未保存の変更あり"
},
"serverTls": {
"title": "HTTPS / TLS",
"port": "HTTP(S) ポート",
+299 -46
View File
@@ -211,6 +211,19 @@
user-select: none;
}
/* Pose layer (U1): carries the continuous base-state wobble
(idle/run/wait/done/error) and the sprite-sheet clip window. Nested
inside `.pet-sprite`, which carries only the one-shot jump hop —
see PetSprite.tsx for why these are split into two elements. */
.pet-sprite-pose {
display: grid;
place-items: center;
/* Match the outer `.pet-sprite` pivot so the base-state wobble
(rotate/scale) hinges at the feet, not the center — otherwise the
pet reads as "floating" instead of "grounded" (Fable review #1). */
transform-origin: 50% 90%;
}
.pet-sprite-fallback {
width: 100%;
height: 100%;
@@ -234,10 +247,10 @@
.pet-sprite-fallback span:last-child { right: 34%; }
.pet-sprite-idle { animation: petIdle 1.8s ease-in-out infinite; }
.pet-sprite-running { animation: petRun 0.65s ease-in-out infinite; }
.pet-sprite-runningAlt { animation: petRun 0.65s ease-in-out infinite reverse; }
.pet-sprite-dispatching { animation: petRun 0.85s ease-in-out infinite; }
.pet-sprite-jumping { animation: petJump 0.55s ease-in-out infinite; }
.pet-sprite-running { animation: petRun 0.65s linear infinite; }
.pet-sprite-runningAlt { animation: petRun 0.65s linear infinite reverse; }
.pet-sprite-dispatching { animation: petRun 0.85s linear infinite; }
.pet-sprite-jumping { animation: petJump 0.55s linear infinite; }
.pet-sprite-waiting { animation: petWait 1.4s ease-in-out infinite; }
.pet-sprite-done { animation: petDone 1s cubic-bezier(.2, 1.35, .32, 1) forwards; }
.pet-sprite-error { animation: petError 0.45s ease-in-out 2; }
@@ -331,9 +344,9 @@
display: grid;
place-items: center;
background: var(--canvas);
color: #0f766e;
color: #334155;
border: 1px solid rgb(15 23 42 / 0.08);
box-shadow: 0 10px 24px rgb(15 23 42 / 0.22), 0 0 0 6px rgb(45 212 191 / 0.18);
box-shadow: 0 10px 24px rgb(15 23 42 / 0.22);
animation: toolSparkPop 3000ms cubic-bezier(.2, 1.25, .35, 1) forwards;
}
@@ -346,6 +359,56 @@
animation: toolSparkFade 3000ms ease-out forwards;
}
/* U7: success/failure color + mark. `.tool-spark-burst` carries the
status class so it can tint both the bubble ring and the particles
(which are colored via `currentColor`) from one place. */
.tool-spark-success .tool-spark-bubble {
color: #15803d;
box-shadow: 0 10px 24px rgb(15 23 42 / 0.22), 0 0 0 6px rgb(34 197 94 / 0.2);
}
.tool-spark-error .tool-spark-bubble {
color: #b91c1c;
box-shadow: 0 10px 24px rgb(15 23 42 / 0.22), 0 0 0 6px rgb(239 68 68 / 0.24);
}
.tool-spark-error-mark {
position: absolute;
top: -3px;
right: -3px;
width: 15px;
height: 15px;
border-radius: 999px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 15px;
text-align: center;
box-shadow: 0 1px 3px rgb(0 0 0 / 0.35);
}
/* U8: combo badge ("×N") for coalesced tool results within the coalesce
window (ToolSpark.tsx COALESCE_WINDOW_MS). */
.tool-spark-combo {
position: absolute;
left: 14px;
top: -22px;
min-width: 16px;
padding: 1px 5px;
border-radius: 999px;
background: rgb(15 23 42 / 0.85);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 14px;
text-align: center;
animation: toolSparkComboPop 220ms cubic-bezier(.34, 1.56, .64, 1);
pointer-events: none;
}
.tool-spark-combo-reduced {
animation: none;
}
.tool-spark-particle {
position: absolute;
display: block;
@@ -353,9 +416,17 @@
pointer-events: none;
opacity: 0;
will-change: transform, opacity;
animation: toolSparkParticle 3000ms cubic-bezier(.2, 1.25, .35, 1) forwards;
animation: toolSparkParticle 3000ms linear forwards;
filter: drop-shadow(0 0 4px rgb(250 204 21 / 0.65));
}
.tool-spark-success .tool-spark-particle {
color: #22c55e;
filter: drop-shadow(0 0 4px rgb(34 197 94 / 0.65));
}
.tool-spark-error .tool-spark-particle {
color: #ef4444;
filter: drop-shadow(0 0 4px rgb(239 68 68 / 0.65));
}
.tool-spark-particle svg {
width: 100%;
@@ -363,6 +434,65 @@
fill: currentColor;
}
/* U6: per-kind visual treatment (search/terminal/file/edit/browser/issue/
plug/star — see ToolSpark.tsx SPARK_MODE). `ToolIcon` (the bubble
glyph) is unchanged across kinds; only the particle/bubble motion
differs here. Not applied when reducedMotion (ToolSpark.tsx omits the
mode class in that case), so these never compete with
`.tool-spark-reduced`'s fade-only override. */
/* search: expanding ripple rings instead of launched stars. */
.tool-spark-mode-ripple .tool-spark-particle svg { display: none; }
.tool-spark-mode-ripple .tool-spark-particle {
border-radius: 999px;
border: 2px solid currentColor;
background: transparent;
animation: toolSparkRipple 1200ms ease-out forwards;
}
/* terminal: the bubble itself flickers like a blinking cursor; no
scattered particles. */
.tool-spark-mode-blink .tool-spark-particle { display: none; }
.tool-spark-mode-blink .tool-spark-bubble {
animation: toolSparkBlink 3000ms linear forwards;
}
/* file / edit: particles become small paper scraps that flutter and
fall, instead of launching outward like sparks. */
.tool-spark-mode-paper .tool-spark-particle svg { display: none; }
.tool-spark-mode-paper .tool-spark-particle {
border-radius: 2px;
background: currentColor;
animation: toolSparkPaper 2600ms ease-in forwards;
}
/* browser: the bubble "opens" like a window (vertical reveal) instead of
popping; no scattered particles. */
.tool-spark-mode-window .tool-spark-particle { display: none; }
.tool-spark-mode-window .tool-spark-bubble {
animation: toolSparkWindow 3000ms cubic-bezier(.2, 1.25, .35, 1) forwards;
}
/* issue: the bubble drops in like a map marker and settles; no
scattered particles. */
.tool-spark-mode-marker .tool-spark-particle { display: none; }
.tool-spark-mode-marker .tool-spark-bubble {
animation: toolSparkMarker 3000ms cubic-bezier(.34, 1.56, .64, 1) forwards;
}
/* plug (MCP): particles flicker like electric sparks while still
following the star launch trajectory. Order matters — toolSparkBolt is
listed after toolSparkParticle so it wins on `opacity` while active;
toolSparkParticle keeps driving `transform` throughout and reclaims
`opacity` once the flicker's iterations finish. */
.tool-spark-mode-bolt .tool-spark-particle {
animation: toolSparkParticle 3000ms linear forwards,
toolSparkBolt 900ms steps(2, jump-end) 3;
}
/* spark (default / uncategorized tools): unchanged star burst — no
override needed, falls through to the base rules above. */
/* `output/...` workspace path links emitted by the linkifier
(lib/output-path-detect.ts) and by the Marked renderer in
lib/markdown-text.tsx. Both produce <a class="output-path-link"
@@ -398,6 +528,29 @@
to { --flow-angle: 360deg; }
}
/* Composer context usage: a thin bottom meter only. */
.water-context {
--water-level: 0%;
--water-color: 59 130 246;
position: absolute;
inset: auto auto 5px 0;
width: var(--water-level);
height: 2px;
border-radius: 999px;
pointer-events: none;
background: rgb(var(--water-color) / .9);
box-shadow: 0 0 4px rgb(var(--water-color) / .2);
transition: width 400ms cubic-bezier(.22, 1, .36, 1);
}
.water-context--mid { --water-color: 245 158 11; }
.water-context--high { --water-color: 249 115 22; }
.water-context--critical { --water-color: 239 68 68; }
@media (prefers-reduced-motion: reduce) {
.water-context { transition: none; }
}
/* Browser/SSH tab entrance: slide in from the right while fading in. */
@keyframes tabAppearSlide {
from { opacity: 0; transform: translateX(10px); }
@@ -411,16 +564,33 @@
100% { box-shadow: 0 0 0 0 rgb(56 189 248 / 0); }
}
@keyframes toast-enter {
from { opacity: 0; transform: translateY(0.5rem); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes petIdle {
0%, 100% { transform: translateY(0) rotate(0deg) scale(1); }
50% { transform: translateY(-6px) rotate(1deg) scale(1.03); }
}
/* Continuous rocking gait (U9). Paired with `linear` timing on the
`.pet-sprite-running` etc. rules above — per-keyframe `ease-in-out`
decelerates to a full stop at every keyframe, which is what produced
visible "landing" pauses at 0%/50%/100% in the old 4-point version.
Using more points with linear interpolation approximates a smooth
sinusoidal bob without ever fully resting at the neutral pose (every
keyframe keeps some lean/lift), so there's no stop point to see. */
@keyframes petRun {
0%, 100% { transform: translateY(0) rotate(0deg) scale(1); }
25% { transform: translateY(-8px) rotate(-7deg) scale(1.07); }
50% { transform: translateY(0) rotate(0deg) scale(1); }
75% { transform: translateY(-8px) rotate(7deg) scale(1.07); }
0% { transform: translateY(0) rotate(-7deg) scale(1.02); }
12.5% { transform: translateY(-4px) rotate(-3deg) scale(1.05); }
25% { transform: translateY(-8px) rotate(0deg) scale(1.07); }
37.5% { transform: translateY(-4px) rotate(3deg) scale(1.05); }
50% { transform: translateY(-1px) rotate(7deg) scale(1.02); }
62.5% { transform: translateY(-4px) rotate(3deg) scale(1.05); }
75% { transform: translateY(-8px) rotate(0deg) scale(1.07); }
87.5% { transform: translateY(-4px) rotate(-3deg) scale(1.05); }
100% { transform: translateY(0) rotate(-7deg) scale(1.02); }
}
@keyframes petWait {
@@ -429,9 +599,12 @@
}
@keyframes petDone {
0% { transform: translateY(0) scale(1); }
32% { transform: translateY(-20px) scale(1.2); }
68% { transform: translateY(-6px) scale(1.06); }
0% { transform: translateY(0) scale(1); }
20% { transform: translateY(-14px) scale(1.14); }
32% { transform: translateY(-20px) scale(1.2); }
50% { transform: translateY(-12px) scale(1.12); }
68% { transform: translateY(-6px) scale(1.06); }
84% { transform: translateY(-2px) scale(1.02); }
100% { transform: translateY(0) scale(1); }
}
@@ -441,66 +614,78 @@
75% { transform: translateX(4px); }
}
/* Continuous hop arc (U9), paired with `linear` timing (see
`.pet-sprite-jumping` above) — the old 60% plateau (a near-landing
value revisited before the loop point) reads as a stutter under
ease-in-out. More points approximate a parabolic up/down arc so the
loop point is the only place velocity actually reaches zero. */
@keyframes petJump {
0%, 100% { transform: translateY(0) scale(1); }
35% { transform: translateY(-22px) scale(1.12); }
60% { transform: translateY(-2px) scale(1.02); }
0% { transform: translateY(0) scale(1); }
15% { transform: translateY(-10px) scale(1.06); }
30% { transform: translateY(-19px) scale(1.11); }
45% { transform: translateY(-22px) scale(1.12); }
60% { transform: translateY(-19px) scale(1.11); }
75% { transform: translateY(-10px) scale(1.06); }
90% { transform: translateY(-2px) scale(1.02); }
100% { transform: translateY(0) scale(1); }
}
/* Frame-by-frame sprite cycling within the active state row.
Explicit per-frame plateaus avoid the steps()-boundary flash that
would briefly visit col N (which is transparent on Codex pets
where the row has fewer than 8 filled frames). Pair with
animation-timing-function: linear and background-repeat: repeat-x. */
animation-timing-function: linear. Positions are supplied by PetSprite
from the full spritesheet column count; CSS percentage positioning is
relative to the image/container size difference, not to one grid cell. */
@keyframes petFrameCycle1 { from, to { background-position-x: 0%; } }
@keyframes petFrameCycle2 {
0%, 49.99% { background-position-x: 0%; }
50%, 100% { background-position-x: -100%; }
50%, 100% { background-position-x: var(--pet-frame-1-position); }
}
@keyframes petFrameCycle3 {
0%, 33.32% { background-position-x: 0%; }
33.33%, 66.65% { background-position-x: -100%; }
66.66%, 100% { background-position-x: -200%; }
33.33%, 66.65% { background-position-x: var(--pet-frame-1-position); }
66.66%, 100% { background-position-x: var(--pet-frame-2-position); }
}
@keyframes petFrameCycle4 {
0%, 24.99% { background-position-x: 0%; }
25%, 49.99% { background-position-x: -100%; }
50%, 74.99% { background-position-x: -200%; }
75%, 100% { background-position-x: -300%; }
25%, 49.99% { background-position-x: var(--pet-frame-1-position); }
50%, 74.99% { background-position-x: var(--pet-frame-2-position); }
75%, 100% { background-position-x: var(--pet-frame-3-position); }
}
@keyframes petFrameCycle5 {
0%, 19.99% { background-position-x: 0%; }
20%, 39.99% { background-position-x: -100%; }
40%, 59.99% { background-position-x: -200%; }
60%, 79.99% { background-position-x: -300%; }
80%, 100% { background-position-x: -400%; }
20%, 39.99% { background-position-x: var(--pet-frame-1-position); }
40%, 59.99% { background-position-x: var(--pet-frame-2-position); }
60%, 79.99% { background-position-x: var(--pet-frame-3-position); }
80%, 100% { background-position-x: var(--pet-frame-4-position); }
}
@keyframes petFrameCycle6 {
0%, 16.65% { background-position-x: 0%; }
16.66%, 33.32% { background-position-x: -100%; }
33.33%, 49.99% { background-position-x: -200%; }
50%, 66.65% { background-position-x: -300%; }
66.66%, 83.32% { background-position-x: -400%; }
83.33%, 100% { background-position-x: -500%; }
16.66%, 33.32% { background-position-x: var(--pet-frame-1-position); }
33.33%, 49.99% { background-position-x: var(--pet-frame-2-position); }
50%, 66.65% { background-position-x: var(--pet-frame-3-position); }
66.66%, 83.32% { background-position-x: var(--pet-frame-4-position); }
83.33%, 100% { background-position-x: var(--pet-frame-5-position); }
}
@keyframes petFrameCycle7 {
0%, 14.27% { background-position-x: 0%; }
14.28%, 28.56% { background-position-x: -100%; }
28.57%, 42.84% { background-position-x: -200%; }
42.85%, 57.13% { background-position-x: -300%; }
57.14%, 71.41% { background-position-x: -400%; }
71.42%, 85.70% { background-position-x: -500%; }
85.71%, 100% { background-position-x: -600%; }
14.28%, 28.56% { background-position-x: var(--pet-frame-1-position); }
28.57%, 42.84% { background-position-x: var(--pet-frame-2-position); }
42.85%, 57.13% { background-position-x: var(--pet-frame-3-position); }
57.14%, 71.41% { background-position-x: var(--pet-frame-4-position); }
71.42%, 85.70% { background-position-x: var(--pet-frame-5-position); }
85.71%, 100% { background-position-x: var(--pet-frame-6-position); }
}
@keyframes petFrameCycle8 {
0%, 12.49% { background-position-x: 0%; }
12.5%, 24.99% { background-position-x: -100%; }
25%, 37.49% { background-position-x: -200%; }
37.5%, 49.99% { background-position-x: -300%; }
50%, 62.49% { background-position-x: -400%; }
62.5%, 74.99% { background-position-x: -500%; }
75%, 87.49% { background-position-x: -600%; }
87.5%, 100% { background-position-x: -700%; }
12.5%, 24.99% { background-position-x: var(--pet-frame-1-position); }
25%, 37.49% { background-position-x: var(--pet-frame-2-position); }
37.5%, 49.99% { background-position-x: var(--pet-frame-3-position); }
50%, 62.49% { background-position-x: var(--pet-frame-4-position); }
62.5%, 74.99% { background-position-x: var(--pet-frame-5-position); }
75%, 87.49% { background-position-x: var(--pet-frame-6-position); }
87.5%, 100% { background-position-x: var(--pet-frame-7-position); }
}
@keyframes toolSparkPop {
@@ -536,6 +721,74 @@
100% { transform: translate(calc(var(--p-vx, 0) * 700px), 150px) rotate(var(--p-rot, 0deg)) scale(.5); opacity: 0; }
}
/* U8 combo badge pop-in. */
@keyframes toolSparkComboPop {
0% { transform: scale(.4); opacity: 0; }
100% { transform: scale(1); opacity: 1; }
}
/* U6 per-kind motion (see `.tool-spark-mode-*` above). */
/* search: rings expand outward from the bubble and fade — a ripple. */
@keyframes toolSparkRipple {
0% { transform: scale(.3); opacity: .9; }
70% { opacity: .35; }
100% { transform: scale(2.6); opacity: 0; }
}
/* terminal: quick pop-in, a few short blinks, then fade — like a typing
cursor. */
@keyframes toolSparkBlink {
0% { transform: scale(.5); opacity: 0; }
10% { transform: scale(1.1); opacity: 1; }
20% { opacity: .25; }
30% { opacity: 1; }
40% { opacity: .25; }
50% { opacity: 1; }
60% { opacity: .25; }
70% { opacity: 1; transform: scale(1); }
90% { opacity: 1; }
100% { opacity: 0; transform: scale(.9); }
}
/* file / edit: paper scraps flutter and drift down, rotating more than
they launch. */
@keyframes toolSparkPaper {
0% { transform: translate(0, 0) rotate(var(--p-rot, 0deg)) scale(.4); opacity: 0; }
10% { transform: translate(calc(var(--p-vx, 0) * 20px), -18px) rotate(var(--p-rot, 0deg)) scale(1); opacity: 1; }
55% { transform: translate(calc(var(--p-vx, 0) * 60px), 40px) rotate(calc(var(--p-rot, 0deg) * 3)) scale(.9); opacity: 1; }
100% { transform: translate(calc(var(--p-vx, 0) * 90px), 120px) rotate(calc(var(--p-rot, 0deg) * 5)) scale(.6); opacity: 0; }
}
/* browser: the bubble unfolds vertically like a window opening. */
@keyframes toolSparkWindow {
0% { transform: translate(0, 12px) scaleY(.08) scaleX(.7); opacity: 0; }
18% { transform: translate(0, -14px) scaleY(1.15) scaleX(1.05); opacity: 1; }
28% { transform: translate(0, -18px) scaleY(1) scaleX(1); opacity: 1; }
85% { transform: translate(0, -20px) scaleY(1) scaleX(1); opacity: 1; }
100% { transform: translate(0, -28px) scaleY(.85) scaleX(.85); opacity: 0; }
}
/* issue: the bubble drops in from above like a map marker and settles. */
@keyframes toolSparkMarker {
0% { transform: translate(0, -40px) scale(.6); opacity: 0; }
30% { transform: translate(0, -18px) scale(1.15); opacity: 1; }
45% { transform: translate(0, -22px) scale(.95); opacity: 1; }
60% { transform: translate(0, -19px) scale(1.05); opacity: 1; }
85% { transform: translate(0, -20px) scale(1); opacity: 1; }
100% { transform: translate(0, -28px) scale(.85); opacity: 0; }
}
/* plug (MCP): opacity-only flicker layered on top of toolSparkParticle's
launch trajectory (see `.tool-spark-mode-bolt` above) — an electric
spark. */
@keyframes toolSparkBolt {
0%, 100% { opacity: 1; }
25% { opacity: .15; }
50% { opacity: 1; }
75% { opacity: .15; }
}
/* === Markdown reader (MDXG-inspired) ===
Zenn/Qiita-like reading polish for .md preview. Scoped to `.mdxg-reader`
so chat bubbles (using bare MarkdownPreview without this class) keep
+1
View File
@@ -35,6 +35,7 @@ const SETTINGS_SECTIONS = [
'tools-external',
// MCP & Connections
'mcp',
'chat-connectors',
// SSH
'ssh',
// Legacy ids kept so old URLs still parse; redirected at runtime via
+14 -407
View File
@@ -11,8 +11,8 @@ import { ownerDisplayName } from '../lib/owner';
import { cronToFormState } from '../lib/cronForm';
import { fetchMyOrgs, listBrowserSessionProfiles, type Visibility } from '../api';
import { useAuthState } from '../App';
type TaskKind = 'agent' | 'script';
import { ScheduleAccessSections, ScheduleTaskConfigurationSection, ScheduleTimingSection } from '../components/schedules/ScheduleEditorSections';
import type { ScheduleFormState, TaskKind } from '../components/schedules/scheduleEditorTypes';
interface ScheduledTask {
id: number;
@@ -39,25 +39,6 @@ interface ScheduledTask {
createdAt: string;
}
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;
}
type ScheduleFilter = 'all' | 'active' | 'paused';
type DetailMode = 'view' | 'edit' | 'new';
@@ -66,19 +47,6 @@ const DAY_KEYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'] as const;
// label/hint are i18n keys (relative to the `schedules` namespace) resolved at
// the render site via t(...). `cron` has a fixed (non-prose) label.
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' },
];
function parseCronToDisplay(cron: string, t: TFunction): string {
if (cron === 'once') return t('cronDisplay.onceOnly');
@@ -789,29 +757,9 @@ function ScheduleDetailPane({
);
}
function EditorFormRow({
label, help, children,
}: {
label: string;
help?: string;
children: React.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>
);
}
const EDITOR_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';
interface ScheduleEditorProps {
mode: 'new' | 'edit';
initialTask: ScheduledTask | null;
/** Space to bind a newly-created schedule to (per-space tab). Undefined = global. */
spaceId?: string;
onCancel: () => void;
onSaved: (newId?: number) => void | Promise<void>;
@@ -951,362 +899,21 @@ function ScheduleEditor({ mode, initialTask, spaceId, onCancel, onSaved }: Sched
return (
<div className="flex flex-col h-full overflow-hidden">
<div className="flex-shrink-0 px-5 py-3.5 border-b border-hairline bg-canvas flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2.5 min-w-0">
<div className="section-label font-mono flex-shrink-0">
{isEdit && initialTask ? `SCHEDULE #${initialTask.id}` : 'NEW'}
</div>
<div className="text-sm font-semibold text-slate-900 truncate">
{isEdit ? t('editor.editTitle') : t('editor.newTitle')}
</div>
</div>
<div className="text-2xs text-slate-500 mt-0.5">
{t('editor.subtitle')}
</div>
</div>
<button
type="button"
onClick={onCancel}
aria-label={t('editor.closeAria')}
className="w-8 h-8 inline-flex items-center justify-center rounded-md text-slate-400 hover:text-slate-600 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring flex-shrink-0"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M18 6L6 18M6 6l12 12" />
</svg>
<div className="min-w-0"><div className="flex items-center gap-2.5 min-w-0"><div className="section-label font-mono flex-shrink-0">{isEdit && initialTask ? `SCHEDULE #${initialTask.id}` : 'NEW'}</div><div className="text-sm font-semibold text-slate-900 truncate">{isEdit ? t('editor.editTitle') : t('editor.newTitle')}</div></div><div className="text-2xs text-slate-500 mt-0.5">{t('editor.subtitle')}</div></div>
<button type="button" onClick={onCancel} aria-label={t('editor.closeAria')} className="w-8 h-8 inline-flex items-center justify-center rounded-md text-slate-400 hover:text-slate-600 hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring flex-shrink-0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" /></svg>
</button>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 bg-surface">
<div className="max-w-[640px] mx-auto space-y-4">
<div 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">
<EditorFormRow label={t('editor.kind')} help={t('editor.kindHelp')}>
<div className="flex gap-1.5">
{(['agent', 'script'] as const).map(k => {
const selected = form.taskKind === k;
return (
<button
key={k}
type="button"
onClick={() => setForm(p => ({ ...p, taskKind: k }))}
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'
}`}
>
{k === 'agent' ? t('editor.kindAgent') : t('editor.kindScript')}
</button>
);
})}
</div>
</EditorFormRow>
<EditorFormRow label={t('editor.title')}>
<input
ref={titleRef}
value={form.title}
onChange={e => setForm(p => ({ ...p, title: e.target.value }))}
className={EDITOR_INPUT_CLASS}
placeholder={form.taskKind === 'script' ? t('editor.titlePlaceholderScript') : t('editor.titlePlaceholderAgent')}
/>
</EditorFormRow>
{form.taskKind === 'agent' ? (
<>
<EditorFormRow label={t('editor.prompt')} help={t('editor.promptHelp')}>
<textarea
value={form.body}
onChange={e => setForm(p => ({ ...p, body: e.target.value }))}
rows={5}
className={`${EDITOR_INPUT_CLASS} resize-y leading-relaxed`}
placeholder={t('editor.promptPlaceholder')}
/>
</EditorFormRow>
<div className="grid grid-cols-2 gap-3">
<EditorFormRow
label="Piece"
help={pieceOptions.find(o => o.value === form.piece)?.description || undefined}
>
<select
value={form.piece}
onChange={e => setForm(p => ({ ...p, piece: e.target.value }))}
className={`${EDITOR_INPUT_CLASS} font-mono`}
>
{pieceOptions.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</EditorFormRow>
<EditorFormRow label={t('editor.outputFormat')}>
<select
value={form.outputFormat}
onChange={e => setForm(p => ({ ...p, outputFormat: e.target.value }))}
className={`${EDITOR_INPUT_CLASS} font-mono`}
>
{OUTPUT_FORMAT_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</EditorFormRow>
</div>
</>
) : (
<>
<EditorFormRow
label={t('editor.scriptName')}
help={t('editor.scriptNameHelp')}
>
<input
value={form.scriptName}
onChange={e => setForm(p => ({ ...p, scriptName: e.target.value }))}
className={`${EDITOR_INPUT_CLASS} font-mono`}
placeholder="weekly-report"
/>
</EditorFormRow>
<EditorFormRow
label="params (JSON)"
help={t('editor.scriptParamsHelp')}
>
<textarea
value={form.scriptParams}
onChange={e => setForm(p => ({ ...p, scriptParams: e.target.value }))}
rows={4}
className={`${EDITOR_INPUT_CLASS} font-mono resize-y leading-relaxed`}
placeholder='{"date":"2026-05-11"}'
/>
</EditorFormRow>
</>
)}
</div>
</div>
<div className="bg-canvas border border-hairline rounded-md p-5">
<div className="section-label mb-3.5">
{t('editor.scheduleSection')}
</div>
<EditorFormRow 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>
</EditorFormRow>
{form.scheduleType !== 'cron' && form.scheduleType !== 'once' && (
<div className="grid grid-cols-2 gap-3 mt-3">
<EditorFormRow label={t('editor.time')}>
<div className="flex items-center gap-1">
<input
type="number"
min={0}
max={23}
value={form.hour}
onChange={e => setForm(p => ({ ...p, hour: Number(e.target.value) }))}
className={`${EDITOR_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 => setForm(p => ({ ...p, minute: Number(e.target.value) }))}
className={`${EDITOR_INPUT_CLASS} w-16 text-center font-mono`}
/>
</div>
</EditorFormRow>
{form.scheduleType === 'weekly' && (
<EditorFormRow label={t('editor.dayOfWeek')}>
<select
value={form.dayOfWeek}
onChange={e => setForm(p => ({ ...p, dayOfWeek: Number(e.target.value) }))}
className={EDITOR_INPUT_CLASS}
>
{DAY_KEYS.map((d, i) => <option key={i} value={i}>{t(`dayOptions.${d}`)}</option>)}
</select>
</EditorFormRow>
)}
{form.scheduleType === 'monthly' && (
<EditorFormRow label={t('editor.dayOfMonth')}>
<input
type="number"
min={1}
max={31}
value={form.dayOfMonth}
onChange={e => setForm(p => ({ ...p, dayOfMonth: Number(e.target.value) }))}
className={EDITOR_INPUT_CLASS}
/>
</EditorFormRow>
)}
</div>
)}
{form.scheduleType === 'cron' && (
<div className="mt-3">
<EditorFormRow label={t('editor.cronExpression')} help={t('editor.cronHelp')}>
<input
value={form.cronExpression}
onChange={e => setForm(p => ({ ...p, cronExpression: e.target.value }))}
className={`${EDITOR_INPUT_CLASS} font-mono`}
placeholder="0 9 * * 1"
/>
</EditorFormRow>
</div>
)}
{form.scheduleType === 'once' && (
<div className="mt-3">
<EditorFormRow label={t('editor.runAt')}>
<input
type="datetime-local"
value={form.scheduledAt}
onChange={e => setForm(p => ({ ...p, scheduledAt: e.target.value }))}
className={EDITOR_INPUT_CLASS}
/>
</EditorFormRow>
</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>
)}
</div>
{authState.mode === 'authenticated' && (
<div 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]">
<label className="inline-flex items-center gap-2">
<input
type="radio"
checked={form.visibility === 'private'}
onChange={() => setForm(p => ({ ...p, visibility: 'private' }))}
/>
<span>{t('editor.visPrivate')}</span>
</label>
<label className="inline-flex items-center gap-2">
<input
type="radio"
checked={form.visibility === 'org'}
onChange={() => setForm(p => ({ ...p, visibility: 'org' }))}
disabled={orgs.length === 0}
/>
<span>{t('editor.visOrg')}</span>
</label>
<label className="inline-flex items-center gap-2">
<input
type="radio"
checked={form.visibility === 'public'}
onChange={() => setForm(p => ({ ...p, visibility: 'public' }))}
/>
<span>{t('editor.visPublic')}</span>
</label>
{form.visibility === 'org' && orgs.length > 1 && (
<select
className={`${EDITOR_INPUT_CLASS} mt-1`}
value={form.visibilityScopeOrgId ?? ''}
onChange={e => setForm(p => ({ ...p, visibilityScopeOrgId: e.target.value || null }))}
>
{orgs.map(o => (
<option key={o.orgId} value={o.orgId}>{o.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>
</div>
)}
{activeSessionProfiles.length > 0 && (
<div 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 =>
setForm(p => ({
...p,
browserSessionProfileId: e.target.value ? Number(e.target.value) : null,
}))
}
className={EDITOR_INPUT_CLASS}
>
<option value="">{t('editor.none')}</option>
{activeSessionProfiles.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
<p className="text-2xs text-slate-500 mt-1">
{t('editor.browserSessionHelp')}
</p>
</div>
)}
{error && (
<div
role="alert"
className="px-3.5 py-2.5 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded-md text-xs text-red-700 dark:text-red-300"
>
{error}
</div>
)}
<div className="h-4" />
</div>
</div>
<div className="flex-1 overflow-y-auto px-6 py-5 bg-surface"><div className="max-w-[640px] mx-auto space-y-4">
<ScheduleTaskConfigurationSection form={form} setForm={setForm} titleRef={titleRef} pieceOptions={pieceOptions} t={t} />
<ScheduleTimingSection form={form} setForm={setForm} preview={preview} t={t} />
<ScheduleAccessSections form={form} setForm={setForm} authenticated={authState.mode === 'authenticated'} orgs={orgs} profiles={activeSessionProfiles} t={t} />
{error && <div role="alert" className="px-3.5 py-2.5 bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 rounded-md text-xs text-red-700 dark:text-red-300"> {error}</div>}
<div className="h-4" />
</div></div>
<div className="flex-shrink-0 px-6 py-3.5 border-t border-hairline flex justify-end gap-2 bg-canvas">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-[13px] font-semibold text-slate-600 rounded-md hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
{t('editor.cancel')}
</button>
<button
type="button"
onClick={() => void handleSubmit()}
disabled={submitting || !form.body.trim()}
className="px-4 py-2 bg-accent hover:bg-accent-deep text-accent-fg text-[13px] font-semibold rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring"
>
{submitting ? (isEdit ? t('editor.saving') : t('editor.creating')) : (isEdit ? t('editor.save') : t('editor.create'))}
</button>
<button type="button" onClick={onCancel} className="px-4 py-2 text-[13px] font-semibold text-slate-600 rounded-md hover:bg-surface-2 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">{t('editor.cancel')}</button>
<button type="button" onClick={() => void handleSubmit()} disabled={submitting || !form.body.trim()} className="px-4 py-2 bg-accent hover:bg-accent-deep text-accent-fg text-[13px] font-semibold rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring">{submitting ? (isEdit ? t('editor.saving') : t('editor.creating')) : (isEdit ? t('editor.save') : t('editor.create'))}</button>
</div>
</div>
);
+182
View File
@@ -0,0 +1,182 @@
// @vitest-environment jsdom
import '../test/dom-setup';
import { useEffect, useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SettingsPage } from './SettingsPage';
import { readUiUrlState, type UiUrlState } from '../lib/urlState';
import { useUrlState } from '../hooks/useUrlState';
vi.mock('../hooks/useSetupState', () => ({
useSetupState: () => ({ data: { a2aEnabled: false } }),
}));
vi.mock('../components/settings/SettingsSidebar', () => ({
USER_SECTIONS: ['preferences'],
LEGACY_SECTION_REDIRECT: { provider: 'llm-workers' },
SettingsSidebar: ({
activeSection,
dirtySectionIds,
onSelectSection,
}: {
activeSection: string;
dirtySectionIds: ReadonlySet<string>;
onSelectSection: (section: string) => void;
}) => (
<>
<output data-testid="active-section">{activeSection}</output>
<output data-testid="dirty-sections">{[...dirtySectionIds].join(',')}</output>
<button type="button" onClick={() => onSelectSection('preferences')}>Open preferences</button>
</>
),
}));
vi.mock('../components/settings/ConfigForm', () => ({
ConfigForm: ({
section,
onDraftStatusChange,
}: {
section: string;
onDraftStatusChange: (status: { dirtySectionIds: ReadonlySet<string> }) => void;
}) => (
<>
<div>Config: {section}</div>
<button type="button" onClick={() => onDraftStatusChange({ dirtySectionIds: new Set(['preferences']) })}>
Mark preferences dirty
</button>
<button type="button" onClick={() => onDraftStatusChange({ dirtySectionIds: new Set() })}>
Clear dirty sections
</button>
</>
),
}));
function SettingsHarness({ initialState }: { initialState: UiUrlState }) {
const [urlState, setUrlState] = useState(initialState);
return (
<>
<output data-testid="url-section">{urlState.section ?? 'none'}</output>
<SettingsPage isAdmin={false} urlState={urlState} setUrlState={setUrlState} />
</>
);
}
function AdminSettingsHarness({ initialState }: { initialState: UiUrlState }) {
const [urlState, setUrlState] = useState(initialState);
return (
<>
<output data-testid="url-section">{urlState.section ?? 'none'}</output>
<SettingsPage isAdmin urlState={urlState} setUrlState={setUrlState} />
</>
);
}
function BrowserHistorySettingsHarness() {
const { urlState, setUrlState, pushUrlState } = useUrlState();
useEffect(() => {
pushUrlState(urlState);
}, [pushUrlState, urlState]);
return <SettingsPage isAdmin={false} urlState={urlState} setUrlState={setUrlState} />;
}
beforeEach(() => {
window.history.replaceState(null, '', '/?page=settings');
});
describe('SettingsPage mobile navigation', () => {
it('uses the shared URL section state to switch between the list and a detail view', async () => {
render(<SettingsHarness initialState={readUiUrlState()} />);
expect(screen.getByRole('button', { name: 'Open preferences' })).toBeInTheDocument();
expect(screen.getByTestId('settings-sidebar-panel')).toHaveClass('block');
expect(screen.getByTestId('settings-detail-panel')).toHaveClass('hidden');
await userEvent.click(screen.getByRole('button', { name: 'Open preferences' }));
expect(screen.getByTestId('settings-sidebar-panel')).toHaveClass('hidden');
expect(screen.getByTestId('settings-detail-panel')).toHaveClass('flex');
expect(screen.getByTestId('url-section')).toHaveTextContent('preferences');
await userEvent.click(screen.getByRole('button', { name: 'settingsPage.sectionList' }));
expect(screen.getByTestId('settings-sidebar-panel')).toHaveClass('block');
expect(screen.getByTestId('settings-detail-panel')).toHaveClass('hidden');
expect(screen.getByTestId('url-section')).toHaveTextContent('none');
});
it('opens a direct section URL in the detail view', () => {
render(<SettingsHarness initialState={{ ...readUiUrlState(), section: 'preferences' }} />);
expect(screen.getByTestId('settings-sidebar-panel')).toHaveClass('hidden');
expect(screen.getByTestId('settings-detail-panel')).toHaveClass('flex');
});
it('keeps the mobile view and URL in sync with browser Back and Forward', async () => {
render(<BrowserHistorySettingsHarness />);
await userEvent.click(screen.getByRole('button', { name: 'Open preferences' }));
expect(window.location.search).toBe('?page=settings&section=preferences');
expect(screen.getByTestId('settings-detail-panel')).toHaveClass('flex');
window.history.back();
await waitFor(() => {
expect(window.location.search).toBe('?page=settings');
expect(screen.getByTestId('settings-sidebar-panel')).toHaveClass('block');
});
window.history.forward();
await waitFor(() => {
expect(window.location.search).toBe('?page=settings&section=preferences');
expect(screen.getByTestId('settings-detail-panel')).toHaveClass('flex');
});
});
});
describe('SettingsPage section normalization', () => {
it('uses the role-specific default section when the URL has no section', () => {
render(<AdminSettingsHarness initialState={readUiUrlState()} />);
expect(screen.getByTestId('active-section')).toHaveTextContent('llm-workers');
});
it('uses preferences as the non-admin default section', () => {
render(<SettingsHarness initialState={readUiUrlState()} />);
expect(screen.getByTestId('active-section')).toHaveTextContent('preferences');
});
it('normalizes a non-admin deep link to an admin section', () => {
render(<SettingsHarness initialState={{ ...readUiUrlState(), section: 'llm-workers' }} />);
expect(screen.getByTestId('active-section')).toHaveTextContent('preferences');
expect(screen.getByTestId('url-section')).toHaveTextContent('preferences');
});
it('rewrites a legacy URL section while rendering its replacement', () => {
render(<AdminSettingsHarness initialState={{ ...readUiUrlState(), section: 'provider' }} />);
expect(screen.getByTestId('active-section')).toHaveTextContent('llm-workers');
expect(screen.getByText('Config: llm-workers')).toBeInTheDocument();
expect(screen.getByTestId('url-section')).toHaveTextContent('llm-workers');
});
});
describe('SettingsPage draft status', () => {
it('passes unsaved section state from the form to the sidebar', async () => {
render(<SettingsHarness initialState={{ ...readUiUrlState(), section: 'preferences' }} />);
expect(screen.getByTestId('dirty-sections')).toHaveTextContent('');
await userEvent.click(screen.getByRole('button', { name: 'Mark preferences dirty' }));
expect(screen.getByTestId('dirty-sections')).toHaveTextContent('preferences');
await userEvent.click(screen.getByRole('button', { name: 'Clear dirty sections' }));
expect(screen.getByTestId('dirty-sections')).toHaveTextContent('');
});
});
describe('SettingsPage workspace context', () => {
it('links the selected workspace to its settings', async () => {
const onOpenWorkspaceSettings = vi.fn();
render(<SettingsPage isAdmin={false} urlState={readUiUrlState()} setUrlState={vi.fn()} workspaceName="Project A" onOpenWorkspaceSettings={onOpenWorkspaceSettings} />);
await userEvent.click(screen.getByRole('button', { name: '「Project A」のワークスペース設定を開く' }));
expect(onOpenWorkspaceSettings).toHaveBeenCalledOnce();
});
});
+61 -13
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUrlState } from '../hooks/useUrlState';
import { useSetupState } from '../hooks/useSetupState';
import type { UiUrlState } from '../lib/urlState';
import type { ConfigDraftStatus } from '../components/settings/types';
import {
SettingsSidebar,
USER_SECTIONS,
@@ -11,15 +12,32 @@ import { ConfigForm } from '../components/settings/ConfigForm';
interface SettingsPageProps {
isAdmin: boolean;
urlState: UiUrlState;
setUrlState: (nextStateOrFn: UiUrlState | ((prev: UiUrlState) => UiUrlState)) => void;
workspaceName?: string;
onOpenWorkspaceSettings?: () => void;
}
export function SettingsPage({ isAdmin }: SettingsPageProps) {
export function SettingsPage({ isAdmin, urlState, setUrlState, workspaceName, onOpenWorkspaceSettings }: SettingsPageProps) {
const { t } = useTranslation('settings');
const { urlState, setUrlState } = useUrlState();
const { data: setup } = useSetupState();
const [dirtySectionIds, setDirtySectionIds] = useState<ReadonlySet<string>>(new Set());
const [focusFieldKey, setFocusFieldKey] = useState<string>();
const [focusFieldRequestId, setFocusFieldRequestId] = useState(0);
const handleDraftStatusChange = useCallback((status: ConfigDraftStatus) => {
setDirtySectionIds(previous => {
if (previous.size === status.dirtySectionIds.size
&& [...previous].every(id => status.dirtySectionIds.has(id))) return previous;
return new Set(status.dirtySectionIds);
});
}, []);
// A2A is off by default; its delegations API route only mounts when enabled.
// Hide the section (and reject deep-links to it) unless the server says a2a is on.
const a2aEnabled = setup?.a2aEnabled === true;
// Chat connectors are off by default; the admin bindings API route only
// mounts when chat.enabled (see chat-subsystem.ts). Same fail-closed
// treatment as a2aEnabled above.
const chatEnabled = setup?.chatEnabled === true;
// admin landing page: first LLM Workers (most-used setting). Non-admin
// lands on preferences. The pre-Step-3 default was 'provider'.
const fallbackSection = isAdmin ? 'llm-workers' : 'preferences';
@@ -29,7 +47,8 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
const requestedSection = LEGACY_SECTION_REDIRECT[rawRequested] ?? rawRequested;
const unavailableSection =
(!isAdmin && !USER_SECTIONS.includes(requestedSection)) ||
(requestedSection === 'a2a-delegations' && !a2aEnabled);
(requestedSection === 'a2a-delegations' && !a2aEnabled) ||
(requestedSection === 'chat-connectors' && !chatEnabled);
const section = unavailableSection ? 'preferences' : requestedSection;
// If the URL still carries a legacy id, rewrite it once so bookmarks
@@ -41,11 +60,9 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
}
}, [urlState.section, setUrlState]);
// モバイル (< md) では listdetail を切替表示する。URL に section が
// 明示されていれば detail から、そうでなければ list から開始。
const [mobileView, setMobileView] = useState<'list' | 'detail'>(
urlState.section ? 'detail' : 'list',
);
// Mobile list/detail is derived from the shared URL state. This keeps direct
// links and browser Back/Forward in sync with the rendered screen.
const mobileView = urlState.section ? 'detail' : 'list';
// 非 admin が admin section の URL に直アクセスした場合、URL も preferences へ正規化して混乱を避ける。
useEffect(() => {
@@ -62,39 +79,70 @@ export function SettingsPage({ isAdmin }: SettingsPageProps) {
}
}, [setup, a2aEnabled, urlState.section, setUrlState]);
// chat 無効時に chat-connectors へ直リンクされたら preferences に正規化する(a2a と同様)。
useEffect(() => {
if (setup && !chatEnabled && urlState.section === 'chat-connectors') {
setUrlState(prev => ({ ...prev, section: 'preferences' as any }));
}
}, [setup, chatEnabled, urlState.section, setUrlState]);
const handleSelectSection = (s: string) => {
setFocusFieldKey(undefined);
setUrlState(prev => ({ ...prev, section: s as any }));
setMobileView('detail');
};
const handleSelectField = (s: string, fieldId: string) => {
setFocusFieldKey(fieldId);
setFocusFieldRequestId(id => id + 1);
setUrlState(prev => ({ ...prev, section: s as any }));
};
const handleReturnToList = () => {
setUrlState(prev => ({ ...prev, section: undefined }));
};
return (
<div className="flex h-full">
{/* Sidebar: モバイルでは list ビュー時のみ全幅、デスクトップは常に 208px */}
<div
data-testid="settings-sidebar-panel"
className={`${mobileView === 'list' ? 'block' : 'hidden'} md:block w-full md:w-52 flex-shrink-0`}
>
<div className="border-b border-hairline px-4 py-3">
<p className="text-xs font-semibold text-slate-700"></p>
<p className="mt-1 text-xs text-slate-500"></p>
{workspaceName && onOpenWorkspaceSettings && (
<button type="button" onClick={onOpenWorkspaceSettings} className="mt-2 text-xs font-medium text-blue-700 hover:underline">
{workspaceName}
</button>
)}
</div>
<SettingsSidebar
activeSection={section}
onSelectSection={handleSelectSection}
onSelectField={handleSelectField}
isAdmin={isAdmin}
a2aEnabled={a2aEnabled}
chatEnabled={chatEnabled}
dirtySectionIds={dirtySectionIds}
/>
</div>
{/* Detail: モバイルでは detail ビュー時のみ表示。先頭に戻るボタン */}
<div
data-testid="settings-detail-panel"
className={`${mobileView === 'detail' ? 'flex' : 'hidden'} md:flex flex-1 flex-col overflow-y-auto`}
>
<button
type="button"
onClick={() => setMobileView('list')}
onClick={handleReturnToList}
className="md:hidden flex items-center gap-1 px-4 py-2 text-xs text-slate-600 hover:text-slate-900 border-b border-hairline"
>
<span aria-hidden></span>
<span>{t('settingsPage.sectionList')}</span>
</button>
<div className="flex-1 p-6">
<ConfigForm section={section} isAdmin={isAdmin} />
<ConfigForm section={section} isAdmin={isAdmin} onDraftStatusChange={handleDraftStatusChange} focusFieldKey={focusFieldKey} focusFieldRequestId={focusFieldRequestId} />
</div>
</div>
</div>