diff --git a/app/routes/ai.tsx b/app/routes/ai.tsx index 402e15e..f1db217 100644 --- a/app/routes/ai.tsx +++ b/app/routes/ai.tsx @@ -115,6 +115,65 @@ function saveLive(msgs: LLMMessage[]) { // Quota exceeded or similar — silently drop persistence. } } +/* Per-message agent attribution + the set of agents that have produced + * turns in the current conversation. Persisted alongside LIVE_KEY so a + * reload mid-thread preserves both who-said-what and the hand-off note + * the next turn carries. + * + * Stored as plain JSON shapes (Maps don't serialize): + * AGENTS_KEY: Array<[agentId, Agent]> ← agentHistory + * MSG_AGENTS_KEY: Array<[index, Agent]> ← messageAgents + */ +const AGENTS_KEY = "crema.ai.agent-history" +const MSG_AGENTS_KEY = "crema.ai.message-agents" + +function loadAgentHistory(): Map { + if (typeof window === "undefined") return new Map() + try { + const raw = localStorage.getItem(AGENTS_KEY) + if (!raw) return new Map() + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return new Map(parsed as [string, Agent][]) + } catch {} + return new Map() +} +function saveAgentHistory(m: Map) { + if (typeof window === "undefined") return + if (m.size === 0) { + localStorage.removeItem(AGENTS_KEY) + return + } + try { + localStorage.setItem(AGENTS_KEY, JSON.stringify([...m.entries()])) + } catch {} +} + +function loadMessageAgents(): Map { + if (typeof window === "undefined") return new Map() + try { + const raw = localStorage.getItem(MSG_AGENTS_KEY) + if (!raw) return new Map() + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return new Map(parsed as [number, Agent][]) + } catch {} + return new Map() +} +function saveMessageAgents(m: Map) { + if (typeof window === "undefined") return + if (m.size === 0) { + localStorage.removeItem(MSG_AGENTS_KEY) + return + } + try { + localStorage.setItem(MSG_AGENTS_KEY, JSON.stringify([...m.entries()])) + } catch {} +} +function clearAgentMaps() { + if (typeof window === "undefined") return + localStorage.removeItem(AGENTS_KEY) + localStorage.removeItem(MSG_AGENTS_KEY) +} + function clearLive() { if (typeof window === "undefined") return localStorage.removeItem(LIVE_KEY) @@ -369,19 +428,29 @@ function ChatSurface({ // the new agent knows it's stepping into a transcript started by another // persona — without that note it answers as if it produced every prior // turn itself, which is jarring. + // Both maps are seeded from localStorage so a reload mid-thread keeps + // attribution + hand-off context intact. Persisted on every change via + // the effect lower down, cleared together with the live snapshot. const [agentHistory, setAgentHistory] = useState>( - () => new Map(), + () => loadAgentHistory(), ) const prevAgentRef = useRef(activeAgent) // Per-message agent attribution: which agent produced the assistant // message at each index. Populated when a turn finishes streaming, used - // in MessageRow to show the right name in the signature line. Survives - // until the conversation is cleared. + // in MessageRow to show the right name in the signature line. const [messageAgents, setMessageAgents] = useState>( - () => new Map(), + () => loadMessageAgents(), ) + // Persist whenever either map changes. + useEffect(() => { + saveAgentHistory(agentHistory) + }, [agentHistory]) + useEffect(() => { + saveMessageAgents(messageAgents) + }, [messageAgents]) + // Hand-off prompt — only emitted when this conversation has been touched // by more than one agent. Lists prior personas the new agent might see in // the transcript. @@ -439,6 +508,7 @@ function ChatSurface({ const resetAndClear = useCallback(() => { initialLive.current = [] clearLive() + clearAgentMaps() setMessages([]) setAgentHistory(new Map()) setMessageAgents(new Map())