// 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 && (
{PLACEHOLDERS[phIdx].text}
)}
{mention && filtered.length > 0 && (
insertChip(item, mention.trigger)} />
)}
);
}
/* ============ User message bubble ============ */
function UserMessage({ text }) {
return ;
}
/* ============ 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 (
);
}
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 });