// Proxa "Chat with agents" left side panel. // Sits between the rail and the main content area. Resizable 375–700px. // On mobile, becomes a bottom sheet drawer with swipe-down to dismiss. const { useState: useStateChat, useRef: useRefChat, useEffect: useEffChat, useLayoutEffect: useLayoutEffChat } = React; /* ============ Proxa "P" glyph (used in rail icon + empty-state mark) ============ */ const ProxaP = ({ size = 22, color = "currentColor" }) => ( ); /* ============ Small icons local to the chat panel ============ */ // Lucide "arrow-left-from-line" — arrow heading left, vertical line on the right. const IconCollapseLeft = (p) => ( ); const IconRefresh = (p) => ( ); const IconArrowUp = (p) => ( ); const IconPlusSmall = (p) => ( ); const IconCaretDown = (p) => ( ); const IconSkillFolder = (p) => ( ); /* ============ Agents ============ */ const AGENTS = [ { key: 'knowledge', label: 'Knowledge Base', question: 'What question would you like me to answer?', prompts: [ { text: "What's our current TBM advance rate?", kind: 'tbm' }, { text: "Any open RFIs needing attention?", kind: 'rfi' }, { text: "What's the Q4 cost-to-complete forecast?", kind: 'forecast' }, { text: "Safety incidents this month?", kind: 'safety' }, { text: "What's our current cash position on Cedar Narrows?", kind: 'cash' }, ], }, { key: 'node', label: 'Data Product agent', question: 'What should this record show?', prompts: [ { text: "Build a mobile dashboard from the latest monthly report", kind: 'na-dashboard' }, { text: "Draft a 1-page slide on this week's status", kind: 'na-slide' }, { text: "Write a project report covering safety, progress, and cost", kind: 'na-report' }, { text: "Compare last 3 weekly reports into a single dashboard", kind: 'na-compare' }, ], }, { key: 'design', label: 'Design agent', question: 'What design challenge can I help with?', prompts: [ { text: "Lay out a new section for Photos", kind: 'na' }, { text: "Compare two report templates", kind: 'na' }, { text: "Generate a chart from this worksheet", kind: 'na' }, ], }, { key: 'admin', label: 'Admin agent', question: 'What admin task can I help with?', prompts: [ { text: "Who has access to this hub?", kind: 'na' }, { text: "Set up a new collection", kind: 'na' }, { text: "Audit changes from the last 7 days", kind: 'na' }, ], }, ]; /* ============ Canned streaming responses for the Node agent (record builder) ============ */ const NODE_RESPONSES = { 'na-dashboard-gen': { statuses: (typeof window !== 'undefined' && window.DASHBOARD_GEN_STAGES) || [ 'Scanning sources in this hub\u2026', 'Reading Monthly Report No. 50 \u00b7 March 2026\u2026', 'Extracting data points: safety, progress, finance\u2026', 'Validating numbers against weekly + daily reports\u2026', 'Structuring sections and KPI tiles\u2026', 'Composing the interactive dashboard\u2026', ], // Slower stage cadence so the chat statuses stay in step with the // record-area placeholder (≈9s total). stageMs: [1100, 1300, 1400, 1500, 1400, 1700], answer: [ "Built ", { bold: "Cedar Narrows Water Supply Tunnel \u2014 Project Overview" }, ". I synthesised data from ", { bold: "12 sources" }, " across the hub: the March monthly report, weeks 218\u2013220, daily shift reports, the Q1 cost-to-complete and the photo set. The dashboard covers safety performance, pipe-installation progress, finance, and the latest workfront photos. Open it on the right to refine.", { br: true }, { cite: { id: 'rec-overview', label: 'Cedar Narrows WST \u2014 Project Overview', target: 'record' } }, ], }, 'na-dashboard': { statuses: [ 'Scanning sources in this collection\u2026', 'Reading Monthly Report No. 50 \u00b7 March 2026\u2026', 'Picking metrics for a mobile layout\u2026', ], answer: [ "Drafted a mobile dashboard with ", { bold: "4 KPI tiles" }, " (Lost-time incidents, NLTI rate, Pipe install %, Net cash), an active workfronts grid, and an open commercial items list. Pulled from the March monthly report. Open the Dashboard output to tune it.", ], }, 'na-slide': { statuses: [ 'Pulling the latest weekly report\u2026', 'Picking 3 headline metrics\u2026', 'Composing a 16:9 slide\u2026', ], answer: [ "Generated a 1-page slide: ", { bold: "Forecast on track. Pipe installation accelerating." }, " Includes pipe-install %, NLTI year-to-date, cost-at-completion, and active workfronts \u2014 grounded in Weekly Report No. 220.", ], }, 'na-report': { statuses: [ 'Aggregating safety, progress, and commercial data\u2026', 'Structuring sections\u2026', 'Writing copy and pulling figures\u2026', ], answer: [ "Drafted a long-form report with 4 sections: ", { bold: "Status & milestones" }, ", ", { bold: "Safety performance" }, ", ", { bold: "Pipe installation progress" }, ", and ", { bold: "Financial position" }, ". Each section is backed by the matching source documents.", ], }, 'na-compare': { statuses: [ 'Reading Weekly 218, 219, 220\u2026', 'Reconciling deltas\u2026', 'Composing a 3-week dashboard\u2026', ], answer: [ "Built a comparison dashboard across the last 3 weekly reports. Highlights: pipe installation up ", { bold: "+12%" }, " week-over-week, cellular-grout backfill stalled at ", { bold: "4.5%" }, ", and 3 new commercial items entered \"disputed\" state. Open the Dashboard output to refine.", ], }, }; /* ============ Canned streaming responses for the Knowledge agent ============ */ // Each step: // {status: '...'} renders a transient status line (replaced by next step) // {answer: [...]} renders the final answer body. Pieces of text or {cite: ...}. const KNOWLEDGE_RESPONSES = { /* ── Tenney North Park: live underwriting model (Python) ── */ 'tn-model-10pct': { statuses: [ 'Loading underwriting model — tenney_uw.py…', 'Applying +10% to insurance ($714 → $785/unit)…', 'Recomputing expenses, NOI and pricing scenarios…', ], answer: [ "A ", { bold: "+10% insurance reprice" }, " ($714 → $785/unit) adds $8.9K of annual expense, trims stabilized NOI to ", { bold: "$3.04M" }, ", and moves the mid-point from $65.6M to ", { bold: "$65.4M" }, " (−$0.2M). Returns barely notice: levered IRR slips ~5 bps. For scale, the same +10% applied to market rents is worth ", { bold: "+$9.9M" }, " — the model is an order of magnitude more sensitive to revenue than to this expense line.", { br: true }, { cite: { id: 'tn_sensitivities', label: 'Sensitivities — insurance ladder', target: 'record' } }, { cite: { id: 'tn_opstat', label: 'Operating Statement — expense detail', target: 'record' } }, ], }, 'tn-model': { statuses: [ 'Loading underwriting model — tenney_uw.py…', 'Setting concessions = 6 weeks (was 2)…', 'Recomputing effective rents and stabilized NOI…', 'Re-running pricing scenarios…', ], answer: [ "At ", { bold: "6 weeks free" }, " the mid-point moves from $65.6M to ", { bold: "$62.5M" }, " (−$3.1M). Effective rents drop to $3,116/unit, stabilized NOI to $2.90M, and the levered IRR on the 5-year quote compresses from 10.4% to ", { bold: "9.7%" }, ". Concessions are the model's largest single driver — roughly ", { bold: "$0.8M of value per week" }, " of free rent. For reference, The Nash is at 6 weeks today; the BLVD offers none.", { br: true }, { cite: { id: 'tn_sensitivities', label: 'Sensitivities — concession ladder', target: 'record' } }, { cite: { id: 'tn_valuation', label: 'Valuation — pricing scenarios', target: 'record' } }, ], }, 'tn-model-driver': { statuses: [ 'Loading underwriting model — tenney_uw.py…', 'Running one-at-a-time sensitivity sweep…', 'Ranking value drivers by $ impact…', ], answer: [ "Ranked by swing across their realistic ranges: ", { bold: "concessions ($6.2M" }, " from 0→8 weeks), ", { bold: "insurance ($3.3M" }, " from $464→$2,000/unit), and ", { bold: "retail ($2.0M" }, " if the ground floor sits dark 24 months). Exit cap matters more for returns than price — every 25 bps costs ~0.9 pts of levered IRR. Concessions are also the only lever that moves before close, which is why the lease-up watch gates the launch.", { br: true }, { cite: { id: 'tn_sensitivities', label: 'Sensitivities — all three ladders', target: 'record' } }, { cite: { id: 'tn_leaseup', label: 'Lease-Up — concession position', target: 'record' } }, ], }, tbm: { statuses: [ 'Searching knowledge base…', 'Reading 3 daily-log records…', 'Cross-checking weekly progress report…', ], answer: [ "TBM ", { bold: "MARK" }, " is averaging ", { bold: "18.4 m/day" }, " over the last 7 shifts on the South drive, up from 14.2 m/day in February. Cumulative advance is ", { bold: "1,847 m" }, " of 2,253 m — about 82% of the alignment.", { br: true }, { cite: { id: 'rec', label: 'TBM daily log — Apr 22', target: 'record' } }, { cite: { id: 'weekly', label: 'Weekly Report 220', target: 'collection', tab: 'weekly' } }, ], }, rfi: { statuses: [ 'Searching knowledge base…', 'Filtering by RFI status = open…', 'Reading 4 source documents…', ], answer: [ "There are ", { bold: "3 open RFIs" }, " on Cedar Narrows WST. ", { bold: "RFI #214" }, " (segment ring tolerance) is overdue by 4 days and held up by an Aecon response. The other two — #218 (grout mix) and #221 (cross-passage waterproofing) — are within their reply windows.", { br: true }, { cite: { id: 'rec', label: 'TBM daily log — Apr 22', target: 'record' } }, { cite: { id: 'collection', label: 'Cedar Narrows WST — Project Overview', target: 'collection', tab: 'overview' } }, ], }, forecast: { statuses: [ 'Searching knowledge base…', 'Reading Q1 cost-to-complete worksheet…', 'Reading March monthly report…', ], answer: [ "Current Q4 forecast is ", { bold: "$71.4M" }, " against a budget of $69.2M — a projected overrun of ", { bold: "+$2.2M (3.2%)" }, " driven mainly by extended TBM rental and an unbudgeted secant-pile crew. Owner change-order recovery of $1.6M is pending.", { br: true }, { cite: { id: 'collection', label: 'Cedar Narrows WST — Monthly Report (March)', target: 'collection', tab: 'monthly' } }, { cite: { id: 'rec', label: 'Q1 2026 cost-to-complete forecast', target: 'record' } }, ], }, safety: { statuses: [ 'Searching knowledge base…', 'Reading shift reports for April…', 'Cross-checking incident log…', ], answer: [ "Two recordable events this month: one ", { bold: "first-aid" }, " (slip near the muck box, Apr 14) and one ", { bold: "near-miss" }, " on the South portal (Apr 21). No lost-time incidents. TRIR YTD remains at 0.84, below the 1.2 target.", { br: true }, { cite: { id: 'rec', label: 'Contractor Shift Report — South, Mar 02', target: 'record' } }, { cite: { id: 'collection', label: 'Cedar Narrows WST — Daily reports', target: 'collection', tab: 'daily' } }, ], }, cash: { statuses: [ 'Searching knowledge base…', 'Reading March cashflow summary…', 'Reading owner-billing schedule…', ], answer: [ "Net cash position on Cedar Narrows is ", { bold: "+$4.8M" }, " as of Apr 30. Billings to date $182M, costs to date $177.2M. Outstanding receivable of $6.1M from Metro Vancouver on Progress Estimate #14 — invoiced Apr 7, expected May 15.", { br: true }, { cite: { id: 'collection', label: 'Cedar Narrows WST — Monthly Report (March)', target: 'collection', tab: 'monthly' } }, ], }, }; /* ============ Source list for @ mentions ============ */ function buildSources() { const items = window.TRAYLOR_ITEMS || []; const onlyCedarNarrows = items.filter((i) => i.coll === 'Cedar Narrows WST'); const synthetic = [ { id: 'c-cedar-narrows', name: 'Cedar Narrows Water Supply Tunnel', type: 'collection', kind: 'collection' }, { id: 'c-finance', name: 'Finance', type: 'collection', kind: 'collection' }, { id: 'c-ops', name: 'Operations', type: 'collection', kind: 'collection' }, ]; return [...synthetic, ...onlyCedarNarrows.map((i) => ({ ...i, kind: 'record' }))]; } /* ============ Custom Claude Skills for Traylor Bros. ============ */ const SKILLS = [ { id: 's-daily-digest', name: 'Daily report digest', desc: 'Summarize today\u2019s shift reports' }, { id: 's-rfi-draft', name: 'RFI drafter', desc: 'Draft a new RFI from a record' }, { id: 's-tbm-advance', name: 'TBM advance analysis', desc: 'Compare drive rate vs plan' }, { id: 's-cost-variance', name: 'Cost variance explainer', desc: 'Explain a cost overrun by category' }, { id: 's-safety-writeup', name: 'Safety incident write-up',desc: 'Generate an OSHA-style report' }, { id: 's-submittal-check', name: 'Submittal checker', desc: 'Compare a submittal to spec' }, { id: 's-owner-update', name: 'Owner update email', desc: 'Draft a weekly status email' }, { id: 's-meeting-notes', name: 'Meeting notes \u2192 actions', desc: 'Extract action items from notes' }, ]; /* ============ Citation chip ============ */ function CitationChip({ data, index, onOpen }) { return ( ); } /* ============ Rendering an answer piece by piece, with streaming ============ */ function StreamingAnswer({ pieces, done, onOpenCite, citeOffset = 1 }) { // pieces: array of strings / {bold:'...'} / {br:true} / {cite:{...}} // For streaming, we incrementally reveal characters of the text + bold pieces. // Citation chips and
appear after their preceding text is fully revealed. // Flatten into tokens with metadata const tokens = pieces.map((p, i) => ({ p, i })); // Total text length we'd reveal const textLen = pieces.reduce((acc, p) => { if (typeof p === 'string') return acc + p.length; if (p && p.bold) return acc + p.bold.length; return acc; }, 0); const [revealed, setRevealed] = useStateChat(0); const reqRef = useRefChat(null); const startRef = useRefChat(null); useEffChat(() => { if (done) { setRevealed(textLen); return; } setRevealed(0); startRef.current = performance.now(); const charsPerSec = 220; const tick = (t) => { const elapsed = (t - startRef.current) / 1000; const chars = Math.min(textLen, Math.floor(elapsed * charsPerSec)); setRevealed(chars); if (chars < textLen) reqRef.current = requestAnimationFrame(tick); }; reqRef.current = requestAnimationFrame(tick); return () => { if (reqRef.current) cancelAnimationFrame(reqRef.current); }; }, [textLen, done]); const fullyDone = revealed >= textLen; // Walk pieces, consuming `revealed` chars let left = revealed; const out = []; let citeIdx = 0; pieces.forEach((p, i) => { if (typeof p === 'string') { const take = Math.max(0, Math.min(p.length, left)); if (take > 0) out.push({p.slice(0, take)}); left -= p.length; } else if (p && p.bold) { const take = Math.max(0, Math.min(p.bold.length, left)); if (take > 0) out.push({p.bold.slice(0, take)}); left -= p.bold.length; } else if (p && p.br) { if (left <= 0 || fullyDone) out.push(); } else if (p && p.cite) { if (fullyDone) { citeIdx += 1; out.push( ); } } }); return (
{out} {!fullyDone && }
); } /* ============ Mention picker — used for both @sources and /skills ============ */ function MentionPicker({ trigger, items, activeIdx, onPick, onHover }) { if (!items.length) return null; const isSkill = trigger === '/'; return (
{isSkill ? 'Skills' : 'Sources'}
{items.slice(0, 6).map((it, idx) => ( ))}
); } /* ============ Agent selector ============ */ function AgentSelector({ value, onChange }) { const [open, setOpen] = useStateChat(false); const ref = useRefChat(null); const agent = AGENTS.find((a) => a.key === value) || AGENTS[0]; useEffChat(() => { if (!open) return; const onDoc = (e) => { if (ref.current && !ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }, [open]); return (
{open && (
{AGENTS.map((a) => ( ))}
)}
); } /* ============ Composer (contentEditable + @/-mentions + agent + send) ============ */ const PLACEHOLDERS = [ { text: 'Type @ for sources', trigger: '@' }, { text: 'Type / for skills', trigger: '/' }, ]; function Composer({ agent, setAgent, onSubmit, busy, pendingSkill, onPendingSkillHandled }) { const editorRef = useRefChat(null); const [hasText, setHasText] = useStateChat(false); // mention: { trigger: '@' | '/', query: string } | null const [mention, setMention] = useStateChat(null); const [mentionActiveIdx, setMentionActiveIdx] = useStateChat(0); const prevQueryRef = useRefChat(null); const sources = useRefChat(buildSources()); // Cycling placeholder const [phIdx, setPhIdx] = useStateChat(0); const [phVisible, setPhVisible] = useStateChat(true); useEffChat(() => { const swap = () => { setPhVisible(false); setTimeout(() => { setPhIdx((i) => (i + 1) % PLACEHOLDERS.length); setPhVisible(true); }, 360); }; const t = setInterval(swap, 3000); return () => clearInterval(t); }, []); const filtered = !mention ? [] : (mention.trigger === '@' ? sources.current : SKILLS ).filter((s) => s.name.toLowerCase().includes(mention.query.toLowerCase())).slice(0, 6); const updateState = () => { const el = editorRef.current; if (!el) return; const text = el.innerText.replace(/\u00a0/g, ' ').trim(); setHasText(text.length > 0); }; // Detect "@…" or "/…" token immediately before caret. const readMentionToken = () => { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) { setMention(null); prevQueryRef.current = null; return; } const range = sel.getRangeAt(0); if (!editorRef.current || !editorRef.current.contains(range.startContainer)) { setMention(null); prevQueryRef.current = null; return; } const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) { setMention(null); prevQueryRef.current = null; return; } const text = node.textContent.slice(0, range.startOffset); // last @ or / position const lastAt = text.lastIndexOf('@'); const lastSlash = text.lastIndexOf('/'); const at = Math.max(lastAt, lastSlash); if (at < 0) { setMention(null); prevQueryRef.current = null; return; } if (at > 0 && !/\s/.test(text[at - 1])) { setMention(null); prevQueryRef.current = null; return; } const trigger = text[at]; const q = text.slice(at + 1); if (/[\s\n]/.test(q)) { setMention(null); prevQueryRef.current = null; return; } const key = trigger + q; setMention({ trigger, query: q }); // Only reset selection when token CHANGES (so arrow-keys keep their position). if (prevQueryRef.current !== key) { setMentionActiveIdx(0); prevQueryRef.current = key; } }; const onInput = () => { updateState(); readMentionToken(); }; const insertChip = (item, trigger) => { const sel = window.getSelection(); if (!sel || sel.rangeCount === 0) return; const range = sel.getRangeAt(0); const node = range.startContainer; if (node.nodeType !== Node.TEXT_NODE) return; const text = node.textContent; const at = trigger === '@' ? text.slice(0, range.startOffset).lastIndexOf('@') : text.slice(0, range.startOffset).lastIndexOf('/'); if (at < 0) return; const before = text.slice(0, at); const after = text.slice(range.startOffset); node.textContent = before; const chip = document.createElement('span'); chip.className = `chat-mchip chat-mchip--${trigger === '/' ? 'skill' : 'source'}`; chip.contentEditable = 'false'; chip.dataset.id = item.id; chip.dataset.kind = item.kind || (trigger === '/' ? 'skill' : 'record'); chip.dataset.trigger = trigger; chip.innerText = `${trigger}${item.name}`; const space = document.createTextNode('\u00a0'); const tail = document.createTextNode(after); node.parentNode.insertBefore(chip, node.nextSibling); node.parentNode.insertBefore(space, chip.nextSibling); node.parentNode.insertBefore(tail, space.nextSibling); const r = document.createRange(); r.setStart(space, 1); r.collapse(true); sel.removeAllRanges(); sel.addRange(r); setMention(null); prevQueryRef.current = null; updateState(); }; const getPlainText = () => { if (!editorRef.current) return ''; return editorRef.current.innerText.replace(/\u00a0/g, ' ').trim(); }; // Programmatic skill insertion (from the Control tab's Skills table) — drops // a /skill mention chip into the composer exactly like typing "/" + picking. useEffChat(() => { if (!pendingSkill) return; const el = editorRef.current; if (!el) return; el.innerHTML = ''; if (pendingSkill.mode === 'edit') { el.appendChild(document.createTextNode('Edit the ')); } const chip = document.createElement('span'); chip.className = 'chat-mchip chat-mchip--skill'; chip.contentEditable = 'false'; chip.dataset.id = pendingSkill.id || ''; chip.dataset.kind = 'skill'; chip.dataset.trigger = '/'; chip.innerText = '/' + pendingSkill.name; el.appendChild(chip); if (pendingSkill.mode === 'edit') el.appendChild(document.createTextNode('\u00a0skill')); el.appendChild(document.createTextNode('\u00a0')); setHasText(true); el.focus(); const r = document.createRange(); r.selectNodeContents(el); r.collapse(false); const sel = window.getSelection(); sel.removeAllRanges(); sel.addRange(r); onPendingSkillHandled && onPendingSkillHandled(); }, [pendingSkill]); const submit = () => { const text = getPlainText(); if (!text || busy) return; onSubmit(text); if (editorRef.current) editorRef.current.innerHTML = ''; setHasText(false); setMention(null); prevQueryRef.current = null; }; const onKeyDown = (e) => { if (mention && filtered.length) { if (e.key === 'ArrowDown') { e.preventDefault(); setMentionActiveIdx((i) => (i + 1) % filtered.length); return; } if (e.key === 'ArrowUp') { e.preventDefault(); setMentionActiveIdx((i) => (i - 1 + filtered.length) % filtered.length); return; } if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); insertChip(filtered[mentionActiveIdx], mention.trigger); return; } if (e.key === 'Escape') { setMention(null); prevQueryRef.current = null; return; } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); } }; // Skip mention re-read on navigation keys so the arrow selection persists. const onKeyUp = (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter' || e.key === 'Tab' || e.key === 'Escape') return; readMentionToken(); }; return (
{!hasText && ( )}
{mention && filtered.length > 0 && ( insertChip(item, mention.trigger)} /> )}
); } /* ============ User message bubble ============ */ function UserMessage({ text }) { return
{text}
; } /* ============ Assistant message — with status streaming + final answer ============ */ function AssistantMessage({ message, onOpenCite, onCardAction }) { // TN cites route to the TN record drawer; everything else uses the app handler. const openCite = (cite) => { if (cite && typeof cite.id === 'string' && cite.id.indexOf('tn_') === 0 && window.openDrawer) { window.openDrawer(cite.id); return; } if (onOpenCite) onOpenCite(cite); }; // message: { id, statuses, statusIdx, answerPieces, answerDone, error, taskRef, card } const { statuses, statusIdx, answerPieces, answerDone, error } = message; const showStatus = !answerPieces && !error; // Control · Tasks: optional task-ref chip + inline flow card (window.TaskRef/TaskCard). const TaskRefC = window.TaskRef; const TaskCardC = window.TaskCard; const refEl = message.taskRef && TaskRefC ? : null; const ProxaIco = ; if (error) { return (
{ProxaIco}
{error}
); } if (showStatus) { return (
{ProxaIco}
{refEl}
{statuses[statusIdx]}
); } // Final answer (streaming) return (
{ProxaIco}
{refEl} {message.card && answerDone && TaskCardC && ( onCardAction && onCardAction(message.card, reply, btn)} /> )}
); } /* ============ Hub skills & workflows (drawer tabs) ============ */ const HUB_SKILLS = [ { name: 'Daily report digest', desc: 'Summarizes the day\u2019s safety export & field sign-in into the daily report', meta: 'Updated 5 h ago' }, { name: 'Cost variance explainer', desc: 'Explains a monthly report cost overrun by category from the cost export', meta: 'Updated 2 d ago' }, { name: 'Owner update email', desc: 'Drafts the weekly owner progress email from this hub\u2019s artifacts', meta: 'Updated 6 d ago' }, ]; const HUB_WORKFLOWS = [ { name: 'Daily report build', desc: 'Assembles AWST daily from the safety export & field sign-in', meta: 'Daily \u00b7 17:00' }, { name: 'Headcount conflict watch', desc: 'Runs when Daily & Team headcount figures disagree', meta: 'On source conflict' }, { name: 'Weekly progress roll-up', desc: 'Compiles Weekly 220 when the project export re-syncs', meta: 'On source change' }, { name: 'Egnyte source sync', desc: 'Pulls the 7 domain Excel exports from /cedar-narrows-tunnel', meta: 'Every 2 h' }, { name: 'Month-end report assembly', desc: 'Builds Monthly No. 50 from P6 schedule & cost reports', meta: '1st of month' }, ]; function DrawerList({ items, onPick }) { return (
{items.map((it) => )}
); } /* ============ Main chat panel ============ */ function ChatPanel({ open, width, onClose, onWidthChange, onOpenCite, mobile, mode, wide, onToggleWide, agent, setAgent, initialPrompt, onInitialPromptHandled, onboardMessage, onOnboardMessageHandled, onGenerateDashboard, pendingSkill, onPendingSkillHandled, pendingTask, onPendingTaskHandled }) { const [messages, setMessages] = useStateChat([]); // {role:'user'|'assistant', ...} const [drawerTab, setDrawerTab] = useStateChat('agents'); // 'agents' | 'skills' | 'workflows' const [busy, setBusy] = useStateChat(false); const scrollRef = useRefChat(null); const dragStartY = useRefChat(null); const [dragY, setDragY] = useStateChat(0); const activeAgent = AGENTS.find((a) => a.key === agent) || AGENTS[0]; // Hub-aware starter prompts: the shared AGENTS list is construction-flavored; // hubs can override per agent via window.HUB_AGENT_PROMPTS[hub][agentKey]. const hubPrompts = (window.HUB_AGENT_PROMPTS && window.HUB_AGENT_PROMPTS[window.__ACTIVE_HUB] && window.HUB_AGENT_PROMPTS[window.__ACTIVE_HUB][activeAgent.key]) || null; const agentPrompts = hubPrompts || activeAgent.prompts; // Auto-scroll on new message useLayoutEffChat(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [messages]); // External prompt submission (e.g. from a record's empty state) React.useEffect(() => { if (!initialPrompt || !open) return; submitFreeform(initialPrompt); onInitialPromptHandled && onInitialPromptHandled(); }, [initialPrompt, open]); // Onboarding: seed a single assistant instruction message when the drawer // opens after a template pick (answerPieces = array of strings / {bold} / {br}). React.useEffect(() => { if (!onboardMessage || !open) return; setMessages([{ id: 'onboard', role: 'assistant', answerPieces: onboardMessage, answerDone: true }]); onOnboardMessageHandled && onOnboardMessageHandled(); }, [onboardMessage, open]); const reset = () => { setMessages([]); setBusy(false); }; const updateMsg = (id, patch) => { setMessages((prev) => prev.map((m) => (m.id === id ? { ...m, ...patch } : m))); }; // Simulate a canned knowledge-agent answer (statuses → streamed body). // If resp.stageMs is set, use those exact per-step durations (used by the // dashboard-generation flow to stay in sync with the record placeholder). const runCanned = (resp, asstId, onDone) => { let i = 0; const stageMs = resp.stageMs; const stepStatus = () => { if (i < resp.statuses.length - 1) { i += 1; updateMsg(asstId, { statusIdx: i }); const wait = stageMs ? (stageMs[i - 1] || 1300) : (700 + Math.random() * 400); setTimeout(stepStatus, wait); } else { // Reveal answer setTimeout(() => { updateMsg(asstId, { answerPieces: resp.answer, answerDone: false }); // mark done after text-stream interval (text length / 220 cps + buffer) const textLen = resp.answer.reduce((acc, p) => { if (typeof p === 'string') return acc + p.length; if (p && p.bold) return acc + p.bold.length; return acc; }, 0); const ms = (textLen / 220) * 1000 + 250; setTimeout(() => { updateMsg(asstId, { answerDone: true }); setBusy(false); if (onDone) onDone(); }, ms); }, 350); } }; const initialWait = stageMs ? (stageMs[0] || 1100) : 600; setTimeout(stepStatus, initialWait); }; /* === Control · Tasks: agent-driven task flows (window.TASK_FLOWS) === A row click in the Control Tasks table calls window.hubOpenTask(id), which sets pendingTask; the effect below opens the flow: statuses stream, then an inline card (upload/survey/conflict/confirm/diff/checklist) hands control to the user and advances the flow to completion. */ const rid = () => Math.random().toString(36).slice(2); const runTaskStep = (taskId, stepIdx, replyText) => { const flow = (window.TASK_FLOWS || {})[taskId]; const step = flow && flow.steps[stepIdx]; if (!step) return; const asstId = rid(); setBusy(true); setMessages((prev) => [ ...prev, ...(replyText ? [{ id: rid(), role: 'user', text: replyText }] : []), { id: asstId, role: 'assistant', statuses: step.statuses, statusIdx: 0, taskRef: stepIdx === 0 ? { type: flow.type, title: flow.title } : null, }, ]); setTimeout(() => runCanned({ statuses: step.statuses, answer: step.answer }, asstId, () => { if (step.card) updateMsg(asstId, { card: { ...step.card, taskId, stepIdx } }); if (step.complete && window.ccCompleteTask) window.ccCompleteTask(taskId); }), 0); }; const handleCardAction = (card, reply, btn) => { const next = (btn && btn.skipTo != null) ? btn.skipTo : card.stepIdx + 1; if (btn && btn.hold) { setMessages((prev) => [ ...prev, { id: rid(), role: 'user', text: reply }, { id: rid(), role: 'assistant', answerPieces: ['Held. The report stays staged and the model is untouched — the task will keep waiting on you until you approve or discard it.'], answerDone: true }, ]); return; } runTaskStep(card.taskId, next, reply); }; React.useEffect(() => { if (!pendingTask || !open) return; onPendingTaskHandled && onPendingTaskHandled(); const flow = (window.TASK_FLOWS || {})[pendingTask.id]; if (!flow) return; // Fresh thread for the task so it opens on its first step. setMessages([]); setBusy(false); setTimeout(() => runTaskStep(pendingTask.id, 0), 0); }, [pendingTask, open]); // Real LLM call via window.claude.complete for ad-hoc questions const runClaude = async (asstId, userText) => { let i = 0; const statuses = [ 'Searching knowledge base…', 'Reading hub context…', 'Reasoning…', ]; const stepStatus = () => { if (i < statuses.length - 1) { i += 1; updateMsg(asstId, { statusIdx: i }); setTimeout(stepStatus, 750); } }; const statusTimer = setTimeout(stepStatus, 700); const sys = `You are the Knowledge agent for Proxa — a knowledge-base product. The active hub is the "Cedar Narrows Water Supply Tunnel" project (Traylor Bros., Inc.). Context: - 1.4-mile water-supply tunnel for Metro Vancouver, $289M, joint venture of Traylor Infrastructure Canada and Aecon. - TBM (named "MARK") averaging 18.4 m/day on the South drive, 1,847 m of 2,253 m complete. - 3 open RFIs (#214 segment ring tolerance overdue, #218 grout mix, #221 cross-passage waterproofing). - Q1 cost-to-complete forecast $71.4M vs $69.2M budget; +$2.2M overrun. - 2 recordable safety events in April (one first-aid Apr 14, one near-miss Apr 21). TRIR YTD 0.84. - Net cash position +$4.8M; $6.1M receivable from Metro Vancouver on PE #14. - Hub contents: monthly reports, weekly reports, daily reports (438), photos (6), records (TBM daily log, RFIs, owner-call notes, shift reports). Answer the user's question in 2–3 short sentences, grounded in the context above. If the question is off-topic for this hub, say so briefly and suggest what you can answer. Plain prose only — no markdown, no headings, no bullet lists.`; try { const reply = await window.claude.complete({ messages: [ { role: 'user', content: `${sys}\n\nUser question: ${userText}` }, ], }); clearTimeout(statusTimer); // Reveal as a single text piece + a generic citation back to the project collection const pieces = [reply.trim(), { br: true }, { cite: { id: 'c-cedar-narrows', label: 'Cedar Narrows WST — Project Overview', target: 'collection', tab: 'overview' } }, ]; updateMsg(asstId, { answerPieces: pieces, answerDone: false }); const textLen = reply.length; const ms = (textLen / 240) * 1000 + 250; setTimeout(() => { updateMsg(asstId, { answerDone: true }); setBusy(false); }, ms); } catch (e) { clearTimeout(statusTimer); updateMsg(asstId, { error: "I couldn't reach the model just now. Try again in a moment." }); setBusy(false); } }; const askPrompt = (prompt) => { if (busy) return; const userId = Math.random().toString(36).slice(2); const asstId = Math.random().toString(36).slice(2); setBusy(true); // Special: the Node agent's dashboard prompt, fired from inside an open // empty record → build the Cedar Narrows WST overview from the hub knowledge // base. Drives the record-area loader (onGenerateDashboard) and streams the // matching gen statuses + answer so chat and record stay in sync. if (agent === 'node' && prompt.kind === 'na-dashboard') { const started = onGenerateDashboard && onGenerateDashboard(); if (started) { const resp = NODE_RESPONSES['na-dashboard-gen']; setMessages((prev) => [ ...prev, { id: userId, role: 'user', text: prompt.text }, { id: asstId, role: 'assistant', statuses: resp.statuses, statusIdx: 0 }, ]); setTimeout(() => runCanned(resp, asstId), 0); return; } } setMessages((prev) => [ ...prev, { id: userId, role: 'user', text: prompt.text }, { id: asstId, role: 'assistant', statuses: ['Searching knowledge base…', 'Reading source documents…', 'Synthesizing answer…'], statusIdx: 0 }, ]); if (agent !== 'knowledge' && !NODE_RESPONSES[prompt.kind]) { // Non-knowledge agents are stubbed unless they have a canned response setTimeout(() => { updateMsg(asstId, { error: `${activeAgent.label} isn't wired up in this prototype yet. Switch back to Knowledge Base to try a live answer.` }); setBusy(false); }, 600); return; } const resp = KNOWLEDGE_RESPONSES[prompt.kind] || NODE_RESPONSES[prompt.kind]; if (resp) { // Use the response's actual statuses updateMsg(asstId, { statuses: resp.statuses, statusIdx: 0 }); // re-schedule with new statuses (the previous useEffect chain referenced old array) // Easiest: rerun stepper after a tick using a local snapshot setTimeout(() => runCanned(resp, asstId), 0); } else { runClaude(asstId, prompt.text); } }; const submitFreeform = (text) => { if (busy) return; const userId = Math.random().toString(36).slice(2); const asstId = Math.random().toString(36).slice(2); setBusy(true); // Special: dashboard-generation prompt (from the empty record's Create // chips). Use the canned na-dashboard-gen response so the chat statuses // mirror the placeholder in the record area. const isDashboardGen = typeof text === 'string' && text.trim() === window.DASHBOARD_GEN_PROMPT; if (isDashboardGen) { const resp = NODE_RESPONSES['na-dashboard-gen']; setMessages((prev) => [ ...prev, { id: userId, role: 'user', text }, { id: asstId, role: 'assistant', statuses: resp.statuses, statusIdx: 0 }, ]); setTimeout(() => runCanned(resp, asstId), 0); return; } setMessages((prev) => [ ...prev, { id: userId, role: 'user', text }, { id: asstId, role: 'assistant', statuses: ['Searching knowledge base…', 'Reading hub context…', 'Reasoning…'], statusIdx: 0 }, ]); if (agent !== 'knowledge') { setTimeout(() => { updateMsg(asstId, { error: `${activeAgent.label} isn't wired up in this prototype yet. Switch back to Knowledge Base to try a live answer.` }); setBusy(false); }, 600); return; } runClaude(asstId, text); }; /* === Resize handle (inline mouse-down handler for reliability) === */ const startResize = (e) => { if (mobile) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startW = width; document.body.classList.add('is-chat-resizing'); const onMove = (ev) => { ev.preventDefault(); // Panel is docked on the right edge: dragging left (negative dx) widens it. const dx = ev.clientX - startX; const w = Math.min(700, Math.max(375, startW - dx)); onWidthChange(w); }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.body.classList.remove('is-chat-resizing'); }; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }; /* === Mobile swipe-down to dismiss === */ const onTouchStart = (e) => { if (!mobile) return; dragStartY.current = e.touches[0].clientY; }; const onTouchMove = (e) => { if (!mobile || dragStartY.current == null) return; const dy = e.touches[0].clientY - dragStartY.current; setDragY(Math.max(0, dy)); }; const onTouchEnd = () => { if (!mobile) return; if (dragY > 100) { onClose(); } setDragY(0); dragStartY.current = null; }; if (!open) return null; const showEmpty = messages.length === 0; const panelStyle = mobile ? { transform: `translateY(${dragY}px)` } : null; return ( <> {mobile &&
} ); } Object.assign(window, { ChatPanel, ProxaP, AGENTS, Composer });