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 }) => (
setUrlState(prev => ({ ...prev, mobileTab: id }))}
className={`flex-1 py-3 text-xs border-b-2 active:bg-surface-2 active:scale-[0.97] transition-[transform,color,background-color,border-color] duration-100 ${
mobileTab === id
? 'text-slate-900 border-accent font-semibold'
: 'text-slate-500 border-transparent hover:text-slate-800 font-medium'
}`}
>
{label}
))}
setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))}
className="px-3 py-3 text-slate-400 hover:text-slate-800 active:scale-[0.92] active:text-slate-700 transition-[transform,color] duration-100"
>
{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 && (
)}
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}
);
}