import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { HelpCircle } from 'lucide-react'; import { useTheme } from '../contexts/ThemeContext'; import { useAppConfig } from '../contexts/AppConfigContext'; import Sidebar from '../components/Sidebar'; import AppHeader from '../components/AppHeader'; import Icon from '../components/canvas/CanvasIcon'; import { INSIGHTS, WIDGET_CATALOG, DEFAULT_LAYOUT, EMPTY_STATE, WORKSPACE_PRESETS, } from '../components/canvas/canvasData'; import { BibliographyWidget, KanbanWidget, PomodoroWidget, WritingWidget, DeadlinesWidget, BudgetWidget, ReadingQueueWidget, NotesWidget, HabitsWidget, GoalsWidget, MeetingsWidget, OutlineWidget, HighlightsWidget, LatexWidget, CalendarWidget, DocumenterWidget, ActivityWidget, PhdJourneyWidget, PhdResourcesWidget, StubWidget, } from '../components/canvas/CanvasWidgets'; import { Reviewer2Widget, DevilsAdvocateWidget, ScopeRealismWidget, ReviewerModal, DevilsModal, ScopeModal, } from '../components/canvas/CanvasCriticWidgets'; import { AddCitationModal, AddTaskModal, AddDeadlineModal, LogWordsModal, ConfirmRemoveModal, ReadingPaperModal, BudgetItemModal, NoteModal, HabitModal, GoalModal, MeetingModal, PaletteModal, CommandPaletteModal, GlobalSearchModal, } from '../components/canvas/CanvasModals'; import CanvasWelcomeTour from '../components/canvas/CanvasWelcomeTour'; import DeliverablesView, { TEMPLATES as DELIVERABLE_TEMPLATES } from '../components/canvas/CanvasDeliverables'; import { MOD } from '../components/canvas/platform'; import '../styles/CanvasPage.css'; const LAYOUT_KEY = 'canvas-layout-v2'; const STATES_KEY = 'canvas-states-v2'; const VIEW_KEY = 'canvas-view-v2'; function renderWidget(type, state, setState, openModal, allStates) { const props = { state, setState, openModal, allStates }; switch (type) { case 'bibliography': return ; case 'kanban': return ; case 'pomodoro': return ; case 'writing': return ; case 'deadlines': return ; case 'budget': return ; case 'reading-queue': return ; case 'notes': return ; case 'habits': return ; case 'goals': return ; case 'meeting-log': return ; case 'reviewer-2': return ; case 'devils-advocate': return ; case 'scope-realism': return ; case 'outline': return ; case 'highlights': return ; case 'latex': return ; case 'calendar': return ; case 'documenter': return ; case 'activity': return ; case 'phd-journey': return ; case 'phd-resources': return ; default: { const meta = WIDGET_CATALOG.find(w => w.type === type); return ; } } } function CanvasWidget({ widget, isDragging, isDragOver, onDragStart, onDragOver, onDragEnd, onDrop, state, setState, onRemove, onResize, openModal, allStates }) { const meta = WIDGET_CATALOG.find(w => w.type === widget.type); if (!meta) return null; const sizes = ['S', 'M', 'L']; const cycleSize = () => onResize(widget.id, sizes[(sizes.indexOf(widget.size) + 1) % sizes.length]); return (
{ e.preventDefault(); onDragOver(widget.id); }} onDrop={(e) => { e.preventDefault(); onDrop(widget.id); }} >
{ onDragStart(widget.id); e.dataTransfer.effectAllowed = 'move'; }} onDragEnd={onDragEnd} >
{meta.name}
{meta.critic && wedge} {widget.size}
{renderWidget(widget.type, state, setState, openModal, allStates)}
); } // ============================================================================ // Insights view — AI-synthesized highlights with stats bar, filters, and // click-to-expand source quotes. // ============================================================================ const INSIGHT_CATEGORIES = [ { id: 'all', label: 'All' }, { id: 'open', label: 'Open' }, { id: 'in-progress', label: 'In progress' }, { id: 'completed', label: 'Completed' }, { id: 'abandoned', label: 'Abandoned' }, { id: 'pinned', label: 'Pinned' }, { id: 'high', label: 'High confidence' }, { id: 'progress', label: 'Progress' }, { id: 'theory', label: 'Theory' }, { id: 'literature', label: 'Literature' }, { id: 'action', label: 'Actions' }, { id: 'risk', label: 'Risks' }, ]; const CATEGORY_TINT = { progress: 'rgba(16, 185, 129, 0.12)', theory: 'rgba(99, 102, 241, 0.12)', literature: 'rgba(245, 158, 11, 0.12)', action: 'rgba(59, 130, 246, 0.12)', risk: 'rgba(220, 38, 38, 0.12)', }; const CATEGORY_FG = { progress: '#10B981', theory: '#818CF8', literature: '#F59E0B', action: '#3B82F6', risk: '#DC2626', }; const confidenceTier = (c) => c >= 75 ? 'high' : c >= 60 ? 'med' : 'low'; // Task statuses live on each individual bullet within an insight, not on the // whole card — Daniel's feedback: "each card is a discrete task, not a set of tasks". const TASK_STATUSES = [ { id: 'open', label: 'Open', color: 'var(--canvas-text-3)', icon: 'sparkles' }, { id: 'in-progress', label: 'In progress', color: '#3B82F6', icon: 'graph' }, { id: 'completed', label: 'Completed', color: '#10B981', icon: 'check' }, { id: 'abandoned', label: 'Abandoned', color: 'var(--canvas-text-4)', icon: 'x' }, ]; const TASK_STATUS_KEY = 'canvas-task-status-v1'; const taskKey = (insId, idx) => `${insId}::${idx}`; function InsightsView({ widgetStates, setWidgetStates, onNavigateToChat }) { const [pinned, setPinned] = useState(() => new Set(INSIGHTS.filter(i => i.pinned).map(i => i.id))); const [taskStatuses, setTaskStatuses] = useState(() => { try { return JSON.parse(localStorage.getItem(TASK_STATUS_KEY) || '{}'); } catch { return {}; } }); const [filter, setFilter] = useState('all'); const [sortBy, setSortBy] = useState('confidence'); const [expanded, setExpanded] = useState(new Set()); const [refreshing, setRefreshing] = useState(false); const [openStatusMenu, setOpenStatusMenu] = useState(null); // 'cards' = current cards-of-tasks layout, 'tasks' = flat task list per Daniel's // "Sections in sidebar, Tasks in the main view" suggestion. const [viewMode, setViewMode] = useState(() => localStorage.getItem('canvas-insights-view') || 'cards'); useEffect(() => { localStorage.setItem('canvas-insights-view', viewMode); }, [viewMode]); useEffect(() => { localStorage.setItem(TASK_STATUS_KEY, JSON.stringify(taskStatuses)); }, [taskStatuses]); const taskStatusOf = (insId, idx) => taskStatuses[taskKey(insId, idx)] || 'open'; const setTaskStatus = (insId, idx, status) => { setTaskStatuses(prev => ({ ...prev, [taskKey(insId, idx)]: status })); const lbl = TASK_STATUSES.find(s => s.id === status)?.label || status; window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `Task marked ${lbl}`, kind: 'success' } })); }; // Roll up to a card-level status: completed if all tasks done, abandoned if all abandoned, // in-progress if any are in-progress, otherwise open. const insightRollup = (ins) => { const states = ins.bullets.map((_, idx) => taskStatusOf(ins.id, idx)); const total = states.length; if (total === 0) return { state: 'open', done: 0, total: 0, pct: 0 }; const done = states.filter(s => s === 'completed').length; const inProg = states.filter(s => s === 'in-progress').length; const abandoned = states.filter(s => s === 'abandoned').length; let state = 'open'; if (done === total) state = 'completed'; else if (abandoned === total) state = 'abandoned'; else if (inProg > 0 || done > 0) state = 'in-progress'; return { state, done, total, inProg, abandoned, pct: Math.round((done / total) * 100) }; }; const togglePin = (id) => { setPinned(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }; const toggleExpand = (id) => { setExpanded(prev => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }; const insightToTaskTitle = (ins) => { const plain = (ins.bullets[0] || ins.summary || ins.title).replace(/<[^>]+>/g, ''); return plain.length > 80 ? plain.slice(0, 77) + '…' : plain; }; const sendToKanban = (ins) => { if (!setWidgetStates) return; const kanban = widgetStates.kanban || EMPTY_STATE.kanban; const card = { id: 'k' + Date.now(), col: 'todo', title: insightToTaskTitle(ins), priority: 'med', meta: `from Insights · ${ins.title}`, }; setWidgetStates(s => ({ ...s, kanban: { ...kanban, cards: [...kanban.cards, card] } })); window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Sent to Kanban (To Do)', kind: 'success' } })); }; // TODO(LLM): real refresh hits the orchestrator and re-synthesizes insights. const handleRefresh = () => { setRefreshing(true); setTimeout(() => setRefreshing(false), 900); window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Refreshing insights… (stub)', kind: 'success' } })); }; // "Ask follow-up": stash a draft prompt + insight context that the chat page // can pick up on next navigation. Backend hookup TODO: include source-conversation IDs. const askFollowUp = (ins) => { const plain = (ins.bullets[0] || ins.summary || '').replace(/<[^>]+>/g, ''); const prompt = `Follow up on the insight "${ins.title}": ${plain}`; try { localStorage.setItem('canvas-chat-handoff', JSON.stringify({ at: Date.now(), prompt, insightId: ins.id, insightTitle: ins.title, })); } catch { /* ignore */ } window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `Follow-up drafted: "${ins.title}" — opening chat`, kind: 'success' }, })); // Use the existing navigation prop if present; falls back to the global event. if (onNavigateToChat) onNavigateToChat(); }; const filtered = useMemo(() => { let r = INSIGHTS; if (filter === 'pinned') r = r.filter(i => pinned.has(i.id)); else if (filter === 'high') r = r.filter(i => i.confidence >= 75); else if (['open', 'in-progress', 'completed', 'abandoned'].includes(filter)) r = r.filter(i => insightRollup(i).state === filter); else if (filter !== 'all') r = r.filter(i => i.category === filter); if (sortBy === 'confidence') r = [...r].sort((a, b) => b.confidence - a.confidence); if (sortBy === 'recent') r = [...r].sort((a, b) => (a.updatedMinutesAgo || 0) - (b.updatedMinutesAgo || 0)); if (sortBy === 'progress') r = [...r].sort((a, b) => insightRollup(b).pct - insightRollup(a).pct); return r; }, [filter, sortBy, pinned, taskStatuses]); // eslint-disable-line react-hooks/exhaustive-deps // Aggregate stats over individual TASKS, not insights (Daniel's framing) const stats = useMemo(() => { const allTasks = INSIGHTS.flatMap(i => i.bullets.map((_, idx) => taskStatusOf(i.id, idx))); const taskTotal = allTasks.length; const completed = allTasks.filter(s => s === 'completed').length; const inProgress = allTasks.filter(s => s === 'in-progress').length; const abandoned = allTasks.filter(s => s === 'abandoned').length; const open = taskTotal - completed - inProgress - abandoned; const totalSources = INSIGHTS.reduce((s, i) => s + (i.sources || 0), 0); const avgConf = Math.round(INSIGHTS.reduce((s, i) => s + i.confidence, 0) / INSIGHTS.length); return { sections: INSIGHTS.length, taskTotal, completed, inProgress, abandoned, open, totalSources, avgConf, pinnedCount: pinned.size, }; }, [pinned, taskStatuses]); // eslint-disable-line react-hooks/exhaustive-deps const lastUpdated = Math.min(...INSIGHTS.map(i => i.updatedMinutesAgo || 0)); // Empty state — defensive (current data is hardcoded but a real backend could send []) if (INSIGHTS.length === 0) { return ( <>

Insights

AI-synthesized from your research conversations.
No insights yet
Have a conversation with your advisors and insights will appear here.
); } return ( <>

Insights

AI-synthesized from your research conversations.
{/* Stats bar */}
{stats.completed}/{stats.taskTotal} tasks done
{stats.completed} done · {stats.inProgress} in progress · {stats.open} open {stats.abandoned > 0 && ` · ${stats.abandoned} abandoned`}
{stats.sections} sections
{stats.avgConf}% avg confidence
updated {lastUpdated} min ago
{/* View toggle: Cards (sections with their tasks) vs Tasks (flat list) */}
{/* Filter + sort */}
{INSIGHT_CATEGORIES.map(c => { const count = c.id === 'all' ? INSIGHTS.length : c.id === 'pinned' ? pinned.size : c.id === 'high' ? INSIGHTS.filter(i => i.confidence >= 75).length : ['open', 'in-progress', 'completed', 'abandoned'].includes(c.id) ? INSIGHTS.filter(i => insightRollup(i).state === c.id).length : INSIGHTS.filter(i => i.category === c.id).length; if (count === 0 && c.id !== 'all') return null; return ( ); })}
{/* Pinned strip — only when there are pins and we're not already filtering by pinned */} {pinned.size > 0 && filter !== 'pinned' && (
Pinned {INSIGHTS.filter(i => pinned.has(i.id)).map(ins => ( ))}
)} {/* TASKS view — flat list of every bullet across insights */} {viewMode === 'tasks' && (() => { const allTasks = filtered.flatMap(ins => ins.bullets.map((b, idx) => ({ ins, idx, text: b, status: taskStatusOf(ins.id, idx), }))); const statusOrder = { open: 0, 'in-progress': 1, completed: 2, abandoned: 3 }; const sorted = [...allTasks].sort((a, b) => statusOrder[a.status] - statusOrder[b.status]); if (sorted.length === 0) { return (
No tasks match this filter
); } return (
{sorted.map(({ ins, idx, text, status }) => { const meta = TASK_STATUSES.find(s => s.id === status); const menuKey = `tv::${ins.id}::${idx}`; const menuOpen = openStatusMenu === menuKey; return (
{menuOpen && (
setOpenStatusMenu(null)}> {TASK_STATUSES.map(s => ( ))}
)}
); })}
); })()} {/* CARDS view (default) */} {viewMode === 'cards' && (filtered.length === 0 ? (
No insights match this filter
) : (
{filtered.map(ins => { const isExpanded = expanded.has(ins.id); const isPinned = pinned.has(ins.id); const tier = confidenceTier(ins.confidence); const tint = CATEGORY_TINT[ins.category] || 'var(--canvas-surface-2)'; const fg = CATEGORY_FG[ins.category] || 'var(--canvas-accent)'; const rollup = insightRollup(ins); return (
{ins.title}
{/* Per-card progress (rolls up the bullet tasks) */} {rollup.total > 0 && (
{rollup.done}/{rollup.total} tasks {rollup.state === 'completed' && ✓ Resolved} {rollup.state === 'abandoned' && Abandoned} {rollup.state === 'in-progress' && In progress}
{rollup.inProg > 0 && } {rollup.abandoned > 0 && }
)}
{ins.summary}
    {ins.bullets.map((b, idx) => { const status = taskStatusOf(ins.id, idx); const meta = TASK_STATUSES.find(s => s.id === status); const menuOpen = openStatusMenu === `${ins.id}::${idx}`; return (
  • {menuOpen && (
    setOpenStatusMenu(null)}> {TASK_STATUSES.map(s => ( ))}
    )}
  • ); })}
{/* Detail panel — quotes from sources, only when expanded */} {isExpanded && ins.quotes && (
Source quotes · {ins.sources} {ins.sources === 1 ? 'source' : 'sources'}
{ins.quotes.map((q, i) => (
{q}
))}
)}
{ins.sources} updated {ins.updatedMinutesAgo}m ago
); })}
))} ); } // Small SVG ring used for the confidence indicator function ConfidenceRing({ value }) { const r = 14; const c = 2 * Math.PI * r; const dash = c * (1 - value / 100); const tier = confidenceTier(value); const color = tier === 'high' ? '#10B981' : tier === 'med' ? '#F59E0B' : '#DC2626'; return (
{value}
); } function PresetPicker({ onPick }) { return (
Start from a preset
Or skip and add widgets one at a time.
{WORKSPACE_PRESETS.map(p => ( ))}
); } function WorkspaceView({ openModal, layout, setLayout, widgetStates, setWidgetStates }) { const [dragId, setDragId] = useState(null); const [dragOverId, setDragOverId] = useState(null); const onDragStart = (id) => setDragId(id); const onDragOver = (id) => { if (id !== dragId) setDragOverId(id); }; const onDragEnd = () => { setDragId(null); setDragOverId(null); }; const onDrop = (targetId) => { if (!dragId || dragId === targetId) { onDragEnd(); return; } const next = [...layout]; const fromIdx = next.findIndex(w => w.id === dragId); const toIdx = next.findIndex(w => w.id === targetId); const [moved] = next.splice(fromIdx, 1); next.splice(toIdx, 0, moved); setLayout(next); onDragEnd(); }; const setWState = (type) => (updater) => { setWidgetStates(s => ({ ...s, [type]: typeof updater === 'function' ? updater(s[type]) : updater, })); }; const removeWidget = (id, label) => { openModal('confirm-remove', { label, onConfirm: () => { setLayout(l => l.filter(w => w.id !== id)); window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: label + ' removed', kind: 'success' } })); }, }); }; const resizeWidget = (id, size) => setLayout(l => l.map(w => w.id === id ? { ...w, size } : w)); // Each widget starts from scratch — fresh empty state, no demo content. const addWidget = (meta) => { const id = 'w-' + Date.now(); setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]); if (EMPTY_STATE[meta.type]) { setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) })); } }; const applyPreset = (preset) => { setLayout(preset.layout.map(w => ({ ...w }))); // Seed empty state for any widget types not already present const seeds = {}; preset.layout.forEach(w => { if (!widgetStates[w.type] && EMPTY_STATE[w.type]) { seeds[w.type] = JSON.parse(JSON.stringify(EMPTY_STATE[w.type])); } }); if (Object.keys(seeds).length) setWidgetStates(s => ({ ...s, ...seeds })); window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: `${preset.name} preset loaded`, kind: 'success' } })); }; const reset = () => { if (!window.confirm('Reset workspace? All widgets and content will be cleared.')) return; setLayout([]); setWidgetStates({}); localStorage.removeItem(LAYOUT_KEY); localStorage.removeItem(STATES_KEY); }; return ( <>

Workspace

{layout.length} widgets · {layout.filter(w => w.critic).length} anti-yes-man · drag headers to reorder, click size pill to resize
{layout.length === 0 && ( )}
{layout.length === 0 && (
Or build from scratch
)} {layout.map((w) => ( ))}
); } function ModalRouter({ modal, onClose }) { if (!modal) return null; const handleBackdropClick = (e) => { if (e.target === e.currentTarget) onClose(); }; let content = null; switch (modal.kind) { case 'palette': content = ; break; case 'add-citation': content = ; break; case 'add-task': content = ; break; case 'add-deadline': content = ; break; case 'log-words': content = ; break; case 'confirm-remove': content = ; break; case 'reviewer-2': content = ; break; case 'devils-advocate': content = ; break; case 'scope-realism': content = ; break; case 'reading-paper': content = ; break; case 'budget-item': content = ; break; case 'note': content = ; break; case 'habit': content = ; break; case 'goal': content = ; break; case 'meeting': content = ; break; case 'command': content = ; break; case 'global-search': content = ; break; default: return null; } return
{content}
; } function ToastStack() { const [toasts, setToasts] = useState([]); useEffect(() => { const handler = (e) => { const id = Date.now() + Math.random(); setToasts(t => [...t, { id, ...e.detail }]); setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 3500); }; window.addEventListener('canvas-toast', handler); return () => window.removeEventListener('canvas-toast', handler); }, []); return (
{toasts.map(t => (
{t.msg}
))}
); } const CanvasPage = ({ user, authToken, onNavigateToHome, onNavigateToChat, onSignOut }) => { const { theme, toggleTheme } = useTheme(); useAppConfig(); const [view, setView] = useState(() => localStorage.getItem(VIEW_KEY) || 'workspace'); const [modal, setModal] = useState(null); const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [tourForceShow, setTourForceShow] = useState(0); const [layout, setLayout] = useState(() => { try { const saved = localStorage.getItem(LAYOUT_KEY); return saved ? JSON.parse(saved) : DEFAULT_LAYOUT; } catch { return DEFAULT_LAYOUT; } }); const [widgetStates, setWidgetStates] = useState(() => { try { const saved = localStorage.getItem(STATES_KEY); return saved ? JSON.parse(saved) : {}; } catch { return {}; } }); useEffect(() => { localStorage.setItem(LAYOUT_KEY, JSON.stringify(layout)); }, [layout]); useEffect(() => { localStorage.setItem(STATES_KEY, JSON.stringify(widgetStates)); }, [widgetStates]); useEffect(() => { localStorage.setItem(VIEW_KEY, view); }, [view]); // Apply canvas theme attribute on body for scoped styling useEffect(() => { document.body.dataset.canvasTheme = theme; return () => { delete document.body.dataset.canvasTheme; }; }, [theme]); const openModal = useCallback((kind, data = {}) => setModal({ kind, data }), []); const closeModal = useCallback(() => setModal(null), []); const exportWorkspace = useCallback(() => { const data = { layout, states: widgetStates }; const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'canvas-workspace.json'; a.click(); window.dispatchEvent(new CustomEvent('canvas-toast', { detail: { msg: 'Workspace exported as JSON', kind: 'success' } })); }, [layout, widgetStates]); const openCommandPalette = useCallback(() => { openModal('command', { layout, onSetView: (v) => setView(v), onAddWidget: (meta) => { const id = 'w-' + Date.now(); setLayout(l => [...l, { id, type: meta.type, size: meta.defaultSize, critic: meta.critic }]); if (EMPTY_STATE[meta.type]) { setWidgetStates(s => ({ ...s, [meta.type]: JSON.parse(JSON.stringify(EMPTY_STATE[meta.type])) })); } }, onToggleTheme: toggleTheme, onExport: exportWorkspace, }); }, [openModal, layout, toggleTheme, exportWorkspace]); const openGlobalSearch = useCallback(() => { openModal('global-search', { states: widgetStates }); }, [openModal, widgetStates]); // Critic widgets dispatch `canvas-open-in-chat` when the user wants real LLM history. useEffect(() => { const handler = () => onNavigateToChat && onNavigateToChat(); window.addEventListener('canvas-open-in-chat', handler); return () => window.removeEventListener('canvas-open-in-chat', handler); }, [onNavigateToChat]); // Esc closes modal, ⌘K opens command palette, ⌘/ opens global content search, // ? opens the welcome tour for help (matches the icon in the topbar). useEffect(() => { const k = (e) => { if (e.key === 'Escape') closeModal(); if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); openCommandPalette(); } if ((e.metaKey || e.ctrlKey) && e.key === '/') { e.preventDefault(); openGlobalSearch(); } // ? key (Shift+/) — only when the user isn't typing in an input if (e.key === '?' && !['INPUT', 'TEXTAREA'].includes(e.target.tagName)) { e.preventDefault(); setTourForceShow(n => n + 1); } }; window.addEventListener('keydown', k); return () => window.removeEventListener('keydown', k); }, [closeModal, openCommandPalette, openGlobalSearch]); // Highlight a widget when picked from the sidebar const flashScrollTo = (selector) => { const el = document.querySelector(selector); if (!el) return; el.scrollIntoView({ block: 'center', behavior: 'smooth' }); el.style.boxShadow = '0 0 0 2px var(--canvas-accent), 0 0 24px var(--canvas-accent-glow)'; setTimeout(() => { el.style.boxShadow = ''; }, 1400); }; // Workspace: group widgets by category for the new sidebar const widgetGroups = useMemo(() => { const groups = {}; layout.forEach(w => { const meta = WIDGET_CATALOG.find(m => m.type === w.type); if (!meta) return; const cat = meta.cat; (groups[cat] ||= { id: cat, label: cat, items: [] }).items.push({ id: w.id, label: meta.name, icon: meta.icon, critic: meta.critic, onClick: () => flashScrollTo(`[data-widget-id="${w.id}"]`), }); }); // Order: critic last const order = ['research', 'writing', 'project', 'wellness', 'career', 'data', 'practical', 'critic']; return order.map(c => groups[c]).filter(Boolean); }, [layout]); // Insights: list of sections — Daniel's feedback said sidebar should show sections here const insightSections = useMemo(() => { let taskMap = {}; try { taskMap = JSON.parse(localStorage.getItem(TASK_STATUS_KEY) || '{}'); } catch { /* ignore */ } return INSIGHTS.map(ins => { const states = ins.bullets.map((_, idx) => taskMap[taskKey(ins.id, idx)] || 'open'); const done = states.filter(s => s === 'completed').length; return { id: ins.id, name: ins.title, icon: ins.icon, category: ins.category, confidence: ins.confidence, taskCount: ins.bullets.length, doneCount: done, onClick: () => flashScrollTo(`#insight-${ins.id}`), }; }); }, [view, layout, widgetStates]); // eslint-disable-line react-hooks/exhaustive-deps // Deliverables: list of projects with sections + history actions const deliverableProjects = useMemo(() => { try { const dStore = JSON.parse(localStorage.getItem('canvas-deliverables-v2') || '{}'); const projects = Object.values(dStore.projects || {}); return projects.map(p => { const t = DELIVERABLE_TEMPLATES.find(x => x.id === p.templateId); return { id: p.id, name: p.name, icon: t?.icon || 'book', versions: p.versions?.length || 0, isActive: p.id === dStore.activeProjectId, sections: (t?.sections || []).map(s => ({ id: s.id, name: s.name, wc: ((p.sections || {})[s.id] || '').trim().split(/\s+/).filter(Boolean).length, onClick: () => { if (p.id !== dStore.activeProjectId) { // Open this project first; section scroll happens after a tick. const next = { ...dStore, activeProjectId: p.id }; localStorage.setItem('canvas-deliverables-v2', JSON.stringify(next)); window.dispatchEvent(new Event('storage')); } setTimeout(() => flashScrollTo(`#notion-section-${s.id}`), 80); }, })), onOpen: () => { const next = { ...dStore, activeProjectId: p.id }; localStorage.setItem('canvas-deliverables-v2', JSON.stringify(next)); window.dispatchEvent(new Event('storage')); }, }; }); } catch { return []; } // re-derive when view or layout changes (layout proxy for "user did something") }, [view, layout, widgetStates]); // eslint-disable-line react-hooks/exhaustive-deps return (
{}} onSelectSession={(id) => onNavigateToChat && onNavigateToChat(id)} onNewChat={() => onNavigateToChat && onNavigateToChat()} pageContext="canvas" canvasSubview={view} widgetGroups={widgetGroups} deliverableProjects={deliverableProjects} insightSections={insightSections} />
setView(v || 'workspace')} onMobileMenu={() => setIsMobileMenuOpen(true)} >
{view === 'insights' && } {view === 'workspace' && } {view === 'deliverables' && }
0}/>
); }; // Subtle floating hint bar showing the most-used keyboard shortcuts. // Auto-hides on small screens and after the first 12s, until the user hovers. function ShortcutHint() { const [visible, setVisible] = useState(true); useEffect(() => { const t = setTimeout(() => setVisible(false), 12000); return () => clearTimeout(t); }, []); return (
setVisible(true)} onMouseLeave={() => setVisible(false)} > {MOD}K commands {MOD}/ search ? help
); } export default CanvasPage;