import { useState, useEffect, useRef, type ReactNode } from 'react'; import { useQuery } from '@tanstack/react-query'; import { LocalTask, type Visibility } from './api'; import { useUrlState } from './hooks/useUrlState'; import { useToast } from './hooks/useToast'; import { useFileBrowser } from './hooks/useFileBrowser'; import { useFilePreview } from './hooks/useFilePreview'; import { useTaskOperations } from './hooks/useTaskOperations'; import { useLocalTaskList } from './hooks/useTaskList'; import { useLocalTask, useLocalTaskComments } from './hooks/useTaskDetail'; import { useSubtaskActivities } from './hooks/useSubtaskActivities'; import { useBranding } from './hooks/useBranding'; import { useSwipeNav } from './hooks/useSwipeNav'; import { useLocalStorageState } from './hooks/useLocalStorageState'; import { useTaskNotifications } from './hooks/useTaskNotifications'; import { DEFAULT_NOTIFY_EVENTS, type NotifyEventSettings } from './lib/notifications'; import { COLUMN_LIST, MOBILE_TAB_LIST, type MobileTabId, type PageId } from './lib/urlState'; import { confirmDiscardUnsaved } from './lib/unsavedGuard'; import { TopBar } from './components/layout/TopBar'; import { NavDrawer } from './components/layout/NavDrawer'; import { useEdgeSwipe } from './hooks/useEdgeSwipe'; import { visibleNavItemsFor, useCompactNav } from './components/layout/TopBar'; import { ResizeHandle } from './components/layout/ResizeHandle'; import { TaskListPanel } from './components/list/TaskListPanel'; import { ChatPane } from './components/chat/ChatPane'; import { LocalDetailPanel } from './components/detail/DetailPanel'; import { CreateTaskDialog } from './components/create/CreateTaskDialog'; import { FilePreview } from './components/files/FilePreview'; import { OutputPreviewProvider } from './lib/output-preview-context'; import { stripOutputPrefix } from './lib/output-path-detect'; import { EmptyState } from './components/shared/EmptyState'; import { SkeletonChatPane } from './components/shared/Skeleton'; import { ChatPetOverlay } from './components/pets/ChatPetOverlay'; import { SettingsPage } from './pages/SettingsPage'; import { PiecesPage } from './pages/PiecesPage'; import { SchedulesPage } from './pages/SchedulesPage'; import { UsersPage } from './pages/UsersPage'; import { AdminCaptchaPage } from './pages/AdminCaptchaPage'; import { SharedView } from './pages/SharedView'; import { UserFolderTab } from './components/userfolder/UserFolderTab'; import { HelpPage } from './pages/HelpPage'; import { TaskListWithSidePanel } from './components/dashboard/TaskListWithSidePanel'; import type { ConsoleStatus } from './lib/ssh-console-types'; export interface AuthUser { id: string; email: string; name: string | null; avatarUrl: string | null; role: 'admin' | 'user'; orgIds?: string[]; defaultVisibility?: Visibility; defaultVisibilityOrgId?: string | null; } type AuthMode = | { mode: 'disabled' } | { mode: 'loading' } | { mode: 'authenticated'; user: AuthUser } | { mode: 'unauthenticated' }; export function useAuthState(): AuthMode { const { data, error, isLoading } = useQuery({ queryKey: ['auth', 'me'], queryFn: async () => { const res = await fetch('/api/auth/me'); if (res.status === 404) return { mode: 'disabled' as const }; if (res.status === 401) return { mode: 'unauthenticated' as const }; if (!res.ok) throw new Error('Auth check failed'); const user = await res.json(); return { mode: 'authenticated' as const, user }; }, retry: false, staleTime: 5 * 60 * 1000, }); if (isLoading) return { mode: 'loading' }; if (error || !data) return { mode: 'disabled' }; return data; } export function App() { // 共有ページ: /ui/shared/:token — 認証不要 const sharedMatch = window.location.pathname.match(/^\/ui\/shared\/([^/]+)/); if (sharedMatch) { return ; } return ; } function AuthenticatedApp() { const auth = useAuthState(); // Redirect to login if unauthenticated if (auth.mode === 'unauthenticated') { window.location.href = '/auth/login'; return null; } // Show loading spinner while checking auth if (auth.mode === 'loading') { return (
); } const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true; const authEnabled = auth.mode !== 'disabled'; const user = auth.mode === 'authenticated' ? auth.user : null; return ; } function AppInner({ isAdmin, authEnabled, user }: { isAdmin: boolean; authEnabled: boolean; user: AuthUser | null }) { // Apply branding (document.title + --brand-primary CSS var) const branding = useBranding(); const { urlState, setUrlState, pushUrlState } = useUrlState(); const { status, search, sort, detailTab, mobileTab, taskId: localTaskId } = urlState; const dashboardWidget = urlState.dashboardWidget ?? 'worker-status'; const setDashboardWidget = (slug: string) => setUrlState(prev => ({ ...prev, dashboardWidget: slug })); // 認証なしで users ページにアクセスした場合は tasks にフォールバック const page = (urlState.page === 'users' && !authEnabled) ? 'tasks' : urlState.page; // UI state const [detailWidth, setDetailWidth] = useLocalStorageState<'normal' | 'focused'>( 'ui.detailMode', 'normal', ); // focused 時の Chat 列幅 (px)。null は default (30vw) を意味する。 const [focusedChatPx, setFocusedChatPx] = useLocalStorageState( 'ui.focusedChatPx', null, ); const [tabletDetailOpen, setTabletDetailOpen] = useState(false); const [navDrawerOpen, setNavDrawerOpen] = useState(false); const hamburgerRef = useRef(null); const compactMode = useCompactNav(isAdmin, authEnabled); const visibleNav = visibleNavItemsFor(isAdmin, authEnabled); const openNavDrawer = () => { setTabletDetailOpen(false); setNavDrawerOpen(true); }; // Shared navigation handler used by both TopBar and NavDrawer. // Guards against discarding unsaved edits before switching pages. const handleNavigatePage = (p: PageId) => { if (p === page) { setNavDrawerOpen(false); return; } if (!confirmDiscardUnsaved()) return; setUrlState(prev => ({ ...prev, page: p })); setNavDrawerOpen(false); }; const edgeSwipe = useEdgeSwipe({ enabled: compactMode && !navDrawerOpen, onOpen: openNavDrawer, }); useEffect(() => { if (!compactMode) setNavDrawerOpen(false); }, [compactMode]); const [showCreateDialog, setShowCreateDialog] = useState(false); /** * When set, CreateTaskDialog opens with the given piece preselected (and a * help-themed placeholder). Used by HelpPage "AI に聞く" so the user lands * directly in the help assistant. Cleared on dialog close. */ const [createInitialPiece, setCreateInitialPiece] = useState(null); const panelOpen = localTaskId !== null; // Toast const { toast, showToast } = useToast(); // URL sync useEffect(() => { pushUrlState(urlState); }, [urlState, pushUrlState]); // Data queries — split per concern so each tab fetches what it needs. // Overview/Chat render as soon as task + comments arrive, without waiting // for the (potentially large) activity.log. ProgressTab fetches that on // its own when mounted. const localTasksQuery = useLocalTaskList(); // ブラウザ通知設定 (localStorage) — 設定 UI は NotificationsForm が管理 const [notifyEnabled] = useLocalStorageState('notify.enabled', true); const [notifyEvents] = useLocalStorageState( 'notify.events', DEFAULT_NOTIFY_EVENTS, ); useTaskNotifications({ tasks: localTasksQuery.data, currentUserId: user?.id ?? null, enabled: notifyEnabled, events: notifyEvents, onNotificationClick: (taskId) => { setUrlState(prev => ({ ...prev, page: 'tasks', taskId })); }, }); // V2: SW posts `open-task` when the user clicks an OS notification and the // SW focuses (or opens) this tab. We route it through the same URL state // transition as V1's onclick handler. useEffect(() => { if (!('serviceWorker' in navigator)) return; const handler = (e: MessageEvent) => { const data = e.data; if (!data || typeof data !== 'object') return; if (data.type === 'open-task' && typeof data.taskId === 'number') { setUrlState(prev => ({ ...prev, page: 'tasks', taskId: data.taskId })); } }; navigator.serviceWorker.addEventListener('message', handler); return () => navigator.serviceWorker.removeEventListener('message', handler); }, [setUrlState]); const localTaskQuery = useLocalTask(localTaskId, panelOpen); const localCommentsQuery = useLocalTaskComments(localTaskId, panelOpen); const localTasks = localTasksQuery.data ?? []; const localTask = localTaskQuery.data ?? null; const localComments = localCommentsQuery.data ?? []; // Both queries must finish before mounting ChatPane — otherwise the task // detail resolves first and ChatPane briefly renders with comments=[], // which trips the "メッセージはまだありません" empty-state. data===undefined // means not yet loaded; once loaded (even with zero comments) it's at // worst []. const chatReady = localTask !== null && localCommentsQuery.data !== undefined; const hasSubtasks = (localTask?.subtasks?.length ?? 0) > 0; const { data: subtaskActivities } = useSubtaskActivities(localTaskId, hasSubtasks); // SSH console status (for conditional mobile SSH tab) const { data: consoleStatus } = useQuery({ queryKey: ['console-status', localTaskId], queryFn: async () => { const r = await fetch(`/api/local/tasks/${localTaskId}/console/status`); return r.ok ? r.json() : { active: false }; }, enabled: !!localTaskId, refetchInterval: 5000, }); const showSshMobileTab = consoleStatus?.active === true; // File browser const fileBrowser = useFileBrowser(localTaskId); // File preview const { previewState, previewLocalFile, previewSubtaskFile, closePreview } = useFilePreview(showToast); // Task operations const { handleCreateTask, handleComment, handleDelete, handleCancel } = useTaskOperations({ taskId: localTaskId, showToast, setUrlState, setShowCreateDialog, }); // Close tablet overlay when task changes useEffect(() => { setTabletDetailOpen(false); }, [localTaskId]); // Counts for TopBar const localColumns = COLUMN_LIST.reduce((acc, s) => { acc[s] = localTasks.filter(t => (t.latestJob?.status ?? 'queued') === s); return acc; }, {} as Record); // File preview handlers (bind taskId and section) const handleLocalFilePreview = (filePath: string, name: string) => { if (localTaskId) previewLocalFile(localTaskId, fileBrowser.section, filePath, name); }; const handleSubtaskFilePreview = (taskId: number, jobId: string, category: string, filePath: string) => { previewSubtaskFile(taskId, jobId, category, filePath); }; // Output path link click handler for the OutputPreviewProvider that // wraps the tasks page. Matches paths look like `output/sub/foo.md` // — strip the `output/` prefix and pass the relative path to // previewLocalFile with section pinned to 'output' (ignoring the // current FileBrowser section, which may be 'logs' or 'input'). const handleOutputPathLinkClick = (matchedPath: string) => { if (!localTaskId) return; const relative = stripOutputPrefix(matchedPath); const displayName = relative.includes('/') ? relative.substring(relative.lastIndexOf('/') + 1) : relative; previewLocalFile(localTaskId, 'output', relative, displayName); }; // TaskListPanel shared props const taskListProps = { localTasks, selectedStatus: status, sortMode: sort, searchQuery: search, activeTaskId: localTaskId, onStatusChange: (s: string) => setUrlState(prev => ({ ...prev, status: s as typeof status })), onSortChange: (s: string) => setUrlState(prev => ({ ...prev, sort: s as typeof sort })), onSearchChange: (q: string) => setUrlState(prev => ({ ...prev, search: q })), onSelectTask: (id: number) => setUrlState(prev => ({ ...prev, taskId: id, detailTab: 'overview' as const })), onOpenCreate: () => setShowCreateDialog(true), }; // Detail panel shared props const detailPanelProps = (overrides?: { detailTab?: string; showWidthToggle?: boolean; onTabChange?: (t: string) => void; onClose?: () => void }) => ({ task: localTask, taskId: localTaskId!, section: fileBrowser.section, currentPath: fileBrowser.currentPath, entries: fileBrowser.entries, pathSegments: fileBrowser.pathSegments, loading: localTaskQuery.isLoading, detailTab: overrides?.detailTab ?? detailTab, detailWidth, showWidthToggle: overrides?.showWidthToggle ?? true, onTabChange: overrides?.onTabChange ?? (t => setUrlState(prev => ({ ...prev, detailTab: t }))), onWidthToggle: () => setDetailWidth(prev => prev === 'focused' ? 'normal' : 'focused'), onClose: overrides?.onClose ?? (() => setUrlState(prev => ({ ...prev, taskId: null, detailTab: 'overview' }))), onDelete: handleDelete, onSectionChange: fileBrowser.setSection, onNavigate: fileBrowser.setCurrentPath, onPreview: handleLocalFilePreview, onRefresh: fileBrowser.refresh, isRefreshing: fileBrowser.isRefreshing, onViewFullLog: () => handleLocalFilePreview('activity.log', 'activity.log'), subtaskActivities, onSubtaskFilePreview: handleSubtaskFilePreview, shareToken: localTask?.shareToken ?? null, }); // Layout calculation const sidebarWidth = 'clamp(240px, 22vw, 280px)'; const detailPanelWidth = 'clamp(280px, 26vw, 440px)'; // normal mode 時のみ使用 const isFocused = detailWidth === 'focused'; const RAIL_PX = 48; const HANDLE_PX = 4; const MIN_CHAT_PX = 280; const MIN_WS_PX = 280; const RESERVED_RIGHT = RAIL_PX + HANDLE_PX + MIN_WS_PX; // = 332 // focused 用 grid: rail | chat (variable) | handle | workspace const focusedGridCols = panelOpen ? `${RAIL_PX}px clamp(${MIN_CHAT_PX}px, var(--chat-w, 30vw), calc(100% - ${RESERVED_RIGHT}px)) ${HANDLE_PX}px minmax(${MIN_WS_PX}px, 1fr)` : `${RAIL_PX}px minmax(0, 1fr)`; // normal 用 grid (現状を維持) const normalGridCols = panelOpen ? `${sidebarWidth} minmax(280px, 1fr) ${detailPanelWidth}` : `${sidebarWidth} minmax(0, 1fr)`; const gridStyle: React.CSSProperties = isFocused ? { gridTemplateColumns: focusedGridCols, ['--chat-w' as string]: focusedChatPx !== null ? `${focusedChatPx}px` : '30vw', } : { gridTemplateColumns: normalGridCols, }; // Dynamic mobile tab list: always show Browser, conditionally show SSH const mobileVisibleTabs: Array<{ id: MobileTabId; label: string }> = [ { id: 'chat', label: 'Chat' }, { id: 'overview', label: 'Overview' }, { id: 'activity', label: 'Progress' }, { id: 'files', label: 'Files' }, { id: 'trace', label: 'Trace' }, { id: 'browser', label: 'Browser' }, ...(showSshMobileTab ? [{ id: 'ssh' as MobileTabId, label: 'SSH' }] : []), ]; const mobileVisibleTabIds = mobileVisibleTabs.map(t => t.id); return (
{toast && (
{toast.message}
)}
{page === 'settings' &&
} {page === 'pieces' &&
} {page === 'schedules' &&
} {page === 'users' && isAdmin && authEnabled &&
} {page === 'captcha' &&
} {page === 'userfolder' &&
} {page === 'help' &&
{ setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} />
} {page === 'tasks' &&
{/* モバイル: 単一カラム (< sm = 640px) */}
{!panelOpen ? (
} activeWidgetSlug={dashboardWidget} onActiveWidgetSlugChange={setDashboardWidget} />
) : ( setUrlState(prev => ({ ...prev, mobileTab: id }))} onSwipeRightFromEdge={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))} visibleTabs={mobileVisibleTabIds}>
{mobileVisibleTabs.map(({ id, label }) => ( ))}
{mobileTab === 'chat' && ( chatReady ? ( ) : ( ) )} {mobileTab !== 'chat' && localTaskId && ( setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })), onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })), })} /> )}
{/* Mobile-only pet overlay. Anchored to the MobileDetailFlow wrapper (which has `relative`) so the pet stays visible across all tabs, not just Chat. Tablet+ uses the ChatPane-internal instance instead. */} {localTask && ( )}
)}
{/* タブレット: 2カラム (sm 〜 lg) */}
} activeWidgetSlug={dashboardWidget} onActiveWidgetSlugChange={setDashboardWidget} />
{chatReady ? ( setTabletDetailOpen(true)} /> ) : panelOpen ? ( ) : ( setShowCreateDialog(true)} /> )}
{/* デスクトップ: >= lg (1024px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
{/* col 1: list or rail. wrapper が bg/border を保持。 */}
setDetailWidth('normal')} /> :
} activeWidgetSlug={dashboardWidget} onActiveWidgetSlugChange={setDashboardWidget} defaultCollapsed={isFocused} />
{/* col 2: Chat */}
{chatReady ? ( ) : panelOpen ? ( ) : ( setShowCreateDialog(true)} /> )}
{/* col 3: Resize handle (focused + panelOpen 時のみ) */} {isFocused && panelOpen && ( { const grid = document.querySelector('[data-focused-grid="1"]'); if (grid) grid.style.setProperty('--chat-w', `${px}px`); }} onResizeEnd={(px) => setFocusedChatPx(px)} onReset={() => setFocusedChatPx(null)} railPx={RAIL_PX} minChatPx={MIN_CHAT_PX} minWorkspacePx={MIN_WS_PX} handlePx={HANDLE_PX} /> )} {/* col 4: Workspace (detail) */} {panelOpen && (
{localTaskId && }
)}
{/* Tablet: detail overlay */} {tabletDetailOpen && panelOpen && (
setTabletDetailOpen(false)}>
e.stopPropagation()}> {localTaskId && ( setTabletDetailOpen(false), })} /> )}
)} } {showCreateDialog && ( { setShowCreateDialog(false); setCreateInitialPiece(null); }} onSubmit={handleCreateTask} initialPiece={createInitialPiece ?? undefined} /> )} {previewState && ( )} {branding.footerText && (
{branding.footerText}
)} setNavDrawerOpen(false)} visibleNav={visibleNav} currentPage={page} onNavigate={handleNavigatePage} appName={branding.appName} logoUrl={branding.logoUrl} returnFocusRef={hamburgerRef} /> ); } /** * Mobile detail wrapper that adds horizontal swipe navigation between * the Chat / Overview / Progress / Files / Trace tabs. Tap-to-switch * via the tab bar still works (the swipe handler ignores touches that * start on form controls / buttons / anchors). */ function MobileDetailFlow({ mobileTab, onTabChange, onSwipeRightFromEdge, visibleTabs, children, }: { mobileTab: MobileTabId; onTabChange: (tab: MobileTabId) => void; onSwipeRightFromEdge?: () => void; visibleTabs: MobileTabId[]; children: ReactNode; }) { const swipe = useSwipeNav({ onSwipeLeft: () => { const idx = visibleTabs.indexOf(mobileTab); if (idx >= 0 && idx < visibleTabs.length - 1) { onTabChange(visibleTabs[idx + 1]); } }, onSwipeRight: () => { const idx = visibleTabs.indexOf(mobileTab); if (idx > 0) { onTabChange(visibleTabs[idx - 1]); } else if (idx === 0) { onSwipeRightFromEdge?.(); } }, }); // `relative` is required so the app-level mobile pet overlay (rendered // inside this wrapper) can anchor with position: absolute. return (
{children}
); }