294 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|