import type { ICopilotAction, ICopilotMessage, ICopilotPopupCustomization, ICopilotQuest, ICopilotQuestOverview, ICopilotQuickActionIcon, ICopilotSetupStep, ICopilotStep, } from '../service/copilot/copilot.interface'; import { resolveSetupStep } from '../service/copilot/setup-steps'; /** * Client-side state for the Reco copilot panel: open state, the conversation * thread, and the streaming flag. */ export interface ICopilotData { /** The merchant's recomaze JWT, set once the panel mounts. */ token: string; /** The merchant's recomaze client id, set once the panel mounts. */ clientId: string; /** Whether the panel is expanded. */ isOpen: boolean; /** Whether a response is currently streaming. */ isStreaming: boolean; /** The conversation, oldest first. */ messages: ICopilotMessage[]; /** Whether the persisted thread has been loaded this session. */ isHydrated: boolean; /** Whether the proactive new-merchant greeting has already fired this session. */ hasAutoOpened: boolean; /** A prompt queued by a dashboard page for the copilot to send next. */ pendingPrompt: string | null; /** A KB draft the Knowledge Base page should open in its review modal. */ kbDraftFileName: string | null; /** A popup-customization proposal the Settings page should pre-fill, or null. */ pendingPopupCustomization: ICopilotPopupCustomization | null; /** Generated quick-action icons the Settings page should apply, or null. */ pendingQuickActionIcons: ICopilotQuickActionIcon[] | null; /** Number of open quests, shown as the launcher badge. */ questCount: number; /** A short notification shown above the launcher, or null. */ questNotification: string | null; /** Whether quests have been refreshed this session. */ questsLoaded: boolean; /** Full quest overview (open quests, points, history) for the home surface. */ questOverview: ICopilotQuestOverview | null; /** The proactive-nudge quest backing the launcher toast, or null. */ questNudge: ICopilotQuest | null; /** Incomplete onboarding steps (authoritative, from the agent). */ pendingSteps: string[]; /** All onboarding steps with completion state, in order. */ setupSteps: ICopilotSetupStep[]; /** True when no onboarding step is complete yet. */ isNewMerchant: boolean; } /** The initial copilot state, matching the previous zustand store defaults. */ export const initialCopilotState: ICopilotData = { token: '', clientId: '', isOpen: false, isStreaming: false, messages: [], isHydrated: false, hasAutoOpened: false, questCount: 0, questNotification: null, questsLoaded: false, questOverview: null, questNudge: null, pendingSteps: [], setupSteps: [], isNewMerchant: false, pendingPrompt: null, kbDraftFileName: null, pendingPopupCustomization: null, pendingQuickActionIcons: null, }; /** localStorage key holding quest ids whose points toast was already shown. */ const CELEBRATED_QUESTS_KEY = 'copilot:celebrated-quests'; /** * Picks the first recently-resolved quest whose points toast hasn't been shown * yet, and marks it as shown. Best-effort: storage failures just skip the toast. * * @param quests - Recently resolved quests from the overview. * @return The quest to celebrate, or null when all were already celebrated. */ export function pickUncelebratedQuest( quests: ICopilotQuest[] ): ICopilotQuest | null { if (typeof window === 'undefined' || quests.length === 0) return null; let seen: string[] = []; try { const stored: unknown = JSON.parse( window.localStorage.getItem(CELEBRATED_QUESTS_KEY) ?? '[]' ); if (Array.isArray(stored)) seen = stored.map(String); } catch { seen = []; } const fresh: ICopilotQuest | null = quests.find(quest => !seen.includes(quest.quest_id)) ?? null; if (fresh) { try { window.localStorage.setItem( CELEBRATED_QUESTS_KEY, JSON.stringify([...seen, fresh.quest_id].slice(-50)) ); } catch { // Best-effort: a blocked storage just means the toast may repeat. } } return fresh; } /** localStorage key holding setup-step names whose done toast was shown. */ const CELEBRATED_STEPS_KEY = 'copilot:celebrated-steps'; /** * Picks the first setup step that completed since the last look, and marks it * as celebrated. On the very first run the currently-completed steps are * seeded silently, so the feature never celebrates work done long ago. * * @param steps - All setup steps with completion state. * @return The freshly completed step name, or null when nothing new. */ export function pickFreshlyCompletedStep( steps: ICopilotSetupStep[] ): string | null { if (typeof window === 'undefined' || steps.length === 0) return null; const completedNow: string[] = steps .filter(step => step.completed) .map(step => step.name); const stored: string | null = window.localStorage.getItem(CELEBRATED_STEPS_KEY); let seen: string[] = []; if (stored === null) { // First run: seed without celebrating, so pre-existing progress is quiet. try { window.localStorage.setItem( CELEBRATED_STEPS_KEY, JSON.stringify(completedNow) ); } catch { // Best-effort: a blocked storage just means the toast may repeat. } return null; } try { const parsed: unknown = JSON.parse(stored); if (Array.isArray(parsed)) seen = parsed.map(String); } catch { seen = []; } const fresh: string | null = completedNow.find(name => !seen.includes(name)) ?? null; if (fresh) { try { window.localStorage.setItem( CELEBRATED_STEPS_KEY, JSON.stringify([...seen, fresh]) ); } catch { // Best-effort, as above. } } return fresh; } /** Every reducer action mirrors one set()-style update from the old store. */ export type CopilotAction = | { type: 'setAuth'; token: string; clientId: string } | { type: 'open' } | { type: 'close' } | { type: 'toggle' } | { type: 'setMessages'; messages: ICopilotMessage[] } | { type: 'setQuestOverview'; overview: ICopilotQuestOverview } | { type: 'dismissQuestNotification' } | { type: 'clearNudgeToast' } | { type: 'requestCopilotTurn'; prompt: string } | { type: 'clearPendingPrompt' } | { type: 'setKbDraftFileName'; fileName: string | null } | { type: 'setPendingPopupCustomization'; suggestion: ICopilotPopupCustomization | null; } | { type: 'setPendingQuickActionIcons'; icons: ICopilotQuickActionIcon[] | null; } | { type: 'addUserMessage'; content: string } | { type: 'startAssistantMessage' } | { type: 'upsertStep'; step: ICopilotStep } | { type: 'setAssistantActions'; actions: ICopilotAction[] } | { type: 'consumeMessageActions'; index: number } | { type: 'setAssistantGeneration'; generation: { kind: 'article' | 'content'; articleId?: string; jobId?: string; }; } | { type: 'setMessageGenerationReady'; index: number } | { type: 'appendToAssistant'; text: string } | { type: 'setStreaming'; value: boolean } | { type: 'failLastAssistant'; message: string } | { type: 'reset' } | { type: 'patch'; patch: Partial }; /** * The single copilot reducer. Each case reproduces the exact update logic of * the corresponding action body from the previous zustand store. * * @param state - The current copilot state. * @param action - The dispatched action. * @return The next copilot state. */ export function copilotReducer( state: ICopilotData, action: CopilotAction ): ICopilotData { switch (action.type) { case 'setAuth': return { ...state, token: action.token, clientId: action.clientId }; case 'open': return { ...state, isOpen: true }; case 'close': return { ...state, isOpen: false }; case 'toggle': return { ...state, isOpen: !state.isOpen }; case 'setMessages': return { ...state, messages: action.messages, isHydrated: true }; case 'setQuestOverview': { const overview: ICopilotQuestOverview = action.overview; const openCount: number = overview.open.length; const setupIncomplete: boolean = overview.pending_steps.length > 0; const nudge: ICopilotQuest | null = overview.nudge ?? null; // A just-resolved quest celebrates its points once, taking the toast // slot for this round; the nudge returns on the next refresh. A freshly // completed setup step gets the same one-time treatment. const celebration: ICopilotQuest | null = pickUncelebratedQuest( overview.recently_resolved ?? [] ); const freshStep: string | null = pickFreshlyCompletedStep( overview.setup_steps ?? [] ); const nextPending: string | null = overview.pending_steps[0] ?? null; const stepToast: string | null = freshStep ? nextPending ? `Nice, "${resolveSetupStep(freshStep).label}" is done! Next up: ${resolveSetupStep(nextPending).label}.` : `Nice, "${resolveSetupStep(freshStep).label}" is done - your setup is complete!` : null; return { ...state, questOverview: overview, questNudge: celebration || stepToast ? null : nudge, questCount: setupIncomplete ? overview.pending_steps.length : openCount, // Celebrate finished quests first, then freshly completed setup steps; // otherwise, while setup is unfinished, nudge toward finishing it; // otherwise nudge the single most impactful task by name. questNotification: celebration ? `Task complete: ${celebration.title} (+${celebration.points} points)` : stepToast ? stepToast : setupIncomplete ? "Let's finish setting up Recomaze. I can guide you." : nudge ? nudge.title : null, questsLoaded: true, pendingSteps: overview.pending_steps, setupSteps: overview.setup_steps, isNewMerchant: overview.is_new_merchant, // A brand-new merchant gets the panel opened onto the home surface. isOpen: overview.is_new_merchant && !state.hasAutoOpened ? true : state.isOpen, hasAutoOpened: overview.is_new_merchant ? true : state.hasAutoOpened, }; } case 'dismissQuestNotification': return { ...state, questNotification: null }; case 'clearNudgeToast': // Clear the toast immediately; dismissNudge persists the dismissal and // re-applies the refreshed overview afterward. return { ...state, questNotification: null, questNudge: null }; case 'requestCopilotTurn': return { ...state, isOpen: true, pendingPrompt: action.prompt }; case 'clearPendingPrompt': return { ...state, pendingPrompt: null }; case 'setKbDraftFileName': return { ...state, kbDraftFileName: action.fileName }; case 'setPendingPopupCustomization': return { ...state, pendingPopupCustomization: action.suggestion }; case 'setPendingQuickActionIcons': return { ...state, pendingQuickActionIcons: action.icons }; case 'addUserMessage': return { ...state, messages: [ ...state.messages, { role: 'user', content: action.content }, ], }; case 'startAssistantMessage': return { ...state, messages: [ ...state.messages, { role: 'assistant', content: '', steps: [] }, ], }; case 'upsertStep': { const messages: ICopilotMessage[] = state.messages.slice(); const lastIndex: number = messages.length - 1; if (lastIndex < 0 || messages[lastIndex].role !== 'assistant') return { ...state, messages }; const steps: ICopilotStep[] = (messages[lastIndex].steps ?? []).slice(); const existingIndex: number = steps.findIndex( entry => entry.id === action.step.id ); if (existingIndex >= 0) { steps[existingIndex] = action.step; } else { steps.push(action.step); } messages[lastIndex] = { ...messages[lastIndex], steps }; return { ...state, messages }; } case 'setAssistantActions': { const messages: ICopilotMessage[] = state.messages.slice(); const lastIndex: number = messages.length - 1; if (lastIndex < 0 || messages[lastIndex].role !== 'assistant') return { ...state, messages }; messages[lastIndex] = { ...messages[lastIndex], actions: action.actions }; return { ...state, messages }; } case 'consumeMessageActions': { const messages: ICopilotMessage[] = state.messages.slice(); if (action.index < 0 || action.index >= messages.length) return { ...state, messages }; messages[action.index] = { ...messages[action.index], actions: undefined, }; return { ...state, messages }; } case 'setAssistantGeneration': { const messages: ICopilotMessage[] = state.messages.slice(); const lastIndex: number = messages.length - 1; if (lastIndex < 0 || messages[lastIndex].role !== 'assistant') return { ...state, messages }; messages[lastIndex] = { ...messages[lastIndex], generationKind: action.generation.kind, generationArticleId: action.generation.articleId, generationJobId: action.generation.jobId, generationStatus: 'generating', }; return { ...state, messages }; } case 'setMessageGenerationReady': { const messages: ICopilotMessage[] = state.messages.slice(); if (action.index < 0 || action.index >= messages.length) return { ...state, messages }; messages[action.index] = { ...messages[action.index], generationStatus: 'ready', }; return { ...state, messages }; } case 'appendToAssistant': { const messages: ICopilotMessage[] = state.messages.slice(); const lastIndex: number = messages.length - 1; if (lastIndex >= 0 && messages[lastIndex].role === 'assistant') { // Preserve steps/actions already attached this turn - only grow content. messages[lastIndex] = { ...messages[lastIndex], content: messages[lastIndex].content + action.text, }; } return { ...state, messages }; } case 'setStreaming': return { ...state, isStreaming: action.value }; case 'failLastAssistant': { const messages: ICopilotMessage[] = state.messages.slice(); const lastIndex: number = messages.length - 1; if (lastIndex >= 0 && messages[lastIndex].role === 'assistant') { messages[lastIndex] = { role: 'assistant', content: action.message }; } return { ...state, messages, isStreaming: false }; } case 'reset': return { ...state, messages: [], isStreaming: false, isHydrated: true, hasAutoOpened: true, }; case 'patch': return { ...state, ...action.patch }; default: return state; } }