maestro/ui/src/components/detail/DetailPanel.tsx
oss-sync 2bab882d08
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (e7c4a56)
2026-06-09 09:43:05 +00:00

294 lines
13 KiB
TypeScript

import { useState, useDeferredValue } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { LocalTask, LocalFileEntry, SubtaskActivity, Visibility, fetchMyOrgs, updateLocalTask } from '../../api';
import { relativeTime } from '../../lib/utils';
import { ownerDisplayName } from '../../lib/owner';
import { DetailTabId } from '../../lib/urlState';
import { DetailHeader } from './DetailHeader';
import { ContinueWithPieceDialog } from './ContinueWithPieceDialog';
import { SkeletonDetailPanel } from '../shared/Skeleton';
import { OverviewTab } from './tabs/OverviewTab';
import { ProgressTab } from './tabs/ProgressTab';
import { FilesTab } from './tabs/FilesTab';
import { TraceTab } from './tabs/TraceTab';
import { BrowserTab } from './tabs/BrowserTab';
import { ConsoleTab } from './tabs/ConsoleTab';
import { BrowserSessionPanel } from '../browser/BrowserSessionPanel';
import type { ConsoleStatus } from '../../lib/ssh-console-types';
import { useAuthState } from '../../App';
import type { SubtaskFilePreviewHandler } from './tabs/SubtasksPanel';
interface LocalDetailPanelProps {
task: LocalTask | null;
taskId: number;
section: 'workspace' | 'input' | 'output' | 'logs';
currentPath: string;
entries: LocalFileEntry[];
pathSegments: string[];
loading: boolean;
detailTab: DetailTabId;
detailWidth: 'normal' | 'focused';
showWidthToggle: boolean;
onTabChange: (tab: DetailTabId) => void;
onWidthToggle: () => void;
onClose: () => void;
onDelete?: () => Promise<void>;
onSectionChange: (section: 'workspace' | 'input' | 'output' | 'logs') => void;
onNavigate: (path: string) => void;
onPreview: (path: string, name: string) => void;
onViewFullLog: () => void;
onRefresh?: () => void;
isRefreshing?: boolean;
subtaskActivities?: SubtaskActivity[];
onSubtaskFilePreview?: SubtaskFilePreviewHandler;
shareToken?: string | null;
onShareChange?: () => void;
}
const LOCAL_TABS: Array<{ id: DetailTabId; label: string }> = [
{ id: 'overview', label: '概要' },
{ id: 'activity', label: '進捗' },
{ id: 'files', label: 'ファイル' },
{ id: 'trace', label: 'トレース' },
{ id: 'browser', label: 'ブラウザ' },
{ id: 'ssh', label: 'SSH' },
];
export function LocalDetailPanel({
task, taskId, section, currentPath, entries, pathSegments,
loading, detailTab, detailWidth, showWidthToggle,
onTabChange, onWidthToggle, onClose, onDelete, onSectionChange, onNavigate, onPreview, onViewFullLog,
onRefresh, isRefreshing, subtaskActivities, onSubtaskFilePreview,
shareToken, onShareChange,
}: LocalDetailPanelProps) {
// Deferred tab id for content rendering. The tab indicator (DetailHeader)
// uses `detailTab` (immediate) so the underline jumps on click. The heavy
// content area below uses `deferredDetailTab` so expensive panels
// (ProgressTab / TraceTab) don't block the click → indicator paint.
// When detailTab !== deferredDetailTab we know a transition is in flight.
const deferredDetailTab = useDeferredValue(detailTab);
const tabTransitionPending = detailTab !== deferredDetailTab;
const [deleting, setDeleting] = useState(false);
const [continueOpen, setContinueOpen] = useState(false);
const [editingVisibility, setEditingVisibility] = useState(false);
const [savingVisibility, setSavingVisibility] = useState(false);
const [editVisibility, setEditVisibility] = useState<Visibility>('private');
const [editScopeOrgId, setEditScopeOrgId] = useState<string | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const qc = useQueryClient();
const authState = useAuthState();
const currentUserId = authState.mode === 'authenticated' ? authState.user.id : null;
const currentUserRole = authState.mode === 'authenticated' ? authState.user.role : null;
const canEditVisibility = task
? (currentUserRole === 'admin' || (currentUserId !== null && task.ownerId === currentUserId))
: false;
const { data: orgs = [] } = useQuery({
queryKey: ['my-orgs'],
queryFn: fetchMyOrgs,
staleTime: 5 * 60 * 1000,
enabled: editingVisibility,
});
// SSH console tab visibility: show whenever an active console session exists for this task.
// (Piece-level pre-show via latestJob.allowedTools is not currently exposed by the API; this
// fallback covers the real case where the AI has actually opened a session.)
const { data: consoleStatus } = useQuery<ConsoleStatus>({
queryKey: ['console-status', task?.id],
queryFn: async () => {
const r = await fetch(`/api/local/tasks/${task!.id}/console/status`);
return r.ok ? r.json() : { active: false };
},
enabled: !!task,
refetchInterval: 5000,
});
const showSshTab = consoleStatus?.active === true;
const visibleTabs = LOCAL_TABS.filter((t) => t.id !== 'ssh' || showSshTab);
const handleStartEdit = () => {
if (!task) return;
setEditVisibility((task.visibility as Visibility) ?? 'private');
setEditScopeOrgId(task.visibilityScopeOrgId ?? null);
setEditError(null);
setEditingVisibility(true);
};
const handleSaveVisibility = async () => {
if (!task) return;
setEditError(null);
setSavingVisibility(true);
try {
await updateLocalTask(task.id, {
visibility: editVisibility,
visibilityScopeOrgId: editVisibility === 'org' ? editScopeOrgId : null,
});
await qc.invalidateQueries({ queryKey: ['localTaskDetail', task.id] });
setEditingVisibility(false);
} catch (err) {
setEditError(err instanceof Error ? err.message : String(err));
} finally {
setSavingVisibility(false);
}
};
const handleDelete = async () => {
if (!onDelete) return;
if (!window.confirm('このタスクを削除しますか?この操作は取り消せません。')) return;
setDeleting(true);
try {
await onDelete();
} finally {
setDeleting(false);
}
};
const jobStatus = task?.latestJob?.status;
const isActiveJob = jobStatus === 'running' || jobStatus === 'dispatching';
return (
<div className="flex flex-col h-full overflow-hidden bg-surface">
<DetailHeader
title={`Task #${taskId}`}
subtitle="ローカルワークスペース"
tabs={visibleTabs}
activeTab={detailTab}
tabTransitionPending={tabTransitionPending}
onTabChange={onTabChange}
onClose={onClose}
detailWidth={detailWidth}
onWidthToggle={showWidthToggle ? onWidthToggle : undefined}
taskId={taskId}
shareToken={shareToken}
onShareChange={onShareChange}
latestJobStatus={task?.latestJob?.status ?? null}
onContinue={task?.latestJob ? () => setContinueOpen(true) : undefined}
/>
{continueOpen && task?.latestJob && (
<ContinueWithPieceDialog
taskId={taskId}
prevJob={{
id: task.latestJob.id,
// task.pieceName tracks last-piece-wins after each Continue, so
// it equals the latest job's piece.
pieceName: task.pieceName,
status: task.latestJob.status,
}}
onClose={() => setContinueOpen(false)}
/>
)}
<div className={`flex-1 min-h-0 p-3 ${detailTab === 'ssh' ? 'overflow-hidden flex flex-col' : 'overflow-y-auto'}`}>
{loading && !task && <SkeletonDetailPanel />}
{task && (
<>
<div className="mb-2 flex items-center gap-2 text-2xs text-slate-500 flex-wrap">
<span>: <b>{ownerDisplayName(task.ownerId, task.ownerName)}</b></span>
<span>·</span>
<span>{relativeTime(task.createdAt)}</span>
{task.visibility === 'private' && <span>· 🔒 </span>}
{task.visibility === 'org' && <span>· 🏢 {task.visibilityScopeOrgName ?? 'org'}</span>}
{task.visibility === 'public' && <span>· 🌐 </span>}
{canEditVisibility && !editingVisibility && (
<button
className="ml-2 underline text-slate-500 hover:text-slate-700"
onClick={handleStartEdit}
>
</button>
)}
</div>
{editingVisibility && (
<div className="mb-3 p-2.5 border border-hairline rounded-md bg-canvas text-xs">
<div className="flex gap-3 flex-wrap">
<label className="flex items-center gap-1">
<input
type="radio"
checked={editVisibility === 'private'}
onChange={() => setEditVisibility('private')}
/>
🔒
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={editVisibility === 'org'}
onChange={() => {
setEditVisibility('org');
if (!editScopeOrgId && orgs.length > 0) setEditScopeOrgId(orgs[0].orgId);
}}
disabled={orgs.length === 0}
/>
🏢
</label>
<label className="flex items-center gap-1">
<input
type="radio"
checked={editVisibility === 'public'}
onChange={() => setEditVisibility('public')}
/>
🌐
</label>
</div>
{editVisibility === 'org' && orgs.length > 1 && (
<select
className="mt-2 px-2 h-7 border border-hairline rounded-md text-xs bg-canvas focus:outline-none focus:ring-2 focus:ring-accent-ring"
value={editScopeOrgId ?? ''}
onChange={e => setEditScopeOrgId(e.target.value)}
>
{orgs.map(o => <option key={o.orgId} value={o.orgId}>{o.orgName}</option>)}
</select>
)}
{editVisibility === 'org' && orgs.length === 1 && (
<div className="mt-1 text-2xs text-slate-500">: {orgs[0].orgName}</div>
)}
{editVisibility === 'org' && orgs.length === 0 && (
<div className="mt-1 text-2xs text-slate-400">使 Gitea </div>
)}
{editError && <div className="mt-1 text-2xs text-red-600">{editError}</div>}
<div className="mt-2 flex gap-2">
<button
disabled={savingVisibility}
onClick={() => void handleSaveVisibility()}
className="px-3 h-7 bg-accent text-accent-fg rounded-md text-xs font-semibold disabled:opacity-50 hover:bg-accent-deep transition-colors"
>
{savingVisibility ? '保存中...' : '保存'}
</button>
<button
disabled={savingVisibility}
onClick={() => { setEditingVisibility(false); setEditError(null); }}
className="px-3 h-7 border border-hairline rounded-md text-xs text-slate-600 hover:bg-surface transition-colors"
>
</button>
</div>
</div>
)}
{task?.latestJob?.status === 'waiting_human' && task?.latestJob?.waitReason === 'browser_login' && (
<BrowserSessionPanel />
)}
{deferredDetailTab === 'overview' && <OverviewTab task={task} subtaskActivities={subtaskActivities} onSubtaskFilePreview={onSubtaskFilePreview} />}
{deferredDetailTab === 'activity' && <ProgressTab task={task} onViewFullLog={onViewFullLog} subtaskActivities={subtaskActivities} />}
{deferredDetailTab === 'files' && <FilesTab section={section} currentPath={currentPath} entries={entries} pathSegments={pathSegments} taskId={taskId} onSectionChange={onSectionChange} onNavigate={onNavigate} onPreview={onPreview} onRefresh={onRefresh} isRefreshing={isRefreshing} />}
{deferredDetailTab === 'trace' && <TraceTab taskId={taskId} />}
{deferredDetailTab === 'browser' && <BrowserTab taskId={taskId} />}
{deferredDetailTab === 'ssh' && <ConsoleTab taskId={taskId} />}
</>
)}
</div>
{!loading && task && (
<div className="flex-shrink-0 border-t border-hairline bg-canvas px-3 py-2.5">
<div className="flex gap-2 items-center">
{onDelete && !isActiveJob ? (
<button
disabled={deleting}
onClick={handleDelete}
className="px-3 h-7 bg-canvas border border-red-200 text-red-700 dark:text-red-300 rounded-md text-xs font-medium disabled:opacity-50 hover:bg-red-50 dark:hover:bg-red-500/15 transition-colors"
>
{deleting ? '削除中...' : '削除'}
</button>
) : null}
</div>
</div>
)}
</div>
);
}