// ========================================================================== // STORE — React Context wrapping the API layer. // All UI reads from useStore(); all writes go through actions that hit api. // // URL-hash routing: // #/t/:threadId → thread (default view: chat + artifacts) // #/t/:threadId/brief → (same thread, focused on brief artifact) // #/agents → scheduled intelligence panel // #/agents/:agentId → agent detail + latest briefing // ========================================================================== const StoreContext = React.createContext(null); const useStore = () => React.useContext(StoreContext); // ---- hash router ---- function useHashRoute() { const [hash, setHash] = React.useState(() => window.location.hash || '#/'); React.useEffect(() => { const onChange = () => setHash(window.location.hash || '#/'); window.addEventListener('hashchange', onChange); return () => window.removeEventListener('hashchange', onChange); }, []); const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean); const route = { raw: hash, name: parts[0] || 'home', // 'home' | 't' | 'agents' | 'briefings' threadId: parts[0] === 't' ? parts[1] : null, threadView: parts[0] === 't' ? (parts[2] || 'chat') : null, agentId: parts[0] === 'agents' ? parts[1] : null, briefingId: parts[0] === 'briefings' ? parts[1] : null, }; const navigate = (to) => { window.location.hash = to.startsWith('#') ? to : '#' + to; }; return { route, navigate }; } function StoreProvider({ children }) { const { route, navigate } = useHashRoute(); const [me, setMe] = React.useState(null); const [threads, setThreads] = React.useState([]); const [agents, setAgents] = React.useState([]); const [messagesByThread, setMessagesByThread] = React.useState({}); const [artifactsByThread, setArtifactsByThread] = React.useState({}); const [streaming, setStreaming] = React.useState({ active: false, threadId: null, skills: [], activeSkillIndex: -1, partial: '', steps: [], plan: null, receipts: [], clarifier: null, activity: [], todos: [] }); const [uploadsInFlight, setUploadsInFlight] = React.useState({}); // {tempId: {progress, name}} const [toast, setToast] = React.useState(null); // Briefing-management state. List for the table view + cache by id for the drawer. const [briefings, setBriefings] = React.useState([]); // summaries for list const [briefingsById, setBriefingsById] = React.useState({}); // full records by id const [briefingFilter, setBriefingFilter] = React.useState({ status: null, owner: null }); // --- bootstrap --- React.useEffect(() => { (async () => { const u = await api.me(); setMe(u); const ts = await api.listThreads(); setThreads(ts); const ag = await api.listAgents(); setAgents(ag); // default route: latest thread if (route.name === 'home' && ts.length) navigate(`/t/${ts[0].id}`); })(); // eslint-disable-next-line }, []); // --- load thread details when route changes to a thread --- React.useEffect(() => { if (!route.threadId) return; if (messagesByThread[route.threadId] && artifactsByThread[route.threadId]) return; (async () => { const [msgs, arts] = await Promise.all([ api.listMessages(route.threadId), api.listArtifacts(route.threadId), ]); setMessagesByThread(m => ({ ...m, [route.threadId]: msgs })); setArtifactsByThread(a => ({ ...a, [route.threadId]: arts })); })(); }, [route.threadId]); // --- derived --- // Auto-load briefings whenever we land on the briefings route or filter changes. React.useEffect(() => { if (route.name !== 'briefings') return; (async () => { try { const data = await api.listBriefings(briefingFilter); setBriefings(data.items || []); } catch (e) { console.error(e); } })(); }, [route.name, briefingFilter.status, briefingFilter.owner]); // Auto-load briefing detail when an id is in the route. React.useEffect(() => { if (!route.briefingId) return; (async () => { try { const rec = await api.getBriefing(route.briefingId); setBriefingsById(map => ({ ...map, [route.briefingId]: rec })); } catch (e) { console.error(e); } })(); }, [route.briefingId]); const currentThread = route.threadId ? threads.find(t => t.id === route.threadId) : null; const currentMessages = route.threadId ? (messagesByThread[route.threadId] || []) : []; const currentArtifacts = route.threadId ? (artifactsByThread[route.threadId] || []) : []; // --- actions --- const actions = { async createThread(opts = {}) { const t = await api.createThread({ title: 'New thread', ...opts }); setThreads(x => [t, ...x]); navigate(`/t/${t.id}`); return t; }, // Spin up an empty draft briefing — the row appears in the table // immediately, then the user lands on a full /briefings/ page // where the CD agent runs intake against the reserved thread_id. // When the marketing brief is emitted, the same record is updated // (via thread_id lookup in store.upsert_from_brief). async createBriefing() { const t = await api.createThread({ title: 'New briefing' }); setThreads(x => [t, ...x]); const rec = await api.createBriefing({ thread_id: t.id, title: 'New briefing', actor: (me && (me.email || me.name)) || 'system', }); setBriefings(x => [ { id: rec.id, thread_id: rec.thread_id, title: rec.title, status: rec.status, owner: rec.owner, assignees: rec.assignees, created_at: rec.created_at, updated_at: rec.updated_at, comment_count: 0, subtitle: '', creative_approved_by: null, business_approved_by: null, }, ...x, ]); setBriefingsById(map => ({ ...map, [rec.id]: rec })); navigate(`/briefings/${rec.id}`); return rec; }, async deleteThread(id) { await api.deleteThread(id); setThreads(x => x.filter(t => t.id !== id)); setMessagesByThread(m => { const n = { ...m }; delete n[id]; return n; }); setArtifactsByThread(a => { const n = { ...a }; delete n[id]; return n; }); const remaining = threads.filter(t => t.id !== id); navigate(remaining.length ? `/t/${remaining[0].id}` : '/'); }, async renameThread(id, title) { const t = await api.renameThread(id, title); setThreads(x => x.map(y => y.id === id ? t : y)); }, async uploadFiles(fileList) { if (!route.threadId) return; const parsed = []; for (const f of fileList) { const tempId = 'tmp_' + Math.random().toString(36).slice(2,8); setUploadsInFlight(s => ({ ...s, [tempId]: { name: f.name, progress: 0.3 } })); try { const result = await api.uploadAndParse(f); parsed.push(result); setUploadsInFlight(s => { const n = { ...s }; delete n[tempId]; return n; }); } catch (e) { setUploadsInFlight(s => { const n = { ...s }; delete n[tempId]; return n; }); setToast({ kind: 'error', text: `Failed to parse ${f.name}` }); } } if (!parsed.length) return; // Append a user message carrying the attachments const userMsg = await api.appendMessage(route.threadId, { role: 'user', content: `Attached ${parsed.length} file${parsed.length>1?'s':''}`, attachments: parsed, }); setMessagesByThread(m => ({ ...m, [route.threadId]: [...(m[route.threadId]||[]), userMsg] })); // Kick the assistant await actions.sendToAssistant('', parsed); }, async sendMessage(text) { if (!route.threadId || !text.trim()) return; const userMsg = await api.appendMessage(route.threadId, { role: 'user', content: text }); setMessagesByThread(m => ({ ...m, [route.threadId]: [...(m[route.threadId]||[]), userMsg] })); await actions.sendToAssistant(text, []); }, async sendToAssistant(text, attachments, options = {}) { const threadId = route.threadId; setStreaming({ active: true, threadId, skills: [], activeSkillIndex: -1, partial: '', steps: [], plan: null, receipts: [], clarifier: null, activity: [], todos: [] }); const onEvent = (ev) => { if (ev.type === 'plan') { // Server emits this on plan_research tool call AND every time // step statuses advance. We replace the whole plan each time. setStreaming(s => ({ ...s, plan: ev.plan })); } else if (ev.type === 'step.start') { setStreaming(s => ({ ...s, steps: [...s.steps, { id: ev.step.id, text: '', tools: [], done: false, isFinal: false }] })); } else if (ev.type === 'step.end') { setStreaming(s => ({ ...s, steps: s.steps.map(st => st.id === ev.step.id ? { ...st, done: true, isFinal: !!ev.step.isFinal } : st), })); } else if (ev.type === 'step.delta') { // Append text to the in-flight step (last in the list). setStreaming(s => { if (!s.steps.length) return s; const last = s.steps.length - 1; const updated = [...s.steps]; updated[last] = { ...updated[last], text: updated[last].text + (ev.text || '') }; return { ...s, steps: updated }; }); } else if (ev.type === 'skill.start') { // Append tool to the current (last) step's tool list, AND keep the // legacy top-level skills rail working. setStreaming(s => { const next = { ...s, skills: [...s.skills, ev.skill].filter((v,i,a)=>a.indexOf(v)===i), activeSkillIndex: ev.index }; if (s.steps.length) { const last = s.steps.length - 1; next.steps = [...s.steps]; next.steps[last] = { ...next.steps[last], tools: [...(next.steps[last].tools || []), { slug: ev.skill, name: ev.skill, description: '' }], }; } return next; }); } else if (ev.type === 'skill.done') { setStreaming(s => ({ ...s, activeSkillIndex: ev.index + 1 })); } else if (ev.type === 'receipt') { setStreaming(s => ({ ...s, receipts: [...(s.receipts || []), ev.receipt] })); } else if (ev.type === 'clarifier') { setStreaming(s => ({ ...s, clarifier: ev.clarifier })); } else if (ev.type === 'content.delta') { setStreaming(s => ({ ...s, partial: ev.text })); } else if (ev.type === 'todos') { // Live TodoWrite update — replace the streaming todos array. setStreaming(s => ({ ...s, todos: Array.isArray(ev.todos) ? ev.todos : [] })); } else if (ev.type === 'activity') { // New activity entry from the agent (tool call, subagent transition, // artifact). Append to the live trail so the InlineStreamingRow can // render it before the message persists. setStreaming(s => ({ ...s, activity: [...(s.activity || []), ev.entry] })); } else if (ev.type === 'activity.update') { // Tool result hydration — replace the matching entry by tool_use_id // with a fresh object reference so React re-renders. setStreaming(s => ({ ...s, activity: (s.activity || []).map(e => e.kind === 'tool' && e.tool_use_id === ev.entry.tool_use_id ? { ...ev.entry } : e ), })); } else if (ev.type === 'artifact') { setArtifactsByThread(a => ({ ...a, [threadId]: [...(a[threadId]||[]), ev.artifact], })); } else if (ev.type === 'artifact.update') { // Critic warnings (or any post-emit enrichment) attach to an // existing artifact. Replace by id with a new object reference so // React re-renders the BriefCard. setArtifactsByThread(a => { const list = a[threadId] || []; const next = list.map(x => x.id === ev.artifact.id ? { ...ev.artifact } : x); return { ...a, [threadId]: next }; }); } else if (ev.type === 'done') { // noop — final message added below } }; try { const { message } = await api.chat({ threadId, userText: text, attachments, onEvent, ...options }); setMessagesByThread(m => ({ ...m, [threadId]: [...(m[threadId]||[]), message] })); } catch (e) { console.error(e); setToast({ kind: 'error', text: 'Something went wrong.' }); } finally { setStreaming({ active: false, threadId: null, skills: [], activeSkillIndex: -1, partial: '', steps: [], plan: null, receipts: [], clarifier: null, activity: [], todos: [] }); } }, async submitClarifier(clarifierAnswers, originalText) { // Append a synthetic user message summarizing the answers const threadId = route.threadId; const summary = Object.entries(clarifierAnswers) .map(([k, v]) => `${k.replace(/_/g,' ')}: ${v}`).join(' · '); // Mark the most recent clarifier message as answered (locks the UI form) setMessagesByThread(m => { const list = [...(m[threadId] || [])]; for (let i = list.length - 1; i >= 0; i--) { if (list[i].role === 'assistant' && list[i].awaitingClarifier) { list[i] = { ...list[i], awaitingClarifier: false, clarifierAnswers }; break; } } return { ...m, [threadId]: list }; }); const userMsg = await api.appendMessage(threadId, { role: 'user', content: summary, isClarifierReply: true, }); setMessagesByThread(m => ({ ...m, [threadId]: [...(m[threadId]||[]), userMsg] })); // Kick the assistant, passing the original prompt and the clarifier answers await actions.sendToAssistant(originalText, [], { clarifierAnswers }); }, async regenerateVariant(variantId, instructions) { const threadId = route.threadId; // Find variant in any artifact in this thread const arts = artifactsByThread[threadId] || []; let target = null; for (const a of arts) { if (a.kind === 'variant-matrix') { const found = a.variants.find(v => v.id === variantId); if (found) { target = { artifact: a, variant: found }; break; } } } if (!target) return; const updated = await api.regenerateVariant(target.variant, instructions); setArtifactsByThread(a => ({ ...a, [threadId]: (a[threadId]||[]).map(ar => { if (ar.id !== target.artifact.id) return ar; return { ...ar, variants: ar.variants.map(v => v.id === variantId ? updated : v) }; }), })); setToast({ kind: 'ok', text: 'Variant refined.' }); }, async createAgent(payload) { const agent = await api.createScheduledAgent(payload); setAgents(a => [...a, agent]); return agent; }, async toggleAgent(id) { const updated = await api.toggleAgent(id); setAgents(a => a.map(x => x.id === id ? updated : x)); }, async deleteAgent(id) { await api.deleteAgent(id); setAgents(a => a.filter(x => x.id !== id)); }, async approveProposal(proposalId) { const res = await api.approveProposal(proposalId); setToast({ kind: 'ok', text: 'Proposal approved. Brief queued to production.' }); return res; }, // ── Briefings management ──────────────────────────────────────────── setBriefingFilter, async loadBriefings() { try { const data = await api.listBriefings(briefingFilter); setBriefings(data.items || []); } catch (e) { console.error('loadBriefings failed', e); setToast({ kind: 'error', text: 'Failed to load briefings.' }); } }, async loadBriefing(id) { if (!id) return null; try { const rec = await api.getBriefing(id); setBriefingsById(map => ({ ...map, [id]: rec })); return rec; } catch (e) { console.error('loadBriefing failed', e); setToast({ kind: 'error', text: 'Failed to load briefing.' }); return null; } }, async transitionBriefing(id, to, reason) { try { const rec = await api.transitionBriefing(id, { to, actor: (me && me.name) || 'demo-user', reason, }); setBriefingsById(map => ({ ...map, [id]: rec })); // Refresh the list so the table reflects the new status. const data = await api.listBriefings(briefingFilter); setBriefings(data.items || []); setToast({ kind: 'ok', text: `Briefing → ${to.replace(/_/g, ' ')}` }); return rec; } catch (e) { console.error('transitionBriefing failed', e); setToast({ kind: 'error', text: e.message || 'Transition failed.' }); throw e; } }, async patchBriefing(id, patch) { try { const rec = await api.patchBriefing(id, { ...patch, actor: (me && me.name) || 'demo-user', }); setBriefingsById(map => ({ ...map, [id]: rec })); const data = await api.listBriefings(briefingFilter); setBriefings(data.items || []); return rec; } catch (e) { console.error('patchBriefing failed', e); setToast({ kind: 'error', text: e.message || 'Update failed.' }); throw e; } }, async commentOnBriefing(id, body) { try { await api.commentBriefing(id, { author: (me && me.name) || 'demo-user', body, }); // Reload the full record so comments and activity refresh together. const rec = await api.getBriefing(id); setBriefingsById(map => ({ ...map, [id]: rec })); return rec; } catch (e) { console.error('commentOnBriefing failed', e); setToast({ kind: 'error', text: e.message || 'Comment failed.' }); throw e; } }, // Dev reset — wipes server-side SessionState + every inku.* localStorage // key, then forces a hard reload so the UI rebuilds from a fresh state. async resetAll() { const result = await api.resetAll(); setToast({ kind: 'ok', text: `Reset complete — server cleared ${result.server_cleared} session(s). Reloading…`, }); // Brief delay so the toast can flash before reload. setTimeout(() => { window.location.reload(); }, 400); }, navigate, setToast, }; // toast auto-dismiss React.useEffect(() => { if (!toast) return; const t = setTimeout(() => setToast(null), 2800); return () => clearTimeout(t); }, [toast]); const value = { me, route, navigate, threads, agents, currentThread, currentMessages, currentArtifacts, streaming, uploadsInFlight, toast, briefings, briefingsById, briefingFilter, ...actions, }; return {children}; } window.StoreProvider = StoreProvider; window.useStore = useStore;