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; 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('private'); const [editScopeOrgId, setEditScopeOrgId] = useState(null); const [editError, setEditError] = useState(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({ 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 (
setContinueOpen(true) : undefined} /> {continueOpen && task?.latestJob && ( setContinueOpen(false)} /> )}
{loading && !task && } {task && ( <>
作成者: {ownerDisplayName(task.ownerId, task.ownerName)} · {relativeTime(task.createdAt)} {task.visibility === 'private' && · 🔒 非公開} {task.visibility === 'org' && · 🏢 {task.visibilityScopeOrgName ?? 'org'}} {task.visibility === 'public' && · 🌐 公開} {canEditVisibility && !editingVisibility && ( )}
{editingVisibility && (
{editVisibility === 'org' && orgs.length > 1 && ( )} {editVisibility === 'org' && orgs.length === 1 && (
共有先: {orgs[0].orgName}
)} {editVisibility === 'org' && orgs.length === 0 && (
組織を使うには Gitea でログインしてください
)} {editError &&
{editError}
}
)} {task?.latestJob?.status === 'waiting_human' && task?.latestJob?.waitReason === 'browser_login' && ( )} {deferredDetailTab === 'overview' && } {deferredDetailTab === 'activity' && } {deferredDetailTab === 'files' && } {deferredDetailTab === 'trace' && } {deferredDetailTab === 'browser' && } {deferredDetailTab === 'ssh' && } )}
{!loading && task && (
{onDelete && !isActiveJob ? ( ) : null}
)}
); }