);
}
// Wrapper that resolves the briefing record from the store before rendering
// the page. Keeps the app's route dispatcher trivial.
function BriefingDetailRoute() {
const { route, briefingsById } = useStore();
const briefing = briefingsById[route.briefingId];
if (!briefing) {
return (
Loading briefing…
);
}
return ;
}
function OwnerInput({ value, onSubmit }) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(value);
React.useEffect(() => { setDraft(value); }, [value]);
if (!editing) {
return (
);
}
return (
setDraft(e.target.value)}
onBlur={() => { setEditing(false); if (draft !== value) onSubmit(draft); }}
onKeyDown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); e.target.blur(); }
if (e.key === 'Escape') { setDraft(value); setEditing(false); }
}}
style={{
padding: '4px 8px', borderRadius: 6,
border: '1px solid var(--ink)',
background: 'var(--bg)', color: 'var(--ink)',
fontSize: 12, fontFamily: 'inherit', outline: 'none',
minWidth: 200,
}}
/>
);
}
function ActionButtons({ briefing, busy, onTransition, onReject }) {
const buttons = [];
switch (briefing.status) {
case 'draft':
buttons.push({ label: 'Submit for creative review', to: 'in_creative_review', primary: true });
buttons.push({ label: 'Archive', to: 'archived' });
break;
case 'in_creative_review':
buttons.push({ label: 'Approve creative · pass to business', to: 'in_business_review', primary: true });
buttons.push({ label: 'Reject', onClick: onReject, danger: true });
break;
case 'in_business_review':
buttons.push({ label: 'Approve business · final approval', to: 'approved', primary: true });
buttons.push({ label: 'Reject', onClick: onReject, danger: true });
break;
case 'approved':
buttons.push({ label: 'Archive', to: 'archived' });
break;
case 'rejected':
buttons.push({ label: 'Reopen as draft', to: 'draft' });
buttons.push({ label: 'Archive', to: 'archived' });
break;
case 'archived':
// Terminal — no actions
break;
}
if (!buttons.length) return null;
return (
);
}
// ─── IntakePane (full-page CD chat) ─────────────────────────────────────
//
// Lives inside BriefingDetailPage when status is `draft` and no marketing
// brief has landed yet. Owns its own streaming state — calls
// api.chat({threadId, userText, onEvent}) and accumulates text + activity
// locally so we don't have to navigate to /t/. When the backend
// emits `briefing_record` (or any artifact that fills the brief), we
// trigger a re-fetch of the briefing record so the parent page flips
// to the review tabs.
//
// Why local (not store) streaming state: the store's streaming slot is
// keyed to route.threadId, but the intake runs while the route is on
// /briefings/. Keeping it local avoids tangling the two flows.
function IntakePane({ threadId, briefingId, onBriefingUpdated }) {
const { loadBriefings } = useStore();
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const [partial, setPartial] = useState('');
const [history, setHistory] = useState([]); // [{role:'user'|'assistant', text}]
const [activity, setActivity] = useState([]); // structured cards (pickers, brief, etc.)
const [todos, setTodos] = useState([]);
const scrollRef = React.useRef(null);
// Load the thread's persisted messages so the whole conversation —
// prose AND the agent activity trail (tool calls, pickers, todos) —
// survives page reloads. `api.appendMessage` already stores
// {activity, todos, steps, plan} alongside each assistant message
// (see api.js:535), so the rehydrate just keeps those fields.
useEffect(() => {
if (!threadId) return;
let cancelled = false;
setInput('');
setStreaming(false);
setPartial('');
setActivity([]);
setTodos([]);
(async () => {
try {
const msgs = await api.listMessages(threadId);
if (cancelled) return;
const restored = (msgs || [])
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => {
const text = typeof m.content === 'string'
? m.content
: (m.content?.[0]?.text || '');
return {
role: m.role,
text,
// Carry the agent receipts forward so the historical turn
// renders with the same trail it had when it streamed.
activity: Array.isArray(m.activity) ? m.activity : [],
todos: Array.isArray(m.todos) ? m.todos : [],
};
});
setHistory(restored);
} catch (e) {
console.error('[intake] failed to load history', e);
setHistory([]);
}
})();
return () => { cancelled = true; };
}, [threadId]);
// Keep the conversation pinned to the bottom as text streams in.
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [partial, history.length, activity.length, todos.length]);
const send = async () => {
const text = input.trim();
if (!text || streaming || !threadId) return;
setInput('');
setHistory(h => [...h, { role: 'user', text }]);
setStreaming(true);
setPartial('');
try {
// Persist the user turn — api.chat reads prior history from
// localStorage to build the SSE request body, and the IntakePane
// mount-effect re-hydrates from this same store on next visit.
await api.appendMessage(threadId, { role: 'user', content: text });
await api.chat({
threadId,
userText: text,
attachments: [],
onEvent: (ev) => {
if (ev.type === 'content.delta') {
setPartial(ev.text);
} else if (ev.type === 'todos') {
setTodos(ev.todos || []);
} else if (ev.type === 'activity') {
// Capture every activity entry — tools, subagents, and
// artifacts (pickers, briefs). The ActivityTrail component
// renders them with full transparency: tool name, args,
// result preview, subagent boundaries.
if (ev.entry) setActivity(a => [...a, ev.entry]);
} else if (ev.type === 'activity.update') {
// Hydrates a tool entry with its result body once the
// backend emits tool.result. Patch in place by tool_use_id.
setActivity(a => a.map(e =>
(e.tool_use_id && ev.entry && e.tool_use_id === ev.entry.tool_use_id)
? ev.entry
: e
));
} else if (ev.type === 'briefing_record' || ev.type === 'briefing.record') {
// The backend either created or updated a record. Either way
// pull a fresh copy so the parent page can flip from intake
// to review tabs.
onBriefingUpdated && onBriefingUpdated();
}
},
});
} catch (err) {
console.error('[briefing-intake] stream failed', err);
setActivity(a => [...a, { kind: 'error', text: String(err.message || err) }]);
} finally {
// Commit the assistant turn — text PLUS its agent receipts
// (activity trail and todos) — as a frozen entry in history.
// `api.chat` also persists the same data to localStorage, so
// re-mount picks it back up as a frozen turn next time.
const turnActivity = activity;
const turnTodos = todos;
setHistory(h => partial
? [...h, { role: 'assistant', text: partial, activity: turnActivity, todos: turnTodos }]
: h
);
setPartial('');
// Reset the live trail — next turn starts fresh.
setActivity([]);
setTodos([]);
setStreaming(false);
// Refresh both list and detail — SSE may have updated either.
loadBriefings && loadBriefings();
onBriefingUpdated && onBriefingUpdated();
}
};
return (
Tell the Creative Director what you want to brief — product, market,
audience cue, anything you have. The agent runs the intake from there.
)}
{history.map((m, i) => (
{m.text}
{/* Persisted agent receipts for this turn — render the
todos and activity trail right under the assistant
bubble, so the user sees what was run when they
reload the page or come back to the briefing. */}
{m.role === 'assistant' && Array.isArray(m.todos) && m.todos.length > 0 && window.TodoListCard && (
)}
{m.role === 'assistant' && Array.isArray(m.activity) && m.activity.length > 0 && window.ActivityTrail && (
e.kind !== 'error')} />
)}
))}
{/* Status pill — surfaced the moment the user hits Send and
stays visible until either tool calls start landing or the
first text chunk streams in. Closes the "did anything
happen?" gap on slow first-byte turns. */}
{streaming && !partial && activity.length === 0 && todos.length === 0 && (
●
Creative Director is thinking…
)}
{streaming && partial && (
{partial}
▍
)}
{/* Live trail — only rendered for the IN-FLIGHT turn. Frozen
into the assistant message above on stream end. */}
{streaming && todos.length > 0 && window.TodoListCard && (
)}
{/* Errors get a small inline pill — surfaced alongside the trail */}
{activity.filter(e => e.kind === 'error').map((e, i) => (