666 lines
28 KiB
TypeScript
666 lines
28 KiB
TypeScript
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 <SharedView token={sharedMatch[1]} />;
|
|
}
|
|
|
|
return <AuthenticatedApp />;
|
|
}
|
|
|
|
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 (
|
|
<div className="h-dvh flex items-center justify-center bg-slate-50">
|
|
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const isAdmin = auth.mode === 'authenticated' ? auth.user.role === 'admin' : true;
|
|
const authEnabled = auth.mode !== 'disabled';
|
|
const user = auth.mode === 'authenticated' ? auth.user : null;
|
|
|
|
return <AppInner isAdmin={isAdmin} authEnabled={authEnabled} user={user} />;
|
|
}
|
|
|
|
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<number | null>(
|
|
'ui.focusedChatPx',
|
|
null,
|
|
);
|
|
const [tabletDetailOpen, setTabletDetailOpen] = useState(false);
|
|
const [navDrawerOpen, setNavDrawerOpen] = useState(false);
|
|
const hamburgerRef = useRef<HTMLButtonElement>(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<string | null>(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<boolean>('notify.enabled', true);
|
|
const [notifyEvents] = useLocalStorageState<NotifyEventSettings>(
|
|
'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<ConsoleStatus>({
|
|
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<string, LocalTask[]>);
|
|
|
|
// 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 (
|
|
<div className="h-dvh flex flex-col overflow-hidden bg-slate-50 text-slate-900" {...edgeSwipe}>
|
|
<TopBar
|
|
currentPage={page}
|
|
onNavigate={handleNavigatePage}
|
|
isAdmin={isAdmin}
|
|
authEnabled={authEnabled}
|
|
user={user}
|
|
appName={branding.appName}
|
|
logoUrl={branding.logoUrl}
|
|
onOpenDrawer={openNavDrawer}
|
|
hamburgerButtonRef={hamburgerRef}
|
|
navDrawerOpen={navDrawerOpen}
|
|
/>
|
|
|
|
<div role="status" aria-live="polite" aria-atomic="true" className="flex-shrink-0">
|
|
{toast && (
|
|
<div className={
|
|
toast.variant === 'error'
|
|
? 'mx-4 mt-2 px-4 py-2.5 bg-red-50 border border-red-200 rounded-xl text-[13px] text-red-800'
|
|
: 'mx-4 mt-2 px-4 py-2.5 bg-green-50 border border-green-200 rounded-xl text-[13px] text-green-800'
|
|
}>
|
|
{toast.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{page === 'settings' && <div className="flex-1 min-h-0 overflow-hidden"><SettingsPage isAdmin={isAdmin} /></div>}
|
|
{page === 'pieces' && <div className="flex-1 min-h-0 overflow-hidden"><PiecesPage showToast={showToast} isAdmin={isAdmin} /></div>}
|
|
{page === 'schedules' && <div className="flex-1 min-h-0 overflow-hidden"><SchedulesPage showToast={showToast} /></div>}
|
|
{page === 'users' && isAdmin && authEnabled && <div className="flex-1 min-h-0 overflow-hidden"><UsersPage /></div>}
|
|
{page === 'captcha' && <div className="flex-1 min-h-0 overflow-hidden"><AdminCaptchaPage isAdmin={isAdmin} /></div>}
|
|
{page === 'userfolder' && <div className="flex-1 min-h-0 overflow-hidden"><UserFolderTab showToast={showToast} /></div>}
|
|
{page === 'help' && <div className="flex-1 min-h-0 overflow-hidden"><HelpPage isAdmin={isAdmin} onAskAi={() => { setCreateInitialPiece('help'); setShowCreateDialog(true); }} selectedId={urlState.help} onSelect={(id) => setUrlState(prev => ({ ...prev, help: id }))} /></div>}
|
|
|
|
{page === 'tasks' && <OutputPreviewProvider openOutputPath={handleOutputPathLinkClick}><div className="flex-1 min-h-0 overflow-hidden">
|
|
{/* モバイル: 単一カラム (< sm = 640px) */}
|
|
<div className="block sm:hidden h-full">
|
|
{!panelOpen ? (
|
|
<div className="p-2 h-full">
|
|
<div className="bg-canvas border border-hairline rounded-md h-full overflow-hidden">
|
|
<TaskListWithSidePanel
|
|
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
|
|
activeWidgetSlug={dashboardWidget}
|
|
onActiveWidgetSlugChange={setDashboardWidget}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<MobileDetailFlow mobileTab={mobileTab} onTabChange={(id) => setUrlState(prev => ({ ...prev, mobileTab: id }))} onSwipeRightFromEdge={() => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId }))} visibleTabs={mobileVisibleTabIds}>
|
|
<div className="flex-shrink-0 flex border-b border-hairline bg-canvas px-2 pt-[env(safe-area-inset-top)]">
|
|
{mobileVisibleTabs.map(({ id, label }) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => 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}
|
|
</button>
|
|
))}
|
|
<button
|
|
aria-label="閉じる"
|
|
onClick={() => 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"
|
|
>
|
|
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
|
<path d="M4 4l8 8M12 4l-8 8" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div key={mobileTab} className="flex-1 min-h-0 overflow-hidden animate-mobile-tab-swap">
|
|
{mobileTab === 'chat' && (
|
|
chatReady ? (
|
|
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
|
|
) : (
|
|
<SkeletonChatPane />
|
|
)
|
|
)}
|
|
{mobileTab !== 'chat' && localTaskId && (
|
|
<LocalDetailPanel
|
|
{...detailPanelProps({
|
|
detailTab: mobileTab === 'overview' ? 'overview'
|
|
: mobileTab === 'activity' ? 'activity'
|
|
: mobileTab === 'trace' ? 'trace'
|
|
: mobileTab === 'browser' ? 'browser'
|
|
: mobileTab === 'ssh' ? 'ssh'
|
|
: 'files',
|
|
showWidthToggle: false,
|
|
onTabChange: t => setUrlState(prev => ({ ...prev, mobileTab: t as MobileTabId })),
|
|
onClose: () => setUrlState(prev => ({ ...prev, taskId: null, mobileTab: 'chat' as MobileTabId })),
|
|
})}
|
|
/>
|
|
)}
|
|
</div>
|
|
{/* 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 && (
|
|
<ChatPetOverlay
|
|
taskId={localTask.id}
|
|
taskStatus={localTask.latestJob?.status ?? null}
|
|
currentActivity={localTask.latestJob?.currentActivity ?? null}
|
|
workerId={localTask.latestJob?.workerId ?? null}
|
|
lastBackendId={localTask.latestJob?.lastBackendId ?? null}
|
|
className="sm:hidden"
|
|
/>
|
|
)}
|
|
</MobileDetailFlow>
|
|
)}
|
|
</div>
|
|
|
|
{/* タブレット: 2カラム (sm 〜 lg) */}
|
|
<div className="hidden sm:grid lg:hidden gap-2 p-2 h-full" style={{ gridTemplateColumns: 'clamp(220px, 30vw, 280px) minmax(0, 1fr)' }}>
|
|
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
|
<TaskListWithSidePanel
|
|
upper={<div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} /></div>}
|
|
activeWidgetSlug={dashboardWidget}
|
|
onActiveWidgetSlugChange={setDashboardWidget}
|
|
/>
|
|
</div>
|
|
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
|
{chatReady ? (
|
|
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} onOpenDetail={() => setTabletDetailOpen(true)} />
|
|
) : panelOpen ? (
|
|
<SkeletonChatPane />
|
|
) : (
|
|
<EmptyState title="スレッドを選択してください" description="左の一覧から選ぶと、会話、進捗、成果物を追えます。" onCreateTask={() => setShowCreateDialog(true)} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* デスクトップ: >= lg (1024px). normal=3 列、focused=rail/chat/handle/ws=4 列 */}
|
|
<div
|
|
className="hidden lg:grid gap-2 p-2 h-full"
|
|
data-focused-grid={isFocused ? '1' : undefined}
|
|
style={gridStyle}
|
|
>
|
|
{/* col 1: list or rail. wrapper が bg/border を保持。 */}
|
|
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
|
<TaskListWithSidePanel
|
|
upper={
|
|
isFocused
|
|
? <TaskListPanel
|
|
{...taskListProps}
|
|
mode="rail"
|
|
onExitFocused={() => setDetailWidth('normal')}
|
|
/>
|
|
: <div className="h-full overflow-hidden p-3"><TaskListPanel {...taskListProps} mode="list" /></div>
|
|
}
|
|
activeWidgetSlug={dashboardWidget}
|
|
onActiveWidgetSlugChange={setDashboardWidget}
|
|
defaultCollapsed={isFocused}
|
|
/>
|
|
</div>
|
|
{/* col 2: Chat */}
|
|
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
|
{chatReady ? (
|
|
<ChatPane task={localTask!} comments={localComments} onSubmit={handleComment} onCancel={handleCancel} />
|
|
) : panelOpen ? (
|
|
<SkeletonChatPane />
|
|
) : (
|
|
<EmptyState title="スレッドを選択してください" description="左の一覧から選ぶと、会話、進捗、成果物を中央で追えます。" onCreateTask={() => setShowCreateDialog(true)} />
|
|
)}
|
|
</div>
|
|
{/* col 3: Resize handle (focused + panelOpen 時のみ) */}
|
|
{isFocused && panelOpen && (
|
|
<ResizeHandle
|
|
onResize={(px) => {
|
|
const grid = document.querySelector<HTMLElement>('[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 && (
|
|
<div className="bg-canvas border border-hairline rounded-md overflow-hidden">
|
|
{localTaskId && <LocalDetailPanel {...detailPanelProps()} />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Tablet: detail overlay */}
|
|
{tabletDetailOpen && panelOpen && (
|
|
<div className="hidden sm:block lg:hidden fixed inset-0 bg-black/40 z-40" onClick={() => setTabletDetailOpen(false)}>
|
|
<div className="absolute right-0 top-0 bottom-0 bg-canvas shadow-2xl flex flex-col overflow-hidden" style={{ width: 'min(480px, 90vw)' }} onClick={e => e.stopPropagation()}>
|
|
{localTaskId && (
|
|
<LocalDetailPanel
|
|
{...detailPanelProps({
|
|
showWidthToggle: false,
|
|
onClose: () => setTabletDetailOpen(false),
|
|
})}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</OutputPreviewProvider>}
|
|
|
|
{showCreateDialog && (
|
|
<CreateTaskDialog
|
|
onClose={() => { setShowCreateDialog(false); setCreateInitialPiece(null); }}
|
|
onSubmit={handleCreateTask}
|
|
initialPiece={createInitialPiece ?? undefined}
|
|
/>
|
|
)}
|
|
{previewState && (
|
|
<FilePreview
|
|
name={previewState.name}
|
|
content={previewState.content}
|
|
imageSrc={previewState.imageSrc}
|
|
markdownImageBaseUrl={previewState.markdownImageBaseUrl}
|
|
onClose={closePreview}
|
|
taskId={previewState.taskId}
|
|
section={previewState.section}
|
|
filePath={previewState.filePath}
|
|
editable={previewState.editable}
|
|
/>
|
|
)}
|
|
{branding.footerText && (
|
|
<footer className="flex-shrink-0 border-t border-slate-200 bg-canvas px-4 py-1.5 text-[10px] text-slate-500 text-center">
|
|
{branding.footerText}
|
|
</footer>
|
|
)}
|
|
<NavDrawer
|
|
open={navDrawerOpen}
|
|
onClose={() => setNavDrawerOpen(false)}
|
|
visibleNav={visibleNav}
|
|
currentPage={page}
|
|
onNavigate={handleNavigatePage}
|
|
appName={branding.appName}
|
|
logoUrl={branding.logoUrl}
|
|
returnFocusRef={hamburgerRef}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 (
|
|
<div className="relative flex flex-col h-full" {...swipe}>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|