This commit is contained in:
@@ -301,7 +301,7 @@ function ProgressCard({ comment, isStaleThinking }: { comment: LocalTaskComment;
|
||||
// movement-complete arrives (live tool calls during running movement).
|
||||
const toolCall = parseToolCallComment(comment.body);
|
||||
if (toolCall) {
|
||||
return <ToolCallsSection toolCalls={[toolCall]} />;
|
||||
return <ToolCallsSection toolCalls={[{ ...toolCall, ts: comment.createdAt }]} />;
|
||||
}
|
||||
|
||||
// Checklist progress → dedicated card (center, retained as per decision)
|
||||
@@ -435,7 +435,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking, hi
|
||||
{author} · {new Date(createdAt).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm leading-relaxed prose prose-sm prose-slate dark:prose-invert max-w-none">
|
||||
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} />
|
||||
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} escapeRawHtml />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -9,9 +9,11 @@ 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 { extractStreamingField, CONTENT_FIELD } from '../../lib/streamFieldExtract';
|
||||
import { supportsFieldSizing, autosizeTextarea } from '../../lib/composerAutosize';
|
||||
|
||||
|
||||
async function toBase64(file: File): Promise<string> {
|
||||
@@ -48,6 +50,14 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
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
|
||||
@@ -272,13 +282,11 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
lastBackendId={task.latestJob?.lastBackendId ?? null}
|
||||
/>
|
||||
{/* Header */}
|
||||
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 py-2.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-slate-900 truncate">{task.title}</h2>
|
||||
<div className="text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||
<div className="flex-shrink-0 border-b border-hairline bg-canvas px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="min-w-0 truncate text-sm font-semibold text-slate-900">{task.title}</h2>
|
||||
<span className="shrink-0 text-[10px] text-slate-400 font-mono tabular-nums">#{task.id} · {task.pieceName}</span>
|
||||
<div className="ml-auto flex items-center gap-1.5 flex-shrink-0">
|
||||
{isBusy && (
|
||||
<div className={`inline-flex items-center gap-1.5 px-1.5 py-0.5 rounded border ${
|
||||
isWaitingSubtasks
|
||||
@@ -444,8 +452,10 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Composer */}
|
||||
<div className="flex-shrink-0 border-t border-hairline bg-canvas p-3" style={{ paddingBottom: 'calc(12px + env(safe-area-inset-bottom, 0px))' }}>
|
||||
{/* 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
|
||||
@@ -462,6 +472,7 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
</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>
|
||||
@@ -475,108 +486,111 @@ export function ChatPane({ task, comments, onSubmit, onCancel, detailTabs, activ
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mb-2">
|
||||
{attachments.map(a => (
|
||||
<span key={a.name} className="inline-flex items-center gap-1 px-1.5 py-0.5 bg-surface-2 border border-hairline rounded text-[10px] text-slate-700 font-mono">
|
||||
{a.name}
|
||||
<button onClick={() => removeAttachment(a.name)} className="text-slate-400 hover:text-slate-700 ml-0.5">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* コンテキスト残量を入力欄の直上に常時表示。概要タブまでスクロールせずに、
|
||||
入力しながら「あとどれくらい書けるか」を把握できる(issue #009)。 */}
|
||||
<div className="mb-2">
|
||||
<ContextUsageGauge
|
||||
compact
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
limitTokens={task.latestJob?.contextLimitTokens}
|
||||
jobStatus={task.latestJob?.status}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1.5 items-end">
|
||||
<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-shrink-0 w-9 h-9 flex items-center justify-center text-slate-500 hover:text-slate-900 hover:bg-surface rounded-md transition-colors disabled:opacity-50 disabled:hover:bg-transparent disabled:cursor-not-allowed"
|
||||
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>
|
||||
<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)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={e => void handlePaste(e)}
|
||||
rows={2}
|
||||
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="flex-1 resize-y border border-hairline rounded-md px-2.5 py-2 text-sm text-slate-900 outline-none focus:border-accent focus:ring-2 focus:ring-accent-ring min-h-[56px] disabled:bg-surface disabled:text-slate-400 disabled:cursor-not-allowed transition-shadow"
|
||||
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"
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
<div className="flex gap-1.5">
|
||||
{canInterject && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 px-2 pb-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => { void handleFiles(e.target.files); e.target.value = ''; }}
|
||||
/>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={composerLocked || submitting}
|
||||
className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-surface hover:text-slate-900 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
|
||||
title={t('pane.attachFile')}
|
||||
aria-label={t('pane.attachFile')}
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</button>
|
||||
{attachments.length > 0 && (
|
||||
<div className="flex max-h-12 min-w-0 flex-1 flex-wrap gap-1 overflow-y-auto">
|
||||
{attachments.map(a => (
|
||||
<span key={a.name} className="inline-flex items-center gap-1 rounded border border-hairline bg-surface-2 px-1.5 py-0.5 font-mono text-[10px] text-slate-700">
|
||||
{a.name}
|
||||
<button onClick={() => removeAttachment(a.name)} className="ml-0.5 text-slate-400 hover:text-slate-700">×</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex flex-shrink-0 items-center gap-2">
|
||||
{/* コンテキスト残量: 入力しながら「あとどれくらい書けるか」を把握
|
||||
できる位置に常時表示(issue #009 の趣旨を維持)。 */}
|
||||
<ContextUsageGauge
|
||||
inline
|
||||
promptTokens={task.latestJob?.contextPromptTokens}
|
||||
limitTokens={task.latestJob?.contextLimitTokens}
|
||||
jobStatus={task.latestJob?.status}
|
||||
/>
|
||||
{isBusy && onCancel ? (
|
||||
<div className="flex gap-1.5">
|
||||
{canInterject && (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
{t('pane.interject')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
disabled={cancelling}
|
||||
onClick={() => void handleCancel()}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-canvas border border-red-200 text-red-700 hover:bg-red-50 dark:border-red-500/40 dark:text-red-300 dark:hover:bg-red-500/15 disabled:opacity-50"
|
||||
title={t('pane.stopAgent')}
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="6" y="6" width="12" height="12" rx="2.5" fill="currentColor" />
|
||||
</svg>
|
||||
{cancelling ? t('pane.stopping') : t('pane.stop')}
|
||||
</button>
|
||||
</div>
|
||||
) : isPending ? (
|
||||
<button
|
||||
disabled={submitting || (!draft.trim() && attachments.length === 0)}
|
||||
onClick={handleSubmit}
|
||||
title={t('pane.addToQueuedHint')}
|
||||
className="inline-flex items-center gap-1.5 px-3 h-9 rounded-md text-xs font-semibold flex-shrink-0 transition-colors bg-amber-100 text-amber-800 border border-amber-300 hover:bg-amber-200 dark:bg-amber-500/15 dark:text-amber-300 dark:border-amber-500/30 dark:hover:bg-amber-500/25 disabled:opacity-50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
||||
<polyline points="15 10 20 15 15 20" />
|
||||
<path d="M4 4v7a4 4 0 0 0 4 4h12" />
|
||||
</svg>
|
||||
{t('pane.interject')}
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The movement group header in the conversation shows the movement's execution
|
||||
* time (e.g. "1m 30s"). Users also want to see WHEN the movement finished, so
|
||||
* the header renders the completion date/time next to the duration.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MovementGroupExpanded, type ChatItem } from './MovementGroup';
|
||||
import type { LocalTaskComment } from '../../api';
|
||||
|
||||
function completionComment(createdAt: string): LocalTaskComment {
|
||||
return {
|
||||
id: 1,
|
||||
taskId: 1,
|
||||
author: 'agent',
|
||||
kind: 'progress',
|
||||
body: JSON.stringify({ movement: 'execute', tools: { Read: 2 }, durationMs: 90000 }),
|
||||
createdAt,
|
||||
injectedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
function movementItem(createdAt: string): ChatItem & { type: 'movement' } {
|
||||
const c = completionComment(createdAt);
|
||||
return {
|
||||
type: 'movement',
|
||||
movementName: 'execute',
|
||||
summary: { movement: 'execute', tools: { Read: 2 }, durationMs: 90000 },
|
||||
inner: [],
|
||||
completionComment: c,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MovementGroupExpanded header', () => {
|
||||
it('shows the movement completion date/time alongside the duration', () => {
|
||||
const iso = '2026-07-06T01:23:45.000Z';
|
||||
const { container } = render(
|
||||
<MovementGroupExpanded
|
||||
item={movementItem(iso)}
|
||||
taskId={1}
|
||||
isLast={false}
|
||||
isRunning={false}
|
||||
animatingIdx={-1}
|
||||
startIdx={0}
|
||||
/>,
|
||||
);
|
||||
// Duration still present.
|
||||
expect(container.textContent).toContain('1m 30s');
|
||||
// The completion timestamp, formatted for the local locale, is shown too.
|
||||
const expected = new Date(iso).toLocaleString();
|
||||
expect(container.textContent).toContain(expected);
|
||||
});
|
||||
});
|
||||
@@ -136,9 +136,12 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, im
|
||||
<path d="M6 4l4 4-4 4" />
|
||||
</svg>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-semibold text-slate-700">{movementName}</span>
|
||||
<span className="text-[10px] text-slate-400 font-mono tabular-nums">{formatDuration(summary.durationMs)}</span>
|
||||
<span className="text-[10px] text-slate-400 tabular-nums" title={new Date(item.completionComment.createdAt).toLocaleString()}>
|
||||
{new Date(item.completionComment.createdAt).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -173,7 +176,7 @@ export function MovementGroupExpanded({ item, taskId, animatingIdx, startIdx, im
|
||||
const tc = isToolCallComment(c) ? parseToolCallComment(c.body) : null;
|
||||
if (tc) {
|
||||
if (toolBuf.length === 0) toolFirstId = c.id;
|
||||
toolBuf.push(tc);
|
||||
toolBuf.push({ ...tc, ts: c.createdAt });
|
||||
} else {
|
||||
flushTools();
|
||||
blocks.push({ kind: 'comment', comment: c, origIdx: i });
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Component tests for PackageRequestApproval — the inline Approve/Deny card
|
||||
* shown in chat when the agent paused on a RequestPackage. Network
|
||||
* (fetchPackageRequests / decidePackageRequest) is fully mocked; i18n uses the
|
||||
* real instance so labels resolve.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import '../../i18n';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { renderWithProviders } from '../../test/render-helpers';
|
||||
import type { PackageRequest } from '../../api';
|
||||
import * as api from '../../api';
|
||||
import { PackageRequestApproval } from './PackageRequestApproval';
|
||||
|
||||
vi.mock('../../api', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../../api')>();
|
||||
return {
|
||||
...actual,
|
||||
fetchPackageRequests: vi.fn(),
|
||||
decidePackageRequest: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedFetch = vi.mocked(api.fetchPackageRequests);
|
||||
const mockedDecide = vi.mocked(api.decidePackageRequest);
|
||||
|
||||
function req(overrides: Partial<PackageRequest> = {}): PackageRequest {
|
||||
return {
|
||||
id: 'req-1',
|
||||
taskId: '7',
|
||||
jobId: 'job-1',
|
||||
spaceId: 'space-1',
|
||||
pieceName: 'chat',
|
||||
movementName: 'execute',
|
||||
spec: 'requests==2.32.3',
|
||||
normalizedName: 'requests',
|
||||
reason: 'need http',
|
||||
status: 'pending',
|
||||
decidedBy: null,
|
||||
createdAt: '2026-07-06T00:00:00Z',
|
||||
decidedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PackageRequestApproval', () => {
|
||||
beforeEach(() => {
|
||||
mockedFetch.mockReset();
|
||||
mockedDecide.mockReset();
|
||||
});
|
||||
|
||||
it('renders nothing when there are no pending requests', async () => {
|
||||
mockedFetch.mockResolvedValue([req({ status: 'approved' })]);
|
||||
const { container } = renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
|
||||
await waitFor(() => expect(mockedFetch).toHaveBeenCalledWith(7));
|
||||
expect(container.querySelector('[data-testid="package-request-approval"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders a card per pending request with spec + reason', async () => {
|
||||
mockedFetch.mockResolvedValue([
|
||||
req({ id: 'r1', spec: 'requests==2.32.3', normalizedName: 'requests', reason: 'http calls' }),
|
||||
req({ id: 'r2', spec: 'pandas', normalizedName: 'pandas', reason: null }),
|
||||
]);
|
||||
renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
|
||||
expect(await screen.findByTestId('package-request-requests')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('package-request-pandas')).toBeInTheDocument();
|
||||
expect(screen.getByText(/requests==2.32.3/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/http calls/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Approve calls decidePackageRequest with approve + id', async () => {
|
||||
mockedFetch.mockResolvedValue([req({ id: 'r1', normalizedName: 'requests' })]);
|
||||
mockedDecide.mockResolvedValue(undefined);
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
|
||||
await user.click(await screen.findByTestId('package-request-approve-requests'));
|
||||
await waitFor(() => expect(mockedDecide).toHaveBeenCalledWith(7, 'r1', 'approve'));
|
||||
});
|
||||
|
||||
it('shows an error row when the decide mutation rejects', async () => {
|
||||
mockedFetch.mockResolvedValue([req({ id: 'r1', normalizedName: 'requests' })]);
|
||||
mockedDecide.mockRejectedValue(new Error('install failed'));
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<PackageRequestApproval taskId={7} poll={false} />);
|
||||
await user.click(await screen.findByTestId('package-request-approve-requests'));
|
||||
expect(await screen.findByTestId('package-request-error')).toHaveTextContent('install failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchPackageRequests, decidePackageRequest } from '../../api';
|
||||
|
||||
/**
|
||||
* Inline approval card shown in the chat when the agent is paused waiting for a
|
||||
* user to approve/deny a Python package it requested (RequestPackage). Approving
|
||||
* installs the wheel into this workspace's overlay and resumes the paused job,
|
||||
* so the chat continues on its own. Mirrors ToolRequestApproval.
|
||||
*/
|
||||
export function PackageRequestApproval({ taskId, poll }: { taskId: number; poll: boolean }) {
|
||||
const { t } = useTranslation('chat');
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: requests = [] } = useQuery({
|
||||
queryKey: ['package-requests', taskId],
|
||||
queryFn: () => fetchPackageRequests(taskId),
|
||||
refetchInterval: poll ? 3000 : false,
|
||||
});
|
||||
|
||||
const decide = useMutation({
|
||||
mutationFn: ({ reqId, decision }: { reqId: string; decision: 'approve' | 'deny' }) =>
|
||||
decidePackageRequest(taskId, reqId, decision),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['package-requests', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTask', taskId] });
|
||||
qc.invalidateQueries({ queryKey: ['localTasks'] });
|
||||
},
|
||||
});
|
||||
|
||||
const pending = requests.filter((r) => r.status === 'pending');
|
||||
if (pending.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2" data-testid="package-request-approval">
|
||||
{pending.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid={`package-request-${r.normalizedName}`}
|
||||
className="rounded-lg border border-sky-300 bg-sky-50 p-3 text-sm dark:border-sky-700/60 dark:bg-sky-900/20"
|
||||
>
|
||||
<div className="font-medium text-sky-900 dark:text-sky-200">
|
||||
{t('packageRequest.title', { spec: r.spec })}
|
||||
</div>
|
||||
{r.reason && (
|
||||
<div className="mt-1 text-sky-800/90 dark:text-sky-200/80">
|
||||
{t('packageRequest.reason')}: {r.reason}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-1 text-xs text-sky-700/70 dark:text-sky-300/60">{t('packageRequest.note')}</div>
|
||||
{decide.isError && (
|
||||
<div className="mt-1 text-xs text-red-700 dark:text-red-300" data-testid="package-request-error">
|
||||
{t('packageRequest.failed')}: {(decide.error as Error)?.message ?? ''}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`package-request-approve-${r.normalizedName}`}
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ reqId: r.id, decision: 'approve' })}
|
||||
className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-700 disabled:opacity-50"
|
||||
>
|
||||
{t('packageRequest.approve')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`package-request-deny-${r.normalizedName}`}
|
||||
disabled={decide.isPending}
|
||||
onClick={() => decide.mutate({ reqId: r.id, decision: 'deny' })}
|
||||
className="rounded-md border border-stone-300 bg-white px-3 py-1 text-xs font-medium text-stone-700 hover:bg-stone-50 disabled:opacity-50 dark:border-stone-600 dark:bg-stone-800 dark:text-stone-200 dark:hover:bg-stone-700"
|
||||
>
|
||||
{t('packageRequest.deny')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Test timestamp display in tool call rows.
|
||||
*/
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ToolCallsSection, type ToolCallData } from './ToolCallsSection';
|
||||
|
||||
const mockToolCall = (ts?: string): ToolCallData => ({
|
||||
callId: 'call-001',
|
||||
movement: 'execute',
|
||||
name: 'Read',
|
||||
args: '{"file_path": "/tmp/test.txt"}',
|
||||
result: 'Success',
|
||||
isError: false,
|
||||
durationMs: 1500,
|
||||
cacheHit: false,
|
||||
ts,
|
||||
});
|
||||
|
||||
describe('ToolCallsSection timestamp display', () => {
|
||||
it('displays execution time when ts is provided', () => {
|
||||
const iso = '2026-07-07T14:30:45.000Z';
|
||||
const tc = mockToolCall(iso);
|
||||
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
|
||||
|
||||
// Time should be displayed (formatted as local time)
|
||||
const timeString = new Date(iso).toLocaleTimeString();
|
||||
expect(container.textContent).toContain(timeString);
|
||||
});
|
||||
|
||||
it('does not display time span when ts is undefined', () => {
|
||||
const tc = mockToolCall();
|
||||
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
|
||||
|
||||
// Duration should still be present
|
||||
expect(container.textContent).toContain('1.5s');
|
||||
});
|
||||
|
||||
it('displays full datetime in title attribute when ts is provided', () => {
|
||||
const iso = '2026-07-07T14:30:45.000Z';
|
||||
const tc = mockToolCall(iso);
|
||||
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
|
||||
|
||||
const timeSpan = container.querySelector('[title]');
|
||||
expect(timeSpan).toBeTruthy();
|
||||
expect(timeSpan?.getAttribute('title')).toBe(new Date(iso).toLocaleString());
|
||||
});
|
||||
|
||||
it('renders time and duration both when ts is provided', () => {
|
||||
const iso = '2026-07-07T14:30:45.123Z';
|
||||
const tc = mockToolCall(iso);
|
||||
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
|
||||
|
||||
// Both time and duration should be present
|
||||
const timeStr = new Date(iso).toLocaleTimeString();
|
||||
expect(container.textContent).toContain(timeStr);
|
||||
expect(container.textContent).toContain('1.5s');
|
||||
});
|
||||
|
||||
it('renders timestamps for all tool calls when present', () => {
|
||||
// Single tool call with ts displays the timestamp
|
||||
const iso = '2026-07-07T14:30:00.000Z';
|
||||
const tc = mockToolCall(iso);
|
||||
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
|
||||
|
||||
const timeStr = new Date(iso).toLocaleTimeString();
|
||||
expect(container.textContent).toContain(timeStr);
|
||||
});
|
||||
|
||||
it('displays cache hit label instead of duration', () => {
|
||||
const iso = '2026-07-07T14:30:45.000Z';
|
||||
const tc = { ...mockToolCall(iso), cacheHit: true };
|
||||
const { container } = render(<ToolCallsSection toolCalls={[tc]} />);
|
||||
|
||||
expect(container.textContent).toContain('cache');
|
||||
// Duration should not be shown for cache hit
|
||||
expect(container.textContent).not.toContain('1s');
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export interface ToolCallData {
|
||||
isError: boolean;
|
||||
durationMs: number;
|
||||
cacheHit: boolean;
|
||||
ts?: string;
|
||||
}
|
||||
|
||||
export function parseToolCallComment(body: string): ToolCallData | null {
|
||||
@@ -116,7 +117,12 @@ function ToolCallRow({ tc }: { tc: ToolCallData }) {
|
||||
)}
|
||||
<span className="font-mono font-medium text-slate-700 flex-shrink-0">{display.name}</span>
|
||||
{summary && <span className="font-mono text-slate-500 truncate min-w-0">{summary}</span>}
|
||||
<span className="text-slate-400 tabular-nums ml-auto flex-shrink-0">
|
||||
{tc.ts && (
|
||||
<span className="text-slate-400 tabular-nums text-[10px] flex-shrink-0 ml-auto" title={new Date(tc.ts).toLocaleString()}>
|
||||
{new Date(tc.ts).toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
<span className={`text-slate-400 tabular-nums flex-shrink-0 ${!tc.ts ? 'ml-auto' : ''}`}>
|
||||
{tc.cacheHit ? 'cache' : formatDuration(tc.durationMs)}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ContextUsageGauge, pickColorClass } from './ContextUsageGauge';
|
||||
|
||||
describe('pickColorClass', () => {
|
||||
it('しきい値 70/85/95% で色が変わる', () => {
|
||||
expect(pickColorClass(0.5)).toBe('bg-emerald-500');
|
||||
expect(pickColorClass(0.7)).toBe('bg-amber-500');
|
||||
expect(pickColorClass(0.85)).toBe('bg-orange-500');
|
||||
expect(pickColorClass(0.95)).toBe('bg-red-500');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ContextUsageGauge inline', () => {
|
||||
it('limitTokens が無ければ何も描画しない', () => {
|
||||
const { container } = render(<ContextUsageGauge inline promptTokens={100} limitTokens={0} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('70%未満はバーのみ(%テキストなし)', () => {
|
||||
render(<ContextUsageGauge inline promptTokens={30_000} limitTokens={100_000} />);
|
||||
const el = screen.getByTestId('context-gauge-inline');
|
||||
expect(el).toBeInTheDocument();
|
||||
expect(el.textContent).not.toContain('%');
|
||||
});
|
||||
|
||||
it('70%以上は%テキストを常時表示する(警告の役割)', () => {
|
||||
render(<ContextUsageGauge inline promptTokens={80_000} limitTokens={100_000} />);
|
||||
expect(screen.getByTestId('context-gauge-inline').textContent).toContain('80%');
|
||||
});
|
||||
|
||||
it('タッチ/SR 向けに aria-label と title でフル数値を提供する', () => {
|
||||
render(<ContextUsageGauge inline promptTokens={80_000} limitTokens={100_000} />);
|
||||
const el = screen.getByTestId('context-gauge-inline');
|
||||
expect(el.getAttribute('title')).toContain('80,000');
|
||||
expect(el.getAttribute('title')).toContain('100,000');
|
||||
expect(el.getAttribute('aria-label')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,17 @@ interface ContextUsageGaugeProps {
|
||||
limitTokens?: number | null;
|
||||
jobStatus?: string;
|
||||
/**
|
||||
* compact: 入力欄の直上に常時表示する低プロファイルのバー。概要タブのカード型
|
||||
* (既定)と同じ色・比率ロジックを共有しつつ、薄い 1 行表示にする。
|
||||
* inline: コンポーザーのツールバー内に置く最小表示。バー+70%以上でのみ%テキスト。
|
||||
* 詳細は title / aria-label で提供される。
|
||||
*/
|
||||
compact?: boolean;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
function pickColorClass(ratio: number): string {
|
||||
export function pickColorClass(ratio: number): string {
|
||||
if (ratio >= 0.95) return 'bg-red-500';
|
||||
if (ratio >= 0.85) return 'bg-orange-500';
|
||||
if (ratio >= 0.70) return 'bg-amber-500';
|
||||
@@ -36,7 +36,7 @@ function pickLabel(jobStatus: string | undefined): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, compact }: ContextUsageGaugeProps) {
|
||||
export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, inline }: ContextUsageGaugeProps) {
|
||||
const { t } = useTranslation('detail');
|
||||
if (!limitTokens || limitTokens <= 0) return null;
|
||||
|
||||
@@ -48,22 +48,23 @@ export function ContextUsageGauge({ promptTokens, limitTokens, jobStatus, compac
|
||||
const colorClass = pickColorClass(ratio);
|
||||
const label = pickLabel(jobStatus);
|
||||
|
||||
if (compact) {
|
||||
if (inline) {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 text-2xs text-slate-500 tabular-nums"
|
||||
data-testid="context-gauge-inline"
|
||||
className="flex min-w-0 items-center gap-1.5"
|
||||
title={`${formatNumber(tokens)} / ${formatNumber(limitTokens)} tokens`}
|
||||
aria-label={t('context.ariaLabel', { remaining: formatNumber(remaining), percent })}
|
||||
>
|
||||
<div className="h-1.5 flex-1 bg-slate-100 rounded-full overflow-hidden">
|
||||
<div className="h-1 w-16 shrink-0 overflow-hidden rounded-full bg-slate-100 dark:bg-slate-700">
|
||||
<div
|
||||
className={`h-full ${colorClass} transition-[width] duration-300 ease-out`}
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0">
|
||||
{awaiting ? t('context.awaiting') : t('context.remaining', { remaining: formatNumber(remaining), percent })}
|
||||
</span>
|
||||
{ratio >= 0.7 && (
|
||||
<span className="shrink-0 text-2xs tabular-nums text-slate-500">{percent}%</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,9 +19,21 @@ beforeAll(async () => {
|
||||
'delegateRuns.eventsEmpty': 'No events',
|
||||
'delegateRuns.subtaskGroupTitle': 'Subtask #{{n}}',
|
||||
'delegateRuns.subtaskSectionHeading': 'Delegate runs in subtasks',
|
||||
'subtasks.delegateSection': 'Delegate (serial)',
|
||||
'delegateRuns.result': 'Result',
|
||||
'delegateRuns.abortReason': 'Abort reason',
|
||||
'delegateRuns.noDescription': '(no description)',
|
||||
'delegateRuns.toolCount': '{{count}} tools',
|
||||
'delegateRuns.childCount': '{{total}} children',
|
||||
'delegateRuns.childCountFailed': '{{total}} children, {{failed}} failed',
|
||||
'delegateRuns.moreEvents': '{{count}} more events (see the Trace tab for the full list)',
|
||||
'delegateRuns.toolSummary': 'Tools used',
|
||||
'delegateRuns.filesChanged': 'Changed files',
|
||||
'delegateRuns.eventsToggle': 'Detailed events ({{count}})',
|
||||
'subtasks.delegateSection': 'Delegated runs',
|
||||
'subtasks.delegateStatus.success': 'Done',
|
||||
'subtasks.delegateStatus.aborted': 'Aborted',
|
||||
'subtasks.delegateStatus.running': 'Running',
|
||||
'subtasks.delegateRunningTool': '{{tool}} running',
|
||||
},
|
||||
common: { loading: 'Loading...' },
|
||||
},
|
||||
@@ -45,6 +57,101 @@ vi.mock('../../../api', () => ({
|
||||
endTs: '2026-01-01T00:01:00Z',
|
||||
eventCount: 2,
|
||||
toolCalls: 1,
|
||||
resultPreview: 'Success result preview',
|
||||
totalTokens: 1234,
|
||||
toolSummary: [{ tool: 'Write', count: 2 }, { tool: 'Read', count: 1 }],
|
||||
filesChanged: ['output/result.md', 'output/summary.txt'],
|
||||
},
|
||||
{
|
||||
delegateRunId: 'c1',
|
||||
parentRunId: 'p1',
|
||||
description: '子委譲1',
|
||||
depth: 2,
|
||||
status: 'success',
|
||||
startTs: '2026-01-01T00:00:30Z',
|
||||
endTs: '2026-01-01T00:00:45Z',
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: null,
|
||||
totalTokens: 0,
|
||||
},
|
||||
{
|
||||
delegateRunId: 'c2',
|
||||
parentRunId: 'p1',
|
||||
description: '子委譲2',
|
||||
depth: 2,
|
||||
status: 'success',
|
||||
startTs: '2026-01-01T00:00:46Z',
|
||||
endTs: '2026-01-01T00:01:00Z',
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: null,
|
||||
totalTokens: 567,
|
||||
},
|
||||
{
|
||||
delegateRunId: 'p2',
|
||||
parentRunId: null,
|
||||
description: '失敗した親委譲',
|
||||
depth: 1,
|
||||
status: 'success',
|
||||
startTs: '2026-01-01T00:02:00Z',
|
||||
endTs: '2026-01-01T00:03:00Z',
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: 'Operation completed with errors',
|
||||
totalTokens: 0,
|
||||
},
|
||||
{
|
||||
delegateRunId: 'c3',
|
||||
parentRunId: 'p2',
|
||||
description: '失敗した子委譲',
|
||||
depth: 2,
|
||||
status: 'aborted',
|
||||
startTs: '2026-01-01T00:02:30Z',
|
||||
endTs: '2026-01-01T00:03:00Z',
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: 'Failed',
|
||||
totalTokens: 100,
|
||||
},
|
||||
{
|
||||
delegateRunId: 'p3',
|
||||
parentRunId: null,
|
||||
description: '結果なし委譲',
|
||||
depth: 1,
|
||||
status: 'success',
|
||||
startTs: '2026-01-01T00:04:00Z',
|
||||
endTs: '2026-01-01T00:05:00Z',
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: null,
|
||||
totalTokens: 0,
|
||||
},
|
||||
{
|
||||
delegateRunId: 'p4',
|
||||
parentRunId: null,
|
||||
description: '実行中の親委譲',
|
||||
depth: 1,
|
||||
status: 'running',
|
||||
startTs: '2026-01-01T00:06:00Z',
|
||||
endTs: null,
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: null,
|
||||
totalTokens: 2000,
|
||||
},
|
||||
{
|
||||
delegateRunId: 'c4',
|
||||
parentRunId: 'p4',
|
||||
description: '実行中の子委譲',
|
||||
depth: 2,
|
||||
status: 'running',
|
||||
startTs: '2026-01-01T00:06:30Z',
|
||||
endTs: null,
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: null,
|
||||
totalTokens: 500,
|
||||
},
|
||||
],
|
||||
subtasks: [
|
||||
@@ -64,15 +171,19 @@ vi.mock('../../../api', () => ({
|
||||
endTs: '2026-01-01T00:01:00Z',
|
||||
eventCount: 1,
|
||||
toolCalls: 0,
|
||||
resultPreview: null,
|
||||
totalTokens: 3456,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
fetchDelegateRunTimeline: vi.fn().mockResolvedValue([]),
|
||||
fetchDelegateRunTimeline: vi.fn(),
|
||||
}));
|
||||
|
||||
import { DelegateRunsSection } from './DelegateRunsSection';
|
||||
import * as api from '../../../api';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
describe('DelegateRunsSection', () => {
|
||||
it('親委譲とサブタスクグループの両方を描画する', async () => {
|
||||
@@ -82,13 +193,308 @@ describe('DelegateRunsSection', () => {
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// すべてのカードが描画される
|
||||
const buttons = await screen.findAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
// 親 run の description が表示される
|
||||
expect(await screen.findByText(/親委譲/)).toBeInTheDocument();
|
||||
expect(screen.getByText('親委譲')).toBeInTheDocument();
|
||||
// サブタスク run の description が表示される
|
||||
expect(await screen.findByText(/サブ委譲/)).toBeInTheDocument();
|
||||
expect(screen.getByText('サブ委譲')).toBeInTheDocument();
|
||||
// サブタスクグループ見出しが表示される(en or ja)
|
||||
expect(
|
||||
await screen.findByText(/Subtask #1|サブタスク #1/),
|
||||
screen.getByText(/Subtask #1|サブタスク #1/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('子の件数バッジが閉じた親カードに表示される(2 子ケース)', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 親委譲のカードが表示される(子が 2 つ)
|
||||
await screen.findByText('親委譲');
|
||||
// テキスト マッチングで「2 children」が見つかる
|
||||
expect(screen.getByText('2 children')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('子に失敗がある場合、赤いバッジで失敗数を表示', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 失敗した親委譲のバッジが赤く、失敗数を含むテキストが表示される
|
||||
await screen.findByText('失敗した親委譲');
|
||||
// バッジに「1 children, 1 failed」が表示される
|
||||
expect(screen.getByText('1 children, 1 failed')).toBeInTheDocument();
|
||||
// そのバッジが赤系背景を持つ
|
||||
const badge = screen.getByText('1 children, 1 failed');
|
||||
expect(badge).toHaveClass('bg-red-100', 'text-red-800');
|
||||
});
|
||||
|
||||
it('子が実行中の場合、青いバッジで件数を表示', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 実行中の親委譲のカードが表示される
|
||||
await screen.findByText('実行中の親委譲');
|
||||
// 青いバッジに「1 children」が表示される
|
||||
const badge = Array.from(screen.getAllByText('1 children')).find((el) =>
|
||||
el.className.includes('bg-blue-100')
|
||||
);
|
||||
expect(badge).toBeDefined();
|
||||
expect(badge).toHaveClass('bg-blue-100', 'text-blue-800');
|
||||
});
|
||||
|
||||
it('子がない親カードはバッジが表示されない', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 結果なし委譲(子なし)のカードが表示される
|
||||
await screen.findByText('結果なし委譲');
|
||||
// 「children」というテキストは結果なし委譲のカード周辺には無いはず
|
||||
// ※ 他の親が children を表示しているので、全体ではある
|
||||
const resultCard = screen.getByText('結果なし委譲').closest('button');
|
||||
expect(resultCard?.textContent).not.toMatch(/children/);
|
||||
});
|
||||
|
||||
it('すべての親カードが見える', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 複数の親カードが表示される
|
||||
await screen.findByText('親委譲');
|
||||
expect(screen.getByText('親委譲')).toBeInTheDocument();
|
||||
expect(screen.getByText('失敗した親委譲')).toBeInTheDocument();
|
||||
expect(screen.getByText('結果なし委譲')).toBeInTheDocument();
|
||||
expect(screen.getByText('実行中の親委譲')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('totalTokens > 0 のカードに「1.2k tok」形式のトークン数が表示される', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// totalTokens = 1234 の「親委譲」カードを探す(フォーマットされて「1.2k tok」になるはず)
|
||||
await screen.findByText('親委譲');
|
||||
// そのカード内にトークン表示が含まれるかを確認
|
||||
const parentCard = screen.getByText('親委譲').closest('button');
|
||||
expect(parentCard?.textContent).toContain('1.2k tok');
|
||||
});
|
||||
|
||||
it('totalTokens = 0 のカードにトークン数は表示されない', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// totalTokens = 0 の「結果なし委譲」カードを探す
|
||||
await screen.findByText('結果なし委譲');
|
||||
const card = screen.getByText('結果なし委譲').closest('button');
|
||||
// そのカード内に「tok」という文字列は無いはず
|
||||
expect(card?.textContent).not.toMatch(/\d+.*tok|tok.*\d+/);
|
||||
});
|
||||
|
||||
it('カードを開くと aria-expanded=true がボタンに付く', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const buttons = await screen.findAllByRole('button');
|
||||
const parentButton = buttons[0];
|
||||
// 初期状態(閉じた)で aria-expanded=false
|
||||
expect(parentButton).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// クリックして開く
|
||||
await userEvent.click(parentButton);
|
||||
expect(parentButton).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
it('25 件のイベントを返すモックでカードを開きイベントトグルを押すと EventLine が 20 件+「他 5 件」案内が出る', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const events = Array.from({ length: 25 }, (_, i) => ({
|
||||
eventId: `event${i}`,
|
||||
ts: new Date(2026, 0, 1, 0, 0, i).toISOString(),
|
||||
seq: i,
|
||||
kind: 'tool_call',
|
||||
payload: { tool: 'Read' },
|
||||
}));
|
||||
vi.mocked(api.fetchDelegateRunTimeline).mockResolvedValueOnce(events);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 親委譲のカードを開く
|
||||
await screen.findByText('親委譲');
|
||||
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
|
||||
await userEvent.click(parentButton);
|
||||
|
||||
// イベントトグルを押す
|
||||
const eventsToggle = await screen.findByText(/Detailed events/i);
|
||||
await userEvent.click(eventsToggle);
|
||||
|
||||
// 「他 5 件」というテキストが表示される
|
||||
expect(screen.getByText(/5 more events/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('20 件以下のイベントなら「他 N 件」案内は出ない', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const events = Array.from({ length: 10 }, (_, i) => ({
|
||||
eventId: `event${i}`,
|
||||
ts: new Date(2026, 0, 1, 0, 0, i).toISOString(),
|
||||
seq: i,
|
||||
kind: 'tool_call',
|
||||
payload: { tool: 'Read' },
|
||||
}));
|
||||
vi.mocked(api.fetchDelegateRunTimeline).mockResolvedValueOnce(events);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 親委譲のカードを開く
|
||||
await screen.findByText('親委譲');
|
||||
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
|
||||
await userEvent.click(parentButton);
|
||||
|
||||
// イベントトグルを押す
|
||||
const eventsToggle = await screen.findByText(/Detailed events/i);
|
||||
await userEvent.click(eventsToggle);
|
||||
|
||||
// 「more events」というテキストは表示されない
|
||||
expect(screen.queryByText(/more events/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toolSummary ありの run でチップが表示される', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 親委譲のカードを開く(toolSummary が設定されている)
|
||||
await screen.findByText('親委譲');
|
||||
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
|
||||
await userEvent.click(parentButton);
|
||||
|
||||
// ツール名とカウントが表示される
|
||||
expect(screen.getByText('Write ×2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Read ×1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toolSummary 空または undefined なら見出しごと出ない', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// toolSummary がない「結果なし委譲」のカードを開く
|
||||
await screen.findByText('結果なし委譲');
|
||||
const card = screen.getByText('結果なし委譲').closest('button') as HTMLButtonElement;
|
||||
await userEvent.click(card);
|
||||
|
||||
// 「Tools used」テキストは見つからない
|
||||
expect(screen.queryByText(/Tools used/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filesChanged ありの run でパスが表示される', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 親委譲のカードを開く(filesChanged が設定されている)
|
||||
await screen.findByText('親委譲');
|
||||
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
|
||||
await userEvent.click(parentButton);
|
||||
|
||||
// ファイルパスが表示される
|
||||
expect(screen.getByText('output/result.md')).toBeInTheDocument();
|
||||
expect(screen.getByText('output/summary.txt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('filesChanged 空または undefined なら見出しごと出ない', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// filesChanged がない「結果なし委譲」のカードを開く
|
||||
await screen.findByText('結果なし委譲');
|
||||
const card = screen.getByText('結果なし委譲').closest('button') as HTMLButtonElement;
|
||||
await userEvent.click(card);
|
||||
|
||||
// 「Changed files」テキストは見つからない
|
||||
expect(screen.queryByText(/Changed files/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('カードを開いた直後はイベント行が描画されず、トグルを押すと表示される', async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const events = Array.from({ length: 3 }, (_, i) => ({
|
||||
eventId: `event${i}`,
|
||||
ts: new Date(2026, 0, 1, 0, 0, i).toISOString(),
|
||||
seq: i,
|
||||
kind: 'tool_call',
|
||||
payload: { tool: 'Read' },
|
||||
}));
|
||||
vi.mocked(api.fetchDelegateRunTimeline).mockResolvedValueOnce(events);
|
||||
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<DelegateRunsSection taskId={1} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
// 親委譲のカードを開く
|
||||
await screen.findByText('親委譲');
|
||||
const parentButton = screen.getByText('親委譲').closest('button') as HTMLButtonElement;
|
||||
await userEvent.click(parentButton);
|
||||
|
||||
// イベントトグルを確認(デフォルトは閉じた状態)
|
||||
const eventsToggle = await screen.findByText(/Detailed events/i);
|
||||
expect(eventsToggle).toHaveAttribute('aria-expanded', 'false');
|
||||
|
||||
// トグルを押す
|
||||
await userEvent.click(eventsToggle);
|
||||
|
||||
// トグルが開いた状態になる
|
||||
expect(eventsToggle).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { POLLING } from '../../../lib/constants.js';
|
||||
import { fetchDelegateRuns, fetchDelegateRunTimeline, type TraceEventLite } from '../../../api';
|
||||
import { buildDelegateRunTree, delegateStatusBadge, formatElapsed, type DelegateRunNode, type DelegateRun, type SubtaskDelegateGroup } from '../../../lib/delegateRuns';
|
||||
import { buildDelegateRunTree, currentRunningTool, delegateStatusBadge, formatElapsed, formatTokens, summarizeDescendants, type DelegateRunNode, type DelegateRun, type SubtaskDelegateGroup } from '../../../lib/delegateRuns';
|
||||
import { useNow } from '../../../hooks/useNow';
|
||||
import { summarizeTraceEvent } from '../../../lib/traceEvent';
|
||||
|
||||
@@ -21,6 +21,7 @@ function EventLine({ event }: { event: TraceEventLite }) {
|
||||
function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: DelegateRunNode; indent?: number; jobId?: string }) {
|
||||
const { t } = useTranslation('detail');
|
||||
const [open, setOpen] = useState(false);
|
||||
const [eventsOpen, setEventsOpen] = useState(false);
|
||||
const badge = delegateStatusBadge(node.status);
|
||||
const running = node.status === 'running';
|
||||
// 実行中カードのみ 1 秒刻みで経過を更新(ネットワーク不要のローカルタイマー)。
|
||||
@@ -34,6 +35,24 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
|
||||
refetchInterval: open && running ? POLLING.FAST : false,
|
||||
});
|
||||
|
||||
const runningTool = running ? currentRunningTool(events ?? []) : null;
|
||||
const descendantSummary = summarizeDescendants(node);
|
||||
|
||||
let childBadgeContent = null;
|
||||
let childBadgeClass = '';
|
||||
if (descendantSummary.total > 0) {
|
||||
if (descendantSummary.failed > 0) {
|
||||
childBadgeContent = t('delegateRuns.childCountFailed', { total: descendantSummary.total, failed: descendantSummary.failed });
|
||||
childBadgeClass = 'bg-red-100 text-red-800';
|
||||
} else if (descendantSummary.running > 0) {
|
||||
childBadgeContent = t('delegateRuns.childCount', { total: descendantSummary.total });
|
||||
childBadgeClass = 'bg-blue-100 text-blue-800';
|
||||
} else {
|
||||
childBadgeContent = t('delegateRuns.childCount', { total: descendantSummary.total });
|
||||
childBadgeClass = 'bg-slate-100 text-slate-600';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="border border-slate-200 rounded-md mb-1.5 overflow-hidden"
|
||||
@@ -42,30 +61,95 @@ function RunCard({ taskId, node, indent = 0, jobId }: { taskId: number; node: De
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-slate-50 transition-colors"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
>
|
||||
<span className={`shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${badge.cls}`}>
|
||||
{t(badge.labelKey)}
|
||||
</span>
|
||||
{childBadgeContent && (
|
||||
<span className={`shrink-0 text-[10px] font-bold px-1.5 py-0.5 rounded-full ${childBadgeClass}`}>
|
||||
{childBadgeContent}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[13px] text-slate-800 font-medium truncate flex-1">
|
||||
{node.description || '(no description)'}
|
||||
{node.description || t('delegateRuns.noDescription')}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-slate-400">
|
||||
depth {node.depth} · {node.toolCalls} tools · {formatElapsed(node.startTs, node.endTs, now)}
|
||||
{t('delegateRuns.toolCount', { count: node.toolCalls })} · {formatElapsed(node.startTs, node.endTs, now)}{node.totalTokens && node.totalTokens > 0 ? ` · ${formatTokens(node.totalTokens)}` : ''}
|
||||
</span>
|
||||
<span className="shrink-0 text-slate-400 text-xs ml-1">{open ? '▲' : '▼'}</span>
|
||||
<span className="shrink-0 text-slate-400 text-xs ml-1" aria-hidden="true">{open ? '▲' : '▼'}</span>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="border-t border-slate-100 px-3 pb-2">
|
||||
{events && events.length > 0 ? (
|
||||
<div className="mt-1">
|
||||
{events.map((e) => <EventLine key={e.eventId} event={e} />)}
|
||||
{runningTool && (
|
||||
<div className="mt-2 px-2 py-1 text-[12px] text-blue-700 bg-blue-50 rounded">
|
||||
{t('subtasks.delegateRunningTool', { tool: runningTool })}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[11px] text-slate-400">
|
||||
{events ? t('delegateRuns.eventsEmpty') : t('common:loading')}
|
||||
)}
|
||||
{(node.status === 'success' || node.status === 'needs_user_input' || node.status === 'aborted') && node.resultPreview && (
|
||||
<div className={`mt-2 p-2 rounded text-[12px] ${
|
||||
node.status === 'aborted'
|
||||
? 'bg-red-50 text-red-700'
|
||||
: 'bg-slate-50 text-slate-700'
|
||||
}`}>
|
||||
<div className="font-semibold mb-1">
|
||||
{t(node.status === 'aborted' ? 'delegateRuns.abortReason' : 'delegateRuns.result')}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words max-h-48 overflow-y-auto">
|
||||
{node.resultPreview}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.toolSummary && node.toolSummary.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-[10px] text-slate-500 mb-1">{t('delegateRuns.toolSummary')}</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{node.toolSummary.map((item) => (
|
||||
<div key={item.tool} className="bg-slate-100 text-slate-600 rounded px-1.5 py-0.5 text-[11px] font-mono">
|
||||
{item.tool} ×{item.count}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.filesChanged && node.filesChanged.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-[10px] text-slate-500 mb-1">{t('delegateRuns.filesChanged')}</div>
|
||||
<div>
|
||||
{node.filesChanged.map((path, idx) => (
|
||||
<div key={idx} className="font-mono text-[11px] text-slate-600 truncate" title={path}>
|
||||
{path}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="mt-2 text-[11px] text-slate-500 hover:text-slate-700 transition-colors"
|
||||
onClick={() => setEventsOpen((v) => !v)}
|
||||
aria-expanded={eventsOpen}
|
||||
>
|
||||
<span aria-hidden="true">{eventsOpen ? '▾' : '▸'}</span> {t('delegateRuns.eventsToggle', { count: node.eventCount })}
|
||||
</button>
|
||||
{eventsOpen && (
|
||||
<>
|
||||
{events && events.length > 0 ? (
|
||||
<div className="mt-1">
|
||||
{events.length > 20 && (
|
||||
<div className="text-[11px] text-slate-400 mb-1">
|
||||
{t('delegateRuns.moreEvents', { count: events.length - 20 })}
|
||||
</div>
|
||||
)}
|
||||
{events.slice(-20).map((e) => <EventLine key={e.eventId} event={e} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[11px] text-slate-400">
|
||||
{events ? t('delegateRuns.eventsEmpty') : t('common:loading')}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{node.children.map((c) => (
|
||||
<RunCard key={c.delegateRunId} taskId={taskId} node={c} indent={indent + 1} jobId={jobId} />
|
||||
))}
|
||||
|
||||
@@ -56,11 +56,40 @@ vi.mock('../../api', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { FilePreview } from './FilePreview';
|
||||
import { FilePreview, MarkdownPreview } from './FilePreview';
|
||||
import { fetchOfficePreview } from '../../api';
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
describe('MarkdownPreview raw-HTML handling', () => {
|
||||
it('file preview (default) renders embedded raw HTML as live elements', () => {
|
||||
// Opening a .md file that intentionally embeds HTML should keep rendering
|
||||
// it — this is the existing behavior we must NOT regress.
|
||||
const { container } = render(
|
||||
<MarkdownPreview content={'text\n\n<div class="hi">boxed</div>'} />,
|
||||
);
|
||||
expect(container.querySelector('div.hi')?.textContent).toBe('boxed');
|
||||
});
|
||||
|
||||
it('escapeRawHtml renders raw HTML as escaped source, not a live element', () => {
|
||||
// The chat result bubble passes escapeRawHtml so an HTML final answer does
|
||||
// not inject its own tags and break the bubble layout.
|
||||
const { container } = render(
|
||||
<MarkdownPreview escapeRawHtml content={'text\n\n<div class="hi">boxed</div>'} />,
|
||||
);
|
||||
expect(container.querySelector('div.hi')).toBeNull();
|
||||
expect(container.textContent).toContain('<div class="hi">boxed</div>');
|
||||
});
|
||||
|
||||
it('escapeRawHtml keeps normal Markdown (headings, fenced code) working', () => {
|
||||
const { container } = render(
|
||||
<MarkdownPreview escapeRawHtml content={'# Hi\n\n```js\nconst a = 1;\n```'} />,
|
||||
);
|
||||
expect(container.querySelector('h1')?.textContent).toContain('Hi');
|
||||
expect(container.querySelector('pre code')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FilePreview body branches', () => {
|
||||
it('markdown: renders HTML and rewrites a relative image href to the raw endpoint', () => {
|
||||
const base = '/api/local/tasks/42/files/raw?path=';
|
||||
|
||||
@@ -276,8 +276,8 @@ function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string }): Renderer {
|
||||
const { imageBaseUrl, slugger } = opts;
|
||||
function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string; escapeRawHtml?: boolean }): Renderer {
|
||||
const { imageBaseUrl, slugger, escapeRawHtml } = opts;
|
||||
const renderer = new Renderer();
|
||||
renderer.link = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
@@ -351,6 +351,19 @@ function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string)
|
||||
return `<img src="${resolvedHref}" alt="${text}"${titleAttr} style="max-width:100%" />`;
|
||||
};
|
||||
}
|
||||
// Chat contexts (the agent result bubble) opt into escaping raw HTML: an LLM
|
||||
// final answer that IS an HTML document must not inject its own tags and
|
||||
// break the bubble layout. Block-level raw HTML is reused through
|
||||
// `renderer.code` so it lands as a syntax-highlighted, copyable code block;
|
||||
// inline raw HTML becomes escaped inline text. File preview (.md) leaves this
|
||||
// off so intentionally-embedded HTML keeps rendering.
|
||||
if (escapeRawHtml) {
|
||||
renderer.html = function ({ text, block }: { text: string; block: boolean }) {
|
||||
if (!block) return escapeHtml(text);
|
||||
const code = text.replace(/\n$/, '');
|
||||
return renderer.code({ type: 'code', raw: text, lang: 'html', text: code });
|
||||
};
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
@@ -405,9 +418,15 @@ interface MarkdownPreviewProps {
|
||||
taskId?: number;
|
||||
/** true で目次サイドバー + リーダースタイル (MDXG) を有効化。チャット吹き出し等では false 推奨。 */
|
||||
showOutline?: boolean;
|
||||
/**
|
||||
* true で本文中の生 HTML タグを描画せずエスケープ表示する。チャットの結果吹き出し
|
||||
* 用: HTML 文書がそのまま最終出力に含まれても、タグが実要素として差し込まれて
|
||||
* レイアウトが壊れるのを防ぐ。.md ファイルプレビューでは false(現状の HTML 描画を維持)。
|
||||
*/
|
||||
escapeRawHtml?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false }: MarkdownPreviewProps): JSX.Element {
|
||||
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false, escapeRawHtml = false }: MarkdownPreviewProps): JSX.Element {
|
||||
const { t } = useTranslation('files');
|
||||
const truncated = content.slice(0, 100000);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -428,7 +447,7 @@ export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = f
|
||||
const segments = useMemo(() => {
|
||||
EMBED_SPLIT_RE.lastIndex = 0;
|
||||
const slugger = showOutline ? buildSlugger() : undefined;
|
||||
const renderer = buildMdRenderer({ imageBaseUrl, slugger });
|
||||
const renderer = buildMdRenderer({ imageBaseUrl, slugger, escapeRawHtml });
|
||||
const parser = new Marked({ gfm: true, renderer });
|
||||
|
||||
const hasEmbed = taskId != null && EMBED_SPLIT_RE.test(truncated);
|
||||
@@ -442,7 +461,7 @@ export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = f
|
||||
const html = DOMPurify.sanitize(parser.parse(seg.value, { async: false }) as string, DOMPURIFY_CONFIG);
|
||||
return { type: 'markdown' as const, html };
|
||||
});
|
||||
}, [truncated, imageBaseUrl, taskId, showOutline]);
|
||||
}, [truncated, imageBaseUrl, taskId, showOutline, escapeRawHtml]);
|
||||
|
||||
// コピーボタン + アンカーリンクのイベント delegation
|
||||
useEffect(() => {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
vi.mock('../../App', () => ({
|
||||
useAuthState: () => ({ mode: 'authenticated', user: { id: 'u1', role: 'admin' } }),
|
||||
}));
|
||||
|
||||
vi.mock('../../api', () => ({
|
||||
fetchSkills: vi.fn(async () => [
|
||||
{ name: 'sys-a', description: 'a system skill', triggers: [], source: 'system', hasDir: true },
|
||||
]),
|
||||
fetchSkillDetail: vi.fn(async () => ({
|
||||
name: 'sys-a',
|
||||
description: 'a system skill',
|
||||
triggers: [],
|
||||
source: 'system',
|
||||
hasDir: true,
|
||||
content: 'body',
|
||||
raw: '---\nname: sys-a\n---\n\nbody',
|
||||
files: [],
|
||||
findings: [],
|
||||
maxSeverity: 'none',
|
||||
})),
|
||||
createSkill: vi.fn(),
|
||||
updateSkill: vi.fn(),
|
||||
deleteSkill: vi.fn(),
|
||||
installSkillFromUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
import { SkillsForm } from './SkillsForm';
|
||||
import { deleteSkill } from '../../api';
|
||||
|
||||
function renderForm() {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SkillsForm />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
});
|
||||
|
||||
describe('SkillsForm delete errors', () => {
|
||||
it('shows a delete failure next to the action buttons instead of only at the top', async () => {
|
||||
vi.mocked(deleteSkill).mockRejectedValueOnce(new Error('Skill not found'));
|
||||
renderForm();
|
||||
|
||||
await userEvent.click(await screen.findByText('sys-a'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(deleteSkill).toHaveBeenCalledWith('sys-a', 'system', undefined);
|
||||
const err = await screen.findByTestId('skill-action-error');
|
||||
expect(err).toHaveTextContent('Skill not found');
|
||||
// The skill stays selected so the user sees the failure in place.
|
||||
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the inline error when selecting another skill', async () => {
|
||||
vi.mocked(deleteSkill).mockRejectedValueOnce(new Error('Skill not found'));
|
||||
renderForm();
|
||||
|
||||
await userEvent.click(await screen.findByText('sys-a'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
await screen.findByTestId('skill-action-error');
|
||||
|
||||
// Re-select from the list (the detail heading also renders the name, so
|
||||
// pick the list entry via its description line).
|
||||
await userEvent.click(screen.getByText('a system skill', { selector: 'div' }));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('skill-action-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not keep showing a stale delete error after a later successful edit', async () => {
|
||||
const { updateSkill } = await import('../../api');
|
||||
vi.mocked(deleteSkill).mockRejectedValueOnce(new Error('Skill not found'));
|
||||
vi.mocked(updateSkill).mockResolvedValueOnce({ ok: true });
|
||||
renderForm();
|
||||
|
||||
await userEvent.click(await screen.findByText('sys-a'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
await screen.findByTestId('skill-action-error');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Edit' }));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('skill-action-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes and clears the selection on success', async () => {
|
||||
vi.mocked(deleteSkill).mockResolvedValueOnce(undefined);
|
||||
renderForm();
|
||||
|
||||
await userEvent.click(await screen.findByText('sys-a'));
|
||||
await userEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('skill-action-error')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,10 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
// Update/delete failures render inline next to the action buttons (via the
|
||||
// mutations' own error state) — the top banner is easy to miss when the
|
||||
// detail pane is scrolled down. The top `error` banner stays for
|
||||
// create/install, whose forms sit near it.
|
||||
const updateMut = useMutation({
|
||||
mutationFn: ({ name, content, scope }: { name: string; content: string; scope: string }) =>
|
||||
updateSkill(name, content, scope, spaceId),
|
||||
@@ -108,7 +112,6 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
setEditMode(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
@@ -119,9 +122,10 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
setEditMode(false);
|
||||
setError(null);
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
});
|
||||
|
||||
const actionError = (deleteMut.error as Error | null)?.message ?? (updateMut.error as Error | null)?.message ?? null;
|
||||
|
||||
const installMut = useMutation({
|
||||
mutationFn: () => installSkillFromUrl(installUrl.trim(), 'user', undefined, spaceId),
|
||||
onSuccess: () => {
|
||||
@@ -141,6 +145,8 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
setNewMode(false);
|
||||
setEditMode(false);
|
||||
setError(null);
|
||||
updateMut.reset();
|
||||
deleteMut.reset();
|
||||
};
|
||||
|
||||
const handleNew = () => {
|
||||
@@ -151,6 +157,8 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
setNewContent('');
|
||||
setNewScope('user');
|
||||
setError(null);
|
||||
updateMut.reset();
|
||||
deleteMut.reset();
|
||||
};
|
||||
|
||||
const handleStartEdit = () => {
|
||||
@@ -160,17 +168,22 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
setEditContent(detailQuery.data.raw);
|
||||
setEditMode(true);
|
||||
setError(null);
|
||||
updateMut.reset();
|
||||
deleteMut.reset();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!selected || !detailQuery.data) return;
|
||||
// A new action supersedes any stale error from the other mutation.
|
||||
deleteMut.reset();
|
||||
updateMut.mutate({ name: selected, content: editContent, scope: detailQuery.data.source });
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!selected || !detailQuery.data) return;
|
||||
if (!confirm(`Delete skill "${selected}"?`)) return;
|
||||
updateMut.reset();
|
||||
deleteMut.mutate({ name: selected, scope: detailQuery.data.source });
|
||||
};
|
||||
|
||||
@@ -366,6 +379,11 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
rows={18}
|
||||
className="block w-full px-2 py-1.5 text-xs font-mono border border-hairline rounded bg-canvas text-slate-700 resize-y"
|
||||
/>
|
||||
{actionError && (
|
||||
<div data-testid="skill-action-error" className="px-3 py-2 rounded bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 text-xs text-red-700 dark:text-red-300">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleSaveEdit}
|
||||
@@ -399,6 +417,11 @@ export function SkillsForm({ spaceId }: SkillsFormProps = {}) {
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
{actionError && !editMode && (
|
||||
<div data-testid="skill-action-error" className="px-3 py-2 rounded bg-red-50 dark:bg-red-500/15 border border-red-200 dark:border-red-500/30 text-xs text-red-700 dark:text-red-300">
|
||||
{actionError}
|
||||
</div>
|
||||
)}
|
||||
{canEdit(detailQuery.data) && !editMode && (
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import '../../test/dom-setup';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { SpaceChatTabBar } from './SpaceChatTabBar';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'chat', labelKey: 'tabs.chat' },
|
||||
{ id: 'overview', labelKey: 'tabs.overview' },
|
||||
{ id: 'files', labelKey: 'tabs.files' },
|
||||
];
|
||||
|
||||
function renderBar(overrides: Partial<Parameters<typeof SpaceChatTabBar>[0]> = {}) {
|
||||
const onSelect = vi.fn();
|
||||
const utils = render(
|
||||
<SpaceChatTabBar
|
||||
tabs={TABS}
|
||||
activeTab="chat"
|
||||
onSelect={onSelect}
|
||||
ariaLabel="チャットタブ"
|
||||
renderLabel={(tb) => tb.labelKey}
|
||||
appearClass={() => ''}
|
||||
actions={<button data-testid="space-chat-delete">del</button>}
|
||||
{...overrides}
|
||||
/>
|
||||
);
|
||||
return { onSelect, ...utils };
|
||||
}
|
||||
|
||||
describe('SpaceChatTabBar', () => {
|
||||
it('タブと actions を描画し、actions はスクロールする tablist の外にある', () => {
|
||||
renderBar();
|
||||
const tablist = screen.getByRole('tablist');
|
||||
const del = screen.getByTestId('space-chat-delete');
|
||||
// actions がタブのスクロールコンテナ(tablist)の子孫だと、タブが溢れたとき
|
||||
// 一緒にスクロールして画面外に流れる。兄弟であることを構造で保証する。
|
||||
expect(tablist.contains(del)).toBe(false);
|
||||
expect(screen.getByTestId('space-chat-actions').contains(del)).toBe(true);
|
||||
expect(tablist.className).toContain('overflow-x-auto');
|
||||
});
|
||||
|
||||
it('ArrowRight で次のタブを選択しフォーカスを移す(末尾で wrap)', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSelect } = renderBar();
|
||||
screen.getByRole('tab', { name: 'tabs.chat' }).focus();
|
||||
await user.keyboard('{ArrowRight}');
|
||||
expect(onSelect).toHaveBeenCalledWith('overview');
|
||||
});
|
||||
|
||||
it('End で末尾タブ、Home で先頭タブを選択する', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { onSelect } = renderBar();
|
||||
screen.getByRole('tab', { name: 'tabs.chat' }).focus();
|
||||
await user.keyboard('{End}');
|
||||
expect(onSelect).toHaveBeenCalledWith('files');
|
||||
await user.keyboard('{Home}');
|
||||
expect(onSelect).toHaveBeenCalledWith('chat');
|
||||
});
|
||||
|
||||
it('aria-selected が activeTab にだけ付く', () => {
|
||||
renderBar({ activeTab: 'overview' });
|
||||
expect(screen.getByRole('tab', { name: 'tabs.overview' })).toHaveAttribute('aria-selected', 'true');
|
||||
expect(screen.getByRole('tab', { name: 'tabs.chat' })).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
it('actions なしのときアクションゾーンを描画しない', () => {
|
||||
renderBar({ actions: undefined });
|
||||
expect(screen.queryByTestId('space-chat-actions')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* SpaceChatTabBar — 会話画面の単一タブバー(2ゾーン構造)。
|
||||
*
|
||||
* 左: タブ群(overflow-x-auto のスクロールゾーン)
|
||||
* 右: actions(shrink-0 の固定ゾーン。継続 / 共有 / 削除ボタンが入る)
|
||||
*
|
||||
* タブが溢れて横スクロールになっても、actions は右端に固定されたまま
|
||||
* スクロールで流れない。これが2ゾーンに分ける理由(旧: アクション専用行
|
||||
* space-chat-actions を廃止して縦約40pxを回収した)。
|
||||
*/
|
||||
import { useRef } from 'react';
|
||||
|
||||
export interface SpaceChatTabDef {
|
||||
id: string;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
interface SpaceChatTabBarProps {
|
||||
tabs: SpaceChatTabDef[];
|
||||
activeTab: string;
|
||||
onSelect: (id: string) => void;
|
||||
ariaLabel: string;
|
||||
renderLabel: (tab: SpaceChatTabDef) => string;
|
||||
/** browser/ssh タブの出現アニメ用クラス(detailTabs.tabAppearClass を渡す) */
|
||||
appearClass: (id: string) => string;
|
||||
/** 右端固定ゾーンに置くアクション群。無ければゾーンごと描画しない。 */
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function SpaceChatTabBar({
|
||||
tabs,
|
||||
activeTab,
|
||||
onSelect,
|
||||
ariaLabel,
|
||||
renderLabel,
|
||||
appearClass,
|
||||
actions,
|
||||
}: SpaceChatTabBarProps) {
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
|
||||
// 矢印キーでタブ間移動(端で wrap)+ Home/End。選択とフォーカスを同時に動かす
|
||||
// (role=tablist の標準操作)。SpaceDetail から移設。
|
||||
const handleKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
let next: number;
|
||||
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
|
||||
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = tabs.length - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
onSelect(tabs[next].id);
|
||||
tabRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-hairline pl-3 pr-2">
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
data-testid="space-chat-tabs"
|
||||
className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto"
|
||||
>
|
||||
{tabs.map((tb, i) => {
|
||||
const active = tb.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tb.id}
|
||||
ref={(el) => { tabRefs.current[i] = el; }}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`space-chat-tab-${tb.id}`}
|
||||
data-testid={`space-chat-tab-${tb.id}`}
|
||||
aria-selected={active}
|
||||
aria-controls="space-chat-tabpanel"
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => onSelect(tb.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, i)}
|
||||
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${appearClass(tb.id)} ${
|
||||
active
|
||||
? 'border-[var(--brand-primary)] text-slate-800'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{renderLabel(tb)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{actions && (
|
||||
<div data-testid="space-chat-actions" className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { AppRunner } from './AppRunner';
|
||||
import { createSpaceGateway } from './app-file-gateway';
|
||||
import { detectAppEntry } from './app-bridge';
|
||||
import { ChatDetailSplit } from './ChatDetailSplit';
|
||||
import { SpaceChatTabBar } from './SpaceChatTabBar';
|
||||
import { OutputPreviewProvider } from '../../lib/output-preview-context';
|
||||
import { stripOutputPrefix } from '../../lib/output-path-detect';
|
||||
import {
|
||||
@@ -134,7 +135,7 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
|
||||
const dot = space.brandColor ?? 'var(--brand-primary)';
|
||||
|
||||
// 狭幅でチャットを開いているときは、スペースタイトルと「チャット|ファイル」タブ行を
|
||||
// 狭幅でチャットを開いているときは、ドット/タイトル/タブ群を1本化した統合ヘッダー行を
|
||||
// 隠してヘッダーの段数を減らす(会話画面が複数バーに埋もれないように)。広幅(md+)では
|
||||
// 常時表示、チャット未選択(一覧表示)の狭幅でも表示する。
|
||||
const chatOpen = tab === 'chat' && spaceTaskId != null;
|
||||
@@ -142,33 +143,32 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-testid="space-detail" className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface px-4 py-3`}>
|
||||
{/* Header + Tabs 統合行: 左からドット/タイトル/タブ群(スクロール)/右端固定
|
||||
(アバター・削除)。2行(約86px)→1行(約40px)。タイトルは max-w-[28ch] で
|
||||
truncate し、タブ群が flex-1 を持つ。 */}
|
||||
<div className={`${hideOnMobileWhenChatOpen} items-center gap-2 border-b border-hairline bg-surface pl-4 pr-2`}>
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-full" style={{ background: dot }} aria-hidden />
|
||||
<SpaceHeaderTitle
|
||||
space={space}
|
||||
canManage={canManage}
|
||||
/>
|
||||
{space.kind === 'case' && (
|
||||
<SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
|
||||
)}
|
||||
{space.kind === 'case' && canManage && (
|
||||
<SpaceDeleteButton
|
||||
spaceId={spaceId}
|
||||
title={space.title}
|
||||
onDeleted={() => onSelectSpace?.(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className={`${hideOnMobileWhenChatOpen} items-center gap-1 border-b border-hairline px-3`}>
|
||||
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
|
||||
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
|
||||
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
|
||||
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
|
||||
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
|
||||
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
|
||||
<SpaceHeaderTitle space={space} canManage={canManage} />
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
<TabButton testid="space-tab-chat" active={tab === 'chat'} onClick={() => setTab('chat')}>{t('detail.tab.chat')}</TabButton>
|
||||
<TabButton testid="space-tab-files" active={tab === 'files'} onClick={() => setTab('files')}>{t('detail.tab.files')}</TabButton>
|
||||
<TabButton testid="space-tab-apps" active={tab === 'apps'} onClick={() => setTab('apps')}>{t('detail.tab.apps')}</TabButton>
|
||||
<TabButton testid="space-tab-calendar" active={tab === 'calendar'} onClick={() => setTab('calendar')}>{t('detail.tab.calendar')}</TabButton>
|
||||
<TabButton testid="space-tab-schedules" active={tab === 'schedules'} onClick={() => setTab('schedules')}>{t('detail.tab.schedules')}</TabButton>
|
||||
<TabButton testid="space-tab-settings" active={tab === 'settings'} onClick={() => setTab('settings')}>{t('detail.tab.settings')}</TabButton>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{space.kind === 'case' && (
|
||||
<SpaceMemberAvatars spaceId={spaceId} onManage={() => setTab('settings')} />
|
||||
)}
|
||||
{space.kind === 'case' && canManage && (
|
||||
<SpaceDeleteButton
|
||||
spaceId={spaceId}
|
||||
title={space.title}
|
||||
onDeleted={() => onSelectSpace?.(undefined)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
@@ -210,8 +210,9 @@ export function SpaceDetail({ spaceId, spaceTaskId, onSelectSpace, onSelectSpace
|
||||
}
|
||||
|
||||
/**
|
||||
* ヘッダーのワークスペース名 + 説明。タイトル右に説明文を薄字で表示し、管理権限が
|
||||
* あれば鉛筆ボタンで編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
|
||||
* ヘッダーのワークスペース名。説明文はタイトルの title 属性(ツールチップ)に退避し、
|
||||
* 行の幅は max-w-[28ch] で制約してタブ群に flex-1 を譲る。管理権限があれば鉛筆ボタンで
|
||||
* 編集ダイアログ(名前・色・説明)を開く。権限が無ければ表示のみ。
|
||||
*/
|
||||
function SpaceHeaderTitle({
|
||||
space,
|
||||
@@ -224,17 +225,13 @@ function SpaceHeaderTitle({
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h1 className="shrink truncate text-[15px] font-bold text-slate-800">{space.title}</h1>
|
||||
{space.description && (
|
||||
<span
|
||||
data-testid="space-description"
|
||||
title={space.description}
|
||||
className="hidden min-w-0 shrink truncate text-xs text-slate-400 sm:inline"
|
||||
>
|
||||
{space.description}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex min-w-0 max-w-[28ch] shrink items-center gap-1.5">
|
||||
<h1
|
||||
title={space.description || undefined}
|
||||
className="min-w-0 truncate text-[15px] font-bold text-slate-800"
|
||||
>
|
||||
{space.title}
|
||||
</h1>
|
||||
{canManage && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -394,7 +391,7 @@ function TabButton({
|
||||
type="button"
|
||||
data-testid={testid}
|
||||
onClick={onClick}
|
||||
className={`-mb-px border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
|
||||
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${
|
||||
active
|
||||
? 'border-[var(--brand-primary)] text-slate-800'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
@@ -667,7 +664,6 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
const detailTabs = useVisibleDetailTabs(taskId);
|
||||
const [activeTab, setActiveTab] = useState<DetailTabId | 'chat'>('chat');
|
||||
const tabs = [{ id: 'chat' as const, labelKey: 'tabs.chat' }, ...detailTabs];
|
||||
const tabRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const fileBrowser = useFileBrowser(taskId);
|
||||
// モバイル(<md)だけスワイプ UI を「単一 DOM 枝」としてマウントする。両枝を CSS で
|
||||
// 隠して二重に描画すると ChatPane の textarea 等が複製され strict locator が壊れる
|
||||
@@ -682,20 +678,6 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
if (!tabs.some(tb => tb.id === activeTab)) setActiveTab('chat');
|
||||
}, [tabs, activeTab]);
|
||||
|
||||
// 矢印キーでタブ間移動(端で wrap)+ Home/End で先頭・末尾へ。選択とフォーカスを
|
||||
// 同時に動かす(role=tablist の標準操作)。
|
||||
const handleTabKeyDown = (e: React.KeyboardEvent, index: number) => {
|
||||
let next: number;
|
||||
if (e.key === 'ArrowRight') next = (index + 1) % tabs.length;
|
||||
else if (e.key === 'ArrowLeft') next = (index - 1 + tabs.length) % tabs.length;
|
||||
else if (e.key === 'Home') next = 0;
|
||||
else if (e.key === 'End') next = tabs.length - 1;
|
||||
else return;
|
||||
e.preventDefault();
|
||||
setActiveTab(tabs[next].id);
|
||||
tabRefs.current[next]?.focus();
|
||||
};
|
||||
|
||||
// タスクのファイルにアップロード/削除できるか。スペースメンバーでもタスク所有者で
|
||||
// なければサーバ側 checkTaskOwnership が 403 を返すため、UI も owner/admin/no-auth に限る。
|
||||
const auth = useAuthState();
|
||||
@@ -801,87 +783,43 @@ function SpaceConversation({ taskId, onBack }: { taskId: number; onBack: () => v
|
||||
{ts('conversation.backToList')}
|
||||
</button>
|
||||
|
||||
{/* アクション行: 削除 / 共有 / 継続 / 可視性。Tasks ページの DetailHeader と
|
||||
同じ実体(ShareButton・ContinueButton・ContinueWithPieceDialog・
|
||||
updateLocalTask)を再利用し、機能パリティを保つ。タブバーとは別行に置き、
|
||||
狭幅でも横並びのまま収まるアイコン主体の密度にする。 */}
|
||||
{chatReady && task && (
|
||||
<div
|
||||
data-testid="space-chat-actions"
|
||||
className="flex items-center gap-1.5 border-b border-hairline px-3 py-1.5"
|
||||
>
|
||||
{/* 公開範囲は選択不可: スペース内のチャットは常にそのスペースの
|
||||
メンバーだけに公開される。セレクタの代わりに静的な説明チップを置く。 */}
|
||||
<span
|
||||
data-testid="space-chat-visibility-note"
|
||||
title={ts('conversation.visibilityTitle')}
|
||||
className="inline-flex h-7 items-center rounded-md border border-hairline bg-canvas px-2 text-2xs text-slate-500"
|
||||
>
|
||||
{ts('conversation.visibilityNote')}
|
||||
</span>
|
||||
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<ContinueButton
|
||||
testid="space-chat-continue"
|
||||
latestJobStatus={task.latestJob?.status ?? null}
|
||||
onClick={() => setContinueOpen(true)}
|
||||
/>
|
||||
<ShareButton
|
||||
testid="space-chat-share"
|
||||
taskId={taskId}
|
||||
shareToken={task.shareToken ?? null}
|
||||
onShareChange={refetchTask}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-chat-delete"
|
||||
onClick={() => void confirmAndDelete()}
|
||||
title={ts('common:delete')}
|
||||
aria-label={ts('common:delete')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 単一インプレース・タブバー。会話+詳細タブ(ファイルを除く)を1本に集約し、
|
||||
戻る導線を「会話」タブのみに統一する(オーバーレイ・✕ を廃止)。 */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={t('chatTabsLabel')}
|
||||
data-testid="space-chat-tabs"
|
||||
className="flex items-center gap-1 overflow-x-auto border-b border-hairline px-3"
|
||||
>
|
||||
{tabs.map((tb, i) => {
|
||||
const active = tb.id === activeTab;
|
||||
return (
|
||||
<button
|
||||
key={tb.id}
|
||||
ref={(el) => { tabRefs.current[i] = el; }}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`space-chat-tab-${tb.id}`}
|
||||
data-testid={`space-chat-tab-${tb.id}`}
|
||||
aria-selected={active}
|
||||
aria-controls="space-chat-tabpanel"
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => setActiveTab(tb.id)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, i)}
|
||||
className={`-mb-px shrink-0 whitespace-nowrap border-b-2 px-3 py-2 text-sm font-semibold transition-colors ${tabAppearClass(tb.id)} ${
|
||||
active
|
||||
? 'border-[var(--brand-primary)] text-slate-800'
|
||||
: 'border-transparent text-slate-500 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{t(tb.labelKey)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SpaceChatTabBar
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onSelect={(id) => setActiveTab(id as DetailTabId | 'chat')}
|
||||
ariaLabel={t('chatTabsLabel')}
|
||||
renderLabel={(tb) => t(tb.labelKey)}
|
||||
appearClass={(id) => tabAppearClass(id as DetailTabId | 'chat')}
|
||||
actions={
|
||||
chatReady && task ? (
|
||||
<>
|
||||
<ContinueButton
|
||||
testid="space-chat-continue"
|
||||
latestJobStatus={task.latestJob?.status ?? null}
|
||||
onClick={() => setContinueOpen(true)}
|
||||
/>
|
||||
<ShareButton
|
||||
testid="space-chat-share"
|
||||
taskId={taskId}
|
||||
shareToken={task.shareToken ?? null}
|
||||
onShareChange={refetchTask}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="space-chat-delete"
|
||||
onClick={() => void confirmAndDelete()}
|
||||
title={ts('common:delete')}
|
||||
aria-label={ts('common:delete')}
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-md border border-hairline bg-canvas text-slate-500 transition-colors hover:border-red-200 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-500/15 dark:hover:text-red-300"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 4h10M6.5 4V2.5h3V4M5 4l.5 9h5l.5-9" />
|
||||
</svg>
|
||||
</button>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="space-chat-tabpanel"
|
||||
|
||||
Reference in New Issue
Block a user