"use client"; import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode, } from "react"; import { usePathname } from "next/navigation"; import type { AssistantCommandId, AssistantMention, NumoIntent, AssistantPageContext, } from "@/lib/assistant-types"; import type { PanelDisplayMode } from "@/components/assistant/panel-geometry"; import type { ResourceInput } from "@/lib/types"; import { projectIdFromPath } from "@/lib/project-id-from-path"; import { trackEvent } from "@/lib/analytics"; export interface OpenAssistantOptions { /** Project context attached by this opening; never replaces the conversation. */ projectId?: string | null; /** * Open ON a persisted conversation instead of the live one. The panel loads * it and marks it read — the entry of inbox rows and "view the session" * gestures, which have no page to push any more. */ conversationId?: string | null; /** Auto-send a one-shot message right after opening. */ prompt?: string; /** * What the "@" and "/" of the original composer set in `prompt`. * Home opens its own composer, mentions and command included: without * these two, the text would arrive at Numo but stripped of what it * designates — a mention becomes text again, a command with no effect. */ mentions?: AssistantMention[]; command?: AssistantCommandId; /** * Attachments from the original composer, ALREADY uploaded: `ResourceInput` * only carries a storage path, not a file. The reception can therefore en * join — the wait which goes up for them is played out in its composition, and the opening * only carries references. */ attachments?: ResourceInput[]; /** Pre-fill the composer without sending (one-shot). */ draft?: string; /** * What the user is currently looking at (e.g. the issue open in the side * panel). Sent with the conversation's messages so Numo can resolve * "ce ticket" and edit it directly. Persists for the opened session. */ pageContext?: AssistantPageContext; /** Provenance retained when an old caller still uses `open` directly. */ intent?: Pick; /** * Display mode the panel should open in. Absent = keep whatever mode the * session is already in (the toolbar toggle stays authoritative). The home * composer asks for `"expanded"`: a message sent from the dashboard is a * work session, not a popover glance. */ displayMode?: PanelDisplayMode; /** * Put the caret in the composer once the panel is ready, without sending or * filling anything: the opener hands the user a context to ask about. */ focusComposer?: boolean; } /** Opening-time presentation choices of `openIntent`. */ export interface OpenIntentOptions { displayMode?: PanelDisplayMode; /** See `OpenAssistantOptions.focusComposer`. */ focusComposer?: boolean; } export interface AssistantPanelContextValue { isOpen: boolean; /** Options consumed by AssistantPanel on the next open cycle. */ pendingOptions: OpenAssistantOptions | null; /** * Page context active for the open session (e.g. the issue being viewed). * Set on open, cleared on close — survives `clearPendingOptions` so manual * follow-up messages keep carrying it. Set in event handlers, so the panel * never has to setState inside an effect. */ activePageContext: AssistantPageContext | null; /** * Ambient context derived from the page the user is currently on (e.g. the * issue whose side panel is open). Unlike `activePageContext`, it exists * independently of open/close — it drives the FAB badge (panel closed) and * is the default context attached to messages when the panel is opened * without an explicit one. Set declaratively via `useAssistantContext`. */ ambientContext: AssistantPageContext | null; /** * Set/clear the ambient context. `ownerId` scopes ownership so a page * unmounting only clears the context if it still owns it — prevents a * navigation's unmount/mount race from wiping the next page's context. */ setAmbientContext: ( ctx: AssistantPageContext | null, ownerId: string, ) => void; /** * Does a surface already show Numo's conversation on screen (Home, MIN-930)? * Declared by the surface itself (`useSuppressAssistantFab`) rather than * deduced from a list of routes. The Numo launchers then stop signalling * Numo's activity, as they do while the panel is open: the user is watching * it already. */ fabSuppressed: boolean; /** `ownerId` counts surfaces: several can be mounted at once. */ setFabSuppressed: (suppressed: boolean, ownerId: string) => void; open: (opts?: OpenAssistantOptions) => void; /** The common entry for every voluntary request to Numo. `opts` carries the opening-time presentation choices (e.g. the home composer opens fullscreen). */ openIntent: (intent: NumoIntent, opts?: OpenIntentOptions) => void; /** * The ambient context at call time, read without subscribing: an action * (such as "Ask Numo" on a selection) needs it once, when it runs, and must * not rerender its toolbar whenever the page publishes a new context. */ readAmbientContext: () => AssistantPageContext | null; /** * Drop the quoted selection from the open session's context. Called when * the user removes its pill, and once the message carrying it is sent. */ clearSelectionContext: () => void; close: () => void; toggle: () => void; /** Called by the panel after consuming pendingOptions. */ clearPendingOptions: () => void; /** Project id derived from the current URL when no override is provided. */ routeProjectId: string | null; } const AssistantPanelContext = createContext( null, ); export type AssistantPanelActions = Pick< AssistantPanelContextValue, "setAmbientContext" | "setFabSuppressed" | "open" | "openIntent" | "close" | "toggle" | "clearPendingOptions" | "readAmbientContext" | "clearSelectionContext" >; const AssistantPanelActionsContext = createContext(null); export function AssistantPanelProvider({ children }: { children: ReactNode }) { const pathname = usePathname(); const routeProjectId = useMemo( () => projectIdFromPath(pathname ?? ""), [pathname], ); const [isOpen, setIsOpen] = useState(false); const [pendingOptions, setPendingOptions] = useState(null); const [activePageContext, setActivePageContext] = useState(null); useEffect(() => { setActivePageContext(null); }, [pathname]); const [ambientContext, setAmbientContextState] = useState(null); // Which mounted surface owns the current ambient context. Only the owner may // clear it, so navigating A→B (B mounts before A's cleanup runs) never wipes // B's freshly-set context. const ambientOwnerRef = useRef(null); // Mirrors `ambientContext` for `readAmbientContext`, so the actions object // stays stable while the page republishes its context. const ambientContextRef = useRef(null); useEffect(() => { ambientContextRef.current = ambientContext; }, [ambientContext]); const readAmbientContext = useCallback(() => ambientContextRef.current, []); const setAmbientContext = useCallback( (ctx: AssistantPageContext | null, ownerId: string) => { if (ctx) { ambientOwnerRef.current = ownerId; setAmbientContextState(ctx); } else if (ambientOwnerRef.current === ownerId) { ambientOwnerRef.current = null; setAmbientContextState(null); } }, [], ); /** * Surfaces that hide the FAB, by id. A SET and not a boolean: * two can overlap (one agent conversation in full page, one * another in a modal on top), and the first to unmount would make * the FAB while the second still covers it. */ const [fabOwners, setFabOwners] = useState>( () => new Set(), ); const setFabSuppressed = useCallback((suppressed: boolean, ownerId: string) => { setFabOwners((prev) => { if (suppressed === prev.has(ownerId)) return prev; const next = new Set(prev); if (suppressed) next.add(ownerId); else next.delete(ownerId); return next; }); }, []); const open = useCallback((opts?: OpenAssistantOptions) => { // `prompt` = ouverture programmatique (home, action de ticket) ; sans lui // it's a direct click on the panel. trackEvent("assistant_opened", { source: opts?.prompt ? "home" : opts?.pageContext ? "issue" : "fab", has_page_context: !!opts?.pageContext, autosend: !!opts?.prompt, }); setPendingOptions(opts ?? null); setActivePageContext(opts?.pageContext ?? (opts?.projectId ? { projectId: opts.projectId } : null)); setIsOpen(true); }, []); const openIntent = useCallback( (intent: NumoIntent, opts?: OpenIntentOptions) => { open({ projectId: intent.projectId, prompt: intent.prompt, pageContext: intent.pageContext, mentions: intent.mentions, command: intent.command, attachments: intent.attachments, intent: { source: intent.source, action: intent.action }, displayMode: opts?.displayMode, ...(opts?.focusComposer ? { focusComposer: true } : {}), }); }, [open], ); const close = useCallback(() => { trackEvent("assistant_closed", {}); setIsOpen(false); setActivePageContext(null); }, []); const toggle = useCallback(() => { setIsOpen((prev) => { if (prev) { trackEvent("assistant_closed", {}); setActivePageContext(null); return false; } trackEvent("assistant_opened", { source: "shortcut", has_page_context: false, autosend: false }); // Toggle-open clears any stale pending options/context from a previous open. setPendingOptions(null); setActivePageContext(null); return true; }); }, []); const clearPendingOptions = useCallback(() => { setPendingOptions(null); }, []); const clearSelectionContext = useCallback(() => { setActivePageContext((prev) => { if (!prev?.selection) return prev; const rest: AssistantPageContext = { ...prev }; delete rest.selection; return Object.values(rest).some((value) => value !== undefined) ? rest : null; }); }, []); const actions = useMemo(() => ({ setAmbientContext, setFabSuppressed, open, openIntent, close, toggle, clearPendingOptions, readAmbientContext, clearSelectionContext, }), [setAmbientContext, setFabSuppressed, open, openIntent, close, toggle, clearPendingOptions, readAmbientContext, clearSelectionContext]); const value = useMemo( () => ({ isOpen, pendingOptions, activePageContext, ambientContext, setAmbientContext, fabSuppressed: fabOwners.size > 0, setFabSuppressed, open, openIntent, close, toggle, clearPendingOptions, readAmbientContext, clearSelectionContext, routeProjectId, }), [ isOpen, pendingOptions, activePageContext, ambientContext, setAmbientContext, fabOwners, setFabSuppressed, open, openIntent, close, toggle, clearPendingOptions, readAmbientContext, clearSelectionContext, routeProjectId, ], ); return ( {children} ); } /** Card and menu actions do not subscribe to the current issue or conversation. */ export function useAssistantPanelActions(): AssistantPanelActions { const actions = useContext(AssistantPanelActionsContext); if (!actions) { throw new Error("useAssistantPanelActions must be used within an AssistantPanelProvider"); } return actions; } /** * The panel actions, or `null` outside an `AssistantPanelProvider`: public * surfaces (`app/f/**`, `app/p/**`) mount the shared editor without Numo. */ export function useOptionalAssistantPanelActions(): AssistantPanelActions | null { return useContext(AssistantPanelActionsContext); } export function useAssistantPanel(): AssistantPanelContextValue { const ctx = useContext(AssistantPanelContext); if (!ctx) { throw new Error( "useAssistantPanel must be used within an AssistantPanelProvider", ); } return ctx; } /** * Declaratively register the assistant's ambient context for the lifetime of a * component. Pass the context the current surface represents (e.g. the issue * open in the side panel), or `null` when there is none. The context is * published while mounted and cleared on unmount; it powers the FAB badge and * the chip above the composer, and rides on messages when the panel is opened * without an explicit context. Safe to call once per page/surface. */ export function useAssistantContext( context: AssistantPageContext | null, ): void { const { setAmbientContext } = useAssistantPanelActions(); const ownerId = useId(); // Serialize to a stable key so the effect re-publishes only on a real change // (the caller passes a fresh object each render). The object is rebuilt from // the key inside the effect — no ref reads during render. const contextKey = context ? JSON.stringify(context) : null; useEffect(() => { const next = contextKey ? (JSON.parse(contextKey) as AssistantPageContext) : null; setAmbientContext(next, ownerId); return () => setAmbientContext(null, ownerId); }, [contextKey, ownerId, setAmbientContext]); } /** * Hides Numo's FAB as long as this component is mounted and `active`. * * To be called from the surface that already has a composer, for one of two reasons that make the button float too much: * * - he COVERS it — a composer pinned at the bottom of the screen, where the FAB falls * often right on its send button (agent conversations); * - he REPEATS it — the welcome, whose composer is in the center of the screen and already does * what the FAB offers. There, the deletion is conditional: the FAB * returns as soon as it has something else to offer, namely a conversation with * reopen (see `useResumableConversation`). * * It's the surface that knows, not the road — the Agents page shows a * conversation under its Conversations tab and a simple list under its * Routines tab, at the same URL. */ export function useSuppressAssistantFab(active = true): void { const { setFabSuppressed } = useAssistantPanelActions(); const ownerId = useId(); useEffect(() => { setFabSuppressed(active, ownerId); return () => setFabSuppressed(false, ownerId); }, [active, ownerId, setFabSuppressed]); }