// Proxa × Cascade Ridge Foods — responsive Hub page POC (org display name = "Cascade Ridge Foods"; sample data models the fictional Cascade Ridge Foods).
const { useState, useRef, useEffect } = React;
// Workspaces (hub groups) inside the active organization ("Cascade Ridge Foods").
// Each workspace is a collapsible list of hubs; workspaces have no page of
// their own. Hub content is poured per the Cascade Ridge Playbook.
const WORKSPACES = [
{ key: 'department', label: 'Department', abbr: 'DEPT', hubs: ['Finance', 'Contracts'] },
{ key: 'projects', label: 'Projects', abbr: 'PROJ', hubs: ['Diligence'] },
{ key: 'assets', label: 'Assets', abbr: 'BLDG', hubs: ['Tenney North Park'] },
];
// Two-letter code shown in the collapsed rail. Multi-word names take the
// initial of the first two words (Evergreen Basin -> EB); single words take
// their first two letters (Finance -> FI).
function hubAbbr(name) {
const w = name.trim().split(/\s+/);
return (w.length >= 2 ? w[0][0] + w[1][0] : name.slice(0, 2)).toUpperCase();
}
// Display names decoupled from the internal hub id (which many branches key
// off). Rename the shown label without touching the identifier 'Finance'.
const HUB_LABELS = { Finance: 'Finance', Contracts: 'Customer' };
// hubAbbr() would make "Studies & Cohorts" into "S&" — spell the lab hubs out.
const HUB_ABBRS = { Finance: 'FI', Contracts: 'CU', Typing: 'TY', 'Studies & Cohorts': 'SC' };
function hubLabel(name) { return HUB_LABELS[name] || name; }
function hubAbbrOf(name) { return HUB_ABBRS[name] || hubAbbr(hubLabel(name)); }
const DIVISIONS = ['CRF2'];
// Tiles on the hub home page; each one behaves as a tab over the panel below.
const HUB_TILES = [
// Artifacts tile = Dashboard + Slides, switched by pills inside the panel.
{ key: 'outputs', label: 'Artifacts', count: '12 sources', Icon: IconArtifact },
{ key: 'collections', label: 'Projects', count: '—', Icon: IconStack },
{ key: 'model', label: 'Model', count: '—', Icon: IconChip },
{ key: 'control', label: 'Control', count: '14 updates', Icon: IconSliders },
{ key: 'library', label: 'Library', count: '526 items', Icon: IconLibrary },
// Tools = Calendar + Lists, switched by pills inside the panel.
{ key: 'tools', label: 'Tools', count: '—', Icon: IconTools }];
// Per-hub tile count overrides.
const HUB_TILE_COUNTS = {
'Tenney North Park': {
dashboard: '6 sources',
control: '4 updates',
library: '23 items',
calendar: '19 tasks · 4 milestones',
settings: '4 members',
},
Finance: {
dashboard: '11 products',
model: '3-statement',
control: '3 updates',
library: '52 items',
calendar: 'Jul-26 close',
},
};
// Per-hub tile label overrides — TN's Calendar tile is the Project DP.
const HUB_TILE_LABELS = {
'Tenney North Park': { calendar: 'Project' },
};
function tileLabel(tile, hub) {
const o = HUB_TILE_LABELS[hub];
return (o && o[tile.key]) || tile.label;
}
function tileCount(tile, hub) {
const o = HUB_TILE_COUNTS[hub];
if (o && o[tile.key]) return o[tile.key];
return tile.count;
}
/* ============ Desktop nav rail ============ */
function Rail({ expanded, onToggleExpand, activeHub, onPickHub, onOpenGraph, graphOpen, onOpenAbout, aboutOpen, onGoHome, onOpenWorkspace, workspaceOpen, createdHubs = [], onCreateHub, groups = WORKSPACES, workspaceName, onOpenLanding, mark }) {
// Workspace groups are all expanded by default.
const [openGroups, setOpenGroups] = useState(() =>
Object.fromEntries(WORKSPACES.map((w) => [w.key, true])));
const toggleGroup = (k) => setOpenGroups((g) => ({ ...g, [k]: !g[k] }));
const pick = (h) => { onPickHub(h); if (onGoHome) onGoHome(); };
/* ----- Collapsed icon rail (default) ----- */
if (!expanded) {
return (
);
}
/* ----- Expanded panel ----- */
return (
);
}
/* ============ Brand wordmark ============ */
function BrandWordmark({ hub }) {
if (hub === 'Tenney North Park') {
return (
);
}
return (
);
}
/* ============ Hub home — ported tab pages (Control / Library / Calendar /
Settings) rendered as isolated documents to avoid CSS/JS collisions with
the host app (both use a `.su-*` source-updates vocabulary). ============ */
const HUB_TAB_KEYS = ['control', 'library', 'calendar', 'settings'];
function HubTabContent({ tabKey, activeHub }) {
const ref = useRef(null);
useEffect(() => {
const host = ref.current;
if (!host) return;
const html =
(activeHub === 'Tenney North Park' && window.TN_TAB_HTML && window.TN_TAB_HTML[tabKey]) ||
(activeHub === 'Contracts' && window.CONTRACTS_TAB_HTML && window.CONTRACTS_TAB_HTML[tabKey]) ||
(window.HUB_TAB_HTML && window.HUB_TAB_HTML[tabKey]) || '';
host.innerHTML = html;
const portal = document.getElementById('hubx-portal');
if (tabKey === 'library' && window.ProxaLibrary) {
window.ProxaLibrary.init(host.querySelector('#sec-library'));
}
if (tabKey === 'settings') {
// Reflect the active hub into the profile fields (image / name /
// description) so Settings matches the hub the user is in.
const meta = HUB_META[activeHub] || {};
const name = meta.title || activeHub;
const desc = meta.desc || '';
const tile = host.querySelector('#logoTile');
if (tile) { tile.classList.remove('empty'); tile.innerHTML = '
' + hubAbbr(activeHub) + '
'; }
const nm = host.querySelector('#hubName'); if (nm) nm.value = name;
const ds = host.querySelector('#hubDesc'); if (ds) ds.value = desc;
const ft = host.querySelector('#hubFootName'); if (ft) ft.textContent = name;
// Move the fixed-position modal out to the portal so it escapes the
// hub's container-type containing block and anchors to the viewport.
if (portal) ['addScrim'].forEach((id) => {
const m = host.querySelector('#' + id);
if (m) portal.appendChild(m);
});
if (window.renderUsers) window.renderUsers();
if (window.updateDescCount) window.updateDescCount();
}
return () => {
try { if (window.closeSourceUpdates) window.closeSourceUpdates(); } catch (e) {}
try { if (window.closeRuns) window.closeRuns(); } catch (e) {}
if (portal) ['addScrim'].forEach((id) => {
const m = portal.querySelector('#' + id);
if (m) m.remove();
});
document.body.style.overflow = '';
};
}, [tabKey, activeHub]);
return ;
}
/* ============ Settings drawer ============ */
// Hub Settings lives in a right-side slide-over drawer (not a full tab page),
// opened by the gear in the hub header. Native, drawer-width layout: a single
// column of the fields that actually matter for a hub's identity.
function SettingsForm({ activeHub, workspace, onClose }) {
// `workspace` (a {name, empty} object) switches the form to workspace
// settings — same layout, workspace identity instead of hub identity.
const meta = workspace ? {} : (HUB_META[activeHub] || {});
const [name, setName] = useState(workspace ? workspace.name : (meta.title || activeHub));
const kindLabel = workspace ? 'Workspace' : 'Hub';
// Styling uses the CX drawer's own CSS vars so this reads as the same surface
// as a data-product drawer.
const inputStyle = {
width: '100%', boxSizing: 'border-box', fontFamily: 'inherit', fontSize: 14, color: 'var(--foreground)',
padding: '10px 12px', border: '1px solid var(--border)', borderRadius: 8, background: 'var(--background)', outline: 'none',
};
const Label = ({ children, help }) => (
);
return (
<>
>
);
}
/* ============ Mobile bottom tab bar ============ */
function TabBar({ active, setActive, onOpenAgentChat, chatOpen, onOpenHubMenu, onCloseHubMenu, hubMenuOpen }) {
return (
);
}
/* ============ Floating chat bubble ============ */
function ChatBubble({ open, onToggle, side }) {
return (
);
}
/* ============ App ============ */
function App() {
const [tab, setTab] = useState('hub');
// 'landing' | 'project' | 'collection' | 'record' | 'photo' | 'graph' | 'workspace' | 'about'
const [view, setView] = useState('landing'); // the POC opens on the landing page
const [railExpanded, setRailExpanded] = useState(false); // collapsed icon rail by default
// Active hub persists across reloads (default: first hub in the rail)
const [activeHub, setActiveHubRaw] = useState(() => {
// validate the stored hub against the current rail (stale names from
// earlier org skins fall back to the first hub)
// Every built workspace's hubs, so a lab hub survives a reload too.
const ALL_HUBS = WORKSPACES.flatMap((w) => w.hubs)
.concat(((window.PROXA_DOMAINS || {}).lab?.built?.rail || []).flatMap((w) => w.hubs));
try {
const stored = localStorage.getItem('proxa-active-hub');
return ALL_HUBS.includes(stored) ? stored : ALL_HUBS[0];
} catch (e) { return ALL_HUBS[0]; }
});
window.__ACTIVE_HUB = activeHub;
const setActiveHub = (h) => {
setActiveHubRaw(h);
window.__ACTIVE_HUB = typeof h === 'string' ? h : '';
try { localStorage.setItem('proxa-active-hub', typeof h === 'string' ? h : ''); } catch (e) {}
};
// New Hub workflow (NEW-HUB.md): hubs created via `+` carry an onboarding flow
// { step, template }. Since the `+` now lands on a real empty hub shell (the
// empty state IS the product), EVERY created hub persists across reloads —
// including one still at step 'template' (it reopens on its empty state).
const [createdHubs, setCreatedHubs] = useState(() => {
try { return Array.from(new Set(JSON.parse(localStorage.getItem('proxa.createdHubs') || '[]'))); } catch (e) { return []; }
});
// Workspaces — Cascade Ridge is the built one; "New workspace" is an empty
// first-time-user org (no hubs in the rail, empty tabs).
// Two built workspaces: Cascade Ridge (a company) and the HLA Typing Lab (a
// research lab). Both are real — same rail, tabs and hub shell; the lab is
// there so the prototype isn't only a business.
const [workspaces, setWorkspaces] = useState([
{ id: 'cascade', name: 'Cascade Ridge Foods', builtIn: true },
{ id: 'lab', name: 'HLA Typing Lab', builtIn: true },
]);
const [activeWorkspace, setActiveWorkspace] = useState('cascade');
const curWorkspace = workspaces.find((w) => w.id === activeWorkspace) || workspaces[0];
const wsIsEmpty = !!curWorkspace.empty;
// Draft hubs for NEW workspaces live per-workspace (session-only); the built
// Cascade Ridge workspace keeps using the persisted `createdHubs` list.
const isCascade = activeWorkspace === 'cascade';
const [wsDraftHubs, setWsDraftHubs] = useState({});
const activeDrafts = isCascade ? createdHubs : (wsDraftHubs[activeWorkspace] || []);
const addDraft = (name) => {
if (isCascade) setCreatedHubs((hs) => [...hs, name]);
else setWsDraftHubs((m) => ({ ...m, [activeWorkspace]: [...(m[activeWorkspace] || []), name] }));
};
const renameDraft = (from, to) => {
if (isCascade) setCreatedHubs((hs) => hs.map((h) => (h === from ? to : h)));
else setWsDraftHubs((m) => ({ ...m, [activeWorkspace]: (m[activeWorkspace] || []).map((h) => (h === from ? to : h)) }));
};
const removeDraft = (name) => {
if (isCascade) setCreatedHubs((hs) => hs.filter((h) => h !== name));
else setWsDraftHubs((m) => ({ ...m, [activeWorkspace]: (m[activeWorkspace] || []).filter((h) => h !== name) }));
};
// What each workspace is modelling (domains.jsx). Cascade Ridge is a company;
// a new workspace has none until the user answers "What are you modelling?".
const [wsDomains, setWsDomains] = useState({ cascade: 'company', lab: 'lab' });
const activeDomain = wsDomains[activeWorkspace];
const domPack = window.proxaDomain ? window.proxaDomain(activeDomain) : null;
// Read by components that aren't passed the domain (the mobile hub sheet).
window.__ACTIVE_DOMAIN = activeDomain;
const pickDomain = (key) => setWsDomains((m) => ({ ...m, [activeWorkspace]: key }));
const switchWorkspace = (id) => { setActiveWorkspace(id); setView('workspace'); };
// A new workspace is empty and domain-less — you answer "What are you
// modelling?" inside it. Passing a domain (and name) skips that step, which
// is how the landing page links straight to a seeded example.
const createWorkspace = (domainKey, name) => {
const id = 'ws-' + (workspaces.length + 1);
const pack = domainKey && window.proxaDomain ? window.proxaDomain(domainKey) : null;
setWorkspaces((list) => [...list, { id, name: name || (pack && pack.orgName) || 'New Workspace', empty: true }]);
if (domainKey) setWsDomains((m) => ({ ...m, [id]: domainKey }));
setActiveWorkspace(id);
setView('workspace');
};
// Remove a created workspace (the built-in Cascade Ridge one can't be
// removed) and fall back to Cascade Ridge if it was active.
const removeWorkspace = (id) => {
if ((workspaces.find((w) => w.id === id) || {}).builtIn) return;
setWorkspaces((list) => list.filter((w) => w.id !== id));
setWsDraftHubs((m) => { const nm = { ...m }; delete nm[id]; return nm; });
setActiveWorkspace((cur) => (cur === id ? 'cascade' : cur));
};
const [hubFlow, setHubFlow] = useState(() => {
try { return JSON.parse(localStorage.getItem('proxa.hubFlow') || '{}'); } catch (e) { return {}; }
});
useEffect(() => {
try {
const hubs = Array.from(new Set(createdHubs));
const flow = {};
hubs.forEach((h) => { if (hubFlow[h]) flow[h] = hubFlow[h]; });
localStorage.setItem('proxa.createdHubs', JSON.stringify(hubs));
localStorage.setItem('proxa.hubFlow', JSON.stringify(flow));
} catch (e) {}
}, [createdHubs, hubFlow]);
const createHub = () => {
const taken = new Set([...WORKSPACES.flatMap((w) => w.hubs), ...activeDrafts]);
let name = 'New Hub', n = 2;
while (taken.has(name)) { name = `New Hub ${n}`; n += 1; }
addDraft(name);
setHubFlow((f) => ({ ...f, [name]: { step: 'template', template: null } }));
setView('project');
// New create-from-scratch entry: land on the "What should we build?" page
// (rendered while flow.step === 'template'), not the Settings drawer.
setTileTab('');
setActiveHub(name);
setSettingsOpen(false);
};
const pickTemplate = (templateId) => {
if (templateId === '__create__') return; // "Create New Template" — stub for now
// Diligence: rename the freshly-created hub to "Diligence" (it has real
// identity + a dashboard) and enter its upload flow.
if (templateId === 'diligence') {
const from = activeHub;
// Keep a single Diligence hub: if one already exists, drop the fresh
// placeholder and switch to it instead of creating a duplicate.
if (activeDrafts.includes('Diligence') && from !== 'Diligence') {
removeDraft(from);
setHubFlow((f) => { const nf = { ...f }; delete nf[from]; return nf; });
setActiveHub('Diligence');
return;
}
renameDraft(from, 'Diligence');
setHubFlow((f) => {
const nf = { ...f }; delete nf[from];
nf['Diligence'] = { step: 'upload', template: 'diligence' };
return nf;
});
setActiveHub('Diligence');
return;
}
// Model templates (finance, contracts, …): the hub is created, and you land
// on Model → Context showing the template's GENERAL context (the cookbook),
// with the assistant open telling you the next step — upload your model.
applyTemplate(activeHub, templateId);
};
// Shared tail of the template pick: flip `hub` to the built step, land on
// Model → Context and open the assistant with the next-step message.
const applyTemplate = (hub, templateId) => {
const tplName = (templateId || 'hub').charAt(0).toUpperCase() + (templateId || 'hub').slice(1);
setHubFlow((f) => ({ ...f, [hub]: { ...(f[hub] || {}), step: 'built', template: templateId, title: `${tplName} Hub`, modeled: false } }));
setSettingsOpen(false);
setTileTab('model');
setModelView('context');
setChatAgent('knowledge');
userPickedAgentRef.current = true;
setAssistantWide(false);
setOnboardMsg([
`Your ${tplName} hub is set up from the ${tplName} template — you're on its `,
{ bold: 'Context' },
` page. The `,
{ bold: 'General' },
` context is the cookbook: what a ${tplName} hub is and how it's normally built. It has no `,
{ bold: 'Specific' },
` context yet — that's derived from your model.`,
{ br: true }, { br: true },
`Next step: upload ${templateId === 'finance' ? 'your financial model spreadsheet' : (window.proxaDomain(activeDomain).sourceNoun || 'your source files')} and I'll instantiate each general item into your specifics, for you to validate.`,
]);
setChatOpen(true);
};
// Empty-workspace template card → actually build the hub: create a draft in
// the ACTIVE workspace named after the template, then run the template flow.
const buildFromTemplate = (templateId, name) => {
const base = name || (templateId || 'Hub').charAt(0).toUpperCase() + (templateId || 'Hub').slice(1);
const taken = new Set([...WORKSPACES.flatMap((w) => w.hubs), ...activeDrafts]);
let hub = base, n = 2;
while (taken.has(hub)) { hub = `${base} ${n}`; n += 1; }
addDraft(hub);
setActiveHub(hub);
setView('project');
applyTemplate(hub, templateId);
};
const advanceStep = (step) => {
setHubFlow((f) => ({ ...f, [activeHub]: { ...(f[activeHub] || {}), step } }));
};
// Simulate the user uploading their model: Claude "reads" it, builds the data
// products and derives Specific context (as Draft). Flips the hub to modeled.
const simulateUpload = () => {
const hub = activeHub;
setHubFlow((f) => ({ ...f, [hub]: { ...(f[hub] || {}), modeled: true } }));
setTileTab('model');
setModelView('context');
setChatAgent('knowledge');
userPickedAgentRef.current = true;
setAssistantWide(false);
const pack = window.proxaDomain(activeDomain);
const firstFile = (pack.files && pack.files[0] && pack.files[0].name) || 'your files';
setOnboardMsg([
`Read your sources — `, { bold: firstFile }, `. I built the data products and derived `,
{ bold: '4 Specific context items' }, `, each instantiating a General cookbook item.`,
{ br: true }, { br: true },
`They're on the `, { bold: 'Context' }, ` page as `, { bold: 'Draft' }, ` — review each and `, { bold: 'Validate' },
` to pin it as memory. Your model is now in `, { bold: 'Data' }, `.`,
]);
setChatOpen(true);
};
const [tileTab, setTileTab] = useState('outputs'); // hub-home active section tab
const [modelView, setModelView] = useState('data'); // Model section sub-view: 'data' | 'context' | 'repo'
const [onboardMsg, setOnboardMsg] = useState(null); // assistant instruction seeded on template-pick
const [settingsOpen, setSettingsOpen] = useState(false); // Settings slide-over drawer
const [settingsScope, setSettingsScope] = useState('hub'); // 'hub' | 'workspace' — what the drawer shows
const [photoId, setPhotoId] = useState(null);
const [photoFrom, setPhotoFrom] = useState('collection'); // 'collection' | 'record'
const [collectionTab, setCollectionTab] = useState('overview'); // remembered for back nav
const [chatOpen, setChatOpen] = useState(false);
const [hubMenuOpen, setHubMenuOpen] = useState(false);
// Prototype: how the assistant presents on desktop — 'dock' (side column) or
// 'beneath' (page peels up to reveal the assistant surface underneath).
const [assistantMode, setAssistantMode] = useState('beneath');
// Beneath surface is dual-width: narrow by default (quick chat, page barely
// disturbed) and wide when a bigger canvas is needed (user expands it, or the
// assistant expands it for output like a generated dashboard).
const [assistantWide, setAssistantWide] = useState(false);
const [chatWidth, setChatWidth] = useState(440); // 375..700
const [chatAgent, setChatAgent] = useState('knowledge');
const [pendingChatPrompt, setPendingChatPrompt] = useState('');
const [pendingSkill, setPendingSkill] = useState(null);
const [pendingTask, setPendingTask] = useState(null);
const userPickedAgentRef = useRef(false);
// newRecord: when set, the record view is rendered in its empty-state form
// (title editable, single "New output" chip, no artifact yet). null when
// viewing an existing record.
const [newRecord, setNewRecord] = useState(null); // null | { name }
// Dashboard-generation simulation (triggered from the "Create a project
// dashboard from hub knowledge base" prompt in the empty-record state).
const [genActive, setGenActive] = useState(false);
const [genStageIdx, setGenStageIdx] = useState(0);
// True once the agent has finished generating the dashboard for the current
// record session. Resets when the user navigates away or starts a fresh
// record. Drives the "single Dashboard chip, 0 comments, no source updates"
// appearance and lets the "+" on the output row create new outputs.
const [agentGenerated, setAgentGenerated] = useState(false);
// True once a template has been inserted into the record as an editable
// artifact via the templates empty-state "Create empty" action.
const [templateArtifact, setTemplateArtifact] = useState(false);
const genTimersRef = useRef([]);
const [isMobile, setIsMobile] = useState(typeof window !== 'undefined' && window.matchMedia('(max-width: 1024px)').matches);
useEffect(() => {
const mq = window.matchMedia('(max-width: 1024px)');
const onChange = () => setIsMobile(mq.matches);
mq.addEventListener('change', onChange);
return () => mq.removeEventListener('change', onChange);
}, []);
// Auto-switch chat agent based on the current view (until user manually picks).
useEffect(() => {
if (userPickedAgentRef.current) return;
if (view === 'record') {
setChatAgent('node');
} else {
setChatAgent('knowledge');
}
}, [view]);
// Clear new-record state when navigating away from the record view
useEffect(() => {
if (view !== 'record' && newRecord) setNewRecord(null);
if (view !== 'record' && genActive) {
clearGenTimers();
setGenActive(false);
setGenStageIdx(0);
}
if (view !== 'record' && agentGenerated) setAgentGenerated(false);
if (view !== 'record' && templateArtifact) setTemplateArtifact(false);
}, [view]);
// Triggered from the "+" button on any of the file tables
const handleCreateRecord = () => {
setNewRecord({ name: 'New Record' });
setAgentGenerated(false);
setTemplateArtifact(false);
userPickedAgentRef.current = false; // let auto-switch flip to Node Agent
setView('record');
};
// Triggered from the empty-state "AI Agent" card (with a prompt → force the
// Data Product agent) or the header Agent bubble (bare → Knowledge Base).
const handleOpenAgentChat = (text) => {
const hasPrompt = typeof text === 'string' && text.trim();
setHubMenuOpen(false);
setChatAgent(hasPrompt ? 'node' : 'knowledge');
userPickedAgentRef.current = true;
setChatOpen(true);
if (hasPrompt) {
setPendingChatPrompt(text.trim());
}
// Special path: dashboard-generation prompt → simulate the agent building
// the Cedar Narrows WST Project Overview dashboard. Stage labels match what the
// chat panel streams, so the placeholder in the record area and the
// assistant "thinking" line in chat stay in sync.
if (typeof text === 'string' && text.trim() === window.DASHBOARD_GEN_PROMPT) {
startDashboardGeneration();
}
};
// Empty-workspace "Add …" CTAs (Hubs/Team/Org/Context/About) open the agent
// with a walkthrough for that section — the same guided pattern as picking a
// template, just starting from nothing instead of a known shape.
const WS_GUIDE_MSGS = {
hub: [
`Let's build your first hub. Tell me what you would like to model — or upload a website, spreadsheet, report or docs — and I'll propose the hub and its `,
{ bold: 'data structure' }, ` so we can then iterate.`,
{ br: true }, { br: true },
`Or pick one of the `, { bold: 'Templates' }, ` rather than starting from scratch.`,
],
team: [
`Let's add your team. Paste names and emails — or upload a roster, a directory export or a paper's author list — and I'll set up members with suggested roles (`,
{ bold: 'Owner' }, `, `, { bold: 'Admin' }, `, `, { bold: 'Editor' }, `, `, { bold: 'Viewer' }, `) so we can then iterate.`,
],
org: [
`Let's draft your structure. Upload a website, a slide, a sheet or even a photo of a napkin sketch — I'll extract the groups, leads and reporting lines into a chart we can iterate on.`,
],
context: [
`Let's capture shared context — the judgment your data can't hold. Tell me the ground rules every hub should honor (definitions, conventions, priorities) and I'll organize them as context items for you to `,
{ bold: 'validate' }, `.`,
],
about: [
`Let's write your About. Give me a sentence or two on what this workspace models and who it serves — I'll draft a short doc anyone new can read, and we can iterate from there.`,
],
};
const guideWorkspaceAdd = (kind) => {
setHubMenuOpen(false);
setChatAgent('knowledge');
userPickedAgentRef.current = true;
setAssistantWide(false);
setOnboardMsg(WS_GUIDE_MSGS[kind] || WS_GUIDE_MSGS.hub);
setChatOpen(true);
};
// Clicking a Planned/Template hub asks the assistant to build it.
const handleProposeHub = (hubName) => {
setHubMenuOpen(false);
setChatAgent('knowledge');
userPickedAgentRef.current = true;
setAssistantWide(false);
setOnboardMsg([
`Would you like me to create the `, { bold: `${hubName} hub` }, `?`,
{ br: true }, { br: true },
`I'll set it up from your model — pulling in the relevant groups, owners and sources — then propose its data structure so we can iterate.`,
]);
setChatOpen(true);
};
const clearGenTimers = () => {
genTimersRef.current.forEach((id) => clearTimeout(id));
genTimersRef.current = [];
};
const startDashboardGeneration = () => {
clearGenTimers();
setGenActive(true);
setGenStageIdx(0);
setTemplateArtifact(false);
const stages = window.DASHBOARD_GEN_STAGES || [];
// Stage timing: most stages ~1.3s, the "composing" final stage a touch
// longer so it feels deliberate. Total budget ≈ 9s.
const stageDurations = [1100, 1300, 1400, 1500, 1400, 1700];
let elapsed = 0;
stages.forEach((_, i) => {
if (i === 0) return; // first stage shown immediately
elapsed += stageDurations[i - 1] || 1300;
const id = setTimeout(() => setGenStageIdx(i), elapsed);
genTimersRef.current.push(id);
});
// Final transition: drop newRecord so the dashboard renders, name + output
// chip switch automatically (record name is hardcoded on the non-empty
// header, output defaults to 'dashboard'). Mark the record as agent-
// generated so the chrome (output chips, comments count, source updates
// indicator) reflects a brand-new artifact.
elapsed += stageDurations[stages.length - 1] || 1500;
const finishId = setTimeout(() => {
setGenActive(false);
setNewRecord(null);
setGenStageIdx(0);
setAgentGenerated(true);
}, elapsed);
genTimersRef.current.push(finishId);
};
useEffect(() => () => clearGenTimers(), []);
// Expose a global so the Control tab's Skills table (plain-HTML, injected via
// hub_tab_content.js) can open the chat with a /skill mention pre-inserted in
// the composer — mirroring how typing "/" picks a skill.
useEffect(() => {
window.hubOpenSkill = (name, mode, id) => {
setChatAgent('knowledge');
userPickedAgentRef.current = true;
setChatOpen(true);
setPendingSkill({ name: name, mode: mode || 'use', id: id || '', nonce: Date.now() });
};
return () => { delete window.hubOpenSkill; };
}, []);
// Control · Tasks: the plain-HTML task table (control_sections.js) calls this
// on a row click to open the chat and run that task's scripted flow.
useEffect(() => {
window.hubOpenTask = (id) => {
setChatAgent('knowledge');
userPickedAgentRef.current = true;
setChatOpen(true);
setPendingTask({ id: id, nonce: Date.now() });
};
return () => { delete window.hubOpenTask; };
}, []);
// Template flow: "+ Add → upload files" in the preview modal → reuse the
// agent dashboard-generation loader, ending on the real Cedar Narrows dashboard.
const handleTemplateGenerate = () => {
startDashboardGeneration();
};
// Node-agent chat prompt ("Build a mobile dashboard…") fired from within an
// open empty record → build the Cedar Narrows dashboard from the hub's knowledge
// base. Returns true if generation was started (i.e. a record is open) so the
// chat panel can pick the matching "generating" response.
const handleAgentDashboardGen = () => {
if (!newRecord) return false;
setAssistantWide(true); // generated dashboard needs the larger canvas
startDashboardGeneration();
return true;
};
// Template flow: "Create empty" → insert the template as an editable artifact
// into the (still empty) record shell, naming the record after the template.
const handleTemplateCreateEmpty = (tpl) => {
setTemplateArtifact(true);
if (tpl && tpl.title) setNewRecord((r) => (r ? { ...r, name: tpl.title } : { name: tpl.title }));
};
const handleAgentChange = (next) => {
userPickedAgentRef.current = true;
setChatAgent(next);
};
const pickHub = (h) => {
setActiveHub(h);
setView('project');
setHubMenuOpen(false);
};
const handleOpenHubMenu = () => {
setChatOpen(false);
setAssistantWide(false);
setHubMenuOpen((o) => !o);
};
const railProps = {
expanded: railExpanded,
onToggleExpand: () => setRailExpanded((o) => !o),
activeHub,
onPickHub: setActiveHub,
onOpenGraph: () => setView('graph'),
graphOpen: view === 'graph',
onOpenAbout: () => setView('about'),
aboutOpen: view === 'about',
onGoHome: () => setView('project'),
onOpenWorkspace: () => setView('workspace'),
workspaceOpen: view === 'workspace',
createdHubs: activeDrafts,
onCreateHub: createHub,
// Rail sections follow the active workspace: Cascade Ridge's are the
// module-level WORKSPACES; a built domain pack (the lab) brings its own.
groups: wsIsEmpty ? [] : ((domPack && domPack.built && domPack.built.rail) || WORKSPACES),
workspaceName: curWorkspace.name,
mark: window.proxaMark ? window.proxaMark(activeDomain) : null,
onOpenLanding: () => setView('landing'),
};
const openPhoto = (from) => (id, tab) => {
setPhotoId(id);
setPhotoFrom(from);
if (from === 'collection' && tab) setCollectionTab(tab);
setView('photo');
};
// Citation click → navigate the main column to the cited record/collection
const openCite = (cite) => {
if (!cite) return;
if (cite.target === 'record') {
setView('record');
} else if (cite.target === 'collection') {
if (cite.tab) setCollectionTab(cite.tab);
setView('collection');
}
};
// CSS vars on the app shell drive the rail + chat column widths.
const docked = assistantMode === 'dock';
const beneathOpen = chatOpen && !isMobile && !docked;
const appStyle = {
'--rail-width': isMobile ? '0px' : (railExpanded ? '264px' : '68px'),
// Beneath mode reveals via depth, not a reserved column, so keep chat-width 0.
'--chat-width': (chatOpen && !isMobile && docked) ? `${chatWidth}px` : '0px',
// Fraction of the content area the page card keeps: wider page = narrower
// surface (0.74) by default; expanded surface leaves the page at 0.42.
'--peel-frac': assistantWide ? 0.42 : 0.74,
};
// NOTE: the beneath surface is a persistent, narrow surface you interact with
// alongside the page — so the page must stay fully clickable. (Earlier a
// click-on-page-to-close handler lived here; it swallowed page clicks like the
// upload button, so it was removed. Close the assistant via its X.)
const renderInner = () => {
if (view === 'workspace') {
return { setActiveHub(h); setView('project'); }}
onOpenSettings={() => { setSettingsScope('workspace'); setSettingsOpen(true); }}
onProposeHub={handleProposeHub}
onOpenAgentChat={handleOpenAgentChat}
workspaces={workspaces}
activeWorkspaceId={activeWorkspace}
onSwitchWorkspace={switchWorkspace}
onCreateWorkspace={createWorkspace}
onRemoveWorkspace={removeWorkspace}
onGuide={guideWorkspaceAdd}
draftHubs={activeDrafts}
onBuildFromTemplate={buildFromTemplate}
onCreateHub={createHub}
domain={activeDomain}
onPickDomain={pickDomain}
domainOf={(id) => wsDomains[id]} />;
}
if (view === 'graph') {
return (
setView('workspace')} domain={activeDomain} />
);
}
if (view === 'about') {
return setView('workspace')} domain={activeDomain} />;
}
if (view === 'colgraph') {
return (
setView('collection')} />
);
}
if (view === 'photo') {
return (
setView(photoFrom)} />
);
}
if (view === 'record') {
return (
setView('collection')}
onOpenPhoto={openPhoto('record')}
newRecord={newRecord}
setNewRecordName={(name) => setNewRecord((r) => r ? { ...r, name } : r)}
onOpenAgentChat={handleOpenAgentChat}
generating={genActive}
genStageIdx={genStageIdx}
genStages={window.DASHBOARD_GEN_STAGES}
agentGenerated={agentGenerated}
onTemplateGenerate={handleTemplateGenerate}
onTemplateCreateEmpty={handleTemplateCreateEmpty}
templateArtifact={templateArtifact} />
);
}
if (view === 'collection') {
return (
setView('project')}
onOpenRecord={() => setView('record')}
onCreateRecord={handleCreateRecord}
onOpenGraph={() => setView('colgraph')}
initialTab={collectionTab}
onTabChange={setCollectionTab}
onOpenPhoto={openPhoto('collection')} />
);
}
// New Hub workflow: a freshly created hub renders through the real hub shell
// (HubHome) as an EMPTY state — standard banner + tiles, no content — with the
// template picker living in the empty Artifacts tab. Diligence keeps its own
// full-screen upload step once picked.
const flow = hubFlow[activeHub];
// Create-from-scratch: brand-new hub (no template chosen) → "What should we build?"
if (flow && flow.step === 'template') {
return ;
}
if (flow && flow.template === 'diligence' && flow.step !== 'built') {
return ;
}
return (
{ setSettingsScope('hub'); setSettingsOpen(true); }}
onOpenAgentChat={handleOpenAgentChat}
domain={activeDomain} />
);
};
const screenLabels = {
graph: 'Context Graph',
about: 'About — Proxa Model',
colgraph: 'Cedar Narrows Collection — Context Graph',
photo: 'Cedar Narrows Photo',
record: 'Cedar Narrows Record',
collection: 'Cedar Narrows Collection',
project: 'Cedar Narrows Hub',
};
// The landing page is the POC's front door — rendered without the app shell
// (no rail, chat or tab bar). Both paths land on the Workspace page.
if (view === 'landing') {
return (
{ setActiveWorkspace('cascade'); setView('workspace'); }}
onLab={() => { setActiveWorkspace('lab'); setView('workspace'); }}
onScratch={() => createWorkspace()} />
);
}
return (