;
}
}
}
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}
onRemove(widget.id, meta.name)} title="Remove">
{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
{refreshing ?
: }
Refresh
{/* View toggle: Cards (sections with their tasks) vs Tasks (flat list) */}
setViewMode('cards')}>
Cards
setViewMode('tasks')}>
Tasks
{/* 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 (
setFilter(c.id)}>
{c.label}{count}
);
})}
setSortBy(e.target.value)}>
↓ confidence
↓ recent
↓ progress
{/* 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 => (
{
const el = document.getElementById(`insight-${ins.id}`);
if (el) {
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);
}
}}>
{ins.title}
))}
)}
{/* 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
setFilter('all')}>Show all
);
}
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 (
setOpenStatusMenu(menuOpen ? null : menuKey)}
title={`Status: ${meta.label}`}
>
{status === 'completed' && }
{status === 'in-progress' && }
{status === 'abandoned' && }
{
setViewMode('cards');
setTimeout(() => {
const el = document.getElementById(`insight-${ins.id}`);
if (el) el.scrollIntoView({ block: 'center', behavior: 'smooth' });
}, 60);
}}
title="Jump to this section"
>
{ins.title}
sendToKanban(ins)} title="Send this section's open tasks to Kanban">
Kanban
{menuOpen && (
setOpenStatusMenu(null)}>
{TASK_STATUSES.map(s => (
{ setTaskStatus(ins.id, idx, s.id); setOpenStatusMenu(null); }}>
{s.label}
))}
)}
);
})}
);
})()}
{/* CARDS view (default) */}
{viewMode === 'cards' && (filtered.length === 0 ? (
No insights match this filter
setFilter('all')}>Show all
) : (
{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 (
{/* 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 && }
)}
{/* 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
askFollowUp(ins)} title="Open this insight in a new chat session">
Ask follow-up
sendToKanban(ins)} title="Add all open tasks from this insight to your Kanban (To Do)">
Add to Kanban
toggleExpand(ins.id)}>
{isExpanded ? 'Collapse' : 'Source quotes'}
togglePin(ins.id)}>
{isPinned ? 'Pinned' : 'Pin'}
);
})}
))}
>
);
}
// 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 => (
onPick(p)}>
{p.name}
{p.desc}
{p.layout.length} widgets
))}
);
}
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
Reset
openModal('palette', {
layout, onAdd: addWidget,
})}>Add widget
{layout.length === 0 && (
)}
{layout.length === 0 && (
Or build from scratch
openModal('palette', { layout, onAdd: addWidget })} style={{ marginTop: 6 }}>
Add your first widget
)}
{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)}
>
setTourForceShow(n => n + 1)} title="Show tour">
{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;