maestro/ui/src/App.tsx
oss-sync e00ea9fb0c
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (d8074a7)
2026-06-05 06:05:30 +00:00

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>
);
}