lib/assistant-panel-context.tsx
Rama main · réplica de lectura del repositorio original.
"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<NumoIntent, "source" | "action">;
/**
* 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<AssistantPanelContextValue | null>(
null,
);
export type AssistantPanelActions = Pick<
AssistantPanelContextValue,
"setAmbientContext" | "setFabSuppressed" | "open" | "openIntent" |
"close" | "toggle" | "clearPendingOptions" | "readAmbientContext" |
"clearSelectionContext"
>;
const AssistantPanelActionsContext = createContext<AssistantPanelActions | null>(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<OpenAssistantOptions | null>(null);
const [activePageContext, setActivePageContext] =
useState<AssistantPageContext | null>(null);
useEffect(() => {
setActivePageContext(null);
}, [pathname]);
const [ambientContext, setAmbientContextState] =
useState<AssistantPageContext | null>(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<string | null>(null);
// Mirrors `ambientContext` for `readAmbientContext`, so the actions object
// stays stable while the page republishes its context.
const ambientContextRef = useRef<AssistantPageContext | null>(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<ReadonlySet<string>>(
() => 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<AssistantPanelActions>(() => ({
setAmbientContext,
setFabSuppressed,
open,
openIntent,
close,
toggle,
clearPendingOptions,
readAmbientContext,
clearSelectionContext,
}), [setAmbientContext, setFabSuppressed, open, openIntent, close, toggle, clearPendingOptions, readAmbientContext, clearSelectionContext]);
const value = useMemo<AssistantPanelContextValue>(
() => ({
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 (
<AssistantPanelActionsContext.Provider value={actions}>
<AssistantPanelContext.Provider value={value}>
{children}
</AssistantPanelContext.Provider>
</AssistantPanelActionsContext.Provider>
);
}
/** 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]);
}