import { MenuIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react"; import type { CreatedConversation, NewConversationCreateOptions, } from "../hooks/use-new-conversation"; import { cn } from "../lib/cn"; import { notifyObserver } from "../lib/notify-observer"; import { useHostTheme, type HostSurfacePreference, type HostThemePreference, } from "../lib/host-theme"; import { useOpenGeni, type ClientOverride } from "../session-context"; import { NewConversation } from "./new-conversation"; import { useClientConfigFlags } from "../hooks/use-client-config-flags"; import { SessionConversation, type SessionConversationProps } from "./session-conversation"; import { SessionList, type SessionListLabels, type SessionListProps } from "./session-list"; import { SessionProxyScope, type SessionProxyBaseUrl } from "./session-proxy-scope"; import { useOpenGeniText, type OpenGeniText } from "../messages/runtime"; export type OpenGeniChatLabels = SessionListLabels & { openChats: string; closeChats: string; newChatPlaceholder: string; /** Heading above the new-chat composer; empty hides it. */ newChatTitle: string; send: string; newChatUnavailable: string; newChatRetry?: string | undefined; newChatPending?: string | undefined; newChatFinishingUploads?: string | undefined; }; const DEFAULT_LABELS = (t: OpenGeniText): Omit => ({ openChats: t("openGeniChat.openChats"), closeChats: t("openGeniChat.closeChats"), newChatPlaceholder: t("openGeniChat.askAnything"), newChatTitle: t("openGeniChat.howCanIHelp"), send: t("openGeniChat.send"), newChatUnavailable: t("openGeniChat.newChatsAreNotEnabled"), }); /** What the first message carries besides its text. */ export type OpenGeniChatCreateOptions = NewConversationCreateOptions; export type OpenGeniChatProps = ClientOverride & SessionProxyBaseUrl & { /** Controlled selection. `null` shows the new-chat composer. */ sessionId?: string | null | undefined; /** Initial selection when uncontrolled. Defaults to a new chat. */ defaultSessionId?: string | null | undefined; onSessionChange?: ((sessionId: string | null) => void) | undefined; /** * Create a chat from its first message. Defaults to `client.createSession` * with `{ initialMessage, idempotencyKey }` plus the attached files and any * model choice, which `createSessionProxyHandler` accepts when the server * supplies a `createSession` hook. Return the new id. */ createSession?: | (( initialMessage: string, idempotencyKey: string, options: OpenGeniChatCreateOptions, ) => Promise) | undefined; /** * Hide the "New chat" entry point. It is also hidden when the session proxy * reports chat creation unavailable (no `createSession` hook) and no * `createSession` prop is given. */ newChat?: boolean | undefined; /** * Forwarded to the conversation (message rendering, tool renderers, * composer...). `attachments`, `modelPicker`, `modelPickerProps`, and * `composerProps` also apply to the new-chat composer. */ conversationProps?: Omit< SessionConversationProps, "sessionId" | "client" | "workspaceId" | "baseUrl" | "headers" | "fetch" >; /** Forwarded to the list (rename/archive toggles, page size). */ listProps?: Pick | undefined; labels?: Partial | undefined; className?: string | undefined; /** Defaults to filling the host. */ height?: CSSProperties["height"]; /** * Light or dark. Defaults to `auto`: follow the host page (an enclosing * `data-og-theme`, `class="dark"`/`data-theme` on or , the * host's `color-scheme`, then its background), not the OS setting alone. */ theme?: HostThemePreference | undefined; /** * `host` (default) derives backgrounds and cards from the host background so * the chat blends in; `theme` uses the `--og-color-*` surface tokens as they * are. Customized surface tokens are always kept. */ surface?: HostSurfacePreference | undefined; }; /** * A complete chat experience: the user's chat list plus the conversation. * The list is a sidebar when the component is wide and a drawer when narrow * (container-based, so it adapts inside panels as well as full pages). * `` needs no provider: it talks to * your session proxy and uses the workspace the proxy resolves. */ export function OpenGeniChat({ baseUrl, headers, fetch, ...props }: OpenGeniChatProps) { if (baseUrl === undefined) return ; const { client, workspaceId, ...rest } = props; return ( ); } function Chat({ client, workspaceId, sessionId: controlledSessionId, defaultSessionId = null, onSessionChange, createSession, newChat = true, conversationProps, listProps, labels: labelOverrides, className, height = "100%", theme, surface, }: Omit) { const t = useOpenGeniText(); const labels = { ...DEFAULT_LABELS(t), ...labelOverrides } as OpenGeniChatLabels; const scope = { client, workspaceId }; const context = useOpenGeni(scope); const [uncontrolled, setUncontrolled] = useState(defaultSessionId); const selected = controlledSessionId !== undefined ? controlledSessionId : uncontrolled; const [drawerOpen, setDrawerOpen] = useState(false); const [listRevision, setListRevision] = useState(0); const [handoff, setHandoff] = useState(null); const root = useRef(null); const hostTheme = useHostTheme(root, { theme, surface }); const config = useClientConfigFlags(context.client); // A host creator replaces the proxy route; otherwise trust the proxy's report. const canCreate = createSession !== undefined || config.sessionCreation; const select = useCallback( (next: string | null) => { setHandoff(null); if (controlledSessionId === undefined) setUncontrolled(next); notifyObserver(onSessionChange, next); setDrawerOpen(false); }, [controlledSessionId, onSessionChange], ); useEffect(() => { if (!handoff) return; // The child's native composer captures its seed on mount. Do not seed it // again after later navigation; voice keeps its separate one-shot handoff // until the lazy control reports consumption. if (selected === handoff.sessionId && !handoff.realtimeModel) setHandoff(null); else if (selected !== null && selected !== handoff.sessionId) setHandoff(null); }, [handoff, selected]); const list = ( select(null) : undefined} onArchived={(archivedId) => { if (archivedId === selected) select(null); }} refreshKey={listRevision} className="h-full" /> ); return (
{drawerOpen ? (
{list}
) : null}
{labels.heading}
{selected ? ( { setHandoff(null); notifyObserver( conversationProps?.realtimeVoiceProps?.onRealtimeAutostartConsumed, ); }, } : {}), }} // Sub-agent chats open in place, like a chat from the list. onOpenSession={conversationProps?.onOpenSession ?? select} {...scope} sessionId={selected} height="100%" /> ) : ( { setListRevision((revision) => revision + 1); select(created.sessionId); setHandoff(created); }} /> )}
); }