'use client'; import { useState, useEffect, useMemo, useRef, useDeferredValue } from 'react'; import { useRouter } from 'next/navigation'; import { motion, AnimatePresence } from 'motion/react'; import { ArrowUp, Check, ChevronDown, ChevronRight, Clock, Copy, Folder, FolderPlus, ImagePlus, Pencil, Trash2, Search, Settings, Sun, Moon, Monitor, ChevronUp, Upload, Sparkles, Atom, X, Presentation, Loader2, } from 'lucide-react'; import { useI18n } from '@/lib/hooks/use-i18n'; import { LanguageSwitcher } from '@/components/language-switcher'; import { createLogger } from '@/lib/logger'; import { Button } from '@/components/ui/button'; import { InputGroup, InputGroupInput, InputGroupButton } from '@/components/ui/input-group'; import { Textarea as UITextarea } from '@/components/ui/textarea'; import { cn } from '@/lib/utils'; import { SettingsDialog } from '@/components/settings'; import { GenerationToolbar } from '@/components/generation/generation-toolbar'; import { AgentBar } from '@/components/agent/agent-bar'; import { useTheme } from '@/lib/hooks/use-theme'; import { nanoid } from 'nanoid'; import { deleteDocumentBlob, storeDocumentBlob } from '@/lib/utils/image-storage'; import { normalizeDocumentMimeType } from '@/lib/document/mime'; import { courseMaterialFingerprint, dedupeCourseMaterialFiles, } from '@/lib/document/course-materials'; import type { SelectedCourseMaterial, SessionDocumentSource, UserRequirements, } from '@/lib/types/generation'; import { useSettingsStore } from '@/lib/store/settings'; import { hasUsableLLMProvider } from '@/lib/store/settings-validation'; import { useUserProfileStore, AVATAR_OPTIONS } from '@/lib/store/user-profile'; import { StageListItem, listStages, deleteStageData, renameStage, getFirstSlideByStages, revokeThumbnailSlideMediaUrls, listFolders, createFolder, renameFolder, deleteFolder, setStageFolder, FolderNameError, type DeleteFolderMode, } from '@/lib/utils/stage-storage'; import type { FolderRecord } from '@/lib/utils/database'; import { displayNameWidth, FOLDER_NAME_MAX_WIDTH } from '@/lib/utils/folder-name-validation'; import { FolderCard } from '@/components/discovery/folder-card'; import { NewFolderDialog } from '@/components/discovery/folder-dialogs'; import { MoveToFolderMenu } from '@/components/discovery/move-to-folder-menu'; import { SlideThumbnail } from '@/components/slide-renderer/SlideThumbnail'; import type { Slide } from '@openmaic/dsl'; import { useMediaGenerationStore } from '@/lib/store/media-generation'; import { toast } from 'sonner'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { useDraftCache } from '@/lib/hooks/use-draft-cache'; import { SpeechButton } from '@/components/audio/speech-button'; import { useImportClassroom } from '@/lib/import/use-import-classroom'; import { isProWorkbenchEnabled, isPptxImportEnabled, shouldShowVocationalTestUi, } from '@/lib/config/feature-flags'; import { useImportPptx } from '@/lib/import/use-import-pptx'; import { InteractiveModeButton } from '@/components/generation/interactive-mode-button'; import { ProBadge } from '@/components/workbench/ProBadge'; import { arrivedByProSwap, startProSwap } from '@/lib/workbench/pro-swap'; import { readLastWorkspaceSessionId, workspaceResumeHref, } from '@/lib/workbench/workspace-session-memory'; const log = createLogger('Home'); const WEB_SEARCH_STORAGE_KEY = 'webSearchEnabled'; const RECENT_OPEN_STORAGE_KEY = 'recentClassroomsOpen'; const INTERACTIVE_MODE_STORAGE_KEY = 'interactiveModeEnabled'; // PPTX import is still scaffolding: `useImportPptx` has no `onImported` consumer // yet, so the flow only logs the parsed slides. Hide the entry point behind a // flag until it's wired end-to-end, so the UI doesn't expose a no-op button. const PPTX_IMPORT_ENABLED = isPptxImportEnabled(); /** The configured runtime probe result, retained across client navigations. */ let workbenchRuntimeCache: boolean | null = null; interface FormState { courseMaterials: SelectedCourseMaterial[]; requirement: string; webSearch: boolean; interactiveMode: boolean; vocationalTestMode: boolean; } const initialFormState: FormState = { courseMaterials: [], requirement: '', webSearch: false, interactiveMode: false, vocationalTestMode: false, }; function HomePage() { const { t } = useI18n(); const { theme, setTheme } = useTheme(); const router = useRouter(); // Do not replay the classic hero's entrance after the route handoff already // carried the lockup and composer into place. const [swapped] = useState(arrivedByProSwap); const heroEnter = (from: Record) => (swapped ? false : from); const showVocationalTestUi = shouldShowVocationalTestUi(); const workbenchBuildEnabled = isProWorkbenchEnabled(); const [workbenchRuntimeEnabled, setWorkbenchRuntimeEnabled] = useState( workbenchRuntimeCache === true, ); useEffect(() => { if (!workbenchBuildEnabled || workbenchRuntimeCache !== null) return; let cancelled = false; fetch('/api/agent/runtime') .then((response) => (response.ok ? response.json() : null)) .then((body) => { workbenchRuntimeCache = body?.enabled === true; if (!cancelled) setWorkbenchRuntimeEnabled(workbenchRuntimeCache); }) .catch(() => { // A failed probe keeps the entry hidden and allows a later visit to retry. }); return () => { cancelled = true; }; }, [workbenchBuildEnabled]); const workbenchEntryEnabled = workbenchBuildEnabled && workbenchRuntimeEnabled; const enterWorkbench = () => { const href = workspaceResumeHref(readLastWorkspaceSessionId()); startProSwap(href, (next) => router.push(next)); }; useEffect(() => { if (workbenchEntryEnabled) router.prefetch('/workspace'); }, [router, workbenchEntryEnabled]); const [form, setForm] = useState(initialFormState); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsSection, setSettingsSection] = useState< import('@/lib/types/settings').SettingsSection | undefined >(undefined); // Draft cache for requirement text const { cachedValue: cachedRequirement, updateCache: updateRequirementCache } = useDraftCache({ key: 'requirementDraft' }); // A usable LLM provider exists ⇒ a concrete model is always selected (#580 // invariant). Gate generation on this single condition (state A vs B) // instead of inspecting modelId directly. const providersConfig = useSettingsStore((s) => s.providersConfig); const hasUsableProvider = hasUsableLLMProvider(providersConfig); const [recentOpen, setRecentOpen] = useState(true); const persistRecentOpen = (next: boolean) => { setRecentOpen(next); try { localStorage.setItem(RECENT_OPEN_STORAGE_KEY, String(next)); } catch { /* ignore */ } }; // Hydrate client-only state after mount (avoids SSR mismatch) useEffect(() => { try { const saved = localStorage.getItem(RECENT_OPEN_STORAGE_KEY); if (saved !== null) setRecentOpen(saved !== 'false'); } catch { /* localStorage unavailable */ } try { const savedWebSearch = localStorage.getItem(WEB_SEARCH_STORAGE_KEY); const savedInteractiveMode = localStorage.getItem(INTERACTIVE_MODE_STORAGE_KEY); const updates: Partial = {}; if (savedWebSearch === 'true') updates.webSearch = true; if (savedInteractiveMode === 'true') updates.interactiveMode = true; if (Object.keys(updates).length > 0) { setForm((prev) => ({ ...prev, ...updates })); } } catch { /* localStorage unavailable */ } }, []); // Restore requirement draft from localStorage on mount. The previous derived-state // pattern initialised `prev` from the cached value itself, so on the first client // render the comparison was always equal and the restore never fired. Use an effect // so the cache is hydrated into the form once we know the live requirement is empty. const draftRestoredRef = useRef(false); useEffect(() => { if (draftRestoredRef.current) return; if (!cachedRequirement) return; draftRestoredRef.current = true; setForm((prev) => (prev.requirement ? prev : { ...prev, requirement: cachedRequirement })); }, [cachedRequirement]); const [themeOpen, setThemeOpen] = useState(false); const [error, setError] = useState(null); // True while the Generate click drains upload-time ingests and builds the // generation session. Doubles as the guard flag that freezes the course // material set for the duration of prep and as the switch that disables the // toolbar's add/remove affordances, so the session is always built from a // set that cannot change under it. const [preparingGenerate, setPreparingGenerate] = useState(false); const [classrooms, setClassrooms] = useState([]); const [thumbnails, setThumbnails] = useState>({}); const [pendingDeleteId, setPendingDeleteId] = useState(null); const [searchOpen, setSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); // Course folders — device-local grouping. `currentFolderId === undefined` // is the root view (folders + unfiled courses); a folder id navigates into // that folder's course list. Searching flattens every course regardless of // folder and annotates each with its folder name. const [folders, setFolders] = useState([]); // True once the initial classroom + folder loads resolve. Guards layout // selection so the hero does not flip between full-screen and compact as the // two async reads land (avoids a visible layout shift on first paint). const [hydrated, setHydrated] = useState(false); const [currentFolderId, setCurrentFolderId] = useState(undefined); const [newFolderOpen, setNewFolderOpen] = useState(false); // When set, the new-folder dialog is creating a folder AND moving this course // into it (entered via the move-menu's "new folder" entry). const [createAndMoveTarget, setCreateAndMoveTarget] = useState(null); const searchInputRef = useRef(null); const searchButtonRef = useRef(null); const toolbarRef = useRef(null); const textareaRef = useRef(null); const thumbnailsRef = useRef>({}); const replaceThumbnails = (slides: Record) => { const previous = thumbnailsRef.current; thumbnailsRef.current = slides; setThumbnails(slides); window.setTimeout(() => revokeThumbnailSlideMediaUrls(previous), 0); }; // Close dropdowns when clicking outside useEffect(() => { if (!themeOpen) return; const handleClickOutside = (e: MouseEvent) => { if (toolbarRef.current && !toolbarRef.current.contains(e.target as Node)) { setThemeOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, [themeOpen]); const loadClassrooms = async () => { try { const list = await listStages(); setClassrooms(list); // Load first slide thumbnails if (list.length > 0) { const slides = await getFirstSlideByStages(list.map((c) => c.id)); replaceThumbnails(slides); } else { replaceThumbnails({}); } } catch (err) { log.error('Failed to load classrooms:', err); toast.error('Persistence is unavailable. Saved classrooms could not be loaded.'); } }; const loadFolders = async () => { try { setFolders(await listFolders()); } catch (err) { log.error('Failed to load folders:', err); } }; // Capture the active folder when an import starts so the imported course // lands in that folder, not whichever folder is active when the async import // resolves (the user may have navigated away in the meantime). const importFolderRef = useRef(undefined); const handleImportSuccess = async (importedStageId: string) => { const folderId = importFolderRef.current; importFolderRef.current = undefined; // File the imported course into the folder that was active when the // import began, before refreshing the list so the card appears in place. if (folderId) { try { await setStageFolder(importedStageId, folderId); } catch (err) { log.error('Failed to assign imported course to folder:', err); toast.error(t('classroom.moveFailed')); } } await loadClassrooms(); }; const { importing, fileInputRef, triggerFileSelect, handleFileChange } = useImportClassroom(handleImportSuccess); const triggerImport = () => { importFolderRef.current = currentFolderId; triggerFileSelect(); }; const { importing: pptxImporting, fileInputRef: pptxFileInputRef, triggerFileSelect: triggerPptxFileSelect, handleFileChange: handlePptxFileChange, } = useImportPptx(); useEffect(() => { // Clear stale media store to prevent cross-course thumbnail contamination. // The store may hold tasks from a previously visited classroom whose elementIds // (gen_img_1, etc.) collide with other courses' placeholders. useMediaGenerationStore.getState().revokeObjectUrls(); useMediaGenerationStore.setState({ tasks: {} }); // Read sessionStorage on the client only (avoids SSR hydration mismatch). // Both reads resolve before flipping `hydrated`, so the hero layout does // not thrash as each lands independently. void Promise.all([loadClassrooms(), loadFolders()]).finally(() => setHydrated(true)); return () => { revokeThumbnailSlideMediaUrls(thumbnailsRef.current); thumbnailsRef.current = {}; }; }, []); const handleDelete = (id: string, e: React.MouseEvent) => { e.stopPropagation(); setPendingDeleteId(id); }; const confirmDelete = async (id: string) => { setPendingDeleteId(null); try { await deleteStageData(id); await loadClassrooms(); } catch (err) { log.error('Failed to delete classroom:', err); toast.error('Failed to delete classroom'); } }; const handleRename = async (id: string, newName: string) => { try { await renameStage(id, newName); setClassrooms((prev) => prev.map((c) => (c.id === id ? { ...c, name: newName } : c))); } catch (err) { log.error('Failed to rename classroom:', err); toast.error(t('classroom.renameFailed')); } }; // ─── Folder handlers ──────────────────────────────────────────────── const handleCreateFolder = async (name: string) => { const folder = await createFolder(name); setFolders((prev) => [...prev, folder]); // If this create came from the move-menu's "new folder" entry, move the // requesting course into the freshly created folder. if (createAndMoveTarget) { await handleMoveCourse(createAndMoveTarget, folder.id); setCreateAndMoveTarget(null); } }; const handleRenameFolder = (folder: FolderRecord) => async (newName: string): Promise => { // Empty or unchanged input just exits editing without an error. const trimmed = newName.trim(); if (!trimmed || trimmed === folder.name) return null; try { await renameFolder(folder.id, newName); setFolders((prev) => prev.map((f) => (f.id === folder.id ? { ...f, name: trimmed } : f))); return null; } catch (err) { if (err instanceof FolderNameError) { if (err.kind === 'duplicate') return t('classroom.folderNameExists'); if (err.kind === 'tooLong') return t('classroom.folderWidth', { width: displayNameWidth(trimmed), max: FOLDER_NAME_MAX_WIDTH, }); return t('classroom.folderNameHint'); } log.error('Failed to rename folder:', err); return t('classroom.folderRenameFailed'); } }; const confirmDeleteFolder = async (folder: FolderRecord, mode: DeleteFolderMode) => { try { await deleteFolder(folder.id, mode); if (currentFolderId === folder.id) setCurrentFolderId(undefined); } catch (err) { log.error('Failed to delete folder:', err); toast.error(t('classroom.folderDeleteFailed')); } finally { // Always refresh authoritative state: in 'remove' mode a partial failure // may have durably deleted some courses before throwing, and the UI must // reflect that rather than leaving stale cards/counts behind. await Promise.all([loadFolders(), loadClassrooms()]); } }; const handleMoveCourse = async (stageId: string, folderId: string | undefined) => { // Optimistic update for snappy UI; the persistence call follows. setClassrooms((prev) => prev.map((c) => (c.id === stageId ? { ...c, folderId } : c))); try { await setStageFolder(stageId, folderId); } catch (err) { log.error('Failed to move course:', err); toast.error(t('classroom.moveFailed')); // Revert on failure. await loadClassrooms(); } }; // From the move-menu's "new folder" entry: remember the course, then open the // folder dialog. The actual create+move happens in handleCreateFolder once the // name is confirmed. (A Radix DropdownMenu is modal, so the name input cannot // live inside it; the dialog is the focus surface.) const handleCreateAndMove = (stageId: string) => () => { setCreateAndMoveTarget(stageId); setNewFolderOpen(true); }; const deferredSearchQuery = useDeferredValue(searchQuery); const filteredClassrooms = useMemo(() => { const q = deferredSearchQuery.trim().toLowerCase(); if (!q) return classrooms; return classrooms.filter((c) => { const name = c.name?.toLowerCase() ?? ''; const desc = c.description?.toLowerCase() ?? ''; return name.includes(q) || desc.includes(q); }); }, [classrooms, deferredSearchQuery]); // Folder-aware view model. Searching collapses the hierarchy: every matching // course is shown flat, annotated with its folder name. Otherwise the root // view shows folder tiles + unfiled courses, and a folder view shows only // that folder's members. const folderNameById = useMemo(() => new Map(folders.map((f) => [f.id, f.name])), [folders]); const isSearching = deferredSearchQuery.trim().length > 0; // The course tiles rendered in the active view: search flattens everything; // a folder shows only its members; the root shows unfiled courses (folder // tiles are rendered separately above them). const visibleClassrooms = useMemo(() => { if (isSearching) return filteredClassrooms; if (currentFolderId) return filteredClassrooms.filter((c) => c.folderId === currentFolderId); return filteredClassrooms.filter( (c) => c.folderId === undefined || !folderNameById.has(c.folderId), ); }, [filteredClassrooms, isSearching, currentFolderId, folderNameById]); const currentFolderClassrooms = useMemo( () => (currentFolderId ? classrooms.filter((c) => c.folderId === currentFolderId) : []), [classrooms, currentFolderId], ); const courseCountByFolder = useMemo(() => { const counts = new Map(); for (const c of classrooms) { if (c.folderId) counts.set(c.folderId, (counts.get(c.folderId) ?? 0) + 1); } return counts; }, [classrooms]); // Up to 3 member course covers (first-slide thumbnails) per folder, for the // folder tile's cover stack. Members are ordered by updatedAt desc so the // frontmost cover is the most recently touched course. const coverSlidesByFolder = useMemo(() => { const byFolder = new Map(); for (const c of [...classrooms].sort((a, b) => b.updatedAt - a.updatedAt)) { if (!c.folderId) continue; const slide = thumbnails[c.id]; if (!slide) continue; const list = byFolder.get(c.folderId) ?? []; if (list.length < 3) list.push(slide); byFolder.set(c.folderId, list); } return byFolder; }, [classrooms, thumbnails]); const currentFolder = folders.find((f) => f.id === currentFolderId); const updateForm = (field: K, value: FormState[K]) => { setForm((prev) => ({ ...prev, [field]: value })); try { if (field === 'webSearch') localStorage.setItem(WEB_SEARCH_STORAGE_KEY, String(value)); if (field === 'interactiveMode') localStorage.setItem(INTERACTIVE_MODE_STORAGE_KEY, String(value)); if (field === 'requirement') updateRequirementCache(value as string); } catch { /* ignore */ } }; const addCourseMaterials = (files: File[]) => { // The set is frozen for the duration of generate-prep: adding is inert // while `preparingGenerate` is set (the toolbar affordance is disabled // via the same state), so nothing can slip into the set mid-prep. if (preparingGenerate) return; const dedupedFiles = dedupeCourseMaterialFiles(form.courseMaterials, files); const startOrder = form.courseMaterials.length + 1; const additions = dedupedFiles.map((file, index) => ({ id: nanoid(8), file, name: file.name, size: file.size, lastModified: file.lastModified, type: file.type, order: startOrder + index, })); if (additions.length === 0) return; setForm((prev) => { // Pure updater: drop any addition the latest state already carries — by // id (a replayed or superseded update) or by content fingerprint (two // addCourseMaterials calls in one render batch both dedupe against the // same stale closure list, so the same file could otherwise enter twice // under two ids and ingest/extract twice) — then append the rest. const missing = additions.filter((addition) => { if (prev.courseMaterials.some((item) => item.id === addition.id)) return false; return !prev.courseMaterials.some( (item) => courseMaterialFingerprint(item) === courseMaterialFingerprint(addition), ); }); if (missing.length === 0) return prev; return { ...prev, courseMaterials: [...prev.courseMaterials, ...missing] }; }); }; const removeCourseMaterial = (id: string) => { // The set is frozen for the duration of generate-prep: removing is inert // while `preparingGenerate` is set (the toolbar affordance is disabled // via the same state), so nothing can slip out of the set mid-prep. if (preparingGenerate) return; setForm((prev) => ({ ...prev, courseMaterials: prev.courseMaterials .filter((item) => item.id !== id) .map((item, index) => ({ ...item, order: index + 1 })), })); }; const handleGenerate = async () => { // No model/provider guard here: generation is gated by `canGenerate` // (requires a usable provider), and under the #580 invariant a usable // provider always has a concrete model. State A (no usable provider) // surfaces through the toolbar's single Configure-Provider affordance. if (preparingGenerate) return; if (!form.requirement.trim()) { setError(t('upload.requirementRequired')); return; } setError(null); // The material list and the extractor provider config are frozen for the // duration of prep: `preparingGenerate` makes add/remove inert and // disables the toolbar affordances (including the extractor Select and the // web-search toggle), so neither can change under the session build below. // Capture both at click time and build the session from this snapshot, // never from live form state or live store state. const frozenMaterials = [...form.courseMaterials].sort((a, b) => a.order - b.order); const settingsSnapshot = useSettingsStore.getState(); const frozenPdfProviderId = settingsSnapshot.pdfProviderId; const frozenPdfProviderConfig = settingsSnapshot.pdfProvidersConfig?.[ settingsSnapshot.pdfProviderId ] ? { apiKey: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].apiKey, baseUrl: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].baseUrl, accessKeyId: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].accessKeyId, accessKeySecret: settingsSnapshot.pdfProvidersConfig[settingsSnapshot.pdfProviderId].accessKeySecret, } : undefined; // Flip the generating UI state before material bytes are copied locally. setPreparingGenerate(true); try { const userProfile = useUserProfileStore.getState(); const requirements: UserRequirements = { requirement: form.requirement, userNickname: userProfile.nickname || undefined, userBio: userProfile.bio || undefined, webSearch: form.webSearch || undefined, interactiveMode: form.vocationalTestMode ? true : form.interactiveMode, ...(form.vocationalTestMode ? { taskEngineMode: true } : {}), }; let documentSources: SessionDocumentSource[] | undefined; let pdfProviderId: string | undefined; let pdfProviderConfig: | { apiKey?: string; baseUrl?: string; accessKeyId?: string; accessKeySecret?: string } | undefined; if (frozenMaterials.length > 0) { // The session is built from the click-time snapshot (frozen above), // never from live store state. pdfProviderId = frozenPdfProviderId; pdfProviderConfig = frozenPdfProviderConfig; const storedDocumentKeys: string[] = []; try { documentSources = []; for (const [index, item] of frozenMaterials.entries()) { const storageKey = await storeDocumentBlob(item.file); storedDocumentKeys.push(storageKey); documentSources.push({ id: item.id, name: item.name, size: item.size, lastModified: item.lastModified, mimeType: normalizeDocumentMimeType({ mimeType: item.file.type, fileName: item.file.name, }), order: index + 1, storageKey, providerId: pdfProviderId, }); } } catch (error) { await Promise.allSettled(storedDocumentKeys.map((key) => deleteDocumentBlob(key))); throw error; } } const sessionState = { sessionId: nanoid(), requirements, pdfText: '', pdfImages: [], imageStorageIds: [], documentSources, // Backward-compatible single-document fields for previously saved sessions. pdfStorageKey: documentSources?.[0]?.storageKey, pdfFileName: documentSources?.[0]?.name, documentMimeType: documentSources?.[0]?.mimeType, pdfProviderId, pdfProviderConfig, sceneOutlines: null, currentStep: 'generating' as const, }; sessionStorage.setItem('generationSession', JSON.stringify(sessionState)); router.push('/generation-preview'); } catch (err) { log.error('Error preparing generation:', err); setError(err instanceof Error ? err.message : t('upload.generateFailed')); } finally { // Unfreeze the set once prep settles (navigation unmounts this page, so // this is normally a no-op on the way out). setPreparingGenerate(false); } }; const formatDate = (timestamp: number) => { const date = new Date(timestamp); const now = new Date(); const diffTime = Math.abs(now.getTime() - date.getTime()); const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24)); if (diffDays === 0) return t('classroom.today'); if (diffDays === 1) return t('classroom.yesterday'); if (diffDays < 7) return `${diffDays} ${t('classroom.daysAgo')}`; return date.toLocaleDateString(); }; const canGenerate = !!form.requirement.trim() && hasUsableProvider; const handleKeyDown = (e: React.KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { e.preventDefault(); if (canGenerate && !preparingGenerate) handleGenerate(); } }; return (
{PPTX_IMPORT_ENABLED && ( )} {/* ═══ Top-right pill (unchanged) ═══ */}
{/* Language Selector */} setThemeOpen(false)} />
{/* Theme Selector */}
{themeOpen && (
)}
{/* Settings Button */}
{ setSettingsOpen(open); if (!open) setSettingsSection(undefined); }} initialSection={settingsSection} /> {/* ═══ Background Decor ═══ */}
{/* ═══ Hero section: title + input (centered, wider) ═══ */} {/* ── Logo ── */}
{workbenchEntryEnabled ? (
) : null}
{/* ── Slogan ── */} {t('home.slogan')} {/* ── Unified input area ── */}
{/* ── Greeting + Profile + Agents ── */}
{/* Textarea */}