From d2435aef8d8cc4bd918774779bf1ce7c1c225a94 Mon Sep 17 00:00:00 2001 From: abhithesys Date: Mon, 4 May 2026 16:43:37 +0530 Subject: [PATCH 01/88] feat(react-headless): add ThreadContext store with apps/artifacts registries + hooks refactor: rename Artifact API to DetailedView across react-headless and react-ui --- .../src/hooks/useActiveArtifact.ts | 46 ------ .../src/hooks/useActiveDetailedView.ts | 45 ++++++ .../react-headless/src/hooks/useAppList.ts | 35 +++++ .../react-headless/src/hooks/useArtifact.ts | 69 --------- .../src/hooks/useArtifactList.ts | 36 +++++ .../src/hooks/useDetailedView.ts | 73 +++++++++ ...rget.ts => useDetailedViewPortalTarget.ts} | 24 +-- packages/react-headless/src/index.ts | 21 ++- .../src/store/ArtifactContext.ts | 24 --- .../react-headless/src/store/ChatProvider.tsx | 24 ++- .../src/store/DetailedViewContext.ts | 24 +++ .../src/store/ThreadContextContext.ts | 25 ++++ .../__tests__/createArtifactStore.test.ts | 94 ------------ .../__tests__/createDetailedViewStore.test.ts | 65 ++++++++ .../createThreadContextStore.test.ts | 141 ++++++++++++++++++ ...st.ts => detailedViewThreadSwitch.test.ts} | 56 +++---- .../__tests__/threadContextSwitch.test.ts | 127 ++++++++++++++++ .../react-headless/src/store/artifactTypes.ts | 44 ------ .../src/store/createArtifactStore.ts | 47 ------ .../src/store/createDetailedViewStore.ts | 41 +++++ .../src/store/createThreadContextStore.ts | 89 +++++++++++ .../src/store/detailedViewTypes.ts | 41 +++++ .../src/store/threadContextTypes.ts | 73 +++++++++ packages/react-ui/src/artifact/Artifact.tsx | 83 ----------- packages/react-ui/src/artifact/index.ts | 2 - .../src/components/BottomTray/Thread.tsx | 4 +- .../BottomTray/stories/BottomTray.mdx | 6 +- .../src/components/CopilotShell/Thread.tsx | 4 +- .../components/CopilotShell/stories/Shell.mdx | 6 +- .../OpenUIChat/stories/OpenUIChat.mdx | 4 +- .../components/Shell/ResizableSeparator.tsx | 2 +- .../react-ui/src/components/Shell/Sidebar.tsx | 6 +- .../react-ui/src/components/Shell/Thread.tsx | 30 ++-- .../components/Shell/components/composer.scss | 2 +- .../components/Shell/conversationStarter.scss | 4 +- .../react-ui/src/components/Shell/index.ts | 2 +- .../src/components/Shell/stories/Shell.mdx | 36 ++--- .../react-ui/src/components/Shell/thread.scss | 24 +-- ...factResize.ts => useDetailedViewResize.ts} | 32 ++-- .../src/components/Shell/welcomeScreen.scss | 4 +- .../src/components/_shared/artifact/index.ts | 3 - .../DetailedViewOverlay.tsx} | 28 ++-- .../DetailedViewPanel.tsx} | 66 ++++---- .../DetailedViewPortalTarget.tsx} | 14 +- .../detailedViewOverlay.scss} | 10 +- .../detailedViewPanel.scss} | 6 +- .../components/_shared/detailed-view/index.ts | 3 + .../react-ui/src/components/_shared/index.ts | 2 +- .../src/components/_shared/shared.scss | 4 +- .../src/detailed-view/DetailedView.tsx | 86 +++++++++++ packages/react-ui/src/detailed-view/index.ts | 2 + packages/react-ui/src/index.ts | 26 ++-- 52 files changed, 1141 insertions(+), 624 deletions(-) delete mode 100644 packages/react-headless/src/hooks/useActiveArtifact.ts create mode 100644 packages/react-headless/src/hooks/useActiveDetailedView.ts create mode 100644 packages/react-headless/src/hooks/useAppList.ts delete mode 100644 packages/react-headless/src/hooks/useArtifact.ts create mode 100644 packages/react-headless/src/hooks/useArtifactList.ts create mode 100644 packages/react-headless/src/hooks/useDetailedView.ts rename packages/react-headless/src/hooks/{useArtifactPortalTarget.ts => useDetailedViewPortalTarget.ts} (56%) delete mode 100644 packages/react-headless/src/store/ArtifactContext.ts create mode 100644 packages/react-headless/src/store/DetailedViewContext.ts create mode 100644 packages/react-headless/src/store/ThreadContextContext.ts delete mode 100644 packages/react-headless/src/store/__tests__/createArtifactStore.test.ts create mode 100644 packages/react-headless/src/store/__tests__/createDetailedViewStore.test.ts create mode 100644 packages/react-headless/src/store/__tests__/createThreadContextStore.test.ts rename packages/react-headless/src/store/__tests__/{artifactThreadSwitch.test.ts => detailedViewThreadSwitch.test.ts} (51%) create mode 100644 packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts delete mode 100644 packages/react-headless/src/store/artifactTypes.ts delete mode 100644 packages/react-headless/src/store/createArtifactStore.ts create mode 100644 packages/react-headless/src/store/createDetailedViewStore.ts create mode 100644 packages/react-headless/src/store/createThreadContextStore.ts create mode 100644 packages/react-headless/src/store/detailedViewTypes.ts create mode 100644 packages/react-headless/src/store/threadContextTypes.ts delete mode 100644 packages/react-ui/src/artifact/Artifact.tsx delete mode 100644 packages/react-ui/src/artifact/index.ts rename packages/react-ui/src/components/Shell/{useArtifactResize.ts => useDetailedViewResize.ts} (69%) delete mode 100644 packages/react-ui/src/components/_shared/artifact/index.ts rename packages/react-ui/src/components/_shared/{artifact/ArtifactOverlay.tsx => detailed-view/DetailedViewOverlay.tsx} (65%) rename packages/react-ui/src/components/_shared/{artifact/ArtifactPanel.tsx => detailed-view/DetailedViewPanel.tsx} (51%) rename packages/react-ui/src/components/_shared/{artifact/ArtifactPortalTarget.tsx => detailed-view/DetailedViewPortalTarget.tsx} (64%) rename packages/react-ui/src/components/_shared/{artifact/artifactOverlay.scss => detailed-view/detailedViewOverlay.scss} (57%) rename packages/react-ui/src/components/_shared/{artifact/artifactPanel.scss => detailed-view/detailedViewPanel.scss} (89%) create mode 100644 packages/react-ui/src/components/_shared/detailed-view/index.ts create mode 100644 packages/react-ui/src/detailed-view/DetailedView.tsx create mode 100644 packages/react-ui/src/detailed-view/index.ts diff --git a/packages/react-headless/src/hooks/useActiveArtifact.ts b/packages/react-headless/src/hooks/useActiveArtifact.ts deleted file mode 100644 index b5d48b082..000000000 --- a/packages/react-headless/src/hooks/useActiveArtifact.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useCallback } from "react"; -import { useStore } from "zustand"; -import { useArtifactStore } from "../store/ArtifactContext"; - -/** - * Return type for {@link useActiveArtifact}. - * - * @category Hooks - */ -type UseActiveArtifactReturn = { - /** Whether any artifact is currently active (panel is open). */ - isArtifactActive: boolean; - /** The ID of the currently active artifact, or `null` if none. */ - activeArtifactId: string | null; - /** Closes whichever artifact is currently active. No-op if none is active. */ - closeArtifact: () => void; -}; - -/** - * Returns global artifact activation state — whether *any* artifact is open, - * and a close action that dismisses it. - * - * Use this in layout components that react to artifact presence (resizing panels, - * showing overlays) without needing to know *which* artifact is active. - * For per-artifact state and actions, use {@link useArtifact} instead. - * - * Must be called within a ``. - * - * @category Hooks - * @returns {@link UseActiveArtifactReturn} - */ -export function useActiveArtifact(): UseActiveArtifactReturn { - const store = useArtifactStore(); - - const activeArtifactId = useStore(store, (s) => s.activeArtifactId); - const isArtifactActive = activeArtifactId !== null; - - const closeArtifact = useCallback(() => { - const state = store.getState(); - if (state.activeArtifactId) { - state.closeArtifact(state.activeArtifactId); - } - }, [store]); - - return { isArtifactActive, activeArtifactId, closeArtifact }; -} diff --git a/packages/react-headless/src/hooks/useActiveDetailedView.ts b/packages/react-headless/src/hooks/useActiveDetailedView.ts new file mode 100644 index 000000000..34bc705b6 --- /dev/null +++ b/packages/react-headless/src/hooks/useActiveDetailedView.ts @@ -0,0 +1,45 @@ +import { useCallback } from "react"; +import { useStore } from "zustand"; +import { useDetailedViewStore } from "../store/DetailedViewContext"; + +/** + * Return type for {@link useActiveDetailedView}. + * + * @category Hooks + */ +type UseActiveDetailedViewReturn = { + /** Whether any detailed view is currently active (panel is open). */ + isDetailedViewActive: boolean; + /** The id of the currently active detailed view, or `null` if none. */ + activeDetailedViewId: string | null; + /** Closes whichever detailed view is currently active. No-op if none is active. */ + closeDetailedView: () => void; +}; + +/** + * Returns global detailed-view activation state — whether *any* view is open, + * and a close action that dismisses it. + * + * Use this in layout components that react to detailed-view presence (resizing + * panels, showing overlays) without needing to know *which* view is active. + * For per-view state and actions, use {@link useDetailedView} instead. + * + * Must be called within a ``. + * + * @category Hooks + * @returns {@link UseActiveDetailedViewReturn} + */ +export function useActiveDetailedView(): UseActiveDetailedViewReturn { + const store = useDetailedViewStore(); + + const activeDetailedViewId = useStore(store, (s) => s.activeDetailedViewId); + const isDetailedViewActive = activeDetailedViewId !== null; + + const closeDetailedView = useCallback(() => { + if (store.getState().activeDetailedViewId !== null) { + store.getState().setActiveDetailedView(null); + } + }, [store]); + + return { isDetailedViewActive, activeDetailedViewId, closeDetailedView }; +} diff --git a/packages/react-headless/src/hooks/useAppList.ts b/packages/react-headless/src/hooks/useAppList.ts new file mode 100644 index 000000000..60b88fdd7 --- /dev/null +++ b/packages/react-headless/src/hooks/useAppList.ts @@ -0,0 +1,35 @@ +import { useStore } from "zustand"; +import { useThreadContextStore } from "../store/ThreadContextContext"; +import type { AppEntry } from "../store/threadContextTypes"; + +/** + * Returns all apps registered in the active thread, grouped by `id` and sorted + * ascending by `version`. The latest version of each app is the last element. + * + * Use this for sidebar lists, app pickers, or any UI that enumerates apps + * attached to the current thread. + * + * Must be called within a ``. + * + * @category Hooks + * @returns Map of app id → ordered version list + * + * @example + * ```tsx + * function AppSidebar() { + * const apps = useAppList(); + * const latest = Object.values(apps).map((versions) => versions[versions.length - 1]); + * return ( + *
    + * {latest.map((app) => ( + *
  • {app.heading}
  • + * ))} + *
+ * ); + * } + * ``` + */ +export function useAppList(): Record { + const store = useThreadContextStore(); + return useStore(store, (s) => s.apps); +} diff --git a/packages/react-headless/src/hooks/useArtifact.ts b/packages/react-headless/src/hooks/useArtifact.ts deleted file mode 100644 index ff1775958..000000000 --- a/packages/react-headless/src/hooks/useArtifact.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { useCallback } from "react"; -import { useStore } from "zustand"; -import { useArtifactStore } from "../store/ArtifactContext"; - -/** - * Return type for {@link useArtifact}. - * - * @category Hooks - */ -type UseArtifactReturn = { - /** Whether this artifact is the currently active (visible) one. */ - isActive: boolean; - /** Activates this artifact. */ - open: () => void; - /** Deactivates this artifact. */ - close: () => void; - /** Toggles this artifact: opens if closed, closes if open. */ - toggle: () => void; -}; - -/** - * Binds a component to a specific artifact by ID, providing activation state - * and actions (open, close, toggle). - * - * Multiple `useArtifact` hooks with different IDs can coexist — - * only one artifact is active at a time. - * - * Must be called within a ``. - * - * @category Hooks - * @param artifactId - Unique identifier for the artifact - * @returns {@link UseArtifactReturn} - * - * @example - * ```tsx - * function PreviewButton({ id }: { id: string }) { - * const { isActive, toggle } = useArtifact(id); - * return ( - * - * ); - * } - * ``` - */ -export function useArtifact(artifactId: string): UseArtifactReturn { - const store = useArtifactStore(); - - const isActive = useStore(store, (s) => s.activeArtifactId === artifactId); - - const open = useCallback(() => { - store.getState().openArtifact(artifactId); - }, [store, artifactId]); - - const close = useCallback(() => { - store.getState().closeArtifact(artifactId); - }, [store, artifactId]); - - const toggle = useCallback(() => { - const state = store.getState(); - if (state.activeArtifactId === artifactId) { - state.closeArtifact(artifactId); - } else { - state.openArtifact(artifactId); - } - }, [store, artifactId]); - - return { isActive, open, close, toggle }; -} diff --git a/packages/react-headless/src/hooks/useArtifactList.ts b/packages/react-headless/src/hooks/useArtifactList.ts new file mode 100644 index 000000000..a9591d5e5 --- /dev/null +++ b/packages/react-headless/src/hooks/useArtifactList.ts @@ -0,0 +1,36 @@ +import { useStore } from "zustand"; +import { useThreadContextStore } from "../store/ThreadContextContext"; +import type { ArtifactEntry } from "../store/threadContextTypes"; + +/** + * Returns all artifacts registered in the active thread, grouped by `id` and + * sorted ascending by `version`. The latest version of each artifact is the + * last element. + * + * Use this for sidebar lists, artifact pickers, or any UI that enumerates + * artifacts attached to the current thread. + * + * Must be called within a ``. + * + * @category Hooks + * @returns Map of artifact id → ordered version list + * + * @example + * ```tsx + * function ArtifactSidebar() { + * const artifacts = useArtifactList(); + * const latest = Object.values(artifacts).map((versions) => versions[versions.length - 1]); + * return ( + *
    + * {latest.map((artifact) => ( + *
  • {artifact.heading}
  • + * ))} + *
+ * ); + * } + * ``` + */ +export function useArtifactList(): Record { + const store = useThreadContextStore(); + return useStore(store, (s) => s.artifacts); +} diff --git a/packages/react-headless/src/hooks/useDetailedView.ts b/packages/react-headless/src/hooks/useDetailedView.ts new file mode 100644 index 000000000..20efd09ec --- /dev/null +++ b/packages/react-headless/src/hooks/useDetailedView.ts @@ -0,0 +1,73 @@ +import { useCallback } from "react"; +import { useStore } from "zustand"; +import { useDetailedViewStore } from "../store/DetailedViewContext"; + +/** + * Return type for {@link useDetailedView}. + * + * @category Hooks + */ +type UseDetailedViewReturn = { + /** Whether this view is the currently active (visible) one. */ + isActive: boolean; + /** Activates this view as the side panel. */ + open: () => void; + /** Closes this view if it is currently active. */ + close: () => void; + /** Toggles this view: opens if closed, closes if open. */ + toggle: () => void; +}; + +/** + * Binds a component to a specific detailed view by id, providing activation + * state and actions (open, close, toggle). + * + * Only one detailed view is active at a time across all kinds (apps, artifacts, + * and custom consumers). The `viewId` format is renderer-defined. The built-in + * App and Artifact renderers use `"${id}:${version}"`; custom consumers may + * pick any unique string. + * + * Must be called within a ``. + * + * @category Hooks + * @param viewId - Unique identifier for the detailed view + * @returns {@link UseDetailedViewReturn} + * + * @example + * ```tsx + * function PreviewButton({ viewId }: { viewId: string }) { + * const { isActive, toggle } = useDetailedView(viewId); + * return ( + * + * ); + * } + * ``` + */ +export function useDetailedView(viewId: string): UseDetailedViewReturn { + const store = useDetailedViewStore(); + + const isActive = useStore(store, (s) => s.activeDetailedViewId === viewId); + + const open = useCallback(() => { + store.getState().setActiveDetailedView(viewId); + }, [store, viewId]); + + const close = useCallback(() => { + if (store.getState().activeDetailedViewId === viewId) { + store.getState().setActiveDetailedView(null); + } + }, [store, viewId]); + + const toggle = useCallback(() => { + const state = store.getState(); + if (state.activeDetailedViewId === viewId) { + state.setActiveDetailedView(null); + } else { + state.setActiveDetailedView(viewId); + } + }, [store, viewId]); + + return { isActive, open, close, toggle }; +} diff --git a/packages/react-headless/src/hooks/useArtifactPortalTarget.ts b/packages/react-headless/src/hooks/useDetailedViewPortalTarget.ts similarity index 56% rename from packages/react-headless/src/hooks/useArtifactPortalTarget.ts rename to packages/react-headless/src/hooks/useDetailedViewPortalTarget.ts index f7abfec65..be055cc6b 100644 --- a/packages/react-headless/src/hooks/useArtifactPortalTarget.ts +++ b/packages/react-headless/src/hooks/useDetailedViewPortalTarget.ts @@ -1,13 +1,13 @@ import { useCallback } from "react"; import { useStore } from "zustand"; -import { useArtifactStore } from "../store/ArtifactContext"; +import { useDetailedViewStore } from "../store/DetailedViewContext"; /** - * Provides access to the artifact portal target DOM node. + * Provides access to the detailed-view portal target DOM node. * * This hook serves two roles: * - **Registering a portal target:** Call `setNode` from a ref callback to - * designate a DOM element as the render target for artifact content. + * designate a DOM element as the render target for detailed-view content. * Only one target should be registered at a time. * - **Reading the portal target:** Read `node` to get the current target * element for use with `createPortal()`. @@ -21,26 +21,26 @@ import { useArtifactStore } from "../store/ArtifactContext"; * ```tsx * // Registering a portal target * function MyPortalTarget() { - * const { setNode } = useArtifactPortalTarget(); + * const { setNode } = useDetailedViewPortalTarget(); * return
; * } * - * // Building a custom artifact panel - * function MyArtifactPanel({ artifactId, children }) { - * const { isActive } = useArtifact(artifactId); - * const { node } = useArtifactPortalTarget(); + * // Building a custom detailed-view panel + * function MyDetailedViewPanel({ viewId, children }) { + * const { isActive } = useDetailedView(viewId); + * const { node } = useDetailedViewPortalTarget(); * if (!isActive || !node) return null; * return createPortal(
{children}
, node); * } * ``` */ -export function useArtifactPortalTarget() { - const store = useArtifactStore(); - const node = useStore(store, (s) => s._artifactPanelNode); +export function useDetailedViewPortalTarget() { + const store = useDetailedViewStore(); + const node = useStore(store, (s) => s._detailedViewPanelNode); const setNode = useCallback( (node: HTMLElement | null) => { - store.getState()._setArtifactPanelNode(node); + store.getState()._setDetailedViewPanelNode(node); }, [store], ); diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 868af6412..e3f11f248 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -1,11 +1,14 @@ -export { useActiveArtifact } from "./hooks/useActiveArtifact"; -export { useArtifact } from "./hooks/useArtifact"; -export { useArtifactPortalTarget } from "./hooks/useArtifactPortalTarget"; +export { useActiveDetailedView } from "./hooks/useActiveDetailedView"; +export { useAppList } from "./hooks/useAppList"; +export { useArtifactList } from "./hooks/useArtifactList"; +export { useDetailedView } from "./hooks/useDetailedView"; +export { useDetailedViewPortalTarget } from "./hooks/useDetailedViewPortalTarget"; export { MessageContext, MessageProvider, useMessage } from "./hooks/useMessage"; export { useThread, useThreadList } from "./hooks/useThread"; -export { ArtifactContext, useArtifactStore } from "./store/ArtifactContext"; export { ChatProvider } from "./store/ChatProvider"; +export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext"; +export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext"; export { agUIAdapter, langGraphAdapter, @@ -20,7 +23,15 @@ export { } from "./stream/formats"; export { processStreamedMessage } from "./stream/processStreamedMessage"; -export type { ArtifactActions, ArtifactState } from "./store/artifactTypes"; +export type { DetailedViewActions, DetailedViewState } from "./store/detailedViewTypes"; + +export type { + AppEntry, + ArtifactEntry, + ThreadContextActions, + ThreadContextState, + ThreadContextStore, +} from "./store/threadContextTypes"; export type { ChatProviderProps, diff --git a/packages/react-headless/src/store/ArtifactContext.ts b/packages/react-headless/src/store/ArtifactContext.ts deleted file mode 100644 index 153a39334..000000000 --- a/packages/react-headless/src/store/ArtifactContext.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { createContext, useContext } from "react"; -import type { StoreApi } from "zustand"; -import type { ArtifactStore } from "./artifactTypes"; - -/** @internal React context holding the artifact Zustand store. Provided by `ChatProvider`. */ -export const ArtifactContext = createContext | null>(null); - -/** - * Returns the raw artifact Zustand store for advanced use cases. - * - * Prefer {@link useArtifact} or {@link useActiveArtifact} for most cases — - * this hook is an escape hatch when you need direct store access. - * - * @category Hooks - * @returns The Zustand `StoreApi` instance - * @throws Error if called outside a `` - */ -export const useArtifactStore = (): StoreApi => { - const store = useContext(ArtifactContext); - if (!store) { - throw new Error("useArtifactStore must be used within a "); - } - return store; -}; diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index 3ec368d64..4930c091c 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -1,27 +1,37 @@ import { useEffect, useState, type FC } from "react"; -import { ArtifactContext } from "./ArtifactContext"; import { ChatContext } from "./ChatContext"; -import { createArtifactStore } from "./createArtifactStore"; import { createChatStore } from "./createChatStore"; +import { createDetailedViewStore } from "./createDetailedViewStore"; +import { createThreadContextStore } from "./createThreadContextStore"; +import { DetailedViewContext } from "./DetailedViewContext"; +import { ThreadContextContext } from "./ThreadContextContext"; import type { ChatProviderProps } from "./types"; export const ChatProvider: FC = ({ children, ...config }) => { const [chatStore] = useState(() => createChatStore(config)); - const [artifactStore] = useState(() => createArtifactStore()); + const [detailedViewStore] = useState(() => createDetailedViewStore()); + const [threadContextStore] = useState(() => createThreadContextStore()); - // Cross-store subscription: reset artifacts when the active thread changes. + // Cross-store subscription: reset detailed-view + thread-context state when the active thread changes. // useEffect (not inline) so the cleanup function unsubscribes on unmount. useEffect(() => { const unsubscribe = chatStore.subscribe( (state) => state.selectedThreadId, - () => artifactStore.getState().resetArtifacts(), + () => { + detailedViewStore.getState().reset(); + threadContextStore.getState().reset(); + }, ); return unsubscribe; - }, [chatStore, artifactStore]); + }, [chatStore, detailedViewStore, threadContextStore]); return ( - {children} + + + {children} + + ); }; diff --git a/packages/react-headless/src/store/DetailedViewContext.ts b/packages/react-headless/src/store/DetailedViewContext.ts new file mode 100644 index 000000000..553e53863 --- /dev/null +++ b/packages/react-headless/src/store/DetailedViewContext.ts @@ -0,0 +1,24 @@ +import { createContext, useContext } from "react"; +import type { StoreApi } from "zustand"; +import type { DetailedViewStore } from "./detailedViewTypes"; + +/** @internal React context holding the detailed-view Zustand store. Provided by `ChatProvider`. */ +export const DetailedViewContext = createContext | null>(null); + +/** + * Returns the raw detailed-view Zustand store for advanced use cases. + * + * Prefer {@link useDetailedView} or {@link useActiveDetailedView} for most cases — + * this hook is an escape hatch when you need direct store access. + * + * @category Hooks + * @returns The Zustand `StoreApi` instance + * @throws Error if called outside a `` + */ +export const useDetailedViewStore = (): StoreApi => { + const store = useContext(DetailedViewContext); + if (!store) { + throw new Error("useDetailedViewStore must be used within a "); + } + return store; +}; diff --git a/packages/react-headless/src/store/ThreadContextContext.ts b/packages/react-headless/src/store/ThreadContextContext.ts new file mode 100644 index 000000000..cd0718c18 --- /dev/null +++ b/packages/react-headless/src/store/ThreadContextContext.ts @@ -0,0 +1,25 @@ +import { createContext, useContext } from "react"; +import type { StoreApi } from "zustand"; +import type { ThreadContextStore } from "./threadContextTypes"; + +/** @internal React context holding the ThreadContext Zustand store. Provided by `ChatProvider`. */ +export const ThreadContextContext = createContext | null>(null); + +/** + * Returns the raw ThreadContext Zustand store for advanced use cases. + * + * Prefer {@link useDetailedView}, {@link useActiveDetailedView}, {@link useAppList}, + * or {@link useArtifactList} for most cases — this hook is an escape hatch when you + * need direct store access. + * + * @category Hooks + * @returns The Zustand `StoreApi` instance + * @throws Error if called outside a `` + */ +export const useThreadContextStore = (): StoreApi => { + const store = useContext(ThreadContextContext); + if (!store) { + throw new Error("useThreadContextStore must be used within a "); + } + return store; +}; diff --git a/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts b/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts deleted file mode 100644 index 40bf14379..000000000 --- a/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createArtifactStore } from "../createArtifactStore"; - -describe("createArtifactStore", () => { - it("has correct initial state", () => { - const store = createArtifactStore(); - const state = store.getState(); - - expect(state.activeArtifactId).toBeNull(); - expect(state._artifactPanelNode).toBeNull(); - }); - - describe("openArtifact", () => { - it("sets activeArtifactId", () => { - const store = createArtifactStore(); - - store.getState().openArtifact("art-1"); - - expect(store.getState().activeArtifactId).toBe("art-1"); - }); - - it("replaces active artifact when opening a different one", () => { - const store = createArtifactStore(); - - store.getState().openArtifact("art-1"); - store.getState().openArtifact("art-2"); - - expect(store.getState().activeArtifactId).toBe("art-2"); - }); - - it("is idempotent when opening the same artifact", () => { - const store = createArtifactStore(); - - store.getState().openArtifact("art-1"); - store.getState().openArtifact("art-1"); - - expect(store.getState().activeArtifactId).toBe("art-1"); - }); - }); - - describe("closeArtifact", () => { - it("clears activeArtifactId when closing the active artifact", () => { - const store = createArtifactStore(); - - store.getState().openArtifact("art-1"); - expect(store.getState().activeArtifactId).toBe("art-1"); - - store.getState().closeArtifact("art-1"); - expect(store.getState().activeArtifactId).toBeNull(); - }); - - it("no-ops when closing a non-active artifact", () => { - const store = createArtifactStore(); - - store.getState().openArtifact("art-1"); - store.getState().closeArtifact("art-2"); - - expect(store.getState().activeArtifactId).toBe("art-1"); - }); - - it("no-ops when nothing is active", () => { - const store = createArtifactStore(); - - store.getState().closeArtifact("art-1"); - - expect(store.getState().activeArtifactId).toBeNull(); - }); - }); - - describe("resetArtifacts", () => { - it("resets activeArtifactId to null", () => { - const store = createArtifactStore(); - - store.getState().openArtifact("art-1"); - expect(store.getState().activeArtifactId).toBe("art-1"); - - store.getState().resetArtifacts(); - expect(store.getState().activeArtifactId).toBeNull(); - }); - }); - - describe("_setArtifactPanelNode", () => { - it("sets and clears DOM reference", () => { - const store = createArtifactStore(); - - const node = {} as HTMLElement; - store.getState()._setArtifactPanelNode(node); - expect(store.getState()._artifactPanelNode).toBe(node); - - store.getState()._setArtifactPanelNode(null); - expect(store.getState()._artifactPanelNode).toBeNull(); - }); - }); -}); diff --git a/packages/react-headless/src/store/__tests__/createDetailedViewStore.test.ts b/packages/react-headless/src/store/__tests__/createDetailedViewStore.test.ts new file mode 100644 index 000000000..c07d355a3 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/createDetailedViewStore.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { createDetailedViewStore } from "../createDetailedViewStore"; + +describe("createDetailedViewStore", () => { + it("has correct initial state", () => { + const store = createDetailedViewStore(); + const state = store.getState(); + + expect(state.activeDetailedViewId).toBeNull(); + expect(state._detailedViewPanelNode).toBeNull(); + }); + + describe("setActiveDetailedView", () => { + it("sets activeDetailedViewId", () => { + const store = createDetailedViewStore(); + + store.getState().setActiveDetailedView("view-1"); + + expect(store.getState().activeDetailedViewId).toBe("view-1"); + }); + + it("replaces active view when called with a different id", () => { + const store = createDetailedViewStore(); + + store.getState().setActiveDetailedView("view-1"); + store.getState().setActiveDetailedView("view-2"); + + expect(store.getState().activeDetailedViewId).toBe("view-2"); + }); + + it("clears with null", () => { + const store = createDetailedViewStore(); + + store.getState().setActiveDetailedView("view-1"); + store.getState().setActiveDetailedView(null); + + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + }); + + describe("reset", () => { + it("resets activeDetailedViewId to null", () => { + const store = createDetailedViewStore(); + + store.getState().setActiveDetailedView("view-1"); + expect(store.getState().activeDetailedViewId).toBe("view-1"); + + store.getState().reset(); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + }); + + describe("_setDetailedViewPanelNode", () => { + it("sets and clears DOM reference", () => { + const store = createDetailedViewStore(); + + const node = {} as HTMLElement; + store.getState()._setDetailedViewPanelNode(node); + expect(store.getState()._detailedViewPanelNode).toBe(node); + + store.getState()._setDetailedViewPanelNode(null); + expect(store.getState()._detailedViewPanelNode).toBeNull(); + }); + }); +}); diff --git a/packages/react-headless/src/store/__tests__/createThreadContextStore.test.ts b/packages/react-headless/src/store/__tests__/createThreadContextStore.test.ts new file mode 100644 index 000000000..d92b1647c --- /dev/null +++ b/packages/react-headless/src/store/__tests__/createThreadContextStore.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { createThreadContextStore } from "../createThreadContextStore"; + +describe("createThreadContextStore", () => { + it("has correct initial state", () => { + const store = createThreadContextStore(); + const state = store.getState(); + + expect(state.apps).toEqual({}); + expect(state.artifacts).toEqual({}); + }); + + describe("registerApp", () => { + it("adds a new entry", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "Q4" }); + + expect(store.getState().apps).toEqual({ + "app-1": [{ id: "app-1", version: 1, heading: "Q4" }], + }); + }); + + it("adds multiple versions sorted ascending by version", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 3, heading: "v3" }); + store.getState().registerApp({ id: "app-1", version: 1, heading: "v1" }); + store.getState().registerApp({ id: "app-1", version: 2, heading: "v2" }); + + expect(store.getState().apps["app-1"]).toEqual([ + { id: "app-1", version: 1, heading: "v1" }, + { id: "app-1", version: 2, heading: "v2" }, + { id: "app-1", version: 3, heading: "v3" }, + ]); + }); + + it("groups separate ids in their own buckets", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "A" }); + store.getState().registerApp({ id: "app-2", version: 1, heading: "B" }); + + expect(Object.keys(store.getState().apps).sort()).toEqual(["app-1", "app-2"]); + }); + + it("updates heading when same (id, version) re-registers with different heading", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "Old" }); + store.getState().registerApp({ id: "app-1", version: 1, heading: "New" }); + + expect(store.getState().apps["app-1"]).toEqual([{ id: "app-1", version: 1, heading: "New" }]); + }); + + it("is referentially stable when same (id, version, heading) re-registers", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "A" }); + const before = store.getState().apps; + + store.getState().registerApp({ id: "app-1", version: 1, heading: "A" }); + const after = store.getState().apps; + + expect(after).toBe(before); + }); + }); + + describe("unregisterApp", () => { + it("removes the matching version", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "v1" }); + store.getState().registerApp({ id: "app-1", version: 2, heading: "v2" }); + store.getState().unregisterApp("app-1", 1); + + expect(store.getState().apps["app-1"]).toEqual([{ id: "app-1", version: 2, heading: "v2" }]); + }); + + it("removes the bucket when last version is removed", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "v1" }); + store.getState().unregisterApp("app-1", 1); + + expect(store.getState().apps).toEqual({}); + }); + + it("is referentially stable when version does not exist", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "v1" }); + const before = store.getState().apps; + + store.getState().unregisterApp("app-1", 99); + + expect(store.getState().apps).toBe(before); + }); + + it("is referentially stable when id does not exist", () => { + const store = createThreadContextStore(); + + const before = store.getState().apps; + + store.getState().unregisterApp("missing", 1); + + expect(store.getState().apps).toBe(before); + }); + }); + + describe("registerArtifact / unregisterArtifact", () => { + it("manages artifacts independently from apps", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "App" }); + store.getState().registerArtifact({ id: "art-1", version: 1, heading: "Artifact" }); + + expect(store.getState().apps["app-1"]?.length).toBe(1); + expect(store.getState().artifacts["art-1"]?.length).toBe(1); + + store.getState().unregisterArtifact("art-1", 1); + + expect(store.getState().apps["app-1"]?.length).toBe(1); + expect(store.getState().artifacts).toEqual({}); + }); + }); + + describe("reset", () => { + it("clears apps and artifacts", () => { + const store = createThreadContextStore(); + + store.getState().registerApp({ id: "app-1", version: 1, heading: "A" }); + store.getState().registerArtifact({ id: "art-1", version: 1, heading: "B" }); + + store.getState().reset(); + + expect(store.getState().apps).toEqual({}); + expect(store.getState().artifacts).toEqual({}); + }); + }); +}); diff --git a/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts b/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts similarity index 51% rename from packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts rename to packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts index 56ebf2487..859ff21d5 100644 --- a/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts +++ b/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts @@ -1,58 +1,58 @@ import { describe, expect, it, vi } from "vitest"; -import { createArtifactStore } from "../createArtifactStore"; import { createChatStore } from "../createChatStore"; +import { createDetailedViewStore } from "../createDetailedViewStore"; const flushPromises = () => new Promise((r) => setTimeout(r, 0)); -describe("artifact thread-switch cleanup", () => { +describe("detailed-view thread-switch cleanup", () => { const setupStores = () => { const chatStore = createChatStore({ processMessage: vi.fn() }); - const artifactStore = createArtifactStore(); + const detailedViewStore = createDetailedViewStore(); const unsubscribe = chatStore.subscribe( (state) => state.selectedThreadId, - () => artifactStore.getState().resetArtifacts(), + () => detailedViewStore.getState().reset(), ); - return { chatStore, artifactStore, unsubscribe }; + return { chatStore, detailedViewStore, unsubscribe }; }; - it("clears active artifact when selectThread is called", async () => { - const { chatStore, artifactStore, unsubscribe } = setupStores(); + it("clears active view when selectThread is called", async () => { + const { chatStore, detailedViewStore, unsubscribe } = setupStores(); - artifactStore.getState().openArtifact("art-1"); - expect(artifactStore.getState().activeArtifactId).toBe("art-1"); + detailedViewStore.getState().setActiveDetailedView("view-1"); + expect(detailedViewStore.getState().activeDetailedViewId).toBe("view-1"); chatStore.getState().selectThread("thread-2"); await flushPromises(); - expect(artifactStore.getState().activeArtifactId).toBeNull(); + expect(detailedViewStore.getState().activeDetailedViewId).toBeNull(); unsubscribe(); }); - it("clears active artifact when switchToNewThread is called", async () => { - const { chatStore, artifactStore, unsubscribe } = setupStores(); + it("clears active view when switchToNewThread is called", async () => { + const { chatStore, detailedViewStore, unsubscribe } = setupStores(); chatStore.setState({ selectedThreadId: "thread-1" }); - artifactStore.getState().openArtifact("art-1"); + detailedViewStore.getState().setActiveDetailedView("view-1"); chatStore.getState().switchToNewThread(); await flushPromises(); - expect(artifactStore.getState().activeArtifactId).toBeNull(); + expect(detailedViewStore.getState().activeDetailedViewId).toBeNull(); unsubscribe(); }); - it("clears active artifact when active thread is deleted", async () => { + it("clears active view when active thread is deleted", async () => { const deleteThread = vi.fn().mockResolvedValue(undefined); const chatStore = createChatStore({ deleteThread, processMessage: vi.fn() }); - const artifactStore = createArtifactStore(); + const detailedViewStore = createDetailedViewStore(); const unsubscribe = chatStore.subscribe( (state) => state.selectedThreadId, - () => artifactStore.getState().resetArtifacts(), + () => detailedViewStore.getState().reset(), ); chatStore.setState({ @@ -66,29 +66,29 @@ describe("artifact thread-switch cleanup", () => { ], }); - artifactStore.getState().openArtifact("art-1"); + detailedViewStore.getState().setActiveDetailedView("view-1"); chatStore.getState().deleteThread("thread-1"); await flushPromises(); - expect(artifactStore.getState().activeArtifactId).toBeNull(); + expect(detailedViewStore.getState().activeDetailedViewId).toBeNull(); unsubscribe(); }); - it("does not clear active artifact when re-selecting the same thread", async () => { - const { chatStore, artifactStore, unsubscribe } = setupStores(); + it("does not clear active view when re-selecting the same thread", async () => { + const { chatStore, detailedViewStore, unsubscribe } = setupStores(); chatStore.setState({ selectedThreadId: "thread-1" }); await flushPromises(); - artifactStore.getState().openArtifact("art-1"); - expect(artifactStore.getState().activeArtifactId).toBe("art-1"); + detailedViewStore.getState().setActiveDetailedView("view-1"); + expect(detailedViewStore.getState().activeDetailedViewId).toBe("view-1"); chatStore.getState().selectThread("thread-1"); await flushPromises(); - expect(artifactStore.getState().activeArtifactId).toBe("art-1"); + expect(detailedViewStore.getState().activeDetailedViewId).toBe("view-1"); unsubscribe(); }); @@ -96,21 +96,21 @@ describe("artifact thread-switch cleanup", () => { it("handles rapid thread switches cleanly", async () => { const loadThread = vi.fn().mockResolvedValue([]); const chatStore = createChatStore({ loadThread, processMessage: vi.fn() }); - const artifactStore = createArtifactStore(); + const detailedViewStore = createDetailedViewStore(); const unsubscribe = chatStore.subscribe( (state) => state.selectedThreadId, - () => artifactStore.getState().resetArtifacts(), + () => detailedViewStore.getState().reset(), ); - artifactStore.getState().openArtifact("art-1"); + detailedViewStore.getState().setActiveDetailedView("view-1"); chatStore.getState().selectThread("thread-1"); chatStore.getState().selectThread("thread-2"); chatStore.getState().selectThread("thread-3"); await flushPromises(); - expect(artifactStore.getState().activeArtifactId).toBeNull(); + expect(detailedViewStore.getState().activeDetailedViewId).toBeNull(); expect(chatStore.getState().selectedThreadId).toBe("thread-3"); unsubscribe(); diff --git a/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts b/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts new file mode 100644 index 000000000..1f08badfa --- /dev/null +++ b/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from "vitest"; +import { createChatStore } from "../createChatStore"; +import { createThreadContextStore } from "../createThreadContextStore"; + +const flushPromises = () => new Promise((r) => setTimeout(r, 0)); + +describe("thread-context thread-switch cleanup", () => { + const setupStores = () => { + const chatStore = createChatStore({ processMessage: vi.fn() }); + const threadContextStore = createThreadContextStore(); + + const unsubscribe = chatStore.subscribe( + (state) => state.selectedThreadId, + () => threadContextStore.getState().reset(), + ); + + return { chatStore, threadContextStore, unsubscribe }; + }; + + const populate = (store: ReturnType) => { + store.getState().registerApp({ id: "app-1", version: 1, heading: "App" }); + store.getState().registerArtifact({ id: "art-1", version: 1, heading: "Artifact" }); + }; + + const expectEmpty = (store: ReturnType) => { + expect(store.getState().apps).toEqual({}); + expect(store.getState().artifacts).toEqual({}); + }; + + it("clears thread context when selectThread is called", async () => { + const { chatStore, threadContextStore, unsubscribe } = setupStores(); + + populate(threadContextStore); + + chatStore.getState().selectThread("thread-2"); + await flushPromises(); + + expectEmpty(threadContextStore); + + unsubscribe(); + }); + + it("clears thread context when switchToNewThread is called", async () => { + const { chatStore, threadContextStore, unsubscribe } = setupStores(); + + chatStore.setState({ selectedThreadId: "thread-1" }); + populate(threadContextStore); + + chatStore.getState().switchToNewThread(); + await flushPromises(); + + expectEmpty(threadContextStore); + + unsubscribe(); + }); + + it("clears thread context when active thread is deleted", async () => { + const deleteThread = vi.fn().mockResolvedValue(undefined); + const chatStore = createChatStore({ deleteThread, processMessage: vi.fn() }); + const threadContextStore = createThreadContextStore(); + + const unsubscribe = chatStore.subscribe( + (state) => state.selectedThreadId, + () => threadContextStore.getState().reset(), + ); + + chatStore.setState({ + selectedThreadId: "thread-1", + threads: [ + { + id: "thread-1", + title: "Test", + createdAt: new Date().toISOString(), + }, + ], + }); + + populate(threadContextStore); + + chatStore.getState().deleteThread("thread-1"); + await flushPromises(); + + expectEmpty(threadContextStore); + + unsubscribe(); + }); + + it("does not clear thread context when re-selecting the same thread", async () => { + const { chatStore, threadContextStore, unsubscribe } = setupStores(); + + chatStore.setState({ selectedThreadId: "thread-1" }); + await flushPromises(); + + populate(threadContextStore); + + chatStore.getState().selectThread("thread-1"); + await flushPromises(); + + expect(threadContextStore.getState().apps["app-1"]?.length).toBe(1); + expect(threadContextStore.getState().artifacts["art-1"]?.length).toBe(1); + + unsubscribe(); + }); + + it("handles rapid thread switches cleanly", async () => { + const loadThread = vi.fn().mockResolvedValue([]); + const chatStore = createChatStore({ loadThread, processMessage: vi.fn() }); + const threadContextStore = createThreadContextStore(); + + const unsubscribe = chatStore.subscribe( + (state) => state.selectedThreadId, + () => threadContextStore.getState().reset(), + ); + + populate(threadContextStore); + + chatStore.getState().selectThread("thread-1"); + chatStore.getState().selectThread("thread-2"); + chatStore.getState().selectThread("thread-3"); + await flushPromises(); + + expectEmpty(threadContextStore); + expect(chatStore.getState().selectedThreadId).toBe("thread-3"); + + unsubscribe(); + }); +}); diff --git a/packages/react-headless/src/store/artifactTypes.ts b/packages/react-headless/src/store/artifactTypes.ts deleted file mode 100644 index 014dd5ad7..000000000 --- a/packages/react-headless/src/store/artifactTypes.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Read-only state slice for the artifact system. - * - * @category Types - */ -export type ArtifactState = { - /** The currently displayed artifact, or `null` if the panel is collapsed. */ - activeArtifactId: string | null; -}; - -/** - * Actions for managing artifacts in the store. - * - * @category Types - */ -export type ArtifactActions = { - /** Activates an artifact by ID. */ - openArtifact: (id: string) => void; - /** - * Deactivates the artifact if it is the currently active one. - * No-op if `id` does not match `activeArtifactId`. - */ - closeArtifact: (id: string) => void; - /** - * Resets `activeArtifactId` to `null`. - * Called automatically on thread switch. - */ - resetArtifacts: () => void; -}; - -/** - * Internal implementation details — not part of the public API. - * - * @internal - */ -export type ArtifactInternals = { - /** @internal */ - _artifactPanelNode: HTMLElement | null; - /** @internal */ - _setArtifactPanelNode: (node: HTMLElement | null) => void; -}; - -/** Combined artifact store type (state + actions + internals). */ -export type ArtifactStore = ArtifactState & ArtifactActions & ArtifactInternals; diff --git a/packages/react-headless/src/store/createArtifactStore.ts b/packages/react-headless/src/store/createArtifactStore.ts deleted file mode 100644 index 070c7571a..000000000 --- a/packages/react-headless/src/store/createArtifactStore.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { createStore } from "zustand"; -import { subscribeWithSelector } from "zustand/middleware"; -import type { ArtifactStore } from "./artifactTypes"; - -/** - * Creates a Zustand store managing artifact state. - * Instantiated once by `ChatProvider` — consumers should not call this directly. - * - * @internal - */ -export const createArtifactStore = () => { - return createStore()( - subscribeWithSelector((set, get) => ({ - activeArtifactId: null, - - openArtifact: (id) => { - set({ activeArtifactId: id }); - }, - - closeArtifact: (id) => { - if (get().activeArtifactId === id) { - set({ activeArtifactId: null }); - } - }, - - resetArtifacts: () => { - set({ activeArtifactId: null }); - }, - - _artifactPanelNode: null, - _setArtifactPanelNode: (node) => { - if ( - process.env["NODE_ENV"] !== "production" && - node && - get()._artifactPanelNode && - get()._artifactPanelNode !== node - ) { - console.warn( - "[OpenUI] Multiple ArtifactPortalTarget instances detected. " + - "Only one should be mounted at a time.", - ); - } - set({ _artifactPanelNode: node }); - }, - })), - ); -}; diff --git a/packages/react-headless/src/store/createDetailedViewStore.ts b/packages/react-headless/src/store/createDetailedViewStore.ts new file mode 100644 index 000000000..25566c5b9 --- /dev/null +++ b/packages/react-headless/src/store/createDetailedViewStore.ts @@ -0,0 +1,41 @@ +import { createStore } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; +import type { DetailedViewStore } from "./detailedViewTypes"; + +/** + * Creates a Zustand store managing detailed-view state. + * Instantiated once by `ChatProvider` — consumers should not call this directly. + * + * @internal + */ +export const createDetailedViewStore = () => { + return createStore()( + subscribeWithSelector((set, get) => ({ + activeDetailedViewId: null, + + setActiveDetailedView: (id) => { + set({ activeDetailedViewId: id }); + }, + + reset: () => { + set({ activeDetailedViewId: null }); + }, + + _detailedViewPanelNode: null, + _setDetailedViewPanelNode: (node) => { + if ( + process.env["NODE_ENV"] !== "production" && + node && + get()._detailedViewPanelNode && + get()._detailedViewPanelNode !== node + ) { + console.warn( + "[OpenUI] Multiple DetailedViewPortalTarget instances detected. " + + "Only one should be mounted at a time.", + ); + } + set({ _detailedViewPanelNode: node }); + }, + })), + ); +}; diff --git a/packages/react-headless/src/store/createThreadContextStore.ts b/packages/react-headless/src/store/createThreadContextStore.ts new file mode 100644 index 000000000..e9f99a521 --- /dev/null +++ b/packages/react-headless/src/store/createThreadContextStore.ts @@ -0,0 +1,89 @@ +import { createStore } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; +import type { AppEntry, ArtifactEntry, ThreadContextStore } from "./threadContextTypes"; + +type RegistryEntry = AppEntry | ArtifactEntry; + +const upsertVersion = ( + registry: Record, + entry: T, +): Record => { + const existing = registry[entry.id] ?? []; + const sameVersionIdx = existing.findIndex((e) => e.version === entry.version); + + if (sameVersionIdx !== -1) { + const current = existing[sameVersionIdx]!; + if (current.heading === entry.heading) return registry; + const next = existing.slice(); + next[sameVersionIdx] = entry; + return { ...registry, [entry.id]: next }; + } + + // Insert sorted ascending by version. + const insertIdx = existing.findIndex((e) => e.version > entry.version); + const next = + insertIdx === -1 + ? [...existing, entry] + : [...existing.slice(0, insertIdx), entry, ...existing.slice(insertIdx)]; + return { ...registry, [entry.id]: next }; +}; + +const removeVersion = ( + registry: Record, + id: string, + version: number, +): Record => { + const existing = registry[id]; + if (!existing) return registry; + const idx = existing.findIndex((e) => e.version === version); + if (idx === -1) return registry; + + if (existing.length === 1) { + const { [id]: _removed, ...rest } = registry; + return rest; + } + + const next = existing.slice(); + next.splice(idx, 1); + return { ...registry, [id]: next }; +}; + +/** + * Creates a Zustand store managing the per-thread registries of apps and artifacts. + * + * Active detailed-view state lives in a separate store + * (see {@link useDetailedView} / {@link useActiveDetailedView}) — TC tracks + * what's *attached* to the thread; detailed-view state tracks what's *visible*. + * + * Instantiated once by `ChatProvider` — consumers should not call this directly. + * + * @internal + */ +export const createThreadContextStore = () => { + return createStore()( + subscribeWithSelector((set) => ({ + apps: {}, + artifacts: {}, + + registerApp: (entry) => { + set((s) => ({ apps: upsertVersion(s.apps, entry) })); + }, + + unregisterApp: (id, version) => { + set((s) => ({ apps: removeVersion(s.apps, id, version) })); + }, + + registerArtifact: (entry) => { + set((s) => ({ artifacts: upsertVersion(s.artifacts, entry) })); + }, + + unregisterArtifact: (id, version) => { + set((s) => ({ artifacts: removeVersion(s.artifacts, id, version) })); + }, + + reset: () => { + set({ apps: {}, artifacts: {} }); + }, + })), + ); +}; diff --git a/packages/react-headless/src/store/detailedViewTypes.ts b/packages/react-headless/src/store/detailedViewTypes.ts new file mode 100644 index 000000000..a8b0f52f8 --- /dev/null +++ b/packages/react-headless/src/store/detailedViewTypes.ts @@ -0,0 +1,41 @@ +/** + * Read-only state slice for the detailed-view system. + * + * @category Types + */ +export type DetailedViewState = { + /** The currently displayed detailed view, or `null` if the panel is collapsed. */ + activeDetailedViewId: string | null; +}; + +/** + * Actions for managing the active detailed view. + * + * @category Types + */ +export type DetailedViewActions = { + /** + * Sets which detailed view is currently active, or `null` to close the panel. + * Only one view is active at a time across all kinds (apps, artifacts, custom). + */ + setActiveDetailedView: (id: string | null) => void; + /** + * Resets `activeDetailedViewId` to `null`. Called automatically on thread switch. + */ + reset: () => void; +}; + +/** + * Internal implementation details — not part of the public API. + * + * @internal + */ +export type DetailedViewInternals = { + /** @internal */ + _detailedViewPanelNode: HTMLElement | null; + /** @internal */ + _setDetailedViewPanelNode: (node: HTMLElement | null) => void; +}; + +/** Combined detailed-view store type (state + actions + internals). */ +export type DetailedViewStore = DetailedViewState & DetailedViewActions & DetailedViewInternals; diff --git a/packages/react-headless/src/store/threadContextTypes.ts b/packages/react-headless/src/store/threadContextTypes.ts new file mode 100644 index 000000000..7e2041637 --- /dev/null +++ b/packages/react-headless/src/store/threadContextTypes.ts @@ -0,0 +1,73 @@ +/** + * A registered app entry in the per-thread context. + * + * Apps are interactive surfaces (e.g. dashboards) produced by tool calls. + * Versions for the same `id` are kept in an ordered list so the sidebar + * can show history; the latest version (highest `version` number) is the + * default open target. + * + * @category Types + */ +export type AppEntry = { + id: string; + version: number; + heading: string; +}; + +/** + * A registered artifact entry in the per-thread context. + * + * Artifacts are durable structured outputs (e.g. presentations, reports) + * produced by tool calls. Same shape as {@link AppEntry} for now — + * separate type so future fields can diverge without a discriminated union. + * + * @category Types + */ +export type ArtifactEntry = { + id: string; + version: number; + heading: string; +}; + +/** + * Read-only state slice for the ThreadContext. + * + * @category Types + */ +export type ThreadContextState = { + /** Apps registered in the active thread, grouped by `id`, sorted ascending by `version`. */ + apps: Record; + /** Artifacts registered in the active thread, grouped by `id`, sorted ascending by `version`. */ + artifacts: Record; +}; + +/** + * Actions for managing the ThreadContext. + * + * @category Types + */ +export type ThreadContextActions = { + /** + * Upserts an app entry by `(id, version)`. + * + * - If no entry with the same `id` exists, creates a new bucket. + * - If a different `version` exists, inserts and keeps versions sorted ascending. + * - If the same `(id, version)` exists, updates `heading` (no-op when unchanged). + */ + registerApp: (entry: AppEntry) => void; + /** Removes an app version. No-op if `(id, version)` is not registered. */ + unregisterApp: (id: string, version: number) => void; + /** + * Upserts an artifact entry by `(id, version)`. See {@link registerApp} for semantics. + */ + registerArtifact: (entry: ArtifactEntry) => void; + /** Removes an artifact version. No-op if `(id, version)` is not registered. */ + unregisterArtifact: (id: string, version: number) => void; + /** + * Clears all registries. Called automatically on thread switch. + */ + reset: () => void; +}; + +/** Combined ThreadContext store type (state + actions). */ +export type ThreadContextStore = ThreadContextState & ThreadContextActions; diff --git a/packages/react-ui/src/artifact/Artifact.tsx b/packages/react-ui/src/artifact/Artifact.tsx deleted file mode 100644 index 1ef54edd2..000000000 --- a/packages/react-ui/src/artifact/Artifact.tsx +++ /dev/null @@ -1,83 +0,0 @@ -"use client"; - -import { useArtifact } from "@openuidev/react-headless"; -import type { ComponentRenderer } from "@openuidev/react-lang"; -import { useId, type ReactNode } from "react"; -import { ArtifactPanel, type ArtifactPanelProps } from "../components/_shared/artifact"; - -/** - * Controls injected into `preview` and `panel` render functions. - */ -export interface ArtifactControls { - /** Whether this artifact is the currently active (visible) one. */ - isActive: boolean; - /** Activates this artifact. */ - open: () => void; - /** Deactivates this artifact. */ - close: () => void; - /** Toggles this artifact: opens if closed, closes if open. */ - toggle: () => void; -} - -/** - * Configuration for {@link Artifact}. - */ -export interface ArtifactConfig

> { - /** Panel title — static string or derived from props. */ - title: string | ((props: P) => string); - /** Renders the inline preview shown in the chat message. */ - preview: (props: P, controls: ArtifactControls) => ReactNode; - /** Renders the content inside the artifact side panel. */ - panel: (props: P, controls: ArtifactControls) => ReactNode; - /** Optional props forwarded to the underlying ``. */ - panelProps?: Pick; -} - -/** - * Factory that returns a `ComponentRenderer

` wiring up `useId`, `useArtifact`, - * and `` internally. Pass the result as `defineComponent`'s `component`. - * - * @example - * ```tsx - * export const ArtifactCodeBlock = defineComponent({ - * name: "ArtifactCodeBlock", - * props: ArtifactCodeBlockSchema, - * description: "Code block that opens in the artifact side panel", - * component: Artifact({ - * title: (props) => props.title, - * preview: (props, { open, isActive }) => ( - * - * ), - * panel: (props) => ( - * - * ), - * }), - * }); - * ``` - */ -export function Artifact

>( - config: ArtifactConfig

, -): ComponentRenderer

{ - const { title, preview, panel, panelProps } = config; - - const ArtifactComponent: ComponentRenderer

= ({ props }) => { - const artifactId = useId(); - const { isActive, open, close, toggle } = useArtifact(artifactId); - - const controls: ArtifactControls = { isActive, open, close, toggle }; - const resolvedTitle = typeof title === "function" ? title(props) : title; - - return ( - <> - {preview(props, controls)} - - {panel(props, controls)} - - - ); - }; - - ArtifactComponent.displayName = `Artifact(${typeof title === "string" ? title : "dynamic"})`; - - return ArtifactComponent; -} diff --git a/packages/react-ui/src/artifact/index.ts b/packages/react-ui/src/artifact/index.ts deleted file mode 100644 index 199e769a9..000000000 --- a/packages/react-ui/src/artifact/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { Artifact } from "./Artifact"; -export type { ArtifactConfig, ArtifactControls } from "./Artifact"; diff --git a/packages/react-ui/src/components/BottomTray/Thread.tsx b/packages/react-ui/src/components/BottomTray/Thread.tsx index 712e9f190..38da67d4a 100644 --- a/packages/react-ui/src/components/BottomTray/Thread.tsx +++ b/packages/react-ui/src/components/BottomTray/Thread.tsx @@ -3,7 +3,7 @@ import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; -import { ArtifactOverlay } from "../_shared/artifact"; +import { DetailedViewOverlay } from "../_shared/detailed-view"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; @@ -27,7 +27,7 @@ export const ThreadContainer = ({ }} > {children} - +

); }; diff --git a/packages/react-ui/src/components/BottomTray/stories/BottomTray.mdx b/packages/react-ui/src/components/BottomTray/stories/BottomTray.mdx index 3d725d49b..2ad6de6fd 100644 --- a/packages/react-ui/src/components/BottomTray/stories/BottomTray.mdx +++ b/packages/react-ui/src/components/BottomTray/stories/BottomTray.mdx @@ -165,12 +165,12 @@ The main tray panel. Controls visibility with `isOpen` prop. ### ThreadContainer -Wrapper for thread content. Manages layout and artifact panels. +Wrapper for thread content. Manages layout and detailed-view panels. ```tsx
} // Artifact content + isDetailedViewActive={false} // Show detailed-view panel + renderDetailedView={() =>
} // Detailed-view content > {children} diff --git a/packages/react-ui/src/components/CopilotShell/Thread.tsx b/packages/react-ui/src/components/CopilotShell/Thread.tsx index 28f0bdcc6..3c98011c2 100644 --- a/packages/react-ui/src/components/CopilotShell/Thread.tsx +++ b/packages/react-ui/src/components/CopilotShell/Thread.tsx @@ -3,7 +3,7 @@ import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; -import { ArtifactOverlay } from "../_shared/artifact"; +import { DetailedViewOverlay } from "../_shared/detailed-view"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; @@ -27,7 +27,7 @@ export const ThreadContainer = ({ }} > {children} - +
); }; diff --git a/packages/react-ui/src/components/CopilotShell/stories/Shell.mdx b/packages/react-ui/src/components/CopilotShell/stories/Shell.mdx index e67888c56..340a87af7 100644 --- a/packages/react-ui/src/components/CopilotShell/stories/Shell.mdx +++ b/packages/react-ui/src/components/CopilotShell/stories/Shell.mdx @@ -134,12 +134,12 @@ The main wrapper for the CopilotShell sidebar. ### ThreadContainer -Wrapper for the thread content with optional artifact panel support. +Wrapper for the thread content with optional detailed-view panel support. ```tsx
} // Artifact content + isDetailedViewActive={false} // Show detailed-view panel + renderDetailedView={() =>
} // Detailed-view content > {children} diff --git a/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.mdx b/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.mdx index b509abfb9..4a71323bf 100644 --- a/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.mdx +++ b/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.mdx @@ -369,8 +369,8 @@ These props only apply when `type="bottom-tray"`. The tray includes a built-in t | `scrollVariant` | `ScrollVariant` | Scroll behavior variant | | `disableThemeProvider` | `boolean` | Disable built-in theme provider | | `messageLoading` | `React.ComponentType` | Custom loading component | -| `isArtifactActive` | `boolean` | Show artifact panel | -| `renderArtifact` | `() => ReactNode` | Artifact content renderer | +| `isDetailedViewActive` | `boolean` | Show detailed-view panel | +| `renderDetailedView` | `() => ReactNode` | Detailed-view content renderer | ## Custom Welcome Component diff --git a/packages/react-ui/src/components/Shell/ResizableSeparator.tsx b/packages/react-ui/src/components/Shell/ResizableSeparator.tsx index fbdf38196..50eccfb2b 100644 --- a/packages/react-ui/src/components/Shell/ResizableSeparator.tsx +++ b/packages/react-ui/src/components/Shell/ResizableSeparator.tsx @@ -10,7 +10,7 @@ interface ResizableSeparatorProps { /** * A draggable vertical separator for resizing panels. - * Used between chat and artifact panels in desktop mode. + * Used between chat and detailed-view panels in desktop mode. */ export const ResizableSeparator = ({ onResize, diff --git a/packages/react-ui/src/components/Shell/Sidebar.tsx b/packages/react-ui/src/components/Shell/Sidebar.tsx index 9cde32be7..7934edc0e 100644 --- a/packages/react-ui/src/components/Shell/Sidebar.tsx +++ b/packages/react-ui/src/components/Shell/Sidebar.tsx @@ -1,4 +1,4 @@ -import { useActiveArtifact } from "@openuidev/react-headless"; +import { useActiveDetailedView } from "@openuidev/react-headless"; import clsx from "clsx"; import { ArrowLeftFromLine, ArrowRightFromLine } from "lucide-react"; import { useEffect } from "react"; @@ -17,7 +17,7 @@ export const SidebarContainer = ({ isSidebarOpen: state.isSidebarOpen, setIsSidebarOpen: state.setIsSidebarOpen, })); - const { isArtifactActive } = useActiveArtifact(); + const { isDetailedViewActive } = useActiveDetailedView(); const { layout } = useLayoutContext() || {}; const isMobile = layout === "mobile"; @@ -46,7 +46,7 @@ export const SidebarContainer = ({ "openui-shell-sidebar-container", { "openui-shell-sidebar-container--collapsed": !isSidebarOpen, - "openui-shell-sidebar-container--hidden": isArtifactActive && !isMobile, + "openui-shell-sidebar-container--hidden": isDetailedViewActive && !isMobile, }, className, )} diff --git a/packages/react-ui/src/components/Shell/Thread.tsx b/packages/react-ui/src/components/Shell/Thread.tsx index d1dd8f883..049eb9d25 100644 --- a/packages/react-ui/src/components/Shell/Thread.tsx +++ b/packages/react-ui/src/components/Shell/Thread.tsx @@ -1,11 +1,11 @@ import type { AssistantMessage, Message, ToolMessage } from "@openuidev/react-headless"; -import { MessageProvider, useActiveArtifact, useThread } from "@openuidev/react-headless"; +import { MessageProvider, useActiveDetailedView, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import React, { memo, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; import { separateContentAndContext } from "../../utils/contentParser"; -import { ArtifactOverlay, ArtifactPortalTarget } from "../_shared/artifact"; +import { DetailedViewOverlay, DetailedViewPortalTarget } from "../_shared/detailed-view"; import { useShellStore } from "../_shared/store"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { Callout } from "../Callout"; @@ -14,7 +14,7 @@ import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; import { ToolCallComponent } from "../ToolCall"; import { ToolResult } from "../ToolResult"; import { ResizableSeparator } from "./ResizableSeparator"; -import { useArtifactResize } from "./useArtifactResize"; +import { useDetailedViewResize } from "./useDetailedViewResize"; export const ThreadContainer = ({ children, @@ -25,7 +25,7 @@ export const ThreadContainer = ({ }) => { const { layout } = useLayoutContext(); const isMobile = layout === "mobile"; - const { isArtifactActive } = useActiveArtifact(); + const { isDetailedViewActive } = useActiveDetailedView(); const { setIsSidebarOpen } = useShellStore((state) => ({ setIsSidebarOpen: state.setIsSidebarOpen, @@ -36,13 +36,13 @@ export const ThreadContainer = ({ const { containerRef, chatPanelRef, - artifactPanelRef, + detailedViewPanelRef, isDragging, handleResize, handleDragStart, handleDragEnd, - } = useArtifactResize({ - isArtifactActive, + } = useDetailedViewResize({ + isDetailedViewActive, isMobile, setIsSidebarOpen, }); @@ -50,7 +50,7 @@ export const ThreadContainer = ({ return (
{children} - {isMobile && } + {isMobile && }
- {/* Desktop only: Resizable separator and artifact panel */} - {!isMobile && isArtifactActive && ( + {/* Desktop only: Resizable separator and detailed-view panel */} + {!isMobile && isDetailedViewActive && ( <>
- +
)} diff --git a/packages/react-ui/src/components/Shell/components/composer.scss b/packages/react-ui/src/components/Shell/components/composer.scss index bfe4431d2..703f593fc 100644 --- a/packages/react-ui/src/components/Shell/components/composer.scss +++ b/packages/react-ui/src/components/Shell/components/composer.scss @@ -13,7 +13,7 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); padding: cssUtils.$space-m 14px; } - .openui-shell-thread-container--artifact-active & { + .openui-shell-thread-container--detailed-view-active & { padding-left: 0; padding-right: cssUtils.$space-m; } diff --git a/packages/react-ui/src/components/Shell/conversationStarter.scss b/packages/react-ui/src/components/Shell/conversationStarter.scss index 5b6bdc627..addd36905 100644 --- a/packages/react-ui/src/components/Shell/conversationStarter.scss +++ b/packages/react-ui/src/components/Shell/conversationStarter.scss @@ -33,8 +33,8 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); padding: 0 cssUtils.$space-l; } - // Artifact active adjustments - .openui-shell-thread-container--artifact-active & { + // Detailed-view active adjustments + .openui-shell-thread-container--detailed-view-active & { padding-left: 0; padding-right: cssUtils.$space-m; } diff --git a/packages/react-ui/src/components/Shell/index.ts b/packages/react-ui/src/components/Shell/index.ts index 2ea9bb301..fda33b3ee 100644 --- a/packages/react-ui/src/components/Shell/index.ts +++ b/packages/react-ui/src/components/Shell/index.ts @@ -1,4 +1,4 @@ -export * from "../_shared/artifact"; +export * from "../_shared/detailed-view"; export * from "../_shared/store"; export * from "./components"; export * from "./Container"; diff --git a/packages/react-ui/src/components/Shell/stories/Shell.mdx b/packages/react-ui/src/components/Shell/stories/Shell.mdx index 313910921..94f6fc8bc 100644 --- a/packages/react-ui/src/components/Shell/stories/Shell.mdx +++ b/packages/react-ui/src/components/Shell/stories/Shell.mdx @@ -149,12 +149,12 @@ The sidebar provides thread management and navigation. ### ThreadContainer -Wrapper for the main chat area with optional artifact panel support. +Wrapper for the main chat area with optional detailed-view panel support. ```tsx
} // Artifact content + isDetailedViewActive={false} // Show detailed-view panel (desktop: side-by-side, mobile: overlay) + renderDetailedView={() =>
} // Detailed-view content > {children} @@ -349,34 +349,36 @@ The Shell automatically adapts to screen size: | --------------------- | ----------------------- | ------------------------------------- | | Sidebar | Collapsible side panel | Hidden, accessible via hamburger menu | | Header | In sidebar | MobileHeader at top of thread | -| Artifact panel | Resizable side-by-side | Full-screen overlay | +| Detailed-view panel | Resizable side-by-side | Full-screen overlay | | Conversation starters | Centered with max-width | Full-width with padding | -## Artifact Panel +## Detailed-View Panel -The Shell supports a resizable artifact panel on desktop: +The Shell supports a resizable detailed-view panel on desktop: ```tsx
{/* Your artifact content (code preview, document, etc.) */}
} + isDetailedViewActive={true} + renderDetailedView={() => ( +
{/* Your detailed-view content (code preview, document, etc.) */}
+ )} > {/* Chat content */}
``` -On desktop, the artifact panel appears to the right of the chat and can be resized by dragging the separator. On mobile, it appears as a full-screen overlay. +On desktop, the detailed-view panel appears to the right of the chat and can be resized by dragging the separator. On mobile, it appears as a full-screen overlay. ## Comparison with Other Components -| Feature | Shell | CopilotShell | BottomTray | -| -------------- | ----------------- | ------------------ | ---------------------- | -| Layout | Full page | Sidebar panel | Floating panel | -| Sidebar | Yes (collapsible) | No | No | -| Thread list | Yes | No | Yes (in header) | -| Artifact panel | Yes (resizable) | Yes (overlay) | No | -| Mobile | Responsive | Responsive | Fullscreen | -| Use case | Primary chat app | Side panel copilot | Secondary/support chat | +| Feature | Shell | CopilotShell | BottomTray | +| ------------- | ----------------- | ------------------ | ---------------------- | +| Layout | Full page | Sidebar panel | Floating panel | +| Sidebar | Yes (collapsible) | No | No | +| Thread list | Yes | No | Yes (in header) | +| Detailed-view | Yes (resizable) | Yes (overlay) | No | +| Mobile | Responsive | Responsive | Fullscreen | +| Use case | Primary chat app | Side panel copilot | Secondary/support chat | ## CSS Classes diff --git a/packages/react-ui/src/components/Shell/thread.scss b/packages/react-ui/src/components/Shell/thread.scss index 3ef323fef..b30466a5e 100644 --- a/packages/react-ui/src/components/Shell/thread.scss +++ b/packages/react-ui/src/components/Shell/thread.scss @@ -4,8 +4,8 @@ // Thread Layout Structure // ============================================================================= // The thread container uses a flexbox layout that supports both: -// 1. Desktop: Resizable side-by-side chat and artifact panels -// 2. Mobile: Full-width chat with absolute-positioned artifact overlay +// 1. Desktop: Resizable side-by-side chat and detailed-view panels +// 2. Mobile: Full-width chat with absolute-positioned detailed-view overlay .openui-shell-thread-container { flex: 1; @@ -14,7 +14,7 @@ flex-direction: column; } -// Wrapper for horizontal layout (chat + separator + artifact) +// Wrapper for horizontal layout (chat + separator + detailed view) .openui-shell-thread-wrapper { flex: 1; display: flex; @@ -22,7 +22,7 @@ height: 100%; } -// Chat panel (left side on desktop, full-width when no artifact) +// Chat panel (left side on desktop, full-width when no detailed view) .openui-shell-thread-chat-panel { display: flex; flex-direction: column; @@ -37,8 +37,8 @@ } } -// Artifact panel (right side on desktop, fills remaining space) -.openui-shell-thread-artifact-panel { +// Detailed-view panel (right side on desktop, fills remaining space) +.openui-shell-thread-detailed-view-panel { display: flex; flex-direction: column; overflow: auto; @@ -142,8 +142,8 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); padding: cssUtils.$space-l; } - // When artifact is active, reduce padding to maximize space - .openui-shell-thread-container--artifact-active & { + // When detailed view is active, reduce padding to maximize space + .openui-shell-thread-container--detailed-view-active & { padding-left: 0; padding-right: cssUtils.$space-m; } @@ -178,8 +178,8 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); padding: 0; } - // When artifact is active, minimize spacing and hide logo - .openui-shell-thread-container--artifact-active & { + // When detailed view is active, minimize spacing and hide logo + .openui-shell-thread-container--detailed-view-active & { gap: 0; padding-right: 0; } @@ -210,7 +210,7 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); display: none; } - .openui-shell-thread-container--artifact-active & { + .openui-shell-thread-container--detailed-view-active & { display: none; } } @@ -225,7 +225,7 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); padding: 0; } - .openui-shell-thread-container--artifact-active & { + .openui-shell-thread-container--detailed-view-active & { padding-left: 0; padding-right: 0; } diff --git a/packages/react-ui/src/components/Shell/useArtifactResize.ts b/packages/react-ui/src/components/Shell/useDetailedViewResize.ts similarity index 69% rename from packages/react-ui/src/components/Shell/useArtifactResize.ts rename to packages/react-ui/src/components/Shell/useDetailedViewResize.ts index b2f095134..ba3f69d13 100644 --- a/packages/react-ui/src/components/Shell/useArtifactResize.ts +++ b/packages/react-ui/src/components/Shell/useDetailedViewResize.ts @@ -1,15 +1,15 @@ import { useCallback, useEffect, useRef, useState } from "react"; -interface UseArtifactResizeProps { - isArtifactActive: boolean; +interface UseDetailedViewResizeProps { + isDetailedViewActive: boolean; isMobile: boolean; setIsSidebarOpen: (isOpen: boolean) => void; } -interface UseArtifactResizeReturn { +interface UseDetailedViewResizeReturn { containerRef: React.RefObject; chatPanelRef: React.RefObject; - artifactPanelRef: React.RefObject; + detailedViewPanelRef: React.RefObject; isDragging: boolean; handleResize: (clientX: number) => void; handleDragStart: () => void; @@ -21,40 +21,40 @@ const MIN_CHAT_WIDTH = 420; const MAX_CHAT_WIDTH_RATIO = 0.8; /** - * Custom hook to manage artifact panel resizing logic (desktop only). + * Custom hook to manage detailed-view panel resizing logic (desktop only). * Handles: * - Chat panel width constraints * - Resize drag events - * - Sidebar state when artifact is active/inactive + * - Sidebar state when detailed view is active/inactive */ -export const useArtifactResize = ({ - isArtifactActive, +export const useDetailedViewResize = ({ + isDetailedViewActive, isMobile, setIsSidebarOpen, -}: UseArtifactResizeProps): UseArtifactResizeReturn => { +}: UseDetailedViewResizeProps): UseDetailedViewResizeReturn => { const [isDragging, setIsDragging] = useState(false); const containerRef = useRef(null); const chatPanelRef = useRef(null); - const artifactPanelRef = useRef(null); + const detailedViewPanelRef = useRef(null); - // Handle sidebar visibility and panel widths when artifact state changes + // Handle sidebar visibility and panel widths when detailed-view state changes useEffect(() => { if (isMobile) return; - if (isArtifactActive) { - // Desktop artifact active: close sidebar and set chat width to 420px + if (isDetailedViewActive) { + // Desktop view active: close sidebar and set chat width to 420px setIsSidebarOpen(false); if (chatPanelRef.current) { chatPanelRef.current.style.width = `${INITIAL_CHAT_WIDTH}px`; } } else { - // Desktop artifact inactive: open sidebar and reset chat width + // Desktop view inactive: open sidebar and reset chat width setIsSidebarOpen(true); if (chatPanelRef.current) { chatPanelRef.current.style.width = "100%"; } } - }, [isArtifactActive, isMobile, setIsSidebarOpen]); + }, [isDetailedViewActive, isMobile, setIsSidebarOpen]); const handleResize = useCallback((clientX: number) => { if (!containerRef.current || !chatPanelRef.current) return; @@ -80,7 +80,7 @@ export const useArtifactResize = ({ return { containerRef, chatPanelRef, - artifactPanelRef, + detailedViewPanelRef, isDragging, handleResize, handleDragStart, diff --git a/packages/react-ui/src/components/Shell/welcomeScreen.scss b/packages/react-ui/src/components/Shell/welcomeScreen.scss index ece74b596..50e3fd23d 100644 --- a/packages/react-ui/src/components/Shell/welcomeScreen.scss +++ b/packages/react-ui/src/components/Shell/welcomeScreen.scss @@ -20,8 +20,8 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); padding: cssUtils.$space-2xl cssUtils.$space-l; } - // Artifact active adjustments - .openui-shell-thread-container--artifact-active & { + // Detailed-view active adjustments + .openui-shell-thread-container--detailed-view-active & { padding-left: 0; padding-right: cssUtils.$space-m; } diff --git a/packages/react-ui/src/components/_shared/artifact/index.ts b/packages/react-ui/src/components/_shared/artifact/index.ts deleted file mode 100644 index 010430867..000000000 --- a/packages/react-ui/src/components/_shared/artifact/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./ArtifactOverlay"; -export * from "./ArtifactPanel"; -export * from "./ArtifactPortalTarget"; diff --git a/packages/react-ui/src/components/_shared/artifact/ArtifactOverlay.tsx b/packages/react-ui/src/components/_shared/detailed-view/DetailedViewOverlay.tsx similarity index 65% rename from packages/react-ui/src/components/_shared/artifact/ArtifactOverlay.tsx rename to packages/react-ui/src/components/_shared/detailed-view/DetailedViewOverlay.tsx index 803041324..fde4234d3 100644 --- a/packages/react-ui/src/components/_shared/artifact/ArtifactOverlay.tsx +++ b/packages/react-ui/src/components/_shared/detailed-view/DetailedViewOverlay.tsx @@ -1,36 +1,36 @@ -import { useActiveArtifact } from "@openuidev/react-headless"; +import { useActiveDetailedView } from "@openuidev/react-headless"; import clsx from "clsx"; import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; import { useMultipleRefs } from "../../../hooks/useMultipleRefs"; -import { ArtifactPortalTarget } from "./ArtifactPortalTarget"; +import { DetailedViewPortalTarget } from "./DetailedViewPortalTarget"; /** - * Props for {@link ArtifactOverlay}. + * Props for {@link DetailedViewOverlay}. * * @category Components */ -export type ArtifactOverlayProps = { +export type DetailedViewOverlayProps = { /** Additional CSS class name(s) applied to the overlay container. */ className?: string; }; /** - * Shared overlay wrapper for the artifact portal target. + * Shared overlay wrapper for the detailed-view portal target. * Used by CopilotShell, BottomTray, and Shell (mobile) layouts. * Renders an absolute-positioned overlay with slide-in/slide-out animations. * * @category Components */ -export const ArtifactOverlay = forwardRef( +export const DetailedViewOverlay = forwardRef( ({ className }, ref) => { - const { isArtifactActive } = useActiveArtifact(); - const [shouldRender, setShouldRender] = useState(isArtifactActive); + const { isDetailedViewActive } = useActiveDetailedView(); + const [shouldRender, setShouldRender] = useState(isDetailedViewActive); const [isExiting, setIsExiting] = useState(false); const internalRef = useRef(null); const mergedRef = useMultipleRefs(ref, internalRef); useEffect(() => { - if (isArtifactActive) { + if (isDetailedViewActive) { // Opening: mount immediately, cancel any in-progress exit setShouldRender(true); setIsExiting(false); @@ -38,7 +38,7 @@ export const ArtifactOverlay = forwardRef( // Closing: start exit animation, defer unmount setIsExiting(true); } - }, [isArtifactActive]); // eslint-disable-line react-hooks/exhaustive-deps + }, [isDetailedViewActive]); // eslint-disable-line react-hooks/exhaustive-deps const handleAnimationEnd = useCallback( (e: React.AnimationEvent) => { @@ -58,16 +58,16 @@ export const ArtifactOverlay = forwardRef(
- +
); }, ); -ArtifactOverlay.displayName = "ArtifactOverlay"; +DetailedViewOverlay.displayName = "DetailedViewOverlay"; diff --git a/packages/react-ui/src/components/_shared/artifact/ArtifactPanel.tsx b/packages/react-ui/src/components/_shared/detailed-view/DetailedViewPanel.tsx similarity index 51% rename from packages/react-ui/src/components/_shared/artifact/ArtifactPanel.tsx rename to packages/react-ui/src/components/_shared/detailed-view/DetailedViewPanel.tsx index 07dac2b5c..de7525984 100644 --- a/packages/react-ui/src/components/_shared/artifact/ArtifactPanel.tsx +++ b/packages/react-ui/src/components/_shared/detailed-view/DetailedViewPanel.tsx @@ -1,4 +1,4 @@ -import { useArtifact, useArtifactPortalTarget } from "@openuidev/react-headless"; +import { useDetailedView, useDetailedViewPortalTarget } from "@openuidev/react-headless"; import clsx from "clsx"; import { X } from "lucide-react"; import { Component, forwardRef, useEffect, type ReactNode } from "react"; @@ -6,26 +6,26 @@ import { createPortal } from "react-dom"; import { useTheme } from "../../ThemeProvider/ThemeProvider"; /** @internal */ -type ArtifactErrorBoundaryProps = { +type DetailedViewErrorBoundaryProps = { children: ReactNode; fallback?: ReactNode; }; -type ArtifactErrorBoundaryState = { +type DetailedViewErrorBoundaryState = { hasError: boolean; }; /** @internal */ -class ArtifactErrorBoundary extends Component< - ArtifactErrorBoundaryProps, - ArtifactErrorBoundaryState +class DetailedViewErrorBoundary extends Component< + DetailedViewErrorBoundaryProps, + DetailedViewErrorBoundaryState > { - constructor(props: ArtifactErrorBoundaryProps) { + constructor(props: DetailedViewErrorBoundaryProps) { super(props); this.state = { hasError: false }; } - static getDerivedStateFromError(): ArtifactErrorBoundaryState { + static getDerivedStateFromError(): DetailedViewErrorBoundaryState { return { hasError: true }; } @@ -38,16 +38,16 @@ class ArtifactErrorBoundary extends Component< } /** - * Props for {@link ArtifactPanel}. + * Props for {@link DetailedViewPanel}. * * @category Components */ -export type ArtifactPanelProps = { - /** Artifact ID this panel renders content for. Must match the ID passed to `useArtifact(id)`. */ - artifactId: string; - /** Content rendered inside the panel when this artifact is active. */ +export type DetailedViewPanelProps = { + /** Detailed-view id this panel renders content for. Must match the id passed to `useDetailedView(viewId)`. */ + viewId: string; + /** Content rendered inside the panel when this view is active. */ children: ReactNode; - /** Display title for the panel header and aria-label. Defaults to `"Artifact"`. */ + /** Display title for the panel header and aria-label. Defaults to `"Detailed view"`. */ title?: string; /** Additional CSS class name(s) applied to the panel container. */ className?: string; @@ -64,12 +64,12 @@ export type ArtifactPanelProps = { /** @internal */ const DefaultHeader = ({ title, onClose }: { title: string; onClose: () => void }) => ( -
- {title} +
+ {title} @@ -77,19 +77,19 @@ const DefaultHeader = ({ title, onClose }: { title: string; onClose: () => void ); /** - * Portals artifact content into the nearest {@link ArtifactPortalTarget}. + * Portals detailed-view content into the nearest {@link DetailedViewPortalTarget}. * - * Renders nothing when the artifact is inactive or no portal target is mounted. + * Renders nothing when the view is inactive or no portal target is mounted. * Wraps children in an error boundary and applies theme-scoped class names. * - * Requires `` to be mounted in the layout. + * Requires `` to be mounted in the layout. * * @category Components */ -export const ArtifactPanel = forwardRef( - ({ artifactId, children, title, className, errorFallback, header = true }, ref) => { - const { isActive, close } = useArtifact(artifactId); - const { node: panelNode } = useArtifactPortalTarget(); +export const DetailedViewPanel = forwardRef( + ({ viewId, children, title, className, errorFallback, header = true }, ref) => { + const { isActive, close } = useDetailedView(viewId); + const { node: panelNode } = useDetailedViewPortalTarget(); const { portalThemeClassName } = useTheme(); useEffect(() => { @@ -97,8 +97,8 @@ export const ArtifactPanel = forwardRef( const timer = setTimeout(() => { console.warn( - "[OpenUI] ArtifactPanel: artifact is active but no render target is mounted. " + - "Ensure is rendered in your layout.", + "[OpenUI] DetailedViewPanel: view is active but no render target is mounted. " + + "Ensure is rendered in your layout.", ); }, 100); return () => clearTimeout(timer); @@ -110,7 +110,7 @@ export const ArtifactPanel = forwardRef( let headerContent: ReactNode = null; if (header === true) { - headerContent = ; + headerContent = ; } else if (header !== false) { headerContent = header; } @@ -118,17 +118,17 @@ export const ArtifactPanel = forwardRef( return createPortal(
{headerContent} - {children} + {children}
, panelNode, ); }, ); -ArtifactPanel.displayName = "ArtifactPanel"; +DetailedViewPanel.displayName = "DetailedViewPanel"; diff --git a/packages/react-ui/src/components/_shared/artifact/ArtifactPortalTarget.tsx b/packages/react-ui/src/components/_shared/detailed-view/DetailedViewPortalTarget.tsx similarity index 64% rename from packages/react-ui/src/components/_shared/artifact/ArtifactPortalTarget.tsx rename to packages/react-ui/src/components/_shared/detailed-view/DetailedViewPortalTarget.tsx index f768fcbda..256bb05eb 100644 --- a/packages/react-ui/src/components/_shared/artifact/ArtifactPortalTarget.tsx +++ b/packages/react-ui/src/components/_shared/detailed-view/DetailedViewPortalTarget.tsx @@ -1,25 +1,25 @@ -import { useArtifactPortalTarget } from "@openuidev/react-headless"; +import { useDetailedViewPortalTarget } from "@openuidev/react-headless"; import { forwardRef, useCallback, useRef } from "react"; /** - * Props for {@link ArtifactPortalTarget}. + * Props for {@link DetailedViewPortalTarget}. */ -export type ArtifactPortalTargetProps = { +export type DetailedViewPortalTargetProps = { /** Additional CSS class name(s) applied to the container element. */ className?: string; }; /** - * Registers a DOM node as the render target for {@link ArtifactPanel} portals. + * Registers a DOM node as the render target for {@link DetailedViewPanel} portals. * * Mount exactly one instance in your layout. Renders a `
` with * `display: contents` so it doesn't affect layout flow. * * @category Components */ -export const ArtifactPortalTarget = forwardRef( +export const DetailedViewPortalTarget = forwardRef( ({ className }, ref) => { - const { setNode } = useArtifactPortalTarget(); + const { setNode } = useDetailedViewPortalTarget(); const forwardedRef = useRef(ref); forwardedRef.current = ref; @@ -40,4 +40,4 @@ export const ArtifactPortalTarget = forwardRef void; + /** Deactivates this view. */ + close: () => void; + /** Toggles this view: opens if closed, closes if open. */ + toggle: () => void; +} + +/** + * Configuration for {@link DetailedView}. + */ +export interface DetailedViewConfig

> { + /** Panel title — static string or derived from props. */ + title: string | ((props: P) => string); + /** Renders the inline preview shown in the chat message. */ + preview: (props: P, controls: DetailedViewControls) => ReactNode; + /** Renders the content inside the detailed-view side panel. */ + actual: (props: P, controls: DetailedViewControls) => ReactNode; + /** Optional props forwarded to the underlying ``. */ + panelProps?: Pick; +} + +/** + * Factory that returns a `ComponentRenderer

` wiring up `useId`, `useDetailedView`, + * and `` internally. Pass the result as `defineComponent`'s `component`. + * + * @example + * ```tsx + * export const DetailedViewCodeBlock = defineComponent({ + * name: "DetailedViewCodeBlock", + * props: DetailedViewCodeBlockSchema, + * description: "Code block that opens in the detailed-view side panel", + * component: DetailedView({ + * title: (props) => props.title, + * preview: (props, { open, isActive }) => ( + * + * ), + * actual: (props) => ( + * + * ), + * }), + * }); + * ``` + */ +export function DetailedView

>( + config: DetailedViewConfig

, +): ComponentRenderer

{ + const { title, preview, actual, panelProps } = config; + + const DetailedViewComponent: ComponentRenderer

= ({ props }) => { + const viewId = useId(); + const { isActive, open, close, toggle } = useDetailedView(viewId); + + const controls: DetailedViewControls = { isActive, open, close, toggle }; + const resolvedTitle = typeof title === "function" ? title(props) : title; + + return ( + <> + {preview(props, controls)} + + {actual(props, controls)} + + + ); + }; + + DetailedViewComponent.displayName = `DetailedView(${typeof title === "string" ? title : "dynamic"})`; + + return DetailedViewComponent; +} diff --git a/packages/react-ui/src/detailed-view/index.ts b/packages/react-ui/src/detailed-view/index.ts new file mode 100644 index 000000000..f8a6012f6 --- /dev/null +++ b/packages/react-ui/src/detailed-view/index.ts @@ -0,0 +1,2 @@ +export { DetailedView } from "./DetailedView"; +export type { DetailedViewConfig, DetailedViewControls } from "./DetailedView"; diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index 3332aac49..67e2c11df 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -2,22 +2,22 @@ export * from "./components/Accordion"; -// Artifact() factory — generates a ComponentRenderer with artifact wiring -export { Artifact } from "./artifact"; -export type { ArtifactConfig, ArtifactControls } from "./artifact"; +// DetailedView() factory — generates a ComponentRenderer with detailed-view wiring +export { DetailedView } from "./detailed-view"; +export type { DetailedViewConfig, DetailedViewControls } from "./detailed-view"; -// Artifact exports (ArtifactPanel/ArtifactPortalTarget also available as Shell.*) -export { useActiveArtifact, useArtifact } from "@openuidev/react-headless"; +// Detailed-view exports (DetailedViewPanel/DetailedViewPortalTarget also available as Shell.*) +export { useActiveDetailedView, useDetailedView } from "@openuidev/react-headless"; export { - ArtifactOverlay, - ArtifactPanel, - ArtifactPortalTarget, -} from "./components/_shared/artifact"; + DetailedViewOverlay, + DetailedViewPanel, + DetailedViewPortalTarget, +} from "./components/_shared/detailed-view"; export type { - ArtifactOverlayProps, - ArtifactPanelProps, - ArtifactPortalTargetProps, -} from "./components/_shared/artifact"; + DetailedViewOverlayProps, + DetailedViewPanelProps, + DetailedViewPortalTargetProps, +} from "./components/_shared/detailed-view"; export * from "./components/Button"; export * from "./components/Buttons"; From 67f1cfc5ece7ef5bf7013adc4a2d6e735ea4855a Mon Sep 17 00:00:00 2001 From: abhithesys Date: Tue, 5 May 2026 12:53:34 +0530 Subject: [PATCH 02/88] Map tool-call results to custom inline preview + detailed-view side panel via defineAppRenderer (react-headless). Renderers match by toolName (literal string or RegExp), captured at ChatProvider mount with first-wins on duplicates and dev-mode warnings on ambiguity. ToolMessageRenderer (react-ui) dispatches each tool message to its matching renderer or falls back to the default ToolResult. Wired into Shell, CopilotShell, and BottomTray Thread components. Args and response are passed raw to parsers; isStreaming is reserved (always false) until streaming protocol lands. --- .../src/hooks/useAppRenderer.ts | 29 +++ packages/react-headless/src/index.ts | 5 + .../src/store/AppRenderersContext.ts | 119 ++++++++++++ .../react-headless/src/store/ChatProvider.tsx | 29 ++- .../__tests__/appRendererRegistry.test.ts | 172 ++++++++++++++++++ .../src/store/appRendererTypes.ts | 88 +++++++++ packages/react-headless/src/store/types.ts | 7 + .../src/components/BottomTray/Thread.tsx | 22 ++- .../src/components/CopilotShell/Thread.tsx | 22 ++- .../react-ui/src/components/Shell/Thread.tsx | 23 ++- .../app-renderer/AppRendererInstance.tsx | 81 +++++++++ .../app-renderer/ToolMessageRenderer.tsx | 49 +++++ .../components/_shared/app-renderer/index.ts | 1 + .../react-ui/src/components/_shared/index.ts | 1 + packages/react-ui/src/index.ts | 6 + 15 files changed, 626 insertions(+), 28 deletions(-) create mode 100644 packages/react-headless/src/hooks/useAppRenderer.ts create mode 100644 packages/react-headless/src/store/AppRenderersContext.ts create mode 100644 packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts create mode 100644 packages/react-headless/src/store/appRendererTypes.ts create mode 100644 packages/react-ui/src/components/_shared/app-renderer/AppRendererInstance.tsx create mode 100644 packages/react-ui/src/components/_shared/app-renderer/ToolMessageRenderer.tsx create mode 100644 packages/react-ui/src/components/_shared/app-renderer/index.ts diff --git a/packages/react-headless/src/hooks/useAppRenderer.ts b/packages/react-headless/src/hooks/useAppRenderer.ts new file mode 100644 index 000000000..8de36efbb --- /dev/null +++ b/packages/react-headless/src/hooks/useAppRenderer.ts @@ -0,0 +1,29 @@ +import { lookupAppRenderer, useAppRendererRegistry } from "../store/AppRenderersContext"; +import type { AppRendererConfig } from "../store/appRendererTypes"; + +/** + * Resolves the AppRenderer config matching a given `toolName`, or `null` if none match. + * + * Thin React wrapper over {@link lookupAppRenderer}: reads the registry from + * `` context and runs the lookup. See `lookupAppRenderer` for + * matching rules and dev-mode ambiguity warnings. + * + * Returns `null` if no `appRenderers` were supplied to the provider — callers + * should fall back to default rendering in that case. + * + * @category Hooks + * + * @example + * ```tsx + * function ToolResultDispatcher({ toolName, ...rest }) { + * const renderer = useAppRenderer(toolName); + * if (!renderer) return ; + * return ; + * } + * ``` + */ +export function useAppRenderer(toolName: string): AppRendererConfig | null { + const registry = useAppRendererRegistry(); + if (!registry) return null; + return lookupAppRenderer(registry, toolName); +} diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index e3f11f248..2c27e98b5 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -1,11 +1,14 @@ export { useActiveDetailedView } from "./hooks/useActiveDetailedView"; export { useAppList } from "./hooks/useAppList"; +export { useAppRenderer } from "./hooks/useAppRenderer"; export { useArtifactList } from "./hooks/useArtifactList"; export { useDetailedView } from "./hooks/useDetailedView"; export { useDetailedViewPortalTarget } from "./hooks/useDetailedViewPortalTarget"; export { MessageContext, MessageProvider, useMessage } from "./hooks/useMessage"; export { useThread, useThreadList } from "./hooks/useThread"; +export { AppRenderersContext, useAppRendererRegistry } from "./store/AppRenderersContext"; +export { defineAppRenderer } from "./store/appRendererTypes"; export { ChatProvider } from "./store/ChatProvider"; export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext"; export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext"; @@ -23,6 +26,8 @@ export { } from "./stream/formats"; export { processStreamedMessage } from "./stream/processStreamedMessage"; +export type { AppRendererConfig, AppRendererControls } from "./store/appRendererTypes"; + export type { DetailedViewActions, DetailedViewState } from "./store/detailedViewTypes"; export type { diff --git a/packages/react-headless/src/store/AppRenderersContext.ts b/packages/react-headless/src/store/AppRenderersContext.ts new file mode 100644 index 000000000..c7c8b8f3a --- /dev/null +++ b/packages/react-headless/src/store/AppRenderersContext.ts @@ -0,0 +1,119 @@ +import { createContext, useContext } from "react"; +import type { AppRendererConfig } from "./appRendererTypes"; + +/** + * Pre-built lookup structure for AppRenderer matching. + * + * Built once at `ChatProvider` mount from the user-supplied `appRenderers` array. + * Subsequent prop changes are ignored (with a dev-mode warning) so renderer + * registration stays stable for the lifetime of the provider. + * + * @internal + */ +export type AppRendererRegistry = { + /** Configs whose `toolName` is a literal string, indexed for O(1) lookup. */ + literal: Map>; + /** Configs whose `toolName` is a `RegExp`, scanned linearly as a fallback. */ + regex: AppRendererConfig[]; +}; + +/** + * Builds an {@link AppRendererRegistry} from a list of configs. + * + * - Splits literal-toolName configs into a Map for O(1) lookup, regex configs into an array. + * - First-wins on duplicate literal `toolName`: subsequent registrations are ignored. + * - In development, logs a warning when a duplicate is dropped so the user can + * reorder their array (custom renderers should come *before* SDK defaults). + * + * @internal + */ +export function buildAppRendererRegistry( + configs: ReadonlyArray>, +): AppRendererRegistry { + const literal = new Map>(); + const regex: AppRendererConfig[] = []; + + for (const config of configs) { + if (typeof config.toolName === "string") { + if (literal.has(config.toolName)) { + if (process.env["NODE_ENV"] !== "production") { + console.warn( + `[OpenUI] AppRenderer for toolName "${config.toolName}" was ignored ` + + `(already registered earlier in the array).`, + ); + } + continue; + } + literal.set(config.toolName, config); + } else { + regex.push(config); + } + } + + return { literal, regex }; +} + +/** + * Resolves the AppRenderer config matching a given `toolName`, or `null` if none match. + * + * Lookup order: + * 1. Literal-toolName map (O(1)) + * 2. Regex configs scanned in array order; first match wins + * + * In development, after finding a match the function continues scanning to detect + * ambiguity (e.g., a literal config and a regex config that both match the same + * tool name) and logs a warning. Production short-circuits on first hit. + * + * @internal + */ +export function lookupAppRenderer( + registry: AppRendererRegistry, + toolName: string, +): AppRendererConfig | null { + const literal = registry.literal.get(toolName); + + if (process.env["NODE_ENV"] !== "production") { + const matches: AppRendererConfig[] = []; + if (literal) matches.push(literal); + for (const r of registry.regex) { + if (r.toolName instanceof RegExp && r.toolName.test(toolName)) { + matches.push(r); + } + } + if (matches.length > 1) { + console.warn( + `[OpenUI] Multiple AppRenderers match toolName "${toolName}". ` + + `Using the first (${String(matches[0]!.toolName)}); ignoring ${matches.length - 1} other(s). ` + + `Reorder your appRenderers array to control priority.`, + ); + } + } + + if (literal) return literal; + + for (const r of registry.regex) { + if (r.toolName instanceof RegExp && r.toolName.test(toolName)) { + return r; + } + } + + return null; +} + +/** @internal React context holding the AppRenderer registry. Provided by `ChatProvider`. */ +export const AppRenderersContext = createContext(null); + +/** + * Returns the raw AppRenderer registry for advanced use cases. + * + * Prefer {@link useAppRenderer} for resolving a specific tool name — + * this hook is an escape hatch for custom dispatching. + * + * Returns `null` if no `appRenderers` were provided to the `` — + * this is not an error since AppRenderers are optional. + * + * @category Hooks + */ +export const useAppRendererRegistry = (): AppRendererRegistry | null => { + return useContext(AppRenderersContext); +}; diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index 4930c091c..eac043f74 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -1,4 +1,5 @@ -import { useEffect, useState, type FC } from "react"; +import { useEffect, useRef, useState, type FC } from "react"; +import { AppRenderersContext, buildAppRendererRegistry } from "./AppRenderersContext"; import { ChatContext } from "./ChatContext"; import { createChatStore } from "./createChatStore"; import { createDetailedViewStore } from "./createDetailedViewStore"; @@ -7,10 +8,30 @@ import { DetailedViewContext } from "./DetailedViewContext"; import { ThreadContextContext } from "./ThreadContextContext"; import type { ChatProviderProps } from "./types"; -export const ChatProvider: FC = ({ children, ...config }) => { +export const ChatProvider: FC = ({ children, appRenderers, ...config }) => { const [chatStore] = useState(() => createChatStore(config)); const [detailedViewStore] = useState(() => createDetailedViewStore()); const [threadContextStore] = useState(() => createThreadContextStore()); + const [appRendererRegistry] = useState(() => buildAppRendererRegistry(appRenderers ?? [])); + + // Dev-mode warning if appRenderers reference changes after mount — + // captured registry is mount-only, so changes are silently ignored otherwise. + const initialAppRenderersRef = useRef(appRenderers); + const hasWarnedRef = useRef(false); + useEffect(() => { + if ( + process.env["NODE_ENV"] !== "production" && + !hasWarnedRef.current && + initialAppRenderersRef.current !== appRenderers + ) { + console.warn( + "[OpenUI] `appRenderers` prop changed after ChatProvider mount. " + + "The original array is kept; new renderers will not be registered. " + + "Memoize the array (useMemo) to avoid this warning.", + ); + hasWarnedRef.current = true; + } + }, [appRenderers]); // Cross-store subscription: reset detailed-view + thread-context state when the active thread changes. // useEffect (not inline) so the cleanup function unsubscribes on unmount. @@ -29,7 +50,9 @@ export const ChatProvider: FC = ({ children, ...config }) => - {children} + + {children} + diff --git a/packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts b/packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts new file mode 100644 index 000000000..1a3333d9c --- /dev/null +++ b/packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts @@ -0,0 +1,172 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { buildAppRendererRegistry, lookupAppRenderer } from "../AppRenderersContext"; +import { defineAppRenderer } from "../appRendererTypes"; + +const makeRenderer = (toolName: string | RegExp, label = String(toolName)) => + defineAppRenderer({ + toolName, + parser: () => ({ label }), + meta: (props) => ({ id: props.label, version: 1, heading: props.label }), + preview: () => null, + actual: () => null, + }); + +describe("buildAppRendererRegistry", () => { + it("returns an empty registry for an empty input", () => { + const registry = buildAppRendererRegistry([]); + + expect(registry.literal.size).toBe(0); + expect(registry.regex).toEqual([]); + }); + + it("indexes literal toolNames in the map", () => { + const r1 = makeRenderer("presentation:create"); + const r2 = makeRenderer("report:create"); + + const registry = buildAppRendererRegistry([r1, r2]); + + expect(registry.literal.size).toBe(2); + expect(registry.literal.get("presentation:create")).toBe(r1); + expect(registry.literal.get("report:create")).toBe(r2); + expect(registry.regex).toEqual([]); + }); + + it("collects regex toolNames in the regex array", () => { + const r1 = makeRenderer(/^presentation:/); + const r2 = makeRenderer(/^report:/); + + const registry = buildAppRendererRegistry([r1, r2]); + + expect(registry.literal.size).toBe(0); + expect(registry.regex).toEqual([r1, r2]); + }); + + it("first-wins on duplicate literal toolName", () => { + const r1 = makeRenderer("presentation:create", "first"); + const r2 = makeRenderer("presentation:create", "second"); + + const registry = buildAppRendererRegistry([r1, r2]); + + expect(registry.literal.get("presentation:create")).toBe(r1); + expect(registry.literal.size).toBe(1); + }); + + describe("dev-mode warnings", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("warns when a duplicate literal toolName is dropped", () => { + buildAppRendererRegistry([ + makeRenderer("presentation:create"), + makeRenderer("presentation:create"), + ]); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain("presentation:create"); + }); + + it("does not warn when duplicate is on a regex toolName", () => { + buildAppRendererRegistry([makeRenderer(/^presentation:/), makeRenderer(/^presentation:/)]); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); +}); + +describe("lookupAppRenderer", () => { + it("returns the literal match when present", () => { + const r = makeRenderer("presentation:create"); + const registry = buildAppRendererRegistry([r]); + + expect(lookupAppRenderer(registry, "presentation:create")).toBe(r); + }); + + it("returns null when no renderer matches", () => { + const registry = buildAppRendererRegistry([makeRenderer("presentation:create")]); + + expect(lookupAppRenderer(registry, "report:create")).toBeNull(); + }); + + it("falls back to regex match when no literal match", () => { + const r = makeRenderer(/^presentation:/); + const registry = buildAppRendererRegistry([r]); + + expect(lookupAppRenderer(registry, "presentation:edit")).toBe(r); + }); + + it("prefers literal match over regex match", () => { + const literalRenderer = makeRenderer("presentation:create", "literal"); + const regexRenderer = makeRenderer(/^presentation:/, "regex"); + const registry = buildAppRendererRegistry([literalRenderer, regexRenderer]); + + expect(lookupAppRenderer(registry, "presentation:create")).toBe(literalRenderer); + }); + + it("uses array order for regex matches (first wins)", () => { + const first = makeRenderer(/^presentation:/, "first"); + const second = makeRenderer(/.*:create$/, "second"); + const registry = buildAppRendererRegistry([first, second]); + + // Both regexes match — suppress the dev-mode ambiguity warning here; + // ambiguity warnings are tested in their own describe block below. + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + expect(lookupAppRenderer(registry, "presentation:create")).toBe(first); + } finally { + warnSpy.mockRestore(); + } + }); + + describe("dev-mode ambiguity warnings", () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it("warns when both a literal and a regex match the same tool name", () => { + const registry = buildAppRendererRegistry([ + makeRenderer("presentation:create"), + makeRenderer(/^presentation:/), + ]); + + lookupAppRenderer(registry, "presentation:create"); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy.mock.calls[0]?.[0]).toContain("presentation:create"); + }); + + it("warns when multiple regexes match the same tool name", () => { + const registry = buildAppRendererRegistry([ + makeRenderer(/^presentation:/), + makeRenderer(/.*:create$/), + ]); + + lookupAppRenderer(registry, "presentation:create"); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); + + it("does not warn when only one renderer matches", () => { + const registry = buildAppRendererRegistry([ + makeRenderer("presentation:create"), + makeRenderer(/^report:/), + ]); + + lookupAppRenderer(registry, "presentation:create"); + + expect(warnSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/react-headless/src/store/appRendererTypes.ts b/packages/react-headless/src/store/appRendererTypes.ts new file mode 100644 index 000000000..e04a989f8 --- /dev/null +++ b/packages/react-headless/src/store/appRendererTypes.ts @@ -0,0 +1,88 @@ +import type { ReactNode } from "react"; + +/** + * Controls passed to an AppRenderer's `preview` and `actual` render functions. + * + * @category Types + */ +export interface AppRendererControls { + /** Whether this app's detailed view is the currently active one. */ + isActive: boolean; + /** Whether the underlying tool response is still streaming (always `false` in v1; reserved for streaming protocol). */ + isStreaming: boolean; + /** Activates this app's detailed view. */ + open: () => void; + /** Closes this app's detailed view if currently active. */ + close: () => void; + /** Toggles this app's detailed view. */ + toggle: () => void; +} + +/** + * Configuration for a single app renderer, returned by {@link defineAppRenderer}. + * + * Renderers are matched against tool calls by `toolName` (literal string or RegExp). + * When a match fires, `parser` converts the raw tool envelope into typed `Props`, + * `meta` derives the registry entry (id, version, heading), and `preview` / `actual` + * render the inline chat preview and side-panel detailed view respectively. + * + * @category Types + */ +export interface AppRendererConfig { + /** + * Tool name to match. Literal strings take priority over regex; first match wins. + * + * String example: `"presentation:create"`. + * Regex example: `/^presentation:/`. + */ + toolName: string | RegExp; + /** + * Converts the raw tool envelope into renderer-shaped `Props`. + * + * Receives `{ args, response }` exactly as the backend emitted them — the SDK + * does not pre-parse JSON. Return `null` to skip rendering this tool result. + */ + parser: (raw: { args: unknown; response: unknown }) => Props | null; + /** + * Derives the {@link AppEntry} fields registered in ThreadContext. + * + * Return `null` to skip registration (the renderer still renders if `parser` + * returned non-null Props, but the entry will not appear in the apps list). + * + * The `id` should be stable across re-runs of the same logical app. + */ + meta: ( + props: Props, + ctx: { isStreaming: boolean }, + ) => { id: string; version: number; heading: string } | null; + /** Renders the inline preview shown in the chat message. */ + preview: (props: Props, controls: AppRendererControls) => ReactNode; + /** Renders the content displayed in the detailed-view side panel. */ + actual: (props: Props, controls: AppRendererControls) => ReactNode; +} + +/** + * Identity helper that returns its argument while preserving `Props` inference. + * + * Without this, users would have to write `const r: AppRendererConfig = {...}` + * to get type checking. With it, `defineAppRenderer({...})` infers `Props` from + * `parser`'s return type. + * + * @category Functions + * + * @example + * ```ts + * const presentationRenderer = defineAppRenderer({ + * toolName: "presentation:create", + * parser: (raw) => raw.response as { id: string; slides: Slide[] }, + * meta: (props) => ({ id: props.id, version: 1, heading: `Presentation ${props.id}` }), + * preview: (props, controls) => , + * actual: (props) => , + * }); + * ``` + */ +export function defineAppRenderer( + config: AppRendererConfig, +): AppRendererConfig { + return config; +} diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 8989da8b1..a7033ab36 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -1,6 +1,7 @@ import type { Message, UserMessage } from "../types/message"; import type { MessageFormat } from "../types/messageFormat"; import type { StreamProtocolAdapter } from "../types/stream"; +import type { AppRendererConfig } from "./appRendererTypes"; export type { Message, UserMessage } from "../types/message"; export type CreateMessage = Omit; @@ -100,5 +101,11 @@ export type ChatProviderProps = ThreadApiConfig & ChatApiConfig & { streamProtocol?: StreamProtocolAdapter; messageFormat?: MessageFormat; + /** + * App renderers matched against tool calls in the conversation. + * Captured at mount; subsequent prop changes are ignored (dev warning). + * Order is priority: first match wins on duplicate `toolName`. + */ + appRenderers?: ReadonlyArray>; children: React.ReactNode; }; diff --git a/packages/react-ui/src/components/BottomTray/Thread.tsx b/packages/react-ui/src/components/BottomTray/Thread.tsx index 38da67d4a..40ae4bbb9 100644 --- a/packages/react-ui/src/components/BottomTray/Thread.tsx +++ b/packages/react-ui/src/components/BottomTray/Thread.tsx @@ -3,6 +3,7 @@ import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; +import { ToolMessageRenderer } from "../_shared/app-renderer"; import { DetailedViewOverlay } from "../_shared/detailed-view"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; @@ -120,11 +121,6 @@ const AssistantMessageContent = ({ message: AssistantMessage; allMessages: Message[]; }) => { - const getToolName = (toolCallId: string) => { - const toolCall = message.toolCalls?.find((tc) => tc.id === toolCallId); - return toolCall?.function.name; - }; - const toolMessages: ToolMessage[] = []; const msgIndex = allMessages.findIndex((m) => m.id === message.id); if (msgIndex !== -1) { @@ -149,9 +145,19 @@ const AssistantMessageContent = ({ {message.toolCalls?.map((toolCall) => ( ))} - {toolMessages.map((tm) => ( - - ))} + {toolMessages.map((tm) => { + const toolCall = message.toolCalls?.find((tc) => tc.id === tm.toolCallId); + const fallback = ; + if (!toolCall) return {fallback}; + return ( + + ); + })} ); }; diff --git a/packages/react-ui/src/components/CopilotShell/Thread.tsx b/packages/react-ui/src/components/CopilotShell/Thread.tsx index 3c98011c2..4ed0b74b7 100644 --- a/packages/react-ui/src/components/CopilotShell/Thread.tsx +++ b/packages/react-ui/src/components/CopilotShell/Thread.tsx @@ -3,6 +3,7 @@ import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; +import { ToolMessageRenderer } from "../_shared/app-renderer"; import { DetailedViewOverlay } from "../_shared/detailed-view"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; @@ -118,11 +119,6 @@ const AssistantMessageContent = ({ message: AssistantMessage; allMessages: Message[]; }) => { - const getToolName = (toolCallId: string) => { - const toolCall = message.toolCalls?.find((tc) => tc.id === toolCallId); - return toolCall?.function.name; - }; - const toolMessages: ToolMessage[] = []; const msgIndex = allMessages.findIndex((m) => m.id === message.id); if (msgIndex !== -1) { @@ -147,9 +143,19 @@ const AssistantMessageContent = ({ {message.toolCalls?.map((toolCall) => ( ))} - {toolMessages.map((tm) => ( - - ))} + {toolMessages.map((tm) => { + const toolCall = message.toolCalls?.find((tc) => tc.id === tm.toolCallId); + const fallback = ; + if (!toolCall) return {fallback}; + return ( + + ); + })} ); }; diff --git a/packages/react-ui/src/components/Shell/Thread.tsx b/packages/react-ui/src/components/Shell/Thread.tsx index 049eb9d25..df0daee16 100644 --- a/packages/react-ui/src/components/Shell/Thread.tsx +++ b/packages/react-ui/src/components/Shell/Thread.tsx @@ -5,6 +5,7 @@ import React, { memo, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; import { separateContentAndContext } from "../../utils/contentParser"; +import { ToolMessageRenderer } from "../_shared/app-renderer"; import { DetailedViewOverlay, DetailedViewPortalTarget } from "../_shared/detailed-view"; import { useShellStore } from "../_shared/store"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; @@ -182,12 +183,6 @@ const AssistantMessageContent = ({ message: AssistantMessage; allMessages: Message[]; }) => { - // Find tool result messages that correspond to this message's tool calls - const getToolName = (toolCallId: string) => { - const toolCall = message.toolCalls?.find((tc) => tc.id === toolCallId); - return toolCall?.function.name; - }; - // Collect tool messages that follow this assistant message const toolMessages: ToolMessage[] = []; const msgIndex = allMessages.findIndex((m) => m.id === message.id); @@ -213,9 +208,19 @@ const AssistantMessageContent = ({ {message.toolCalls?.map((toolCall) => ( ))} - {toolMessages.map((tm) => ( - - ))} + {toolMessages.map((tm) => { + const toolCall = message.toolCalls?.find((tc) => tc.id === tm.toolCallId); + const fallback = ; + if (!toolCall) return {fallback}; + return ( + + ); + })} ); }; diff --git a/packages/react-ui/src/components/_shared/app-renderer/AppRendererInstance.tsx b/packages/react-ui/src/components/_shared/app-renderer/AppRendererInstance.tsx new file mode 100644 index 000000000..92134484e --- /dev/null +++ b/packages/react-ui/src/components/_shared/app-renderer/AppRendererInstance.tsx @@ -0,0 +1,81 @@ +import { + useDetailedView, + useThreadContextStore, + type AppRendererConfig, + type AppRendererControls, +} from "@openuidev/react-headless"; +import { useEffect, useId, useMemo } from "react"; +import { DetailedViewPanel } from "../detailed-view"; + +/** + * Renders a matched AppRenderer for a single tool call/response. + * + * Lifecycle: + * 1. Run `parser({ args, response })` to derive Props. + * 2. Run `meta(props, ctx)` to derive ThreadContext entry. + * 3. If meta returns non-null, register the entry on mount; unregister on unmount. + * 4. Render `preview(props, controls)` inline + `` containing + * `actual(props, controls)` for the side panel. + * + * If `parser` returns `null`, renders nothing (caller should fall back). + * If `meta` returns `null`, renders inline + panel but skips ThreadContext registration — + * a fallback `viewId` derived from `useId()` is used so `controls` remain functional. + * + * Internal — consumers should use {@link ToolMessageRenderer}. + * + * @internal + */ +export function AppRendererInstance({ + renderer, + args, + response, +}: { + renderer: AppRendererConfig; + args: unknown; + response: unknown; +}) { + const fallbackId = useId(); + const tcStore = useThreadContextStore(); + + const props = useMemo(() => renderer.parser({ args, response }), [renderer, args, response]); + + const meta = useMemo(() => { + if (props === null) return null; + return renderer.meta(props, { isStreaming: false }); + }, [renderer, props]); + + // viewId derives from meta when present, otherwise from React's useId + // so `controls.open` still works for an inline-only renderer. + const viewId = meta ? `${meta.id}:${meta.version}` : fallbackId; + + // Register entry on mount; unregister on unmount or when (id, version) changes. + // Heading-only changes upsert via the store's idempotent registerApp. + useEffect(() => { + if (!meta) return; + tcStore.getState().registerApp(meta); + return () => { + tcStore.getState().unregisterApp(meta.id, meta.version); + }; + }, [tcStore, meta?.id, meta?.version, meta?.heading]); // eslint-disable-line react-hooks/exhaustive-deps + + const { isActive, open, close, toggle } = useDetailedView(viewId); + + if (props === null) return null; + + const controls: AppRendererControls = { + isActive, + open, + close, + toggle, + isStreaming: false, + }; + + return ( + <> + {renderer.preview(props, controls)} + + {renderer.actual(props, controls)} + + + ); +} diff --git a/packages/react-ui/src/components/_shared/app-renderer/ToolMessageRenderer.tsx b/packages/react-ui/src/components/_shared/app-renderer/ToolMessageRenderer.tsx new file mode 100644 index 000000000..e5ea7911c --- /dev/null +++ b/packages/react-ui/src/components/_shared/app-renderer/ToolMessageRenderer.tsx @@ -0,0 +1,49 @@ +import { useAppRenderer, type ToolCall, type ToolMessage } from "@openuidev/react-headless"; +import type { ReactNode } from "react"; +import { AppRendererInstance } from "./AppRendererInstance"; + +/** + * Props for {@link ToolMessageRenderer}. + * + * @category Components + */ +export type ToolMessageRendererProps = { + /** The tool message containing the response payload. */ + toolMessage: ToolMessage; + /** The matching tool call from the parent assistant message (provides `name` + `arguments`). */ + toolCall: ToolCall; + /** Rendered when no AppRenderer matches `toolCall.function.name`. */ + fallback: ReactNode; +}; + +/** + * Dispatches a tool result to a matching AppRenderer if one is registered, + * otherwise renders `fallback` (typically the default ``). + * + * Looks up `toolCall.function.name` against the AppRenderer registry provided + * by ``. On match, hands off to + * {@link AppRendererInstance} which runs the parser, registers in ThreadContext, + * and renders the inline preview + detailed-view panel. + * + * Tool args (`toolCall.function.arguments`) and response (`toolMessage.content`) + * are passed to the renderer's `parser` raw — the SDK does not pre-parse JSON. + * + * @category Components + */ +export const ToolMessageRenderer = ({ + toolMessage, + toolCall, + fallback, +}: ToolMessageRendererProps) => { + const renderer = useAppRenderer(toolCall.function.name); + + if (!renderer) return <>{fallback}; + + return ( + + ); +}; diff --git a/packages/react-ui/src/components/_shared/app-renderer/index.ts b/packages/react-ui/src/components/_shared/app-renderer/index.ts new file mode 100644 index 000000000..f5645ca88 --- /dev/null +++ b/packages/react-ui/src/components/_shared/app-renderer/index.ts @@ -0,0 +1 @@ +export { ToolMessageRenderer, type ToolMessageRendererProps } from "./ToolMessageRenderer"; diff --git a/packages/react-ui/src/components/_shared/index.ts b/packages/react-ui/src/components/_shared/index.ts index 3e9842243..65c4a91dc 100644 --- a/packages/react-ui/src/components/_shared/index.ts +++ b/packages/react-ui/src/components/_shared/index.ts @@ -1,3 +1,4 @@ +export * from "./app-renderer"; export * from "./detailed-view"; export * from "./store"; export * from "./types"; diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index 8062c87c8..b6c6950b2 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -6,6 +6,12 @@ export * from "./components/Accordion"; export { DetailedView } from "./detailed-view"; export type { DetailedViewConfig, DetailedViewControls } from "./detailed-view"; +// ToolMessageRenderer — dispatches tool results to matching AppRenderers +export { + ToolMessageRenderer, + type ToolMessageRendererProps, +} from "./components/_shared/app-renderer"; + // Detailed-view exports (DetailedViewPanel/DetailedViewPortalTarget also available as Shell.*) export { useActiveDetailedView, useDetailedView } from "@openuidev/react-headless"; export { From 19cd211b93d51a2c08c06b273e13c5dc3e047487 Mon Sep 17 00:00:00 2001 From: abhithesys Date: Tue, 5 May 2026 18:20:38 +0530 Subject: [PATCH 03/88] feat: add defineAppRenderer + defineArtifactRenderer with end-to-end migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Map tool-call results to custom inline preview + detailed-view side panel via renderers, and migrate openui-artifact-demo to demonstrate. react-headless: - defineAppRenderer / defineArtifactRenderer factories tag the config with `kind` so a matched renderer registers in the apps or artifacts ThreadContext slice - AppRenderersContext + useAppRenderer for toolName lookup at mount (literal Map + regex array; first-wins; dev-mode duplicate + ambiguity warnings) - processStreamedMessage handles TOOL_CALL_RESULT: creates a ToolMessage via createMessage (signature broadened from AssistantMessage to Message) - TEXT_MESSAGE_START is now a no-op — the previous delete+recreate broke ordering when tool messages had already been appended; persistence layers should map ids on save instead. Drop the unused deleteMessage callback from processStreamedMessage's interface. react-ui: - ToolMessageRenderer dispatches matched tool messages; RendererInstance runs parser → meta → register/unregister (routed by `kind`) and renders preview inline + actual via DetailedViewPanel - GenUIAssistantMessage renders matched tool messages outside BehindTheScenes so the inline preview appears in the chat surface - withChatProvider forwards `appRenderers` to ChatProvider (was missing from the prop allowlist) examples/openui-artifact-demo: - Replace ArtifactCodeBlock openui-lang component with a `create_code_block` tool + codeBlockRenderer (defineArtifactRenderer) - enrichedArgsAdapter splits the backend's {_request, _response} envelope into standard TOOL_CALL_ARGS + TOOL_CALL_RESULT events so processStreamed Message and the bridge see the same shape as a standard agentic flow - Override chat library preamble so the LLM is allowed to mix openui-lang with tool calls - Add create_code_block tool definition (declarative; returns {ok:true}) --- examples/openui-artifact-demo/README.md | 38 ++++--- .../src/app/api/chat/route.ts | 35 ++++++ .../openui-artifact-demo/src/app/page.tsx | 21 ++-- .../components/ArtifactCodeBlock/index.tsx | 36 ------ .../components/ArtifactCodeBlock/schema.ts | 9 -- .../src/generated/system-prompt.txt | 28 ++--- .../src/lib/codeBlockRenderer.tsx | 67 +++++++++++ .../src/lib/enrichedArgsAdapter.ts | 77 +++++++++++++ examples/openui-artifact-demo/src/library.ts | 72 +++++------- packages/react-headless/src/index.ts | 8 +- .../src/store/AppRenderersContext.ts | 2 +- .../__tests__/appRendererRegistry.test.ts | 27 ++++- .../src/store/appRendererTypes.ts | 84 +++++++++++--- .../src/store/createChatStore.ts | 2 - packages/react-headless/src/store/types.ts | 2 +- .../__tests__/processStreamedMessage.test.ts | 107 ++++++++++++++++++ .../src/stream/processStreamedMessage.ts | 34 ++++-- .../src/components/BottomTray/Thread.tsx | 2 +- .../src/components/CopilotShell/Thread.tsx | 2 +- .../OpenUIChat/GenUIAssistantMessage.tsx | 24 ++++ .../OpenUIChat/withChatProvider.tsx | 1 + .../react-ui/src/components/Shell/Thread.tsx | 2 +- .../react-ui/src/components/_shared/index.ts | 2 +- .../RendererInstance.tsx} | 23 ++-- .../ToolMessageRenderer.tsx | 6 +- .../{app-renderer => tool-renderer}/index.ts | 0 packages/react-ui/src/index.ts | 2 +- 27 files changed, 526 insertions(+), 187 deletions(-) delete mode 100644 examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx delete mode 100644 examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts create mode 100644 examples/openui-artifact-demo/src/lib/codeBlockRenderer.tsx create mode 100644 examples/openui-artifact-demo/src/lib/enrichedArgsAdapter.ts create mode 100644 packages/react-headless/src/stream/__tests__/processStreamedMessage.test.ts rename packages/react-ui/src/components/_shared/{app-renderer/AppRendererInstance.tsx => tool-renderer/RendererInstance.tsx} (73%) rename packages/react-ui/src/components/_shared/{app-renderer => tool-renderer}/ToolMessageRenderer.tsx (89%) rename packages/react-ui/src/components/_shared/{app-renderer => tool-renderer}/index.ts (100%) diff --git a/examples/openui-artifact-demo/README.md b/examples/openui-artifact-demo/README.md index e2f54d200..13f63fe3f 100644 --- a/examples/openui-artifact-demo/README.md +++ b/examples/openui-artifact-demo/README.md @@ -1,14 +1,15 @@ # OpenUI Artifact Demo -A demo application showcasing the OpenUI artifact system for displaying generated code in a resizable side panel. +A demo application showcasing the new `defineAppRenderer` API: tool calls map to +custom inline previews + a resizable detailed-view side panel. ## Features -- **Artifact Code Blocks**: AI-generated code appears as compact previews in chat -- **Side Panel**: Click "View Code" to open the full code in a resizable artifact panel -- **Syntax Highlighting**: Full Prism-based syntax highlighting in the artifact panel -- **Multiple Artifacts**: Multiple code blocks per conversation, one active at a time -- **Copy to Clipboard**: One-click code copying from the artifact panel +- **Code blocks via tool calls**: AI emits a `create_code_block` tool call instead of an openui-lang component. +- **Inline preview**: Compact code preview rendered in the chat for each call. +- **Detailed-view side panel**: Click "View Code" to open the full code with syntax highlighting. +- **Artifacts registry**: Each rendered code block registers in ThreadContext (artifacts list) keyed by filename. +- **Multiple files per response**: One tool call per file; multiple calls render multiple panels. ## Getting Started @@ -24,23 +25,28 @@ pnpm --filter openui-artifact-demo dev ``` Set your OpenAI API key: + ```bash export OPENAI_API_KEY=your-key-here ``` ## How It Works -This example extends the standard OpenUI chat library with a custom `ArtifactCodeBlock` component that integrates with the OpenUI artifact system: - 1. User asks for code (e.g., "Build me a React login form") -2. AI generates a response using `ArtifactCodeBlock` components -3. Each code block shows an inline preview in the chat -4. Clicking "View Code" opens the full code in the artifact side panel -5. The panel is resizable and supports syntax highlighting + copy +2. AI emits a `create_code_block` tool call with `{ language, title, codeString }` +3. Backend's agentic loop (`runTools`) executes the (declarative) tool — it just returns `{ ok: true }` +4. Backend forwards the tool call + result to the client over SSE, with the result enriched into the args envelope as `{ _request, _response }` +5. Client's `enrichedArgsAdapter` unpacks the envelope into proper `TOOL_CALL_ARGS` + `TOOL_CALL_RESULT` events +6. `processStreamedMessage` creates a `ToolMessage` for the result +7. `ToolMessageRenderer` matches `create_code_block` against the `appRenderers` array, dispatches to `codeBlockRenderer` +8. The renderer's `parser` validates the args, `meta` registers the entry in ThreadContext, `preview` renders the inline card, `actual` renders the side panel content via `DetailedViewPanel` ## Architecture -- `src/components/ArtifactCodeBlock/` — Custom genui component with inline preview and artifact panel view -- `src/library.ts` — Extended component library with ArtifactCodeBlock -- `src/app/page.tsx` — Main page using FullScreen layout -- `src/app/api/chat/route.ts` — API route for OpenAI streaming +- `src/lib/codeBlockRenderer.tsx` — `defineAppRenderer` config wiring parser + meta + preview + actual +- `src/lib/enrichedArgsAdapter.ts` — Custom stream adapter that splits the example's `{_request, _response}` envelope into standard events +- `src/components/ArtifactCodeBlock/InlinePreview.tsx` — Inline preview component (reused as the renderer's `preview`) +- `src/components/ArtifactCodeBlock/ArtifactView.tsx` — Side-panel content (reused as the renderer's `actual`) +- `src/library.ts` — Re-exports the standard chat library + adds prompt rules instructing the LLM to use the `create_code_block` tool +- `src/app/page.tsx` — `FullScreen` layout wired with `appRenderers={[codeBlockRenderer]}` and the custom adapter +- `src/app/api/chat/route.ts` — OpenAI route with `create_code_block` registered as a tool diff --git a/examples/openui-artifact-demo/src/app/api/chat/route.ts b/examples/openui-artifact-demo/src/app/api/chat/route.ts index 851a94950..fef74b9d4 100644 --- a/examples/openui-artifact-demo/src/app/api/chat/route.ts +++ b/examples/openui-artifact-demo/src/app/api/chat/route.ts @@ -85,6 +85,13 @@ function searchWeb({ query }: { query: string }): Promise { }); } +// `create_code_block` is a declarative tool — the LLM emits the call to render +// a code block in the chat; there's nothing to execute server-side. Returning +// `{ ok: true }` satisfies the agentic loop so the LLM continues with text. +function createCodeBlock(): Promise { + return Promise.resolve(JSON.stringify({ ok: true })); +} + // ── Tool definitions ── // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -145,6 +152,34 @@ const tools: any[] = [ parse: JSON.parse, }, }, + { + type: "function", + function: { + name: "create_code_block", + description: + "Render a code block with syntax highlighting in the chat. Use for ANY code output — never put code inside text content. One call per file.", + parameters: { + type: "object", + properties: { + language: { + type: "string", + description: "Syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css').", + }, + title: { + type: "string", + description: "Filename or short title (e.g. 'LoginForm.tsx', 'sort.py').", + }, + codeString: { + type: "string", + description: "The code content to render.", + }, + }, + required: ["language", "title", "codeString"], + }, + function: createCodeBlock, + parse: JSON.parse, + }, + }, ]; // ── SSE helpers ── diff --git a/examples/openui-artifact-demo/src/app/page.tsx b/examples/openui-artifact-demo/src/app/page.tsx index a68e6c6f9..3967ecfa6 100644 --- a/examples/openui-artifact-demo/src/app/page.tsx +++ b/examples/openui-artifact-demo/src/app/page.tsx @@ -2,9 +2,11 @@ import "@openuidev/react-ui/components.css"; import { useTheme } from "@/hooks/use-system-theme"; -import { openAIAdapter, openAIMessageFormat } from "@openuidev/react-headless"; -import { FullScreen } from "@openuidev/react-ui"; +import { codeBlockRenderer } from "@/lib/codeBlockRenderer"; +import { enrichedArgsAdapter } from "@/lib/enrichedArgsAdapter"; import { artifactDemoLibrary } from "@/library"; +import { openAIMessageFormat } from "@openuidev/react-headless"; +import { FullScreen } from "@openuidev/react-ui"; export default function Page() { const mode = useTheme(); @@ -22,8 +24,9 @@ export default function Page() { signal: abortController.signal, }); }} - streamProtocol={openAIAdapter()} + streamProtocol={enrichedArgsAdapter()} componentLibrary={artifactDemoLibrary} + appRenderers={[codeBlockRenderer]} agentName="Artifact Demo" theme={{ mode }} conversationStarters={{ @@ -31,23 +34,19 @@ export default function Page() { options: [ { displayText: "React login form", - prompt: - "Build me a React login form with email and password validation", + prompt: "Build me a React login form with email and password validation", }, { displayText: "Python REST API", - prompt: - "Create a FastAPI REST API with CRUD endpoints for a todo app", + prompt: "Create a FastAPI REST API with CRUD endpoints for a todo app", }, { displayText: "CSS animation", - prompt: - "Write a CSS animation for a bouncing loading indicator", + prompt: "Write a CSS animation for a bouncing loading indicator", }, { displayText: "SQL schema", - prompt: - "Design a SQL schema for a blog with users, posts, and comments", + prompt: "Design a SQL schema for a blog with users, posts, and comments", }, ], }} diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx deleted file mode 100644 index ecf780567..000000000 --- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/index.tsx +++ /dev/null @@ -1,36 +0,0 @@ -"use client"; - -import { defineComponent } from "@openuidev/react-lang"; -import { Artifact } from "@openuidev/react-ui"; -import { ArtifactView } from "./ArtifactView"; -import { InlinePreview } from "./InlinePreview"; -import { ArtifactCodeBlockSchema } from "./schema"; - -export { ArtifactCodeBlockSchema } from "./schema"; -export type { ArtifactCodeBlockProps } from "./schema"; - -export const ArtifactCodeBlock = defineComponent({ - name: "ArtifactCodeBlock", - props: ArtifactCodeBlockSchema, - description: - "Code block that opens in the artifact side panel for full viewing with syntax highlighting", - component: Artifact({ - title: (props) => props.title as string, - preview: (props, { open, isActive }) => ( - - ), - panel: (props) => ( - - ), - }), -}); diff --git a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts b/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts deleted file mode 100644 index 02ce83ac7..000000000 --- a/examples/openui-artifact-demo/src/components/ArtifactCodeBlock/schema.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { z } from "zod"; - -export const ArtifactCodeBlockSchema = z.object({ - language: z.string(), - title: z.string(), - codeString: z.string(), -}); - -export type ArtifactCodeBlockProps = z.infer; diff --git a/examples/openui-artifact-demo/src/generated/system-prompt.txt b/examples/openui-artifact-demo/src/generated/system-prompt.txt index f7263f410..dd2457f5c 100644 --- a/examples/openui-artifact-demo/src/generated/system-prompt.txt +++ b/examples/openui-artifact-demo/src/generated/system-prompt.txt @@ -1,4 +1,8 @@ -You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang. +You are an AI assistant that responds with TWO INDEPENDENT channels: +1. **openui-lang** — for chat content (intro text, explanations, follow-ups). Must be valid openui-lang starting with `root = Card([...])`. +2. **Tool calls** — for code output via the `create_code_block` tool. Tool calls happen via the OpenAI tools API, separately from openui-lang. + +Both channels can appear in the same response. Code goes in tool calls; surrounding chat goes in openui-lang. NEVER reference a tool call from inside openui-lang. ## Syntax Rules @@ -28,7 +32,6 @@ ImageBlock(src: string, alt?: string) — Image block with loading state ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview CodeBlock(language: string, codeString: string) — Syntax-highlighted code block Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections -ArtifactCodeBlock(language: string, title: string, codeString: string) — Code block that opens in the artifact side panel for full viewing with syntax highlighting ### Tables Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array. @@ -201,16 +204,6 @@ emailField = FormControl("Email", Input("email", "you@example.com", "email", { r msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 })) btns = Buttons([Button("Submit", Action([@ToAssistant("Submit")]), "primary")]) -Example — Code generation with artifacts: -root = Card([intro, code1, explanation, code2, followUps]) -intro = TextContent("Here's a React login form with validation:", "default") -code1 = ArtifactCodeBlock("typescript", "LoginForm.tsx", "import React, { useState } from 'react';\n\nexport function LoginForm() {\n const [email, setEmail] = useState('');\n const [password, setPassword] = useState('');\n\n return (\n

\n setEmail(e.target.value)} />\n setPassword(e.target.value)} />\n \n
\n );\n}") -explanation = TextContent("And the validation helper:", "default") -code2 = ArtifactCodeBlock("typescript", "validate.ts", "export function validateEmail(email: string): boolean {\n return /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/.test(email);\n}") -followUps = FollowUpBlock([fu1, fu2]) -fu1 = FollowUpItem("Add password strength indicator") -fu2 = FollowUpItem("Add form styling with Tailwind") - ## Important Rules - When asked about data, generate realistic/plausible data - Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.) @@ -232,8 +225,9 @@ Before finishing, walk your output and verify: - For forms, define one FormControl reference per field so controls can stream progressively. - For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields). - Never nest Form inside Form. -- ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock. -- Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql'). -- Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css'). -- You can include multiple ArtifactCodeBlocks in one response — each with a unique title. -- Surround code blocks with TextContent for explanations. +- Tool calls and openui-lang are INDEPENDENT response channels. Emit them in parallel; do NOT reference tool calls from openui-lang. +- For ANY code output, emit a `create_code_block` tool call. NEVER put code inside TextContent, MarkdownBlock, or any openui-lang component. NEVER add a forward reference like `code = ...` for code blocks — they live entirely in the tool call. +- Set `title` to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql'). Set `language` to the syntax-highlighting language (e.g. 'typescript', 'python', 'sql', 'css'). +- Call the tool once per file. For multiple files, emit multiple tool calls in a single response. +- openui-lang is for the SURROUNDING chat (intro text, brief explanations, follow-up suggestions). Use TextContent + FollowUpBlock as needed. Example shape when responding with code: `root = Card([intro, followUps])` plus one or more parallel `create_code_block` tool calls. +- If your only response is code (no surrounding text), emit ONLY the tool call(s) — no openui-lang at all. diff --git a/examples/openui-artifact-demo/src/lib/codeBlockRenderer.tsx b/examples/openui-artifact-demo/src/lib/codeBlockRenderer.tsx new file mode 100644 index 000000000..3e40ebceb --- /dev/null +++ b/examples/openui-artifact-demo/src/lib/codeBlockRenderer.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { defineArtifactRenderer } from "@openuidev/react-headless"; +import { ArtifactView } from "@/components/ArtifactCodeBlock/ArtifactView"; +import { InlinePreview } from "@/components/ArtifactCodeBlock/InlinePreview"; + +type CodeBlockProps = { + language: string; + title: string; + codeString: string; +}; + +const isCodeBlockProps = (value: unknown): value is CodeBlockProps => + !!value && + typeof value === "object" && + typeof (value as CodeBlockProps).language === "string" && + typeof (value as CodeBlockProps).title === "string" && + typeof (value as CodeBlockProps).codeString === "string"; + +/** + * Artifact renderer for the `create_code_block` tool call. + * + * Code blocks are durable structured outputs — they belong in the artifacts + * registry (read via `useArtifactList`). The LLM emits a tool call instead of + * an openui-lang component when it wants to render code. Args are + * JSON-stringified by OpenAI; this parser deserializes them and validates the + * shape. Each rendered code block becomes an entry in ThreadContext keyed by + * its title (filename), and clicking "View Code" opens the full code in the + * detailed-view side panel. + */ +export const codeBlockRenderer = defineArtifactRenderer({ + toolName: "create_code_block", + + parser: ({ args }) => { + if (typeof args !== "string") return null; + try { + const parsed = JSON.parse(args); + return isCodeBlockProps(parsed) ? parsed : null; + } catch { + return null; + } + }, + + meta: (props) => ({ + id: props.title, // filename is a stable per-block identifier + version: 1, + heading: props.title, + }), + + preview: (props, { open, isActive }) => ( + + ), + + actual: (props) => ( + + ), +}); diff --git a/examples/openui-artifact-demo/src/lib/enrichedArgsAdapter.ts b/examples/openui-artifact-demo/src/lib/enrichedArgsAdapter.ts new file mode 100644 index 000000000..6c59ec986 --- /dev/null +++ b/examples/openui-artifact-demo/src/lib/enrichedArgsAdapter.ts @@ -0,0 +1,77 @@ +import { + EventType, + openAIAdapter, + type AGUIEvent, + type StreamProtocolAdapter, +} from "@openuidev/react-headless"; + +/** + * Custom stream adapter for this example's backend. + * + * The backend's `runTools` integration encodes each tool result by enriching + * the tool call's `arguments` field with `{ _request, _response }` JSON. The + * standard `openAIAdapter` would deliver this as a single `TOOL_CALL_ARGS` + * event whose `delta` is the enriched envelope. + * + * This adapter wraps `openAIAdapter` and splits enriched envelopes into: + * - `TOOL_CALL_ARGS` containing only the original `_request` payload, and + * - a synthetic `TOOL_CALL_END` for the call, + * - followed by `TOOL_CALL_RESULT` carrying `_response` as a tool message. + * + * The result: `defineAppRenderer` sees a clean `{ args, response }` pair and + * a `ToolMessage` is created in the thread for downstream UI dispatch. + * + * Plain (non-enriched) tool calls are passed through untouched. + */ +export function enrichedArgsAdapter(): StreamProtocolAdapter { + return { + async *parse(response): AsyncIterable { + for await (const event of openAIAdapter().parse(response)) { + if (event.type !== EventType.TOOL_CALL_ARGS) { + yield event; + continue; + } + + // The backend delivers the enriched args in one chunk, so the delta + // contains the full JSON envelope. Try to split it; if the shape isn't + // enriched, fall through to the standard pass-through. + let parsed: unknown; + try { + parsed = JSON.parse(event.delta); + } catch { + yield event; + continue; + } + + if ( + !parsed || + typeof parsed !== "object" || + !("_request" in parsed) || + !("_response" in parsed) + ) { + yield event; + continue; + } + + const { _request, _response } = parsed as { _request: unknown; _response: unknown }; + + yield { + type: EventType.TOOL_CALL_ARGS, + toolCallId: event.toolCallId, + delta: JSON.stringify(_request ?? {}), + }; + yield { + type: EventType.TOOL_CALL_END, + toolCallId: event.toolCallId, + }; + yield { + type: EventType.TOOL_CALL_RESULT, + toolCallId: event.toolCallId, + messageId: crypto.randomUUID(), + role: "tool", + content: typeof _response === "string" ? _response : JSON.stringify(_response ?? {}), + }; + } + }, + }; +} diff --git a/examples/openui-artifact-demo/src/library.ts b/examples/openui-artifact-demo/src/library.ts index 0ab637d04..16f4b4af3 100644 --- a/examples/openui-artifact-demo/src/library.ts +++ b/examples/openui-artifact-demo/src/library.ts @@ -1,55 +1,37 @@ -import type { ComponentGroup, PromptOptions } from "@openuidev/react-lang"; -import { createLibrary } from "@openuidev/react-lang"; -import { - openuiChatComponentGroups, - openuiChatLibrary, - openuiChatPromptOptions, -} from "@openuidev/react-ui/genui-lib"; +import type { PromptOptions } from "@openuidev/react-lang"; +import { openuiChatLibrary, openuiChatPromptOptions } from "@openuidev/react-ui/genui-lib"; -import { ArtifactCodeBlock } from "./components/ArtifactCodeBlock"; +// ── Library ── +// +// Code blocks are no longer registered as openui-lang components; they are +// rendered via the `create_code_block` tool call (see `lib/codeBlockRenderer`) +// and dispatched by `ToolMessageRenderer` in react-ui. The library stays as +// the standard chat library — it still handles all non-code response shapes +// (TextContent, FollowUpBlock, etc.). -// ── Component Groups — extend chat groups, add ArtifactCodeBlock to Content ── +export const artifactDemoLibrary = openuiChatLibrary; -const artifactComponentGroups: ComponentGroup[] = openuiChatComponentGroups.map((group) => { - if (group.name === "Content") { - return { - ...group, - components: [...group.components, "ArtifactCodeBlock"], - }; - } - return group; -}); - -// ── Library — all chat components + ArtifactCodeBlock ── - -export const artifactDemoLibrary = createLibrary({ - root: "Card", - componentGroups: artifactComponentGroups, - components: [...Object.values(openuiChatLibrary.components), ArtifactCodeBlock], -}); - -// ── Prompt Options — extend chat rules with artifact-specific instructions ── +// ── Prompt options — instruct the LLM to use the tool for code ── export const artifactDemoPromptOptions: PromptOptions = { + ...openuiChatPromptOptions, + // Override the default preamble — the chat library's default says "ENTIRE + // response must be valid openui-lang code", which suppresses tool calls. + // For this example we want both: openui-lang for chat content + tool calls + // for code blocks rendered via defineAppRenderer. + preamble: + "You are an AI assistant that responds with TWO INDEPENDENT channels:\n" + + "1. **openui-lang** — for chat content (intro text, explanations, follow-ups). Must be valid openui-lang starting with `root = Card([...])`.\n" + + "2. **Tool calls** — for code output via the `create_code_block` tool. Tool calls happen via the OpenAI tools API, separately from openui-lang.\n\n" + + "Both channels can appear in the same response. Code goes in tool calls; surrounding chat goes in openui-lang. NEVER reference a tool call from inside openui-lang.", additionalRules: [ ...(openuiChatPromptOptions.additionalRules ?? []), - "ALWAYS use ArtifactCodeBlock for ANY code output. NEVER use regular CodeBlock.", - "Set title to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql').", - "Set language to the correct syntax highlighting language (e.g. 'typescript', 'python', 'sql', 'css').", - "You can include multiple ArtifactCodeBlocks in one response — each with a unique title.", - "Surround code blocks with TextContent for explanations.", - ], - examples: [ - ...(openuiChatPromptOptions.examples ?? []), - `Example — Code generation with artifacts: -root = Card([intro, code1, explanation, code2, followUps]) -intro = TextContent("Here's a React login form with validation:", "default") -code1 = ArtifactCodeBlock("typescript", "LoginForm.tsx", "import React, { useState } from 'react';\\n\\nexport function LoginForm() {\\n const [email, setEmail] = useState('');\\n const [password, setPassword] = useState('');\\n\\n return (\\n
\\n setEmail(e.target.value)} />\\n setPassword(e.target.value)} />\\n \\n
\\n );\\n}") -explanation = TextContent("And the validation helper:", "default") -code2 = ArtifactCodeBlock("typescript", "validate.ts", "export function validateEmail(email: string): boolean {\\n return /^[^\\\\s@]+@[^\\\\s@]+\\\\.[^\\\\s@]+$/.test(email);\\n}") -followUps = FollowUpBlock([fu1, fu2]) -fu1 = FollowUpItem("Add password strength indicator") -fu2 = FollowUpItem("Add form styling with Tailwind")`, + "Tool calls and openui-lang are INDEPENDENT response channels. Emit them in parallel; do NOT reference tool calls from openui-lang.", + "For ANY code output, emit a `create_code_block` tool call. NEVER put code inside TextContent, MarkdownBlock, or any openui-lang component. NEVER add a forward reference like `code = ...` for code blocks — they live entirely in the tool call.", + "Set `title` to the filename (e.g. 'LoginForm.tsx', 'sort.py', 'schema.sql'). Set `language` to the syntax-highlighting language (e.g. 'typescript', 'python', 'sql', 'css').", + "Call the tool once per file. For multiple files, emit multiple tool calls in a single response.", + "openui-lang is for the SURROUNDING chat (intro text, brief explanations, follow-up suggestions). Use TextContent + FollowUpBlock as needed. Example shape when responding with code: `root = Card([intro, followUps])` plus one or more parallel `create_code_block` tool calls.", + "If your only response is code (no surrounding text), emit ONLY the tool call(s) — no openui-lang at all.", ], }; diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 2c27e98b5..76baaad45 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -8,7 +8,7 @@ export { MessageContext, MessageProvider, useMessage } from "./hooks/useMessage" export { useThread, useThreadList } from "./hooks/useThread"; export { AppRenderersContext, useAppRendererRegistry } from "./store/AppRenderersContext"; -export { defineAppRenderer } from "./store/appRendererTypes"; +export { defineAppRenderer, defineArtifactRenderer } from "./store/appRendererTypes"; export { ChatProvider } from "./store/ChatProvider"; export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext"; export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext"; @@ -26,7 +26,11 @@ export { } from "./stream/formats"; export { processStreamedMessage } from "./stream/processStreamedMessage"; -export type { AppRendererConfig, AppRendererControls } from "./store/appRendererTypes"; +export type { + AppRendererConfig, + AppRendererControls, + AppRendererKind, +} from "./store/appRendererTypes"; export type { DetailedViewActions, DetailedViewState } from "./store/detailedViewTypes"; diff --git a/packages/react-headless/src/store/AppRenderersContext.ts b/packages/react-headless/src/store/AppRenderersContext.ts index c7c8b8f3a..19cfc54b4 100644 --- a/packages/react-headless/src/store/AppRenderersContext.ts +++ b/packages/react-headless/src/store/AppRenderersContext.ts @@ -28,7 +28,7 @@ export type AppRendererRegistry = { * @internal */ export function buildAppRendererRegistry( - configs: ReadonlyArray>, + configs: ReadonlyArray>, ): AppRendererRegistry { const literal = new Map>(); const regex: AppRendererConfig[] = []; diff --git a/packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts b/packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts index 1a3333d9c..220f366f4 100644 --- a/packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts +++ b/packages/react-headless/src/store/__tests__/appRendererRegistry.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildAppRendererRegistry, lookupAppRenderer } from "../AppRenderersContext"; -import { defineAppRenderer } from "../appRendererTypes"; +import { defineAppRenderer, defineArtifactRenderer } from "../appRendererTypes"; const makeRenderer = (toolName: string | RegExp, label = String(toolName)) => defineAppRenderer({ @@ -80,6 +80,31 @@ describe("buildAppRendererRegistry", () => { }); }); +describe("defineAppRenderer / defineArtifactRenderer", () => { + const baseConfig = { + toolName: "tool", + parser: () => ({ x: 1 }), + meta: () => ({ id: "x", version: 1, heading: "X" }), + preview: () => null, + actual: () => null, + }; + + it("defineAppRenderer tags kind as 'app'", () => { + expect(defineAppRenderer(baseConfig).kind).toBe("app"); + }); + + it("defineArtifactRenderer tags kind as 'artifact'", () => { + expect(defineArtifactRenderer(baseConfig).kind).toBe("artifact"); + }); + + it("preserves all other config fields", () => { + const r = defineArtifactRenderer({ ...baseConfig, toolName: /^x/ }); + expect(r.toolName).toEqual(/^x/); + expect(r.parser).toBe(baseConfig.parser); + expect(r.meta).toBe(baseConfig.meta); + }); +}); + describe("lookupAppRenderer", () => { it("returns the literal match when present", () => { const r = makeRenderer("presentation:create"); diff --git a/packages/react-headless/src/store/appRendererTypes.ts b/packages/react-headless/src/store/appRendererTypes.ts index e04a989f8..ed1d19b76 100644 --- a/packages/react-headless/src/store/appRendererTypes.ts +++ b/packages/react-headless/src/store/appRendererTypes.ts @@ -1,41 +1,62 @@ import type { ReactNode } from "react"; /** - * Controls passed to an AppRenderer's `preview` and `actual` render functions. + * Which ThreadContext slice the renderer registers into when matched. + * + * - `"app"` (default): registers in the `apps` slice (read via `useAppList`). + * - `"artifact"`: registers in the `artifacts` slice (read via `useArtifactList`). + * + * Both kinds share the same renderer shape (parser, meta, preview, actual) and + * the same active-detailed-view slot — the kind only routes registry membership. + * + * @category Types + */ +export type AppRendererKind = "app" | "artifact"; + +/** + * Controls passed to a renderer's `preview` and `actual` render functions. * * @category Types */ export interface AppRendererControls { - /** Whether this app's detailed view is the currently active one. */ + /** Whether this renderer's detailed view is the currently active one. */ isActive: boolean; /** Whether the underlying tool response is still streaming (always `false` in v1; reserved for streaming protocol). */ isStreaming: boolean; - /** Activates this app's detailed view. */ + /** Activates this renderer's detailed view. */ open: () => void; - /** Closes this app's detailed view if currently active. */ + /** Closes this renderer's detailed view if currently active. */ close: () => void; - /** Toggles this app's detailed view. */ + /** Toggles this renderer's detailed view. */ toggle: () => void; } /** - * Configuration for a single app renderer, returned by {@link defineAppRenderer}. + * Configuration for a single app or artifact renderer, returned by + * {@link defineAppRenderer} or {@link defineArtifactRenderer}. * * Renderers are matched against tool calls by `toolName` (literal string or RegExp). * When a match fires, `parser` converts the raw tool envelope into typed `Props`, * `meta` derives the registry entry (id, version, heading), and `preview` / `actual` * render the inline chat preview and side-panel detailed view respectively. * + * The `kind` field controls which ThreadContext slice the entry registers into; + * defaults to `"app"`. + * * @category Types */ export interface AppRendererConfig { /** * Tool name to match. Literal strings take priority over regex; first match wins. * - * String example: `"presentation:create"`. - * Regex example: `/^presentation:/`. + * String example: `"create_presentation"`. + * Regex example: `/^presentation_/`. */ toolName: string | RegExp; + /** + * Which ThreadContext slice this renderer registers into. Defaults to `"app"`. + */ + kind?: AppRendererKind; /** * Converts the raw tool envelope into renderer-shaped `Props`. * @@ -44,12 +65,12 @@ export interface AppRendererConfig { */ parser: (raw: { args: unknown; response: unknown }) => Props | null; /** - * Derives the {@link AppEntry} fields registered in ThreadContext. + * Derives the registry entry fields for ThreadContext. * * Return `null` to skip registration (the renderer still renders if `parser` - * returned non-null Props, but the entry will not appear in the apps list). + * returned non-null Props, but the entry will not appear in the apps/artifacts list). * - * The `id` should be stable across re-runs of the same logical app. + * The `id` should be stable across re-runs of the same logical entry. */ meta: ( props: Props, @@ -62,7 +83,8 @@ export interface AppRendererConfig { } /** - * Identity helper that returns its argument while preserving `Props` inference. + * Identity helper that returns its argument while preserving `Props` inference, + * and tags `kind` as `"app"` so the entry registers into the apps slice. * * Without this, users would have to write `const r: AppRendererConfig = {...}` * to get type checking. With it, `defineAppRenderer({...})` infers `Props` from @@ -72,17 +94,43 @@ export interface AppRendererConfig { * * @example * ```ts - * const presentationRenderer = defineAppRenderer({ - * toolName: "presentation:create", + * const dashboardRenderer = defineAppRenderer({ + * toolName: "create_dashboard", + * parser: (raw) => raw.response as { id: string; widgets: Widget[] }, + * meta: (props) => ({ id: props.id, version: 1, heading: `Dashboard ${props.id}` }), + * preview: (props, controls) => , + * actual: (props) => , + * }); + * ``` + */ +export function defineAppRenderer( + config: Omit, "kind">, +): AppRendererConfig { + return { ...config, kind: "app" }; +} + +/** + * Identity helper that returns its argument while preserving `Props` inference, + * and tags `kind` as `"artifact"` so the entry registers into the artifacts slice. + * + * Same shape as {@link defineAppRenderer}; the only difference is which + * ThreadContext list the entry appears in. + * + * @category Functions + * + * @example + * ```ts + * const presentationRenderer = defineArtifactRenderer({ + * toolName: "create_presentation", * parser: (raw) => raw.response as { id: string; slides: Slide[] }, * meta: (props) => ({ id: props.id, version: 1, heading: `Presentation ${props.id}` }), * preview: (props, controls) => , - * actual: (props) => , + * actual: (props) => , * }); * ``` */ -export function defineAppRenderer( - config: AppRendererConfig, +export function defineArtifactRenderer( + config: Omit, "kind">, ): AppRendererConfig { - return config; + return { ...config, kind: "artifact" }; } diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index 95e7d7fb8..4d1df405e 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -254,8 +254,6 @@ export const createChatStore = (config: StoreConfig) => { set((s) => ({ messages: s.messages.map((m) => (m.id === msg.id ? msg : m)), })), - deleteMessage: (id) => - set((s) => ({ messages: s.messages.filter((m) => m.id !== id) })), adapter: streamProtocol, }); } catch (e) { diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index a7033ab36..7bbce0fa5 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -106,6 +106,6 @@ export type ChatProviderProps = ThreadApiConfig & * Captured at mount; subsequent prop changes are ignored (dev warning). * Order is priority: first match wins on duplicate `toolName`. */ - appRenderers?: ReadonlyArray>; + appRenderers?: ReadonlyArray>; children: React.ReactNode; }; diff --git a/packages/react-headless/src/stream/__tests__/processStreamedMessage.test.ts b/packages/react-headless/src/stream/__tests__/processStreamedMessage.test.ts new file mode 100644 index 000000000..f3c98b9aa --- /dev/null +++ b/packages/react-headless/src/stream/__tests__/processStreamedMessage.test.ts @@ -0,0 +1,107 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { EventType, type AGUIEvent, type Message, type StreamProtocolAdapter } from "../../types"; +import { processStreamedMessage } from "../processStreamedMessage"; + +// jsdom is not enabled for this package; stub the rAF API used by the debouncer. +beforeAll(() => { + const g = globalThis as unknown as { + requestAnimationFrame?: (cb: FrameRequestCallback) => number; + cancelAnimationFrame?: (id: number) => void; + }; + if (typeof g.requestAnimationFrame !== "function") { + g.requestAnimationFrame = (cb: FrameRequestCallback) => + setTimeout(() => cb(performance.now()), 0) as unknown as number; + g.cancelAnimationFrame = (id: number) => clearTimeout(id); + } +}); + +const adapterFromEvents = (events: AGUIEvent[]): StreamProtocolAdapter => ({ + async *parse() { + for (const event of events) { + yield event; + } + }, +}); + +const flush = () => new Promise((r) => setTimeout(r, 10)); + +describe("processStreamedMessage — TOOL_CALL_RESULT handling", () => { + it("creates a ToolMessage when a TOOL_CALL_RESULT event is emitted", async () => { + const adapter = adapterFromEvents([ + { type: EventType.TEXT_MESSAGE_START, messageId: "msg-1", role: "assistant" }, + { + type: EventType.TOOL_CALL_START, + toolCallId: "tc-1", + toolCallName: "code_block:create", + }, + { type: EventType.TOOL_CALL_ARGS, toolCallId: "tc-1", delta: '{"language":"ts"}' }, + { type: EventType.TOOL_CALL_END, toolCallId: "tc-1" }, + { + type: EventType.TOOL_CALL_RESULT, + toolCallId: "tc-1", + content: '{"ok":true}', + messageId: "msg-tool-1", + role: "tool", + }, + ]); + + const created: Message[] = []; + const updated: Message[] = []; + + await processStreamedMessage({ + response: new Response(""), + createMessage: (m) => created.push(m), + updateMessage: (m) => updated.push(m), + adapter, + }); + + await flush(); + + // Expect: one assistant message created, then one tool message created + expect(created).toHaveLength(2); + expect(created[0]?.role).toBe("assistant"); + expect(created[1]).toMatchObject({ + role: "tool", + toolCallId: "tc-1", + content: '{"ok":true}', + }); + }); + + it("creates one ToolMessage per TOOL_CALL_RESULT event", async () => { + const adapter = adapterFromEvents([ + { type: EventType.TEXT_MESSAGE_START, messageId: "msg-1", role: "assistant" }, + { type: EventType.TOOL_CALL_START, toolCallId: "tc-1", toolCallName: "a" }, + { type: EventType.TOOL_CALL_START, toolCallId: "tc-2", toolCallName: "b" }, + { + type: EventType.TOOL_CALL_RESULT, + toolCallId: "tc-1", + content: "res-1", + messageId: "msg-tool-1", + role: "tool", + }, + { + type: EventType.TOOL_CALL_RESULT, + toolCallId: "tc-2", + content: "res-2", + messageId: "msg-tool-2", + role: "tool", + }, + ]); + + const created: Message[] = []; + + await processStreamedMessage({ + response: new Response(""), + createMessage: (m) => created.push(m), + updateMessage: vi.fn(), + adapter, + }); + + await flush(); + + const toolMessages = created.filter((m) => m.role === "tool"); + expect(toolMessages).toHaveLength(2); + expect(toolMessages[0]).toMatchObject({ toolCallId: "tc-1", content: "res-1" }); + expect(toolMessages[1]).toMatchObject({ toolCallId: "tc-2", content: "res-2" }); + }); +}); diff --git a/packages/react-headless/src/stream/processStreamedMessage.ts b/packages/react-headless/src/stream/processStreamedMessage.ts index 507f28c2a..ad77e57c2 100644 --- a/packages/react-headless/src/stream/processStreamedMessage.ts +++ b/packages/react-headless/src/stream/processStreamedMessage.ts @@ -1,4 +1,4 @@ -import { AssistantMessage, EventType, StreamProtocolAdapter } from "../types"; +import { AssistantMessage, EventType, Message, StreamProtocolAdapter, ToolMessage } from "../types"; import { agUIAdapter } from "./adapters"; /** @@ -6,12 +6,10 @@ import { agUIAdapter } from "./adapters"; */ interface Parameters { response: Response; - /** A function that creates a new assistant message in the thread */ - createMessage: (message: AssistantMessage) => void; + /** A function that creates a new message in the thread (assistant or tool). */ + createMessage: (message: Message) => void; /** A function that updates an existing assistant message in the thread */ updateMessage: (message: AssistantMessage) => void; - /** A function that deletes an assistant message from the thread */ - deleteMessage: (messageId: string) => void; /** The adapter to use for parsing the stream */ adapter?: StreamProtocolAdapter; } @@ -23,7 +21,6 @@ export const processStreamedMessage = async ({ response, createMessage, updateMessage, - deleteMessage, adapter = agUIAdapter(), }: Parameters): Promise => { let currentMessage: AssistantMessage = { @@ -96,14 +93,27 @@ export const processStreamedMessage = async ({ break; case EventType.TEXT_MESSAGE_START: - // Use the ID from the event if it differs from our optimistic ID - if (event.messageId !== currentMessage.id) { - deleteMessage(currentMessage.id); - currentMessage = { ...currentMessage, id: event.messageId }; - isFirst = true; // Will trigger createMessage with new ID - } + // The optimistic id is kept regardless of `event.messageId` — swapping + // ids mid-stream by deleting + re-creating the assistant message + // breaks ordering when tool messages have already been appended + // between the original create and this event (e.g. from + // TOOL_CALL_RESULT). Persistence layers should map ids on save. break; + case EventType.TOOL_CALL_RESULT: { + // Append a tool message to the thread for this tool call. + // The current assistant message (with its toolCalls) is preserved as-is; + // subsequent text/tool-call events keep updating it. + const toolMessage: ToolMessage = { + id: crypto.randomUUID(), + role: "tool", + toolCallId: event.toolCallId, + content: event.content, + }; + createMessage(toolMessage); + continue; // skip the trailing isFirst/update logic — this event doesn't touch currentMessage + } + case EventType.RUN_ERROR: { const msg = (event as any).message || (event as any).error || "Stream error"; throw new Error(typeof msg === "string" ? msg : JSON.stringify(msg)); diff --git a/packages/react-ui/src/components/BottomTray/Thread.tsx b/packages/react-ui/src/components/BottomTray/Thread.tsx index 40ae4bbb9..2e700698d 100644 --- a/packages/react-ui/src/components/BottomTray/Thread.tsx +++ b/packages/react-ui/src/components/BottomTray/Thread.tsx @@ -3,8 +3,8 @@ import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; -import { ToolMessageRenderer } from "../_shared/app-renderer"; import { DetailedViewOverlay } from "../_shared/detailed-view"; +import { ToolMessageRenderer } from "../_shared/tool-renderer"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; diff --git a/packages/react-ui/src/components/CopilotShell/Thread.tsx b/packages/react-ui/src/components/CopilotShell/Thread.tsx index 4ed0b74b7..c20b0c87f 100644 --- a/packages/react-ui/src/components/CopilotShell/Thread.tsx +++ b/packages/react-ui/src/components/CopilotShell/Thread.tsx @@ -3,8 +3,8 @@ import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; -import { ToolMessageRenderer } from "../_shared/app-renderer"; import { DetailedViewOverlay } from "../_shared/detailed-view"; +import { ToolMessageRenderer } from "../_shared/tool-renderer"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index 2a3728137..a245b537b 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -6,6 +6,7 @@ import type { ActionEvent, Library } from "@openuidev/react-lang"; import { BuiltinActionType, Renderer } from "@openuidev/react-lang"; import { useCallback, useMemo } from "react"; import { separateContentAndContext, wrapContent, wrapContext } from "../../utils/contentParser"; +import { ToolMessageRenderer } from "../_shared/tool-renderer"; import { AssistantMessageContainer } from "../Shell"; import { BehindTheScenes, ToolCallComponent } from "../ToolCall"; import { ToolResult } from "../ToolResult"; @@ -110,6 +111,19 @@ export const GenUIAssistantMessage = ({ [processMessage], ); + // Partition tool messages: those handled by an AppRenderer render *outside* + // the BehindTheScenes panel via ToolMessageRenderer (matching by toolName). + // The rest fall back to the default ToolResult inside BehindTheScenes. + const dispatchableToolMessages = toolMessages + .map((tm) => { + const toolCall = message.toolCalls?.find((tc) => tc.id === tm.toolCallId); + return toolCall ? { tm, toolCall } : null; + }) + .filter( + (x): x is { tm: ToolMessage; toolCall: NonNullable[number] } => + x !== null, + ); + const hasToolActivity = (message.toolCalls && message.toolCalls.length > 0) || toolMessages.length > 0; @@ -131,6 +145,16 @@ export const GenUIAssistantMessage = ({ ))} )} + {dispatchableToolMessages.map(({ tm, toolCall }) => ( + + ))} > = "loadThread", "streamProtocol", "messageFormat", + "appRenderers", ]); export function withChatProvider(WrappedComponent: React.ComponentType) { diff --git a/packages/react-ui/src/components/Shell/Thread.tsx b/packages/react-ui/src/components/Shell/Thread.tsx index df0daee16..4f408f5eb 100644 --- a/packages/react-ui/src/components/Shell/Thread.tsx +++ b/packages/react-ui/src/components/Shell/Thread.tsx @@ -5,9 +5,9 @@ import React, { memo, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; import { separateContentAndContext } from "../../utils/contentParser"; -import { ToolMessageRenderer } from "../_shared/app-renderer"; import { DetailedViewOverlay, DetailedViewPortalTarget } from "../_shared/detailed-view"; import { useShellStore } from "../_shared/store"; +import { ToolMessageRenderer } from "../_shared/tool-renderer"; import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { Callout } from "../Callout"; import { MarkDownRenderer } from "../MarkDownRenderer"; diff --git a/packages/react-ui/src/components/_shared/index.ts b/packages/react-ui/src/components/_shared/index.ts index 65c4a91dc..f1cab8d7d 100644 --- a/packages/react-ui/src/components/_shared/index.ts +++ b/packages/react-ui/src/components/_shared/index.ts @@ -1,5 +1,5 @@ -export * from "./app-renderer"; export * from "./detailed-view"; export * from "./store"; +export * from "./tool-renderer"; export * from "./types"; export * from "./utils"; diff --git a/packages/react-ui/src/components/_shared/app-renderer/AppRendererInstance.tsx b/packages/react-ui/src/components/_shared/tool-renderer/RendererInstance.tsx similarity index 73% rename from packages/react-ui/src/components/_shared/app-renderer/AppRendererInstance.tsx rename to packages/react-ui/src/components/_shared/tool-renderer/RendererInstance.tsx index 92134484e..a5830ea7e 100644 --- a/packages/react-ui/src/components/_shared/app-renderer/AppRendererInstance.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/RendererInstance.tsx @@ -8,12 +8,13 @@ import { useEffect, useId, useMemo } from "react"; import { DetailedViewPanel } from "../detailed-view"; /** - * Renders a matched AppRenderer for a single tool call/response. + * Renders a matched renderer (app or artifact) for a single tool call/response. * * Lifecycle: * 1. Run `parser({ args, response })` to derive Props. * 2. Run `meta(props, ctx)` to derive ThreadContext entry. * 3. If meta returns non-null, register the entry on mount; unregister on unmount. + * The `kind` field on the renderer routes to apps (default) or artifacts. * 4. Render `preview(props, controls)` inline + `` containing * `actual(props, controls)` for the side panel. * @@ -25,7 +26,7 @@ import { DetailedViewPanel } from "../detailed-view"; * * @internal */ -export function AppRendererInstance({ +export function RendererInstance({ renderer, args, response, @@ -49,14 +50,20 @@ export function AppRendererInstance({ const viewId = meta ? `${meta.id}:${meta.version}` : fallbackId; // Register entry on mount; unregister on unmount or when (id, version) changes. - // Heading-only changes upsert via the store's idempotent registerApp. + // The `kind` field on the renderer routes to the correct ThreadContext slice + // (apps for `defineAppRenderer`, artifacts for `defineArtifactRenderer`). + // Heading-only changes upsert via the store's idempotent register* actions. + const kind = renderer.kind ?? "app"; useEffect(() => { if (!meta) return; - tcStore.getState().registerApp(meta); - return () => { - tcStore.getState().unregisterApp(meta.id, meta.version); - }; - }, [tcStore, meta?.id, meta?.version, meta?.heading]); // eslint-disable-line react-hooks/exhaustive-deps + const state = tcStore.getState(); + if (kind === "artifact") { + state.registerArtifact(meta); + return () => tcStore.getState().unregisterArtifact(meta.id, meta.version); + } + state.registerApp(meta); + return () => tcStore.getState().unregisterApp(meta.id, meta.version); + }, [tcStore, kind, meta?.id, meta?.version, meta?.heading]); // eslint-disable-line react-hooks/exhaustive-deps const { isActive, open, close, toggle } = useDetailedView(viewId); diff --git a/packages/react-ui/src/components/_shared/app-renderer/ToolMessageRenderer.tsx b/packages/react-ui/src/components/_shared/tool-renderer/ToolMessageRenderer.tsx similarity index 89% rename from packages/react-ui/src/components/_shared/app-renderer/ToolMessageRenderer.tsx rename to packages/react-ui/src/components/_shared/tool-renderer/ToolMessageRenderer.tsx index e5ea7911c..5d8f3cd1c 100644 --- a/packages/react-ui/src/components/_shared/app-renderer/ToolMessageRenderer.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolMessageRenderer.tsx @@ -1,6 +1,6 @@ import { useAppRenderer, type ToolCall, type ToolMessage } from "@openuidev/react-headless"; import type { ReactNode } from "react"; -import { AppRendererInstance } from "./AppRendererInstance"; +import { RendererInstance } from "./RendererInstance"; /** * Props for {@link ToolMessageRenderer}. @@ -22,7 +22,7 @@ export type ToolMessageRendererProps = { * * Looks up `toolCall.function.name` against the AppRenderer registry provided * by ``. On match, hands off to - * {@link AppRendererInstance} which runs the parser, registers in ThreadContext, + * {@link RendererInstance} which runs the parser, registers in ThreadContext, * and renders the inline preview + detailed-view panel. * * Tool args (`toolCall.function.arguments`) and response (`toolMessage.content`) @@ -40,7 +40,7 @@ export const ToolMessageRenderer = ({ if (!renderer) return <>{fallback}; return ( - Date: Thu, 7 May 2026 15:24:31 +0530 Subject: [PATCH 04/88] A collapsible right-side panel that surfaces ThreadContext entries. - "Workspace" heading with collapse/expand toggle - Apps section reads useAppList(); artifacts section reads useArtifactList() - Each item is the latest version per id; clicking activates the matching DetailedView via setActiveDetailedView("${id}:${version}") - Active item highlighted; empty states per section + overall hint - Auto-collapses when a DetailedView opens (focus on the view) and auto-expands when it closes; manual toggles between transitions are preserved - Hidden on mobile layout (desktop only for v1) - Uses existing cssUtils tokens (spacing, typography, colors, radius) - Wired into FullScreen via ComposedStandalone - ShellStore gains isWorkspaceOpen / setIsWorkspaceOpen --- .../OpenUIChat/ComposedStandalone.tsx | 2 + .../src/components/Shell/WorkspaceSidebar.tsx | 156 ++++++++++++++++ .../react-ui/src/components/Shell/index.ts | 1 + .../react-ui/src/components/Shell/shell.scss | 1 + .../components/Shell/workspaceSidebar.scss | 174 ++++++++++++++++++ .../src/components/_shared/store/store.tsx | 4 + 6 files changed, 338 insertions(+) create mode 100644 packages/react-ui/src/components/Shell/WorkspaceSidebar.tsx create mode 100644 packages/react-ui/src/components/Shell/workspaceSidebar.scss diff --git a/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx b/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx index aadd15b91..46afdb7a7 100644 --- a/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx +++ b/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx @@ -16,6 +16,7 @@ import { ThreadHeader, ThreadList, WelcomeScreen, + WorkspaceSidebar, } from "../Shell"; import { CustomComposerAdapter } from "./CustomComposerAdapter"; import { ShareThread } from "./ShareThread"; @@ -138,6 +139,7 @@ const FullScreenInner = ({ )} + ); }; diff --git a/packages/react-ui/src/components/Shell/WorkspaceSidebar.tsx b/packages/react-ui/src/components/Shell/WorkspaceSidebar.tsx new file mode 100644 index 000000000..d66e71a06 --- /dev/null +++ b/packages/react-ui/src/components/Shell/WorkspaceSidebar.tsx @@ -0,0 +1,156 @@ +import { + useActiveDetailedView, + useAppList, + useArtifactList, + useDetailedView, + useDetailedViewStore, + type AppEntry, + type ArtifactEntry, +} from "@openuidev/react-headless"; +import clsx from "clsx"; +import { AppWindow, ArrowLeftFromLine, ArrowRightFromLine, FileText } from "lucide-react"; +import { useEffect } from "react"; +import { IconButton } from "../IconButton"; +import { useShellStore } from "../_shared/store"; + +/** + * Right-side collapsible sidebar that lists the apps and artifacts attached + * to the current thread (sourced from `useAppList` / `useArtifactList`). + * + * Each item activates the corresponding `DetailedView` when clicked. Use + * inside the Shell layout — a `ChatProvider` ancestor is required. + * + * @category Components + */ +export const WorkspaceSidebar = ({ className }: { className?: string }) => { + const { isWorkspaceOpen, setIsWorkspaceOpen } = useShellStore((state) => ({ + isWorkspaceOpen: state.isWorkspaceOpen, + setIsWorkspaceOpen: state.setIsWorkspaceOpen, + })); + const { isDetailedViewActive } = useActiveDetailedView(); + + // Auto-collapse the workspace when a DetailedView opens (focus on the view); + // auto-expand when it closes. Fires only on transition, so manual toggles + // while the active state is unchanged are preserved. + useEffect(() => { + setIsWorkspaceOpen(!isDetailedViewActive); + }, [isDetailedViewActive, setIsWorkspaceOpen]); + + const apps = useAppList(); + const artifacts = useArtifactList(); + const appLatest = latestPerId(apps); + const artifactLatest = latestPerId(artifacts); + + const isEmpty = appLatest.length === 0 && artifactLatest.length === 0; + + return ( +
+
+ Workspace + : + } + onClick={() => setIsWorkspaceOpen(!isWorkspaceOpen)} + size="small" + variant="secondary" + aria-label={isWorkspaceOpen ? "Collapse workspace" : "Expand workspace"} + className="openui-shell-workspace-sidebar__toggle-button" + /> +
+ +
+ + + {isEmpty && ( +
+ Apps and artifacts created by the assistant will appear here. +
+ )} +
+
+ ); +}; + +const WorkspaceSection = ({ + title, + entries, + kind, + emptyHint, +}: { + title: string; + entries: ReadonlyArray; + kind: "app" | "artifact"; + emptyHint: string; +}) => { + if (entries.length === 0) { + return ( +
+
{title}
+
{emptyHint}
+
+ ); + } + + return ( +
+
{title}
+
    + {entries.map((entry) => ( + + ))} +
+
+ ); +}; + +const WorkspaceItem = ({ + entry, + kind, +}: { + entry: AppEntry | ArtifactEntry; + kind: "app" | "artifact"; +}) => { + const viewId = `${entry.id}:${entry.version}`; + const { isActive } = useDetailedView(viewId); + const store = useDetailedViewStore(); + const onClick = () => store.getState().setActiveDetailedView(viewId); + + const Icon = kind === "app" ? AppWindow : FileText; + + return ( +
  • + +
  • + ); +}; + +/** Picks the latest version (highest version number, kept as the last element after sort). */ +function latestPerId( + registry: Record, +): T[] { + return Object.values(registry) + .map((versions) => versions[versions.length - 1]) + .filter((entry): entry is T => entry !== undefined); +} diff --git a/packages/react-ui/src/components/Shell/index.ts b/packages/react-ui/src/components/Shell/index.ts index fda33b3ee..8885eb1e6 100644 --- a/packages/react-ui/src/components/Shell/index.ts +++ b/packages/react-ui/src/components/Shell/index.ts @@ -9,3 +9,4 @@ export * from "./Sidebar"; export * from "./Thread"; export * from "./ThreadList"; export * from "./WelcomeScreen"; +export * from "./WorkspaceSidebar"; diff --git a/packages/react-ui/src/components/Shell/shell.scss b/packages/react-ui/src/components/Shell/shell.scss index 542413566..093fe6738 100644 --- a/packages/react-ui/src/components/Shell/shell.scss +++ b/packages/react-ui/src/components/Shell/shell.scss @@ -1,5 +1,6 @@ @use "../../cssUtils" as cssUtils; @use "./sidebar.scss"; +@use "./workspaceSidebar.scss"; @use "./threadlist.scss"; @use "./thread.scss"; @use "./mobileHeader.scss"; diff --git a/packages/react-ui/src/components/Shell/workspaceSidebar.scss b/packages/react-ui/src/components/Shell/workspaceSidebar.scss new file mode 100644 index 000000000..a31228cf5 --- /dev/null +++ b/packages/react-ui/src/components/Shell/workspaceSidebar.scss @@ -0,0 +1,174 @@ +@use "../../cssUtils" as cssUtils; + +$workspace-sidebar-width: 280px; +$workspace-sidebar-collapsed-width: 52px; +$workspace-sidebar-padding: cssUtils.$space-m; + +.openui-shell-workspace-sidebar { + border-left: 1px solid cssUtils.$border-default; + height: 100%; + width: $workspace-sidebar-width; + flex-shrink: 0; + padding: $workspace-sidebar-padding; + display: flex; + flex-direction: column; + gap: cssUtils.$space-m; + background-color: cssUtils.$foreground; + overflow: hidden; + transition: + width 0.4s ease-in-out, + padding 0.4s ease-in-out; + + // Hide entirely on mobile — workspace lives only on desktop layouts for now. + .openui-shell-container--mobile & { + display: none; + } + + &--collapsed { + width: $workspace-sidebar-collapsed-width; + padding: $workspace-sidebar-padding cssUtils.$space-xs; + gap: cssUtils.$space-xs; + } + + &__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: cssUtils.$space-s; + flex-shrink: 0; + + .openui-shell-workspace-sidebar--collapsed & { + flex-direction: column-reverse; + justify-content: flex-end; + align-items: center; + } + } + + &__title { + @include cssUtils.typography(label, default-heavy); + color: cssUtils.$text-neutral-primary; + flex-grow: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + + .openui-shell-workspace-sidebar--collapsed & { + display: none; + } + } + + &__content { + display: flex; + flex-direction: column; + gap: cssUtils.$space-l; + overflow-y: auto; + overflow-x: hidden; + flex: 1; + min-height: 0; + transition: + opacity 0.2s ease-in-out 0.3s, + visibility 0.4s 0.5s; + + scrollbar-width: thin; + scrollbar-color: cssUtils.$highlight-strong transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-thumb { + background-color: cssUtils.$highlight-strong; + border-radius: cssUtils.$radius-full; + } + + .openui-shell-workspace-sidebar--collapsed & { + opacity: 0; + visibility: hidden; + transition: + opacity 0.2s ease-in-out, + visibility 0s 0.2s; + } + } + + &__empty { + @include cssUtils.typography(label, small); + color: cssUtils.$text-neutral-tertiary; + padding: cssUtils.$space-s cssUtils.$space-2xs; + } + + &__section { + display: flex; + flex-direction: column; + gap: cssUtils.$space-2xs; + } + + &__section-header { + @include cssUtils.typography(label, extra-small-heavy); + color: cssUtils.$text-neutral-tertiary; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: cssUtils.$space-2xs cssUtils.$space-2xs; + } + + &__section-empty { + @include cssUtils.typography(label, small); + color: cssUtils.$text-neutral-tertiary; + padding: cssUtils.$space-2xs cssUtils.$space-s; + font-style: italic; + } + + &__list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: cssUtils.$space-3xs; + } + + &__item { + @include cssUtils.button-reset; + @include cssUtils.typography(label, default); + width: 100%; + display: flex; + align-items: center; + gap: cssUtils.$space-s; + padding: cssUtils.$space-2xs cssUtils.$space-s; + border-radius: cssUtils.$radius-s; + color: cssUtils.$text-neutral-primary; + cursor: pointer; + transition: background-color 0.15s ease; + text-align: left; + + &:hover { + background-color: cssUtils.$highlight; + } + + &:active { + background-color: cssUtils.$highlight-strong; + } + + &--active { + background-color: cssUtils.$highlight-strong; + color: cssUtils.$text-accent-primary; + } + } + + &__item-icon { + flex-shrink: 0; + color: cssUtils.$text-neutral-secondary; + + .openui-shell-workspace-sidebar__item--active & { + color: cssUtils.$text-accent-primary; + } + } + + &__item-label { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} diff --git a/packages/react-ui/src/components/_shared/store/store.tsx b/packages/react-ui/src/components/_shared/store/store.tsx index cab14e1c2..eaa07c038 100644 --- a/packages/react-ui/src/components/_shared/store/store.tsx +++ b/packages/react-ui/src/components/_shared/store/store.tsx @@ -4,9 +4,11 @@ import { useShallow } from "zustand/react/shallow"; interface ShellState { isSidebarOpen: boolean; + isWorkspaceOpen: boolean; agentName: string; logoUrl: string; setIsSidebarOpen: (isOpen: boolean) => void; + setIsWorkspaceOpen: (isOpen: boolean) => void; setAgentName: (name: string) => void; setLogoUrl: (url: string) => void; } @@ -14,9 +16,11 @@ interface ShellState { export const createShellStore = ({ logoUrl, agentName }: { logoUrl: string; agentName: string }) => create((set) => ({ isSidebarOpen: true, + isWorkspaceOpen: true, agentName: agentName, logoUrl: logoUrl, setIsSidebarOpen: (isOpen: boolean) => set({ isSidebarOpen: isOpen }), + setIsWorkspaceOpen: (isOpen: boolean) => set({ isWorkspaceOpen: isOpen }), setAgentName: (name: string) => set({ agentName: name }), setLogoUrl: (url: string) => set({ logoUrl: url }), })); From 5ee23c1aa8193449b9068b6d5655406282fdd195 Mon Sep 17 00:00:00 2001 From: abhithesys Date: Tue, 12 May 2026 13:48:42 +0530 Subject: [PATCH 05/88] feat(sdk): streaming AppRenderer dispatch + OpenAI Responses tool-result handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppRenderers now fire on tool calls whose args are still streaming, before the tool result is paired in. Same React instance persists across the streaming → completed transition (keyed by toolCall.id), so renderers can swap between partial and final UI without remounting. - react-ui: ToolMessageRenderer accepts ToolMessage|null and signals isStreaming when null; RendererInstance accepts an isStreaming prop and propagates to controls + meta ctx; GenUIAssistantMessage dispatches every tool call (paired or not). - react-headless: openAIResponsesAdapter handles function_call_output items in response.output_item.added → yields TOOL_CALL_RESULT. Backends that inject synthetic events for server-side tool execution can now surface tool results to the SDK store (OpenAI itself silently absorbs function_call_output items into the conversation without echoing them). - AppRendererControls.isStreaming JSDoc rewritten — was "always false in v1; reserved for streaming protocol", now describes actual behavior. AppRendererConfig.parser JSDoc notes args may be partial JSON and response may be null during streaming. AppRendererConfig.meta documents the ctx.isStreaming parameter. --- .../src/store/appRendererTypes.ts | 30 +++++++++++++++++-- .../src/stream/adapters/openai-responses.ts | 13 ++++++++ .../OpenUIChat/GenUIAssistantMessage.tsx | 29 +++++++++--------- .../tool-renderer/RendererInstance.tsx | 14 +++++++-- .../tool-renderer/ToolMessageRenderer.tsx | 22 +++++++++----- 5 files changed, 81 insertions(+), 27 deletions(-) diff --git a/packages/react-headless/src/store/appRendererTypes.ts b/packages/react-headless/src/store/appRendererTypes.ts index ed1d19b76..c39c64392 100644 --- a/packages/react-headless/src/store/appRendererTypes.ts +++ b/packages/react-headless/src/store/appRendererTypes.ts @@ -21,7 +21,17 @@ export type AppRendererKind = "app" | "artifact"; export interface AppRendererControls { /** Whether this renderer's detailed view is the currently active one. */ isActive: boolean; - /** Whether the underlying tool response is still streaming (always `false` in v1; reserved for streaming protocol). */ + /** + * `true` while the tool call is still streaming — i.e. its arguments are + * arriving incrementally and no tool result has been paired in yet. Becomes + * `false` once the tool result message lands and the renderer is invoked + * with the full `response`. + * + * The same component instance is reused across the streaming → completed + * transition, so renderers can rely on this flag to swap UI states (e.g. + * show a skeleton or "streaming…" badge during partial args, then the final + * view) without remounting. + */ isStreaming: boolean; /** Activates this renderer's detailed view. */ open: () => void; @@ -61,7 +71,18 @@ export interface AppRendererConfig { * Converts the raw tool envelope into renderer-shaped `Props`. * * Receives `{ args, response }` exactly as the backend emitted them — the SDK - * does not pre-parse JSON. Return `null` to skip rendering this tool result. + * does not pre-parse JSON. Return `null` to skip rendering this tool call. + * + * Called on every update to args or response, including during streaming. + * Implementations must therefore be tolerant of: + * - `args` as a *partial* JSON string (the LLM is still emitting it), and + * - `response` as `null` (the tool result hasn't arrived yet — see + * {@link AppRendererControls.isStreaming}). + * + * Once the tool call completes, the parser is re-invoked with full `args` + * and a non-null `response`. Returning a stable `Props` shape across the + * streaming → completed transition lets the same renderer instance update + * smoothly without remounting. */ parser: (raw: { args: unknown; response: unknown }) => Props | null; /** @@ -69,8 +90,11 @@ export interface AppRendererConfig { * * Return `null` to skip registration (the renderer still renders if `parser` * returned non-null Props, but the entry will not appear in the apps/artifacts list). + * A common pattern is to return `null` while `ctx.isStreaming === true` so the + * entry only appears in the registry once the tool result has arrived. * - * The `id` should be stable across re-runs of the same logical entry. + * The `id` should be stable across re-runs of the same logical entry — when + * `(id, version)` changes, the registry entry is unregistered and re-registered. */ meta: ( props: Props, diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 17c0ea94b..8fc0ffc5e 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -42,6 +42,19 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ toolCallId: item.call_id, toolCallName: item.name, }; + } else if (item.type === "function_call_output") { + // Fired when a function_call_output we submitted as input is + // integrated into a conversation-linked response — surfaces + // server-side tool execution to the SDK store. + yield { + type: EventType.TOOL_CALL_RESULT, + messageId: item.id, + toolCallId: item.call_id, + content: + typeof item.output === "string" + ? item.output + : JSON.stringify(item.output), + }; } break; } diff --git a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx index a245b537b..731739a9e 100644 --- a/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx +++ b/packages/react-ui/src/components/OpenUIChat/GenUIAssistantMessage.tsx @@ -111,18 +111,16 @@ export const GenUIAssistantMessage = ({ [processMessage], ); - // Partition tool messages: those handled by an AppRenderer render *outside* - // the BehindTheScenes panel via ToolMessageRenderer (matching by toolName). - // The rest fall back to the default ToolResult inside BehindTheScenes. - const dispatchableToolMessages = toolMessages - .map((tm) => { - const toolCall = message.toolCalls?.find((tc) => tc.id === tm.toolCallId); - return toolCall ? { tm, toolCall } : null; - }) - .filter( - (x): x is { tm: ToolMessage; toolCall: NonNullable[number] } => - x !== null, - ); + // Iterate every tool call from the assistant message and pair it with its + // tool message if one has arrived yet. Streaming-in-progress tool calls + // (no paired tool message) are dispatched too — the matched AppRenderer + // sees `controls.isStreaming = true` and partial args. The same component + // instance is reused when the tool message arrives, so the lifecycle is + // smooth (no remount, no ThreadContext re-register). + const dispatchableEntries = (message.toolCalls ?? []).map((toolCall) => { + const tm = toolMessages.find((m) => m.toolCallId === toolCall.id) ?? null; + return { toolCall, tm }; + }); const hasToolActivity = (message.toolCalls && message.toolCalls.length > 0) || toolMessages.length > 0; @@ -145,9 +143,12 @@ export const GenUIAssistantMessage = ({ ))} )} - {dispatchableToolMessages.map(({ tm, toolCall }) => ( + {dispatchableEntries.map(({ tm, toolCall }) => ( ({ renderer, args, response, + isStreaming = false, }: { renderer: AppRendererConfig; args: unknown; response: unknown; + isStreaming?: boolean; }) { const fallbackId = useId(); const tcStore = useThreadContextStore(); @@ -42,8 +50,8 @@ export function RendererInstance({ const meta = useMemo(() => { if (props === null) return null; - return renderer.meta(props, { isStreaming: false }); - }, [renderer, props]); + return renderer.meta(props, { isStreaming }); + }, [renderer, props, isStreaming]); // viewId derives from meta when present, otherwise from React's useId // so `controls.open` still works for an inline-only renderer. @@ -74,7 +82,7 @@ export function RendererInstance({ open, close, toggle, - isStreaming: false, + isStreaming, }; return ( diff --git a/packages/react-ui/src/components/_shared/tool-renderer/ToolMessageRenderer.tsx b/packages/react-ui/src/components/_shared/tool-renderer/ToolMessageRenderer.tsx index 5d8f3cd1c..9aabbd794 100644 --- a/packages/react-ui/src/components/_shared/tool-renderer/ToolMessageRenderer.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolMessageRenderer.tsx @@ -8,8 +8,12 @@ import { RendererInstance } from "./RendererInstance"; * @category Components */ export type ToolMessageRendererProps = { - /** The tool message containing the response payload. */ - toolMessage: ToolMessage; + /** + * The tool message containing the response payload, or `null` while the tool + * call is still streaming (args have not finished arriving and no result yet). + * The matched renderer is rendered with `controls.isStreaming = true` in that case. + */ + toolMessage: ToolMessage | null; /** The matching tool call from the parent assistant message (provides `name` + `arguments`). */ toolCall: ToolCall; /** Rendered when no AppRenderer matches `toolCall.function.name`. */ @@ -17,16 +21,19 @@ export type ToolMessageRendererProps = { }; /** - * Dispatches a tool result to a matching AppRenderer if one is registered, - * otherwise renders `fallback` (typically the default ``). + * Dispatches a tool call (streaming or completed) to a matching AppRenderer if + * one is registered, otherwise renders `fallback` (typically the default + * ``). * * Looks up `toolCall.function.name` against the AppRenderer registry provided * by ``. On match, hands off to * {@link RendererInstance} which runs the parser, registers in ThreadContext, * and renders the inline preview + detailed-view panel. * - * Tool args (`toolCall.function.arguments`) and response (`toolMessage.content`) - * are passed to the renderer's `parser` raw — the SDK does not pre-parse JSON. + * Tool args (`toolCall.function.arguments`) and response (`toolMessage.content`, + * or `null` while streaming) are passed to the renderer's `parser` raw — the + * SDK does not pre-parse JSON. The renderer's `parser` is responsible for + * handling partial-JSON args during streaming. * * @category Components */ @@ -43,7 +50,8 @@ export const ToolMessageRenderer = ({ ); }; From e38ec0e48ffd2bae62e4a04d97f0b35915ca5d12 Mon Sep 17 00:00:00 2001 From: abhithesys Date: Tue, 12 May 2026 13:50:56 +0530 Subject: [PATCH 06/88] Reference implementation of wrapping OpenAI's Conversations + Responses APIs behind the SDK's AG-UI wire shape. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Persistent chat: thread storage via OpenAI Conversations API with a JSON file index at .data/threads.json (Conversations API has no list method) - Hosted LLM: route streams from openai.responses.create with conversation: threadId so OpenAI auto-persists user input + responses - Tool execution: manual loop (Responses API has no runTools helper), capped at MAX_TOOL_TURNS, with a synthetic response.output_item.added event injected after each tool execution so the SDK surfaces results live - Streaming artifact: create_code_artifact tool with strict mode + property order (language → title → code) and a partial-JSON parser, rendered via defineArtifactRenderer using controls.isStreaming - Frontend wires openAIResponsesAdapter + openAIConversationMessageFormat; processMessage sends only the latest message since OpenAI holds history --- examples/openui-responses-chat/.dockerignore | 39 +++ examples/openui-responses-chat/.gitignore | 44 ++++ examples/openui-responses-chat/Dockerfile | 74 ++++++ examples/openui-responses-chat/README.md | 54 +++++ .../openui-responses-chat/eslint.config.mjs | 18 ++ examples/openui-responses-chat/next.config.ts | 8 + examples/openui-responses-chat/package.json | 34 +++ .../openui-responses-chat/postcss.config.mjs | 7 + .../src/app/api/chat/route.ts | 134 +++++++++++ .../src/app/api/threads/create/route.ts | 37 +++ .../src/app/api/threads/delete/[id]/route.ts | 19 ++ .../src/app/api/threads/get/[id]/route.ts | 20 ++ .../src/app/api/threads/get/route.ts | 6 + .../src/app/api/threads/update/[id]/route.ts | 20 ++ .../openui-responses-chat/src/app/globals.css | 2 + .../openui-responses-chat/src/app/layout.tsx | 22 ++ .../openui-responses-chat/src/app/page.tsx | 71 ++++++ .../src/generated/system-prompt.txt | 223 ++++++++++++++++++ .../src/hooks/use-system-theme.tsx | 41 ++++ .../src/lib/codeArtifactRenderer.tsx | 101 ++++++++ .../src/lib/thread-index.ts | 59 +++++ .../openui-responses-chat/src/lib/tools.ts | 194 +++++++++++++++ examples/openui-responses-chat/src/library.ts | 1 + examples/openui-responses-chat/tsconfig.json | 34 +++ pnpm-lock.yaml | 63 ++++- 25 files changed, 1323 insertions(+), 2 deletions(-) create mode 100644 examples/openui-responses-chat/.dockerignore create mode 100644 examples/openui-responses-chat/.gitignore create mode 100644 examples/openui-responses-chat/Dockerfile create mode 100644 examples/openui-responses-chat/README.md create mode 100644 examples/openui-responses-chat/eslint.config.mjs create mode 100644 examples/openui-responses-chat/next.config.ts create mode 100644 examples/openui-responses-chat/package.json create mode 100644 examples/openui-responses-chat/postcss.config.mjs create mode 100644 examples/openui-responses-chat/src/app/api/chat/route.ts create mode 100644 examples/openui-responses-chat/src/app/api/threads/create/route.ts create mode 100644 examples/openui-responses-chat/src/app/api/threads/delete/[id]/route.ts create mode 100644 examples/openui-responses-chat/src/app/api/threads/get/[id]/route.ts create mode 100644 examples/openui-responses-chat/src/app/api/threads/get/route.ts create mode 100644 examples/openui-responses-chat/src/app/api/threads/update/[id]/route.ts create mode 100644 examples/openui-responses-chat/src/app/globals.css create mode 100644 examples/openui-responses-chat/src/app/layout.tsx create mode 100644 examples/openui-responses-chat/src/app/page.tsx create mode 100644 examples/openui-responses-chat/src/generated/system-prompt.txt create mode 100644 examples/openui-responses-chat/src/hooks/use-system-theme.tsx create mode 100644 examples/openui-responses-chat/src/lib/codeArtifactRenderer.tsx create mode 100644 examples/openui-responses-chat/src/lib/thread-index.ts create mode 100644 examples/openui-responses-chat/src/lib/tools.ts create mode 100644 examples/openui-responses-chat/src/library.ts create mode 100644 examples/openui-responses-chat/tsconfig.json diff --git a/examples/openui-responses-chat/.dockerignore b/examples/openui-responses-chat/.dockerignore new file mode 100644 index 000000000..999ffc10a --- /dev/null +++ b/examples/openui-responses-chat/.dockerignore @@ -0,0 +1,39 @@ +# Node +node_modules +.pnpm-store +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Next.js +.next +out + +# Git +.git +.gitignore + +# Logs +logs +*.log + +# Env files +.env +.env.* +!.env.example + +# OS files +.DS_Store +Thumbs.db + +# Build / cache +dist +build +.turbo +.cache +coverage + +# Editor +.vscode +.idea \ No newline at end of file diff --git a/examples/openui-responses-chat/.gitignore b/examples/openui-responses-chat/.gitignore new file mode 100644 index 000000000..456479ec2 --- /dev/null +++ b/examples/openui-responses-chat/.gitignore @@ -0,0 +1,44 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# local thread index (created at runtime) +/.data/ + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/examples/openui-responses-chat/Dockerfile b/examples/openui-responses-chat/Dockerfile new file mode 100644 index 000000000..d6d5be06b --- /dev/null +++ b/examples/openui-responses-chat/Dockerfile @@ -0,0 +1,74 @@ +# syntax=docker/dockerfile:1.7 +# -------------------------------------------------- +# Build stage +# -------------------------------------------------- +FROM node:20-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache libc6-compat +ARG PNPM_VERSION=9.12.0 +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate + +ENV NEXT_TELEMETRY_DISABLED=1 + +COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.json ./ + +COPY packages/openui-cli/package.json ./packages/openui-cli/ +COPY packages/react-ui/package.json ./packages/react-ui/ +COPY packages/react-headless/package.json ./packages/react-headless/ +COPY packages/lang-core/package.json ./packages/lang-core/ +COPY packages/react-lang/package.json ./packages/react-lang/ +COPY examples/openui-chat/package.json ./examples/openui-chat/ + +RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile --ignore-scripts + +COPY packages/openui-cli ./packages/openui-cli +COPY packages/react-ui ./packages/react-ui +COPY packages/react-headless ./packages/react-headless +COPY packages/lang-core ./packages/lang-core +COPY packages/react-lang ./packages/react-lang +COPY examples/openui-chat ./examples/openui-chat + +RUN pnpm --filter @openuidev/cli build +RUN pnpm --filter @openuidev/react-ui build +RUN pnpm --filter @openuidev/react-headless build +RUN pnpm --filter @openuidev/lang-core build +RUN pnpm --filter @openuidev/react-lang build + +WORKDIR /app/examples/openui-chat +RUN node /app/packages/openui-cli/dist/index.js generate src/library.ts --out src/generated/system-prompt.txt \ + && pnpm build + +# -------------------------------------------------- +# Runtime stage +# -------------------------------------------------- +FROM node:20-alpine AS runner + +WORKDIR /app + +RUN apk add --no-cache libc6-compat + +ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 HOSTNAME=0.0.0.0 + +RUN addgroup -S nodejs && adduser -S nextjs -G nodejs +USER nextjs + +# Copy full standalone output to avoid brittle partial-copy assumptions +COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/.next/standalone ./ + +# Static assets expected by Next at runtime +COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/.next/static ./examples/openui-chat/.next/static + +# If your app has a public directory, include this line +# COPY --from=builder --chown=nextjs:nodejs /app/examples/openui-chat/public ./examples/openui-chat/public + +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node -e "fetch('http://127.0.0.1:3000').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "examples/openui-chat/server.js"] diff --git a/examples/openui-responses-chat/README.md b/examples/openui-responses-chat/README.md new file mode 100644 index 000000000..717d547ba --- /dev/null +++ b/examples/openui-responses-chat/README.md @@ -0,0 +1,54 @@ +This is an [OpenUI](https://openui.com) Agent Chat project bootstrapped with [`openui-cli`](https://openui.com/docs/chat/quick-start). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `src/app/api/route.ts` and improving your agent +by adding system prompts or tools. + +## Learn More + +To learn more about OpenUI, take a look at the following resources: + +- [OpenUI Documentation](https://openui.com/docs) - learn about OpenUI features and API. +- [OpenUI GitHub repository](https://github.com/thesysdev/openui) - your feedback and contributions are welcome! + +## Docker Usage + +You can build the image either from the example directory or from the repository root. + +### Option 1: From examples/openui-responses-chat + +```bash +cd examples/openui-responses-chat +docker build -f Dockerfile -t openui-responses-chat ../.. +docker run --rm -p 3000:3000 -e OPENAI_API_KEY=your_api_key openui-responses-chat +``` + +### Option 2: From repository root + +```bash +docker build -f examples/openui-responses-chat/Dockerfile -t openui-responses-chat . +docker run --rm -p 3000:3000 -e OPENAI_API_KEY=your_api_key openui-responses-chat +``` + +⚠️ The build context must be the repository root (either `.` or `../..`) because this example depends on pnpm workspace packages. + +Notes: + +- The Docker build uses pnpm workspace dependencies from the monorepo. +- Runtime uses Next.js standalone output (`node examples/openui-responses-chat/server.js`). +- A placeholder API key will start the app, but chat requests will return `401`. diff --git a/examples/openui-responses-chat/eslint.config.mjs b/examples/openui-responses-chat/eslint.config.mjs new file mode 100644 index 000000000..05e726d1b --- /dev/null +++ b/examples/openui-responses-chat/eslint.config.mjs @@ -0,0 +1,18 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +const eslintConfig = defineConfig([ + ...nextVitals, + ...nextTs, + // Override default ignores of eslint-config-next. + globalIgnores([ + // Default ignores of eslint-config-next: + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); + +export default eslintConfig; diff --git a/examples/openui-responses-chat/next.config.ts b/examples/openui-responses-chat/next.config.ts new file mode 100644 index 000000000..7e1f0ae21 --- /dev/null +++ b/examples/openui-responses-chat/next.config.ts @@ -0,0 +1,8 @@ +import type { NextConfig } from "next"; + +const nextConfig: NextConfig = { + output: "standalone", + turbopack: {}, +}; + +export default nextConfig; diff --git a/examples/openui-responses-chat/package.json b/examples/openui-responses-chat/package.json new file mode 100644 index 000000000..6365a4ccf --- /dev/null +++ b/examples/openui-responses-chat/package.json @@ -0,0 +1,34 @@ +{ + "name": "openui-responses-chat", + "version": "0.1.0", + "private": true, + "scripts": { + "generate:prompt": "pnpm --filter @openuidev/cli build && pnpm exec openui generate src/library.ts --out src/generated/system-prompt.txt", + "dev": "pnpm generate:prompt && next dev", + "build": "next build", + "start": "next start", + "lint": "eslint" + }, + "dependencies": { + "@openuidev/react-headless": "workspace:*", + "@openuidev/react-lang": "workspace:*", + "@openuidev/react-ui": "workspace:*", + "lucide-react": "^0.575.0", + "next": "16.1.6", + "openai": "^6.22.0", + "react": "19.2.3", + "react-dom": "19.2.3", + "zod": "^4.0.0" + }, + "devDependencies": { + "@openuidev/cli": "workspace:*", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "eslint": "^9", + "eslint-config-next": "16.1.6", + "tailwindcss": "^4", + "typescript": "^5" + } +} diff --git a/examples/openui-responses-chat/postcss.config.mjs b/examples/openui-responses-chat/postcss.config.mjs new file mode 100644 index 000000000..61e36849c --- /dev/null +++ b/examples/openui-responses-chat/postcss.config.mjs @@ -0,0 +1,7 @@ +const config = { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; + +export default config; diff --git a/examples/openui-responses-chat/src/app/api/chat/route.ts b/examples/openui-responses-chat/src/app/api/chat/route.ts new file mode 100644 index 000000000..b47356dc0 --- /dev/null +++ b/examples/openui-responses-chat/src/app/api/chat/route.ts @@ -0,0 +1,134 @@ +import { readFileSync } from "fs"; +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import type { + ResponseFunctionToolCall, + ResponseInputItem, + ResponseStreamEvent, +} from "openai/resources/responses/responses"; +import { join } from "path"; +import { executeTool, tools } from "@/lib/tools"; + +const systemPrompt = readFileSync( + join(process.cwd(), "src/generated/system-prompt.txt"), + "utf-8", +); + +const MODEL = "gpt-5.4"; +const MAX_TOOL_TURNS = 5; + +export async function POST(req: NextRequest) { + const { input, threadId } = (await req.json()) as { + input: ResponseInputItem[]; + threadId?: string; + }; + + const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + + const encoder = new TextEncoder(); + let controllerClosed = false; + + const readable = new ReadableStream({ + async start(controller) { + const enqueue = (chunk: string) => { + if (controllerClosed) return; + try { + controller.enqueue(encoder.encode(chunk)); + } catch { + /* already closed */ + } + }; + const close = () => { + if (controllerClosed) return; + controllerClosed = true; + try { + controller.close(); + } catch { + /* already closed */ + } + }; + + try { + let nextInput: ResponseInputItem[] = input; + + for (let turn = 0; turn < MAX_TOOL_TURNS; turn++) { + const stream = await client.responses.create({ + model: MODEL, + instructions: systemPrompt, + input: nextInput, + conversation: threadId, + tools, + stream: true, + }); + + let lastResponse: ResponseStreamEvent extends infer E + ? E extends { type: "response.completed"; response: infer R } + ? R + : never + : never = null as never; + + for await (const event of stream) { + enqueue(`data: ${JSON.stringify(event)}\n\n`); + if (event.type === "response.completed") { + lastResponse = event.response as typeof lastResponse; + } + } + + const fnCalls: ResponseFunctionToolCall[] = (lastResponse?.output ?? []).filter( + (o): o is ResponseFunctionToolCall => o.type === "function_call", + ); + + if (fnCalls.length === 0) break; + + const outputs: ResponseInputItem[] = []; + for (const fc of fnCalls) { + const result = await executeTool(fc.name, fc.arguments); + + // OpenAI doesn't echo function_call_output items in the response + // stream — it absorbs them into the conversation silently. Inject + // a synthetic OpenAI-shape event so the SDK adapter can surface + // the tool result to the live store (mirrors a real + // `response.output_item.added` for a function_call_output item). + enqueue( + `data: ${JSON.stringify({ + type: "response.output_item.added", + item: { + id: `fco_${fc.call_id}`, + type: "function_call_output", + call_id: fc.call_id, + output: result, + status: "completed", + }, + output_index: 0, + sequence_number: 0, + })}\n\n`, + ); + + outputs.push({ + type: "function_call_output", + call_id: fc.call_id, + output: result, + }); + } + nextInput = outputs; + } + + enqueue("data: [DONE]\n\n"); + close(); + } catch (err) { + const msg = err instanceof Error ? err.message : "Stream error"; + console.error("Responses route error:", msg); + enqueue(`data: ${JSON.stringify({ type: "error", message: msg })}\n\n`); + close(); + } + }, + }); + + return new Response(readable, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + }, + }); +} diff --git a/examples/openui-responses-chat/src/app/api/threads/create/route.ts b/examples/openui-responses-chat/src/app/api/threads/create/route.ts new file mode 100644 index 000000000..4e68e32bf --- /dev/null +++ b/examples/openui-responses-chat/src/app/api/threads/create/route.ts @@ -0,0 +1,37 @@ +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import type { ResponseInputItem } from "openai/resources/responses/responses"; +import { addThread } from "@/lib/thread-index"; + +function extractFirstUserText(items: ResponseInputItem[]): string { + for (const item of items) { + const i = item as { type?: string; role?: string; content?: unknown }; + if (i.type === "message" && i.role === "user") { + if (typeof i.content === "string") return i.content; + if (Array.isArray(i.content)) { + for (const part of i.content as Array<{ type: string; text?: string }>) { + if (part.type === "input_text" && part.text) return part.text; + } + } + } + } + return "New conversation"; +} + +export async function POST(req: NextRequest) { + const { messages } = (await req.json()) as { messages: ResponseInputItem[] }; + + const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + + const conversation = await client.conversations.create({}); + + const title = extractFirstUserText(messages).slice(0, 60); + const thread = { + id: conversation.id, + title, + createdAt: new Date(conversation.created_at * 1000).toISOString(), + }; + + await addThread(thread); + return Response.json(thread); +} diff --git a/examples/openui-responses-chat/src/app/api/threads/delete/[id]/route.ts b/examples/openui-responses-chat/src/app/api/threads/delete/[id]/route.ts new file mode 100644 index 000000000..6f0ea93f6 --- /dev/null +++ b/examples/openui-responses-chat/src/app/api/threads/delete/[id]/route.ts @@ -0,0 +1,19 @@ +import OpenAI from "openai"; +import { removeThread } from "@/lib/thread-index"; + +export async function DELETE( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + + try { + await client.conversations.delete(id); + } catch (e) { + console.warn("Failed to delete OpenAI conversation; removing from index anyway:", e); + } + await removeThread(id); + + return new Response(null, { status: 204 }); +} diff --git a/examples/openui-responses-chat/src/app/api/threads/get/[id]/route.ts b/examples/openui-responses-chat/src/app/api/threads/get/[id]/route.ts new file mode 100644 index 000000000..fe327ec28 --- /dev/null +++ b/examples/openui-responses-chat/src/app/api/threads/get/[id]/route.ts @@ -0,0 +1,20 @@ +import OpenAI from "openai"; +import type { ConversationItem } from "openai/resources/conversations/items"; + +export async function GET( + _req: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + + const items: ConversationItem[] = []; + for await (const item of client.conversations.items.list(id, { + limit: 100, + order: "asc", + })) { + items.push(item); + } + + return Response.json(items); +} diff --git a/examples/openui-responses-chat/src/app/api/threads/get/route.ts b/examples/openui-responses-chat/src/app/api/threads/get/route.ts new file mode 100644 index 000000000..8e58bedde --- /dev/null +++ b/examples/openui-responses-chat/src/app/api/threads/get/route.ts @@ -0,0 +1,6 @@ +import { readIndex } from "@/lib/thread-index"; + +export async function GET() { + const threads = await readIndex(); + return Response.json({ threads }); +} diff --git a/examples/openui-responses-chat/src/app/api/threads/update/[id]/route.ts b/examples/openui-responses-chat/src/app/api/threads/update/[id]/route.ts new file mode 100644 index 000000000..507ca893b --- /dev/null +++ b/examples/openui-responses-chat/src/app/api/threads/update/[id]/route.ts @@ -0,0 +1,20 @@ +import { NextRequest } from "next/server"; +import OpenAI from "openai"; +import { updateIndexEntry } from "@/lib/thread-index"; + +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + const updates = (await req.json()) as { title?: string }; + + const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + + if (typeof updates.title === "string") { + await client.conversations.update(id, { metadata: { title: updates.title } }); + } + + const updated = await updateIndexEntry(id, { title: updates.title }); + return Response.json(updated ?? { id, ...updates }); +} diff --git a/examples/openui-responses-chat/src/app/globals.css b/examples/openui-responses-chat/src/app/globals.css new file mode 100644 index 000000000..3d552a61f --- /dev/null +++ b/examples/openui-responses-chat/src/app/globals.css @@ -0,0 +1,2 @@ +@import "tailwindcss"; + diff --git a/examples/openui-responses-chat/src/app/layout.tsx b/examples/openui-responses-chat/src/app/layout.tsx new file mode 100644 index 000000000..7e44b0451 --- /dev/null +++ b/examples/openui-responses-chat/src/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import { ThemeProvider } from "@/hooks/use-system-theme"; +import "./globals.css"; + +export const metadata: Metadata = { + title: "OpenUI Chat", + description: "Generative UI Chat with OpenAI SDK", +}; + +export default function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/examples/openui-responses-chat/src/app/page.tsx b/examples/openui-responses-chat/src/app/page.tsx new file mode 100644 index 000000000..65df84c86 --- /dev/null +++ b/examples/openui-responses-chat/src/app/page.tsx @@ -0,0 +1,71 @@ +"use client"; +import "@openuidev/react-ui/components.css"; + +import { useTheme } from "@/hooks/use-system-theme"; +import { + openAIConversationMessageFormat, + openAIResponsesAdapter, +} from "@openuidev/react-headless"; +import { FullScreen } from "@openuidev/react-ui"; +import { openuiChatLibrary } from "@openuidev/react-ui/genui-lib"; +import { codeArtifactRenderer } from "@/lib/codeArtifactRenderer"; + +export default function Page() { + const mode = useTheme(); + + return ( +
    + { + // OpenAI persists via `conversation: threadId` linkage, so send + // only the latest message — full history lives server-side. + const latest = messages.slice(-1); + return fetch("/api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + threadId, + input: openAIConversationMessageFormat.toApi(latest), + }), + signal: abortController.signal, + }); + }} + streamProtocol={openAIResponsesAdapter()} + componentLibrary={openuiChatLibrary} + appRenderers={[codeArtifactRenderer]} + agentName="OpenUI Chat (Responses API)" + theme={{ mode }} + conversationStarters={{ + variant: "short", + options: [ + { + displayText: "Weather in Tokyo", + prompt: "What's the weather like in Tokyo right now?", + }, + { + displayText: "AAPL stock price", + prompt: "What's the current Apple stock price?", + }, + { + displayText: "Quicksort in Python", + prompt: + "Write a quicksort implementation in Python using create_code_artifact.", + }, + { + displayText: "Contact form", + prompt: + "Build me a contact form with name, email, topic, and message fields.", + }, + { + displayText: "Data table", + prompt: + "Show me a table of the top 5 programming languages by popularity with year created.", + }, + ], + }} + /> +
    + ); +} diff --git a/examples/openui-responses-chat/src/generated/system-prompt.txt b/examples/openui-responses-chat/src/generated/system-prompt.txt new file mode 100644 index 000000000..e98309971 --- /dev/null +++ b/examples/openui-responses-chat/src/generated/system-prompt.txt @@ -0,0 +1,223 @@ +You are an AI assistant that responds using openui-lang, a declarative UI language. Your ENTIRE response must be valid openui-lang code — no markdown, no explanations, just openui-lang. + +## Syntax Rules + +1. Each statement is on its own line: `identifier = Expression` +2. `root` is the entry point — every program must define `root = Card(...)` +3. Expressions are: strings ("..."), numbers, booleans (true/false), null, arrays ([...]), objects ({...}), or component calls TypeName(arg1, arg2, ...) +4. Use references for readability: define `name = ...` on one line, then use `name` later +5. EVERY variable (except root) MUST be referenced by at least one other variable. Unreferenced variables are silently dropped and will NOT render. Always include defined variables in their parent's children/items array. +6. Arguments are POSITIONAL (order matters, not names). Write `Stack([children], "row", "l")` NOT `Stack([children], direction: "row", gap: "l")` — colon syntax is NOT supported and silently breaks +7. Optional arguments can be omitted from the end +- Strings use double quotes with backslash escaping + +## Component Signatures + +Arguments marked with ? are optional. Sub-components can be inline or referenced; prefer references for better streaming. +Props typed `ActionExpression` accept an Action([@steps...]) expression. See the Action section for available steps (@ToAssistant, @OpenUrl). +Props marked `$binding` accept a `$variable` reference for two-way binding. + +### Content +CardHeader(title?: string, subtitle?: string) — Header with optional title and subtitle +TextContent(text: string, size?: "small" | "default" | "large" | "small-heavy" | "large-heavy") — Text block. Supports markdown. Optional size: "small" | "default" | "large" | "small-heavy" | "large-heavy". +MarkDownRenderer(textMarkdown: string, variant?: "clear" | "card" | "sunk") — Renders markdown text with optional container variant +Callout(variant: "info" | "warning" | "error" | "success" | "neutral", title: string, description: string, visible?: $binding) — Callout banner. Optional visible is a reactive $boolean — auto-dismisses after 3s by setting $visible to false. +TextCallout(variant?: "neutral" | "info" | "warning" | "success" | "danger", title?: string, description?: string) — Text callout with variant, title, and description +Image(alt: string, src?: string) — Image with alt text and optional URL +ImageBlock(src: string, alt?: string) — Image block with loading state +ImageGallery(images: {src: string, alt?: string, details?: string}[]) — Gallery grid of images with modal preview +CodeBlock(language: string, codeString: string) — Syntax-highlighted code block +Separator(orientation?: "horizontal" | "vertical", decorative?: boolean) — Visual divider between content sections + +### Tables +Table(columns: Col[]) — Data table — column-oriented. Each Col holds its own data array. +Col(label: string, data: any, type?: "string" | "number" | "action") — Column definition — holds label + data array + +### Charts (2D) +BarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Vertical bars; use for comparing values across categories with one or more series +LineChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Lines over categories; use for trends and continuous data over time +AreaChart(labels: string[], series: Series[], variant?: "linear" | "natural" | "step", xLabel?: string, yLabel?: string) — Filled area under lines; use for cumulative totals or volume trends over time +RadarChart(labels: string[], series: Series[]) — Spider/web chart; use for comparing multiple variables across one or more entities +HorizontalBarChart(labels: string[], series: Series[], variant?: "grouped" | "stacked", xLabel?: string, yLabel?: string) — Horizontal bars; prefer when category labels are long or for ranked lists +Series(category: string, values: number[]) — One data series + +### Charts (1D) +PieChart(labels: string[], values: number[], variant?: "pie" | "donut") — Circular slices; use plucked arrays: PieChart(data.categories, data.values) +RadialChart(labels: string[], values: number[]) — Radial bars; use plucked arrays: RadialChart(data.categories, data.values) +SingleStackedBarChart(labels: string[], values: number[]) — Single horizontal stacked bar; use plucked arrays: SingleStackedBarChart(data.categories, data.values) +Slice(category: string, value: number) — One slice with label and numeric value + +### Charts (Scatter) +ScatterChart(datasets: ScatterSeries[], xLabel?: string, yLabel?: string) — X/Y scatter plot; use for correlations, distributions, and clustering +ScatterSeries(name: string, points: Point[]) — Named dataset +Point(x: number, y: number, z?: number) — Data point with numeric coordinates + +### Forms +Form(name: string, buttons: Buttons, fields?: FormControl[]) — Form container with fields and explicit action buttons +FormControl(label: string, input: Input | TextArea | Select | DatePicker | Slider | CheckBoxGroup | RadioGroup, hint?: string) — Field with label, input component, and optional hint text +Label(text: string) — Text label +Input(name: string, placeholder?: string, type?: "text" | "email" | "password" | "number" | "url", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) +TextArea(name: string, placeholder?: string, rows?: number, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) +Select(name: string, items: SelectItem[], placeholder?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) +SelectItem(value: string, label: string) — Option for Select +DatePicker(name: string, mode?: "single" | "range", rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) +Slider(name: string, variant: "continuous" | "discrete", min: number, max: number, step?: number, defaultValue?: number[], label?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) — Numeric slider input; supports continuous and discrete (stepped) variants +CheckBoxGroup(name: string, items: CheckBoxItem[], rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding>) +CheckBoxItem(label: string, description: string, name: string, defaultChecked?: boolean) +RadioGroup(name: string, items: RadioItem[], defaultValue?: string, rules?: {required?: boolean, email?: boolean, url?: boolean, numeric?: boolean, min?: number, max?: number, minLength?: number, maxLength?: number, pattern?: string}, value?: $binding) +RadioItem(label: string, description: string, value: string) +SwitchGroup(name: string, items: SwitchItem[], variant?: "clear" | "card" | "sunk", value?: $binding>) — Group of switch toggles +SwitchItem(label?: string, description?: string, name: string, defaultChecked?: boolean) — Individual switch toggle +- Define EACH FormControl as its own reference — do NOT inline all controls in one array. +- NEVER nest Form inside Form. +- Form requires explicit buttons. Always pass a Buttons(...) reference as the third Form argument. +- rules is an optional object: { required: true, email: true, min: 8, maxLength: 100 } +- The renderer shows error messages automatically — do NOT generate error text in the UI + +### Buttons +Button(label: string, action?: ActionExpression, variant?: "primary" | "secondary" | "tertiary", type?: "normal" | "destructive", size?: "extra-small" | "small" | "medium" | "large") — Clickable button +Buttons(buttons: Button[], direction?: "row" | "column") — Group of Button components. direction: "row" (default) | "column". + +### Lists & Follow-ups +ListBlock(items: ListItem[], variant?: "number" | "image") — A list of items with number or image indicators. Each item can optionally have an action. +ListItem(title: string, subtitle?: string, image?: {src: string, alt: string}, actionLabel?: string, action?: ActionExpression) — Item in a ListBlock — displays a title with an optional subtitle and image. When action is provided, the item becomes clickable. +FollowUpBlock(items: FollowUpItem[]) — List of clickable follow-up suggestions placed at the end of a response +FollowUpItem(text: string) — Clickable follow-up suggestion — when clicked, sends text as user message +- Use ListBlock with ListItem references for numbered, clickable lists. +- Use FollowUpBlock with FollowUpItem references at the end of a response to suggest next actions. +- Clicking a ListItem or FollowUpItem sends its text to the LLM as a user message. +- Example: list = ListBlock([item1, item2]) item1 = ListItem("Option A", "Details about A") + +### Sections +SectionBlock(sections: SectionItem[], isFoldable?: boolean) — Collapsible accordion sections. Auto-opens sections as they stream in. Use SectionItem for each section. +SectionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock)[]) — Section with a label and collapsible content — used inside SectionBlock +- SectionBlock renders collapsible accordion sections that auto-open as they stream. +- Each section needs a unique `value` id, a `trigger` label, and a `content` array. +- Example: sections = SectionBlock([s1, s2]) s1 = SectionItem("intro", "Introduction", [content1]) +- Set isFoldable=false to render sections as flat headers instead of accordion. + +### Layout +Tabs(items: TabItem[]) — Tabbed container +TabItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is tab label, content is array of components +Accordion(items: AccordionItem[]) — Collapsible sections +AccordionItem(value: string, trigger: string, content: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[]) — value is unique id, trigger is section title +Steps(items: StepsItem[]) — Step-by-step guide +StepsItem(title: string, details: string) — title and details text for one step +Carousel(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps)[][], variant?: "card" | "sunk") — Horizontal scrollable carousel +- Use Tabs to present alternative views — each TabItem has a value id, trigger label, and content array. +- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]]) +- IMPORTANT: Every slide in a Carousel must have the same structure — same component types in the same order. +- For image carousels use: [[title, image, description, tags], ...] — every slide must follow this exact pattern. +- Use real, publicly accessible image URLs (e.g. https://picsum.photos/seed/KEYWORD/800/500). Never hallucinate image URLs. + +### Data Display +TagBlock(tags: string[]) — tags is an array of strings +Tag(text: string, icon?: string, size?: "sm" | "md" | "lg", variant?: "neutral" | "info" | "success" | "warning" | "danger") — Styled tag/badge with optional icon and variant + +### Other +Card(children: (TextContent | MarkDownRenderer | CardHeader | Callout | TextCallout | CodeBlock | Image | ImageBlock | ImageGallery | Separator | HorizontalBarChart | RadarChart | PieChart | RadialChart | SingleStackedBarChart | ScatterChart | AreaChart | BarChart | LineChart | Table | TagBlock | Form | Buttons | Steps | ListBlock | FollowUpBlock | SectionBlock | Tabs | Carousel)[]) — Vertical container for all content in a chat response. Children stack top to bottom automatically. + +## Action — Button Behavior + +Action([@steps...]) wires button clicks to operations. Steps are @-prefixed built-in actions. Steps execute in order. +Buttons without an explicit Action prop automatically send their label to the assistant (equivalent to Action([@ToAssistant(label)])). + +Available steps: +- @ToAssistant("message") — Send a message to the assistant (for conversational buttons like "Tell me more", "Explain this") +- @OpenUrl("https://...") — Navigate to a URL + +Example — simple nav: +``` +viewBtn = Button("View", Action([@OpenUrl("https://example.com")])) +``` + +- Action can be assigned to a variable or inlined: Button("Go", onSubmit) and Button("Go", Action([...])) both work + +## Hoisting & Streaming (CRITICAL) + +openui-lang supports hoisting: a reference can be used BEFORE it is defined. The parser resolves all references after the full input is parsed. + +During streaming, the output is re-parsed on every chunk. Undefined references are temporarily unresolved and appear once their definitions stream in. This creates a progressive top-down reveal — structure first, then data fills in. + +**Recommended statement order for optimal streaming:** +1. `root = Card(...)` — UI shell appears immediately +2. Component definitions — fill in as they stream +3. Data values — leaf content last + +Always write the root = Card(...) statement first so the UI shell appears immediately, even before child data has streamed in. + +## Examples + +Example 1 — Table with follow-ups: + +root = Card([title, tbl, followUps]) +title = TextContent("Top Languages", "large-heavy") +tbl = Table([Col("Language", langs), Col("Users (M)", users), Col("Year", years)]) +langs = ["Python", "JavaScript", "Java"] +users = [15.7, 14.2, 12.1] +years = [1991, 1995, 1995] +followUps = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Tell me more about Python") +fu2 = FollowUpItem("Show me a JavaScript comparison") + +Example 2 — Clickable list: + +root = Card([title, list]) +title = TextContent("Choose a topic", "large-heavy") +list = ListBlock([item1, item2, item3]) +item1 = ListItem("Getting started", "New to the platform? Start here.") +item2 = ListItem("Advanced features", "Deep dives into powerful capabilities.") +item3 = ListItem("Troubleshooting", "Common issues and how to fix them.") + +Example 3 — Image carousel with consistent slides + follow-ups: + +root = Card([header, carousel, followups]) +header = CardHeader("Featured Destinations", "Discover highlights and best time to visit") +carousel = Carousel([[t1, img1, d1, tags1], [t2, img2, d2, tags2], [t3, img3, d3, tags3]], "card") +t1 = TextContent("Paris, France", "large-heavy") +img1 = ImageBlock("https://picsum.photos/seed/paris/800/500", "Eiffel Tower at night") +d1 = TextContent("City of light — best Apr–Jun and Sep–Oct.", "default") +tags1 = TagBlock(["Landmark", "City Break", "Culture"]) +t2 = TextContent("Kyoto, Japan", "large-heavy") +img2 = ImageBlock("https://picsum.photos/seed/kyoto/800/500", "Bamboo grove in Arashiyama") +d2 = TextContent("Temples and bamboo groves — best Mar–Apr and Nov.", "default") +tags2 = TagBlock(["Temples", "Autumn", "Culture"]) +t3 = TextContent("Machu Picchu, Peru", "large-heavy") +img3 = ImageBlock("https://picsum.photos/seed/machupicchu/800/500", "Inca citadel in the clouds") +d3 = TextContent("High-altitude Inca citadel — best May–Sep.", "default") +tags3 = TagBlock(["Andes", "Hike", "UNESCO"]) +followups = FollowUpBlock([fu1, fu2]) +fu1 = FollowUpItem("Show me only beach destinations") +fu2 = FollowUpItem("Turn this into a comparison table") + +Example 4 — Form with validation: + +root = Card([title, form]) +title = TextContent("Contact Us", "large-heavy") +form = Form("contact", btns, [nameField, emailField, msgField]) +nameField = FormControl("Name", Input("name", "Your name", "text", { required: true, minLength: 2 })) +emailField = FormControl("Email", Input("email", "you@example.com", "email", { required: true, email: true })) +msgField = FormControl("Message", TextArea("message", "Tell us more...", 4, { required: true, minLength: 10 })) +btns = Buttons([Button("Submit", Action([@ToAssistant("Submit")]), "primary")]) + +## Important Rules +- When asked about data, generate realistic/plausible data +- Choose components that best represent the content (tables for comparisons, charts for trends, forms for input, etc.) + +## Final Verification +Before finishing, walk your output and verify: +1. root = Card(...) is the FIRST line (for optimal streaming). +2. Every referenced name is defined. Every defined name (other than root) is reachable from root. + +- Every response is a single Card(children) — children stack vertically automatically. No layout params are needed on Card. +- Card is the only layout container. Do NOT use Stack. Use Tabs to switch between sections, Carousel for horizontal scroll. +- Use FollowUpBlock at the END of a Card to suggest what the user can do or ask next. +- Use ListBlock when presenting a set of options or steps the user can click to select. +- Use SectionBlock to group long responses into collapsible sections — good for reports, FAQs, and structured content. +- Use SectionItem inside SectionBlock: each item needs a unique value id, a trigger (header label), and a content array. +- Carousel takes an array of slides, where each slide is an array of content: carousel = Carousel([[t1, img1], [t2, img2]]) +- IMPORTANT: Every slide in a Carousel must use the same component structure in the same order — e.g. all slides: [title, image, description, tags]. +- For image carousels, always use real accessible URLs like https://picsum.photos/seed/KEYWORD/800/500. Never hallucinate or invent image URLs. +- For forms, define one FormControl reference per field so controls can stream progressively. +- For forms, always provide the second Form argument with Buttons(...) actions: Form(name, buttons, fields). +- Never nest Form inside Form. diff --git a/examples/openui-responses-chat/src/hooks/use-system-theme.tsx b/examples/openui-responses-chat/src/hooks/use-system-theme.tsx new file mode 100644 index 000000000..7c110c21d --- /dev/null +++ b/examples/openui-responses-chat/src/hooks/use-system-theme.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { createContext, useContext, useLayoutEffect, useState } from "react"; + +type ThemeMode = "light" | "dark"; + +interface ThemeContextType { + mode: ThemeMode; +} + +const ThemeContext = createContext(undefined); + +function getSystemMode(): ThemeMode { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; +} + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [mode, setMode] = useState(getSystemMode); + + useLayoutEffect(() => { + const mq = window.matchMedia("(prefers-color-scheme: dark)"); + const handler = (e: MediaQueryListEvent) => setMode(e.matches ? "dark" : "light"); + mq.addEventListener("change", handler); + return () => mq.removeEventListener("change", handler); + }, []); + + useLayoutEffect(() => { + document.body.setAttribute("data-theme", mode); + }, [mode]); + + return {children}; +} + +export function useTheme(): ThemeMode { + const ctx = useContext(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx.mode; +} diff --git a/examples/openui-responses-chat/src/lib/codeArtifactRenderer.tsx b/examples/openui-responses-chat/src/lib/codeArtifactRenderer.tsx new file mode 100644 index 000000000..98d9eae4d --- /dev/null +++ b/examples/openui-responses-chat/src/lib/codeArtifactRenderer.tsx @@ -0,0 +1,101 @@ +"use client"; + +import { defineArtifactRenderer } from "@openuidev/react-headless"; +import { CodeBlock } from "@openuidev/react-ui"; + +type CodeArtifactProps = { + language: string; + title: string; + code: string; +}; + +// Best-effort extractor for partial JSON args during streaming. +// Strict mode + property order (language → title → code) means we can pull +// completed fields out even when the tail of `code` hasn't streamed in yet. +function parseCodeArgs(raw: string): CodeArtifactProps | null { + if (!raw) return null; + + try { + const parsed = JSON.parse(raw) as Partial; + return { + language: parsed.language ?? "", + title: parsed.title ?? "", + code: parsed.code ?? "", + }; + } catch { + // Fall through to partial extraction + } + + const language = raw.match(/"language"\s*:\s*"([^"]*)/)?.[1] ?? ""; + const title = raw.match(/"title"\s*:\s*"([^"]*)/)?.[1] ?? ""; + // Capture from `"code":"` up to the unescaped closing `"` (or end of buffer). + const codeMatch = raw.match(/"code"\s*:\s*"((?:[^"\\]|\\.)*)/); + const code = codeMatch ? unescapeJSONString(codeMatch[1]) : ""; + + if (!language && !title && !code) return null; + return { language, title, code }; +} + +function unescapeJSONString(s: string): string { + return s + .replace(/\\n/g, "\n") + .replace(/\\t/g, "\t") + .replace(/\\r/g, "\r") + .replace(/\\"/g, '"') + .replace(/\\\\/g, "\\"); +} + +export const codeArtifactRenderer = defineArtifactRenderer({ + toolName: "create_code_artifact", + parser: ({ args }) => { + if (typeof args !== "string") return null; + return parseCodeArgs(args); + }, + meta: (props) => + props.title ? { id: `code:${props.title}`, version: 1, heading: props.title } : null, + preview: (props, { isStreaming, isActive, toggle }) => ( + + ), + actual: (props) => ( +
    + +
    + ), +}); diff --git a/examples/openui-responses-chat/src/lib/thread-index.ts b/examples/openui-responses-chat/src/lib/thread-index.ts new file mode 100644 index 000000000..c89d3f721 --- /dev/null +++ b/examples/openui-responses-chat/src/lib/thread-index.ts @@ -0,0 +1,59 @@ +import { promises as fs } from "fs"; +import { join } from "path"; + +const DATA_DIR = join(process.cwd(), ".data"); +const INDEX_FILE = join(DATA_DIR, "threads.json"); + +export type IndexedThread = { + id: string; + title: string; + createdAt: string; +}; + +async function ensureFile(): Promise { + await fs.mkdir(DATA_DIR, { recursive: true }); + try { + await fs.access(INDEX_FILE); + } catch { + await fs.writeFile(INDEX_FILE, "[]"); + } +} + +export async function readIndex(): Promise { + await ensureFile(); + const raw = await fs.readFile(INDEX_FILE, "utf-8"); + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +async function writeIndex(threads: IndexedThread[]): Promise { + await ensureFile(); + await fs.writeFile(INDEX_FILE, JSON.stringify(threads, null, 2)); +} + +export async function addThread(thread: IndexedThread): Promise { + const all = await readIndex(); + all.unshift(thread); + await writeIndex(all); +} + +export async function removeThread(id: string): Promise { + const all = await readIndex(); + await writeIndex(all.filter((t) => t.id !== id)); +} + +export async function updateIndexEntry( + id: string, + patch: Partial, +): Promise { + const all = await readIndex(); + const idx = all.findIndex((t) => t.id === id); + if (idx === -1) return null; + all[idx] = { ...all[idx], ...patch }; + await writeIndex(all); + return all[idx]; +} diff --git a/examples/openui-responses-chat/src/lib/tools.ts b/examples/openui-responses-chat/src/lib/tools.ts new file mode 100644 index 000000000..7983ee124 --- /dev/null +++ b/examples/openui-responses-chat/src/lib/tools.ts @@ -0,0 +1,194 @@ +import type { FunctionTool } from "openai/resources/responses/responses"; + +export const tools: FunctionTool[] = [ + { + type: "function", + name: "get_weather", + description: "Get current weather for a location.", + parameters: { + type: "object", + properties: { location: { type: "string", description: "City name" } }, + required: ["location"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "get_stock_price", + description: "Get stock price for a ticker symbol.", + parameters: { + type: "object", + properties: { + symbol: { type: "string", description: "Ticker symbol, e.g. AAPL" }, + }, + required: ["symbol"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "calculate", + description: "Evaluate a math expression.", + parameters: { + type: "object", + properties: { + expression: { type: "string", description: "Math expression to evaluate" }, + }, + required: ["expression"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "search_web", + description: "Search the web for information.", + parameters: { + type: "object", + properties: { query: { type: "string", description: "Search query" } }, + required: ["query"], + additionalProperties: false, + }, + strict: true, + }, + { + type: "function", + name: "create_code_artifact", + description: + "Render a code snippet artifact for the user. Use this whenever the user asks for code.", + parameters: { + type: "object", + // Property order is preserved by strict mode; keep `code` last so the + // frontend can extract earlier fields (language, title) from partial + // streaming JSON before `code` is complete. + properties: { + language: { + type: "string", + description: "Language identifier (e.g. 'python', 'typescript', 'rust').", + }, + title: { + type: "string", + description: "Short title for the artifact.", + }, + code: { + type: "string", + description: "The code content.", + }, + }, + required: ["language", "title", "code"], + additionalProperties: false, + }, + strict: true, + }, +]; + +type ToolImpl = (args: Record) => Promise; + +const knownTemps: Record = { + tokyo: 22, + "san francisco": 18, + london: 14, + "new york": 25, + paris: 19, + sydney: 27, + mumbai: 33, + berlin: 16, +}; +const conditions = ["Sunny", "Partly Cloudy", "Cloudy", "Light Rain", "Clear Skies"]; + +const knownPrices: Record = { + AAPL: 189.84, + GOOGL: 141.8, + TSLA: 248.42, + MSFT: 378.91, + AMZN: 178.25, + NVDA: 875.28, + META: 485.58, +}; + +export const toolImpls: Record = { + get_weather: async ({ location }) => { + const loc = String(location ?? ""); + const temp = knownTemps[loc.toLowerCase()] ?? Math.floor(Math.random() * 30 + 5); + const condition = conditions[Math.floor(Math.random() * conditions.length)]; + return { + location: loc, + temperature_celsius: temp, + temperature_fahrenheit: Math.round(temp * 1.8 + 32), + condition, + humidity_percent: Math.floor(Math.random() * 40 + 40), + wind_speed_kmh: Math.floor(Math.random() * 25 + 5), + forecast: [ + { day: "Tomorrow", high: temp + 2, low: temp - 4, condition: "Partly Cloudy" }, + { day: "Day After", high: temp + 1, low: temp - 3, condition: "Sunny" }, + ], + }; + }, + + get_stock_price: async ({ symbol }) => { + const s = String(symbol ?? "").toUpperCase(); + const price = knownPrices[s] ?? Math.floor(Math.random() * 500 + 20); + const change = parseFloat((Math.random() * 8 - 4).toFixed(2)); + return { + symbol: s, + price: parseFloat((price + change).toFixed(2)), + change, + change_percent: parseFloat(((change / price) * 100).toFixed(2)), + volume: `${(Math.random() * 50 + 10).toFixed(1)}M`, + day_high: parseFloat((price + Math.abs(change) + 1.5).toFixed(2)), + day_low: parseFloat((price - Math.abs(change) - 1.2).toFixed(2)), + }; + }, + + calculate: async ({ expression }) => { + const expr = String(expression ?? ""); + try { + const sanitized = expr.replace(/[^0-9+\-*/().%\s,Math.sqrtpowabsceilfloorround]/g, ""); + const result = new Function(`return (${sanitized})`)(); + return { expression: expr, result: Number(result) }; + } catch { + return { expression: expr, error: "Invalid expression" }; + } + }, + + search_web: async ({ query }) => { + const q = String(query ?? ""); + return { + query: q, + results: [ + { + title: `Top result for "${q}"`, + snippet: `Comprehensive overview of ${q} with the latest information.`, + }, + { + title: `${q} - Latest News`, + snippet: `Recent developments and updates related to ${q}.`, + }, + { + title: `Understanding ${q}`, + snippet: `An in-depth guide explaining everything about ${q}.`, + }, + ], + }; + }, + + // No-op: the artifact lives entirely in the tool-call args. The frontend + // renders from streaming args; this echo is just a marker for the LLM that + // the artifact was delivered. + create_code_artifact: async (args) => ({ ok: true, ...args }), +}; + +export async function executeTool(name: string, args: string): Promise { + const impl = toolImpls[name]; + if (!impl) return JSON.stringify({ error: `Tool '${name}' not implemented` }); + let parsed: Record = {}; + try { + parsed = JSON.parse(args); + } catch { + return JSON.stringify({ error: "Invalid arguments JSON" }); + } + const result = await impl(parsed); + return typeof result === "string" ? result : JSON.stringify(result); +} diff --git a/examples/openui-responses-chat/src/library.ts b/examples/openui-responses-chat/src/library.ts new file mode 100644 index 000000000..c7ceecfc1 --- /dev/null +++ b/examples/openui-responses-chat/src/library.ts @@ -0,0 +1 @@ +export { openuiChatLibrary as library, openuiChatPromptOptions as promptOptions } from "@openuidev/react-ui/genui-lib"; diff --git a/examples/openui-responses-chat/tsconfig.json b/examples/openui-responses-chat/tsconfig.json new file mode 100644 index 000000000..cf9c65d3e --- /dev/null +++ b/examples/openui-responses-chat/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts" + ], + "exclude": ["node_modules"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 46f5095dc..b37660d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -669,6 +669,64 @@ importers: specifier: ~5.9.2 version: 5.9.3 + examples/openui-responses-chat: + dependencies: + '@openuidev/react-headless': + specifier: workspace:* + version: link:../../packages/react-headless + '@openuidev/react-lang': + specifier: workspace:* + version: link:../../packages/react-lang + '@openuidev/react-ui': + specifier: workspace:* + version: link:../../packages/react-ui + lucide-react: + specifier: ^0.575.0 + version: 0.575.0(react@19.2.3) + next: + specifier: 16.1.6 + version: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.89.2) + openai: + specifier: ^6.22.0 + version: 6.34.0(ws@8.20.0)(zod@4.3.6) + react: + specifier: 19.2.3 + version: 19.2.3 + react-dom: + specifier: 19.2.3 + version: 19.2.3(react@19.2.3) + zod: + specifier: ^4.0.0 + version: 4.3.6 + devDependencies: + '@openuidev/cli': + specifier: workspace:* + version: link:../../packages/openui-cli + '@tailwindcss/postcss': + specifier: ^4 + version: 4.2.1 + '@types/node': + specifier: ^20 + version: 20.19.35 + '@types/react': + specifier: ^19 + version: 19.2.14 + '@types/react-dom': + specifier: ^19 + version: 19.2.3(@types/react@19.2.14) + eslint: + specifier: ^9 + version: 9.29.0(jiti@2.6.1) + eslint-config-next: + specifier: 16.1.6 + version: 16.1.6(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3) + tailwindcss: + specifier: ^4 + version: 4.2.2 + typescript: + specifier: ^5 + version: 5.9.3 + examples/react-email: dependencies: '@openuidev/cli': @@ -15555,6 +15613,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -27230,7 +27289,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)): + eslint-module-utils@2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)): dependencies: debug: 3.2.7 optionalDependencies: @@ -27252,7 +27311,7 @@ snapshots: doctrine: 2.1.0 eslint: 9.29.0(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0)(eslint@9.29.0(jiti@2.6.1)))(eslint@9.29.0(jiti@2.6.1)) + eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.56.1(eslint@9.29.0(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.29.0(jiti@2.6.1)) hasown: 2.0.2 is-core-module: 2.16.1 is-glob: 4.0.3 From 134f89612c1a2829c6f99ed88ce7d2b190b29d8a Mon Sep 17 00:00:00 2001 From: abhithesys Date: Mon, 18 May 2026 19:45:44 +0530 Subject: [PATCH 07/88] feat(sdk)!: ChatProvider adapter-based API (storage + llm) Collapse the legacy flat-props ChatProvider config behind two adapter interfaces: - ChatStorage (thread, pinning?, share?) drives the storage channel - ChatLLM (send, streamProtocol) drives the LLM channel Drops apiUrl, threadApiUrl, processMessage, loadThread, fetchThreadList, createThread/deleteThread/updateThread props, messageFormat, and the top-level streamProtocol prop. Their behavior is now configured per adapter; the bundled fetchLLM factory captures the previous default fetch + messageFormat path. react-headless: - Add src/adapters: ChatStorage / ChatLLM / Thread/Pinning/ShareStorage types, fetchLLM factory, internal _defaultStorage (in-memory, not exported) used when ChatProvider has no storage prop - Rewrite createChatStore to consume { storage, llm }; thread CRUD now delegates to storage.thread.*, message send to llm.send - ChatProvider props collapse to { storage?, llm, appRenderers, children } - Guard process.env reads with `typeof process !== "undefined"` so the provider/registry/detailed-view store work in non-Node runtimes - Migrate createChatStore / threadContextSwitch / detailedViewThreadSwitch tests behind a shared makeStore() helper; drop the apiUrl / threadApiUrl / messageFormat / ephemeral-thread describe blocks (those paths now live in the fetchLLM adapter) react-ui: - withChatProvider HOC: replace prop-key filtering with a destructure over the new shape - Add shared __test-helpers/mockChat.ts (makeMockStorage, makeMockLLM, mockSSEResponse) used by every story - Migrate Shell, BottomTray, CopilotShell, and OpenUIChat stories to examples/openui-artifact-demo: import fetchLLM from react-headless. --- .../openui-artifact-demo/src/app/page.tsx | 18 +- .../src/adapters/_defaultStorage.ts | 48 ++ .../react-headless/src/adapters/fetchLLM.ts | 45 ++ packages/react-headless/src/adapters/index.ts | 13 + packages/react-headless/src/adapters/types.ts | 49 ++ packages/react-headless/src/index.ts | 12 + .../src/store/AppRenderersContext.ts | 4 +- .../react-headless/src/store/ChatProvider.tsx | 18 +- .../store/__tests__/__helpers/makeStore.ts | 43 ++ .../store/__tests__/createChatStore.test.ts | 524 ++---------------- .../detailedViewThreadSwitch.test.ts | 10 +- .../__tests__/threadContextSwitch.test.ts | 10 +- .../src/store/createChatStore.ts | 134 +---- .../src/store/createDetailedViewStore.ts | 3 +- packages/react-headless/src/store/types.ts | 61 +- .../react-ui/src/__test-helpers/mockChat.ts | 62 +++ .../BottomTray/stories/BottomTray.stories.tsx | 206 +++---- .../CopilotShell/stories/Shell.stories.tsx | 146 ++--- .../OpenUIChat/stories/OpenUIChat.stories.tsx | 49 +- .../OpenUIChat/withChatProvider.tsx | 64 +-- .../Shell/stories/Shell.stories.tsx | 175 ++---- 21 files changed, 617 insertions(+), 1077 deletions(-) create mode 100644 packages/react-headless/src/adapters/_defaultStorage.ts create mode 100644 packages/react-headless/src/adapters/fetchLLM.ts create mode 100644 packages/react-headless/src/adapters/index.ts create mode 100644 packages/react-headless/src/adapters/types.ts create mode 100644 packages/react-headless/src/store/__tests__/__helpers/makeStore.ts create mode 100644 packages/react-ui/src/__test-helpers/mockChat.ts diff --git a/examples/openui-artifact-demo/src/app/page.tsx b/examples/openui-artifact-demo/src/app/page.tsx index 3967ecfa6..f8fc9a80f 100644 --- a/examples/openui-artifact-demo/src/app/page.tsx +++ b/examples/openui-artifact-demo/src/app/page.tsx @@ -5,7 +5,7 @@ import { useTheme } from "@/hooks/use-system-theme"; import { codeBlockRenderer } from "@/lib/codeBlockRenderer"; import { enrichedArgsAdapter } from "@/lib/enrichedArgsAdapter"; import { artifactDemoLibrary } from "@/library"; -import { openAIMessageFormat } from "@openuidev/react-headless"; +import { fetchLLM, openAIMessageFormat } from "@openuidev/react-headless"; import { FullScreen } from "@openuidev/react-ui"; export default function Page() { @@ -14,17 +14,11 @@ export default function Page() { return (
    { - return fetch("/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messages: openAIMessageFormat.toApi(messages), - }), - signal: abortController.signal, - }); - }} - streamProtocol={enrichedArgsAdapter()} + llm={fetchLLM({ + url: "/api/chat", + streamAdapter: enrichedArgsAdapter(), + messageFormat: openAIMessageFormat, + })} componentLibrary={artifactDemoLibrary} appRenderers={[codeBlockRenderer]} agentName="Artifact Demo" diff --git a/packages/react-headless/src/adapters/_defaultStorage.ts b/packages/react-headless/src/adapters/_defaultStorage.ts new file mode 100644 index 000000000..85fec9617 --- /dev/null +++ b/packages/react-headless/src/adapters/_defaultStorage.ts @@ -0,0 +1,48 @@ +import type { Thread } from "../store/types"; +import type { Message, UserMessage } from "../types/message"; +import type { ChatStorage } from "./types"; + +/** + * Internal default storage — in-memory, no persistence across reload. + * Used by `` when no `storage` prop is provided. Not exported + * from the package; callers who want explicit in-memory behavior should + * construct their own adapter object. + */ +export function createDefaultInMemoryStorage(): ChatStorage { + let threads: Thread[] = []; + const messagesByThread = new Map(); + + return { + thread: { + async listThreads() { + return { threads }; + }, + async createThread(firstMessage: UserMessage) { + const thread: Thread = { + id: crypto.randomUUID(), + title: + typeof firstMessage.content === "string" + ? firstMessage.content.slice(0, 40) || "New thread" + : "New thread", + createdAt: new Date().toISOString(), + }; + threads = [thread, ...threads]; + messagesByThread.set(thread.id, [ + { ...firstMessage, id: firstMessage.id ?? crypto.randomUUID() }, + ]); + return thread; + }, + async getMessages(threadId: string) { + return messagesByThread.get(threadId) ?? []; + }, + async updateThread(thread: Thread) { + threads = threads.map((t) => (t.id === thread.id ? thread : t)); + return thread; + }, + async deleteThread(id: string) { + threads = threads.filter((t) => t.id !== id); + messagesByThread.delete(id); + }, + }, + }; +} diff --git a/packages/react-headless/src/adapters/fetchLLM.ts b/packages/react-headless/src/adapters/fetchLLM.ts new file mode 100644 index 000000000..946c907c6 --- /dev/null +++ b/packages/react-headless/src/adapters/fetchLLM.ts @@ -0,0 +1,45 @@ +import { identityMessageFormat, type MessageFormat } from "../types/messageFormat"; +import type { StreamProtocolAdapter } from "../types/stream"; +import type { ChatLLM } from "./types"; + +export interface FetchLLMOptions { + /** Endpoint that accepts POST'd messages and returns a streaming Response. */ + url: string; + /** Stream protocol adapter for parsing the response body (e.g., agUIAdapter, openAIAdapter). */ + streamAdapter: StreamProtocolAdapter; + /** Wire-format conversion for outgoing messages. Defaults to identity (canonical Message). */ + messageFormat?: MessageFormat; + /** Extra headers merged into the request. */ + headers?: Record; + /** Override fetch implementation (for tests, custom auth wrappers, etc.). */ + fetch?: typeof fetch; +} + +/** + * Generic HTTP-based LLM adapter. POSTs `{ threadId, messages }` (in the chosen wire format) + * to `url` and returns the streaming `Response` for downstream processing. + */ +export function fetchLLM({ + url, + streamAdapter, + messageFormat = identityMessageFormat, + headers, + fetch: customFetch, +}: FetchLLMOptions): ChatLLM { + const fetchImpl = customFetch ?? globalThis.fetch.bind(globalThis); + return { + send: ({ threadId, messages, signal }) => { + const wire = messageFormat.toApi(messages); + return fetchImpl(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify({ threadId, messages: wire }), + signal, + }); + }, + streamProtocol: streamAdapter, + }; +} diff --git a/packages/react-headless/src/adapters/index.ts b/packages/react-headless/src/adapters/index.ts new file mode 100644 index 000000000..83fc4263d --- /dev/null +++ b/packages/react-headless/src/adapters/index.ts @@ -0,0 +1,13 @@ +export type { + ChatLLM, + ChatStorage, + PinningStorage, + ShareStorage, + ShareTarget, + ThreadStorage, +} from "./types"; + +export { fetchLLM } from "./fetchLLM"; +export type { FetchLLMOptions } from "./fetchLLM"; + +// _defaultStorage is intentionally NOT exported — it's internal to ChatProvider. diff --git a/packages/react-headless/src/adapters/types.ts b/packages/react-headless/src/adapters/types.ts new file mode 100644 index 000000000..f28a43c1e --- /dev/null +++ b/packages/react-headless/src/adapters/types.ts @@ -0,0 +1,49 @@ +import type { Thread } from "../store/types"; +import type { Message, UserMessage } from "../types/message"; +import type { StreamProtocolAdapter } from "../types/stream"; + +// ── Storage adapter interfaces ── + +export interface ThreadStorage { + listThreads(cursor?: string): Promise<{ threads: Thread[]; nextCursor?: string }>; + createThread(firstMessage: UserMessage): Promise; + getMessages(threadId: string): Promise; + updateThread(thread: Thread): Promise; + deleteThread(id: string): Promise; +} + +export interface PinningStorage { + load(): Promise; + save(ids: string[]): Promise; +} + +export type ShareTarget = + | { kind: "thread"; id: string } + | { kind: "artifact"; id: string }; + +export interface ShareStorage { + createShare(target: ShareTarget): Promise<{ url: string }>; +} + +export interface ChatStorage { + thread: ThreadStorage; + pinning?: PinningStorage; + share?: ShareStorage; + // artifact, search, ... — added as features land +} + +// ── LLM adapter interface ── + +export interface ChatLLM { + send(params: { + threadId: string; + messages: Message[]; + signal: AbortSignal; + }): Promise; + streamProtocol: StreamProtocolAdapter; +} + +// Re-exports kept here so adapter consumers can import everything in one shot. +export type { Thread } from "../store/types"; +export type { Message, UserMessage } from "../types/message"; +export type { StreamProtocolAdapter } from "../types/stream"; diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 76baaad45..dbab3e801 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -26,6 +26,18 @@ export { } from "./stream/formats"; export { processStreamedMessage } from "./stream/processStreamedMessage"; +// ── Adapter interfaces + factories ── +export type { + ChatLLM, + ChatStorage, + FetchLLMOptions, + PinningStorage, + ShareStorage, + ShareTarget, + ThreadStorage, +} from "./adapters"; +export { fetchLLM } from "./adapters"; + export type { AppRendererConfig, AppRendererControls, diff --git a/packages/react-headless/src/store/AppRenderersContext.ts b/packages/react-headless/src/store/AppRenderersContext.ts index 19cfc54b4..6911a3ac4 100644 --- a/packages/react-headless/src/store/AppRenderersContext.ts +++ b/packages/react-headless/src/store/AppRenderersContext.ts @@ -36,7 +36,7 @@ export function buildAppRendererRegistry( for (const config of configs) { if (typeof config.toolName === "string") { if (literal.has(config.toolName)) { - if (process.env["NODE_ENV"] !== "production") { + if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") { console.warn( `[OpenUI] AppRenderer for toolName "${config.toolName}" was ignored ` + `(already registered earlier in the array).`, @@ -72,7 +72,7 @@ export function lookupAppRenderer( ): AppRendererConfig | null { const literal = registry.literal.get(toolName); - if (process.env["NODE_ENV"] !== "production") { + if (typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production") { const matches: AppRendererConfig[] = []; if (literal) matches.push(literal); for (const r of registry.regex) { diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index eac043f74..54cf3cdd8 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type FC } from "react"; +import { createDefaultInMemoryStorage } from "../adapters/_defaultStorage"; import { AppRenderersContext, buildAppRendererRegistry } from "./AppRenderersContext"; import { ChatContext } from "./ChatContext"; import { createChatStore } from "./createChatStore"; @@ -8,8 +9,18 @@ import { DetailedViewContext } from "./DetailedViewContext"; import { ThreadContextContext } from "./ThreadContextContext"; import type { ChatProviderProps } from "./types"; -export const ChatProvider: FC = ({ children, appRenderers, ...config }) => { - const [chatStore] = useState(() => createChatStore(config)); +export const ChatProvider: FC = ({ + children, + storage, + llm, + appRenderers, +}) => { + const [chatStore] = useState(() => + createChatStore({ + storage: storage ?? createDefaultInMemoryStorage(), + llm, + }), + ); const [detailedViewStore] = useState(() => createDetailedViewStore()); const [threadContextStore] = useState(() => createThreadContextStore()); const [appRendererRegistry] = useState(() => buildAppRendererRegistry(appRenderers ?? [])); @@ -20,7 +31,8 @@ export const ChatProvider: FC = ({ children, appRenderers, .. const hasWarnedRef = useRef(false); useEffect(() => { if ( - process.env["NODE_ENV"] !== "production" && + typeof process !== "undefined" && + process.env?.["NODE_ENV"] !== "production" && !hasWarnedRef.current && initialAppRenderersRef.current !== appRenderers ) { diff --git a/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts b/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts new file mode 100644 index 000000000..065d0bdc3 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/__helpers/makeStore.ts @@ -0,0 +1,43 @@ +import { vi } from "vitest"; +import type { ChatLLM, ChatStorage, ThreadStorage } from "../../../adapters/types"; +import { createChatStore } from "../../createChatStore"; + +export interface MakeStoreOverrides extends Partial { + send?: ChatLLM["send"]; + streamProtocol?: ChatLLM["streamProtocol"]; + pinning?: ChatStorage["pinning"]; + share?: ChatStorage["share"]; +} + +/** + * Build a chat store with mocked storage + LLM adapters. Per-field overrides + * for any storage method or `llm.send`/`llm.streamProtocol`. Anything not + * overridden gets a vi.fn() mock with a sensible default return. + */ +export function makeStore(overrides: MakeStoreOverrides = {}) { + const { send, streamProtocol, pinning, share, ...threadOverrides } = overrides; + + const storage: ChatStorage = { + thread: { + listThreads: vi.fn().mockResolvedValue({ threads: [] }), + createThread: vi.fn().mockResolvedValue({ + id: "new", + title: "New", + createdAt: new Date().toISOString(), + }), + getMessages: vi.fn().mockResolvedValue([]), + updateThread: vi.fn(async (t) => t), + deleteThread: vi.fn().mockResolvedValue(undefined), + ...threadOverrides, + }, + pinning, + share, + }; + + const llm: ChatLLM = { + send: send ?? vi.fn().mockResolvedValue(new Response("", { status: 200 })), + streamProtocol: streamProtocol ?? { parse: async function* () {} }, + }; + + return createChatStore({ storage, llm }); +} diff --git a/packages/react-headless/src/store/__tests__/createChatStore.test.ts b/packages/react-headless/src/store/__tests__/createChatStore.test.ts index c50d0dd1c..1d28dfbab 100644 --- a/packages/react-headless/src/store/__tests__/createChatStore.test.ts +++ b/packages/react-headless/src/store/__tests__/createChatStore.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { createChatStore } from "../createChatStore"; import type { Message, Thread, UserMessage } from "../types"; +import { makeStore } from "./__helpers/makeStore"; // ── Helpers ── @@ -25,9 +25,9 @@ describe("createChatStore", () => { describe("loadThreads", () => { it("fetches threads and sets state", async () => { const threads = [makeThread("t1"), makeThread("t2", 1)]; - const fetchThreadList = vi.fn().mockResolvedValue({ threads }); + const listThreads = vi.fn().mockResolvedValue({ threads }); - const store = createChatStore({ fetchThreadList, processMessage: vi.fn() }); + const store = makeStore({ listThreads }); expect(store.getState().isLoadingThreads).toBe(false); store.getState().loadThreads(); @@ -38,14 +38,14 @@ describe("createChatStore", () => { expect(store.getState().isLoadingThreads).toBe(false); expect(store.getState().threads).toHaveLength(2); expect(store.getState().hasMoreThreads).toBe(false); - expect(fetchThreadList).toHaveBeenCalledWith(undefined); + expect(listThreads).toHaveBeenCalledWith(undefined); }); it("sets threadListError on failure", async () => { const error = new Error("network"); - const fetchThreadList = vi.fn().mockRejectedValue(error); + const listThreads = vi.fn().mockRejectedValue(error); - const store = createChatStore({ fetchThreadList, processMessage: vi.fn() }); + const store = makeStore({ listThreads }); store.getState().loadThreads(); await flushPromises(); @@ -54,17 +54,17 @@ describe("createChatStore", () => { }); it("handles pagination cursor", async () => { - const fetchThreadList = vi.fn().mockResolvedValue({ + const listThreads = vi.fn().mockResolvedValue({ threads: [makeThread("t1")], nextCursor: "page2", }); - const store = createChatStore({ fetchThreadList, processMessage: vi.fn() }); + const store = makeStore({ listThreads }); store.getState().loadThreads(); await flushPromises(); expect(store.getState().hasMoreThreads).toBe(true); - expect(fetchThreadList).toHaveBeenCalledWith(undefined); + expect(listThreads).toHaveBeenCalledWith(undefined); }); }); @@ -72,12 +72,12 @@ describe("createChatStore", () => { it("appends threads using cursor", async () => { const page1 = [makeThread("t1")]; const page2 = [makeThread("t2", 1)]; - const fetchThreadList = vi + const listThreads = vi .fn() .mockResolvedValueOnce({ threads: page1, nextCursor: "c2" }) .mockResolvedValueOnce({ threads: page2 }); - const store = createChatStore({ fetchThreadList, processMessage: vi.fn() }); + const store = makeStore({ listThreads }); store.getState().loadThreads(); await flushPromises(); @@ -88,29 +88,29 @@ describe("createChatStore", () => { expect(store.getState().threads).toHaveLength(2); expect(store.getState().hasMoreThreads).toBe(false); - expect(fetchThreadList).toHaveBeenCalledWith("c2"); + expect(listThreads).toHaveBeenCalledWith("c2"); }); it("no-ops when no more pages", async () => { - const fetchThreadList = vi.fn().mockResolvedValue({ threads: [makeThread("t1")] }); + const listThreads = vi.fn().mockResolvedValue({ threads: [makeThread("t1")] }); - const store = createChatStore({ fetchThreadList, processMessage: vi.fn() }); + const store = makeStore({ listThreads }); store.getState().loadThreads(); await flushPromises(); store.getState().loadMoreThreads(); await flushPromises(); - expect(fetchThreadList).toHaveBeenCalledTimes(1); + expect(listThreads).toHaveBeenCalledTimes(1); }); }); describe("selectThread", () => { it("sets selectedThreadId, loads messages, clears previous", async () => { const messages: Message[] = [makeMessage("m1"), makeMessage("m2", "assistant")]; - const loadThread = vi.fn().mockResolvedValue(messages); + const getMessages = vi.fn().mockResolvedValue(messages); - const store = createChatStore({ loadThread, processMessage: vi.fn() }); + const store = makeStore({ getMessages }); store.setState({ messages: [makeMessage("old")] }); @@ -124,14 +124,14 @@ describe("createChatStore", () => { expect(store.getState().messages).toEqual(messages); expect(store.getState().isLoadingMessages).toBe(false); - expect(loadThread).toHaveBeenCalledWith("t1"); + expect(getMessages).toHaveBeenCalledWith("t1"); }); it("sets threadError on load failure", async () => { const error = new Error("load failed"); - const loadThread = vi.fn().mockRejectedValue(error); + const getMessages = vi.fn().mockRejectedValue(error); - const store = createChatStore({ loadThread, processMessage: vi.fn() }); + const store = makeStore({ getMessages }); store.getState().selectThread("t1"); await flushPromises(); @@ -142,7 +142,7 @@ describe("createChatStore", () => { describe("switchToNewThread", () => { it("clears selection, messages, and errors", () => { - const store = createChatStore({ processMessage: vi.fn() }); + const store = makeStore(); store.setState({ selectedThreadId: "t1", @@ -163,7 +163,7 @@ describe("createChatStore", () => { const newThread = makeThread("t-new"); const createThread = vi.fn().mockResolvedValue(newThread); - const store = createChatStore({ createThread, processMessage: vi.fn() }); + const store = makeStore({ createThread }); store.setState({ threads: [makeThread("t-existing")] }); const result = await store.getState().createThread({ @@ -181,7 +181,7 @@ describe("createChatStore", () => { describe("deleteThread", () => { it("removes thread from list", async () => { const deleteThread = vi.fn().mockResolvedValue(undefined); - const store = createChatStore({ deleteThread, processMessage: vi.fn() }); + const store = makeStore({ deleteThread }); store.setState({ threads: [makeThread("t1"), makeThread("t2", 1)] }); @@ -194,7 +194,7 @@ describe("createChatStore", () => { it("switches to new thread if deleted thread was selected", async () => { const deleteThread = vi.fn().mockResolvedValue(undefined); - const store = createChatStore({ deleteThread, processMessage: vi.fn() }); + const store = makeStore({ deleteThread }); store.setState({ threads: [makeThread("t1")], @@ -218,7 +218,7 @@ describe("createChatStore", () => { }), ); - const store = createChatStore({ deleteThread, processMessage: vi.fn() }); + const store = makeStore({ deleteThread }); store.setState({ threads: [makeThread("t1")] }); store.getState().deleteThread("t1"); @@ -237,7 +237,7 @@ describe("createChatStore", () => { const updated = { ...makeThread("t1"), title: "Renamed" }; const updateThread = vi.fn().mockResolvedValue(updated); - const store = createChatStore({ updateThread, processMessage: vi.fn() }); + const store = makeStore({ updateThread }); store.setState({ threads: [makeThread("t1")] }); store.getState().updateThread(updated); @@ -252,10 +252,10 @@ describe("createChatStore", () => { // ──────────────────────────────────────────── describe("message CRUD", () => { - let store: ReturnType; + let store: ReturnType; beforeEach(() => { - store = createChatStore({ processMessage: vi.fn() }); + store = makeStore(); store.setState({ messages: [makeMessage("m1"), makeMessage("m2", "assistant")] }); }); @@ -285,15 +285,15 @@ describe("createChatStore", () => { }); // ──────────────────────────────────────────── - // processMessage + // processMessage (calls llm.send) // ──────────────────────────────────────────── describe("processMessage", () => { - it("appends optimistic user message and calls processMessage", async () => { - const processMessage = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + it("appends optimistic user message and calls llm.send", async () => { + const send = vi.fn().mockResolvedValue(new Response("", { status: 200 })); - const store = createChatStore({ - processMessage, + const store = makeStore({ + send, streamProtocol: { parse: async function* () {} }, }); @@ -304,17 +304,17 @@ describe("createChatStore", () => { expect(store.getState().messages).toHaveLength(1); expect(store.getState().messages[0].role).toBe("user"); expect(store.getState().isRunning).toBe(false); - expect(processMessage).toHaveBeenCalledOnce(); + expect(send).toHaveBeenCalledOnce(); }); it("creates thread when none selected", async () => { const newThread = makeThread("t-auto"); const createThread = vi.fn().mockResolvedValue(newThread); - const processMessage = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + const send = vi.fn().mockResolvedValue(new Response("", { status: 200 })); - const store = createChatStore({ + const store = makeStore({ createThread, - processMessage, + send, streamProtocol: { parse: async function* () {} }, }); @@ -325,24 +325,24 @@ describe("createChatStore", () => { }); it("no-ops when already running", async () => { - const processMessage = vi.fn().mockResolvedValue(new Response("", { status: 200 })); + const send = vi.fn().mockResolvedValue(new Response("", { status: 200 })); - const store = createChatStore({ - processMessage, + const store = makeStore({ + send, streamProtocol: { parse: async function* () {} }, }); store.setState({ isRunning: true, selectedThreadId: "t1" }); await store.getState().processMessage({ role: "user", content: "hello" }); - expect(processMessage).not.toHaveBeenCalled(); + expect(send).not.toHaveBeenCalled(); }); it("sets threadError on failure", async () => { - const processMessage = vi.fn().mockRejectedValue(new Error("api down")); + const send = vi.fn().mockRejectedValue(new Error("api down")); - const store = createChatStore({ - processMessage, + const store = makeStore({ + send, streamProtocol: { parse: async function* () {} }, }); store.setState({ selectedThreadId: "t1" }); @@ -361,14 +361,14 @@ describe("createChatStore", () => { describe("cancelMessage", () => { it("aborts in-flight request", async () => { - let capturedAbort: AbortController; - const processMessage = vi.fn().mockImplementation(({ abortController }) => { - capturedAbort = abortController; + let capturedSignal: AbortSignal; + const send = vi.fn().mockImplementation(({ signal }) => { + capturedSignal = signal; return new Promise(() => {}); // never resolves }); - const store = createChatStore({ - processMessage, + const store = makeStore({ + send, streamProtocol: { parse: async function* () {} }, }); store.setState({ selectedThreadId: "t1" }); @@ -382,415 +382,7 @@ describe("createChatStore", () => { await flushPromises(); expect(store.getState().isRunning).toBe(false); - expect(capturedAbort!.signal.aborted).toBe(true); - }); - }); - - // ──────────────────────────────────────────── - // apiUrl (default fetch-based processMessage) - // ──────────────────────────────────────────── - - describe("apiUrl", () => { - let fetchSpy: ReturnType; - - beforeEach(() => { - fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - }); - - it("sends POST to apiUrl with threadId and messages", async () => { - const sseBody = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: "hi" })}\n\ndata: [DONE]\n\n`; - const stream = new ReadableStream({ - start(c) { - c.enqueue(new TextEncoder().encode(sseBody)); - c.close(); - }, - }); - fetchSpy.mockResolvedValue(new Response(stream)); - - const store = createChatStore({ apiUrl: "/api/chat" }); - store.setState({ selectedThreadId: "t1" }); - - await store.getState().processMessage({ role: "user", content: "hello" }); - - expect(fetchSpy).toHaveBeenCalledOnce(); - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toBe("/api/chat"); - expect(opts.method).toBe("POST"); - - const body = JSON.parse(opts.body); - expect(body.threadId).toBe("t1"); - expect(body.messages).toHaveLength(1); - expect(body.messages[0].role).toBe("user"); - }); - - it("passes abortController.signal to fetch", async () => { - fetchSpy.mockImplementation(() => new Promise(() => {})); - - const store = createChatStore({ apiUrl: "/api/chat" }); - store.setState({ selectedThreadId: "t1" }); - - store.getState().processMessage({ role: "user", content: "hi" }); - await flushPromises(); - - const [, opts] = fetchSpy.mock.calls[0]; - expect(opts.signal).toBeInstanceOf(AbortSignal); - }); - - it("streams response via processStreamedMessage", async () => { - const sseBody = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: "response text" })}\n\ndata: [DONE]\n\n`; - const stream = new ReadableStream({ - start(c) { - c.enqueue(new TextEncoder().encode(sseBody)); - c.close(); - }, - }); - fetchSpy.mockResolvedValue(new Response(stream)); - - const store = createChatStore({ apiUrl: "/api/chat" }); - store.setState({ selectedThreadId: "t1" }); - - await store.getState().processMessage({ role: "user", content: "hello" }); - - expect(store.getState().messages).toHaveLength(2); - expect(store.getState().messages[0].role).toBe("user"); - expect(store.getState().messages[1].role).toBe("assistant"); - expect((store.getState().messages[1] as any).content).toBe("response text"); - }); - - it("throws when neither apiUrl nor processMessage provided", async () => { - const store = createChatStore({}); - store.setState({ selectedThreadId: "t1" }); - - await store.getState().processMessage({ role: "user", content: "hello" }); - - expect(store.getState().threadError).toBeInstanceOf(Error); - expect(store.getState().threadError?.message).toContain("apiUrl or processMessage required"); - }); - - it("applies messageFormat.toApi to outbound messages", async () => { - const sseBody = `data: [DONE]\n\n`; - const stream = new ReadableStream({ - start(c) { - c.enqueue(new TextEncoder().encode(sseBody)); - c.close(); - }, - }); - fetchSpy.mockResolvedValue(new Response(stream)); - - const toApi = vi.fn((msgs: Message[]) => msgs.map((m) => ({ custom: m.id }))); - - const store = createChatStore({ - apiUrl: "/api/chat", - messageFormat: { toApi, fromApi: (d) => d as Message[] }, - }); - store.setState({ selectedThreadId: "t1" }); - - await store.getState().processMessage({ role: "user", content: "hi" }); - - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.messages[0]).toHaveProperty("custom"); - expect(toApi).toHaveBeenCalled(); - }); - - it("uses ephemeral threadId when no thread selected and no createThread", async () => { - const sseBody = `data: [DONE]\n\n`; - const stream = new ReadableStream({ - start(c) { - c.enqueue(new TextEncoder().encode(sseBody)); - c.close(); - }, - }); - fetchSpy.mockResolvedValue(new Response(stream)); - - const store = createChatStore({ apiUrl: "/api/chat" }); - - await store.getState().processMessage({ role: "user", content: "hello" }); - - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.threadId).toBe("ephemeral"); - }); - }); - - // ──────────────────────────────────────────── - // threadApiUrl (default fetch-based thread ops) - // ──────────────────────────────────────────── - - describe("threadApiUrl", () => { - let fetchSpy: ReturnType; - - beforeEach(() => { - fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - }); - - it("loadThreads fetches from threadApiUrl/get", async () => { - const threads = [makeThread("t1"), makeThread("t2", 1)]; - fetchSpy.mockResolvedValue(new Response(JSON.stringify({ threads }))); - - const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" }); - store.getState().loadThreads(); - await flushPromises(); - - expect(fetchSpy).toHaveBeenCalledWith("/api/threads/get"); - expect(store.getState().threads).toHaveLength(2); - }); - - it("loadMoreThreads passes cursor as query param", async () => { - fetchSpy - .mockResolvedValueOnce( - new Response(JSON.stringify({ threads: [makeThread("t1")], nextCursor: "abc" })), - ) - .mockResolvedValueOnce(new Response(JSON.stringify({ threads: [makeThread("t2", 1)] }))); - - const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" }); - store.getState().loadThreads(); - await flushPromises(); - - store.getState().loadMoreThreads(); - await flushPromises(); - - expect(fetchSpy).toHaveBeenCalledWith("/api/threads/get?cursor=abc"); - expect(store.getState().threads).toHaveLength(2); - }); - - it("createThread POSTs to threadApiUrl/create", async () => { - const thread = makeThread("t-new"); - fetchSpy.mockResolvedValue(new Response(JSON.stringify(thread))); - - const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" }); - - const result = await store.getState().createThread({ - id: "m1", - role: "user", - content: "hello", - } as UserMessage); - - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toBe("/api/threads/create"); - expect(opts.method).toBe("POST"); - expect(JSON.parse(opts.body)).toHaveProperty("messages"); - expect(result.id).toBe("t-new"); - }); - - it("deleteThread DELETEs threadApiUrl/delete/:id", async () => { - fetchSpy.mockResolvedValue(new Response("")); - - const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" }); - store.setState({ threads: [makeThread("t1")] }); - - store.getState().deleteThread("t1"); - await flushPromises(); - - expect(fetchSpy).toHaveBeenCalledWith("/api/threads/delete/t1", { method: "DELETE" }); - expect(store.getState().threads).toHaveLength(0); - }); - - it("updateThread PATCHes threadApiUrl/update/:id", async () => { - const updated = { ...makeThread("t1"), title: "Renamed" }; - fetchSpy.mockResolvedValue(new Response(JSON.stringify(updated))); - - const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" }); - store.setState({ threads: [makeThread("t1")] }); - - store.getState().updateThread(updated); - await flushPromises(); - - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toBe("/api/threads/update/t1"); - expect(opts.method).toBe("PATCH"); - expect(store.getState().threads[0]!.title).toBe("Renamed"); - }); - - it("selectThread GETs threadApiUrl/get/:id and applies messageFormat.fromApi", async () => { - const rawMessages = [{ custom: "data" }]; - const parsed: Message[] = [makeMessage("m1")]; - const fromApi = vi.fn().mockReturnValue(parsed); - - fetchSpy.mockResolvedValue(new Response(JSON.stringify(rawMessages))); - - const store = createChatStore({ - threadApiUrl: "/api/threads", - apiUrl: "/api/chat", - messageFormat: { toApi: (m) => m, fromApi }, - }); - - store.getState().selectThread("t1"); - await flushPromises(); - - expect(fetchSpy).toHaveBeenCalledWith("/api/threads/get/t1"); - expect(fromApi).toHaveBeenCalledWith(rawMessages); - expect(store.getState().messages).toEqual(parsed); - }); - - it("processMessage auto-creates thread via threadApiUrl when none selected", async () => { - const thread = makeThread("t-auto"); - fetchSpy.mockResolvedValueOnce(new Response(JSON.stringify(thread))).mockResolvedValueOnce( - new Response( - new ReadableStream({ - start(c) { - c.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); - c.close(); - }, - }), - ), - ); - - const store = createChatStore({ threadApiUrl: "/api/threads", apiUrl: "/api/chat" }); - - await store.getState().processMessage({ role: "user", content: "hello" }); - - expect(fetchSpy.mock.calls[0][0]).toBe("/api/threads/create"); - expect(store.getState().selectedThreadId).toBe("t-auto"); - }); - }); - - // ──────────────────────────────────────────── - // messageFormat round-tripping - // ──────────────────────────────────────────── - - describe("messageFormat", () => { - let fetchSpy: ReturnType; - - beforeEach(() => { - fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - }); - - const doneStream = () => - new ReadableStream({ - start(c) { - c.enqueue(new TextEncoder().encode("data: [DONE]\n\n")); - c.close(); - }, - }); - - const customFormat = { - toApi: (msgs: Message[]) => msgs.map((m) => ({ r: m.role, c: (m as any).content })), - fromApi: (data: unknown) => - (data as Array<{ r: string; c: string }>).map((d, i) => ({ - id: `parsed-${i}`, - role: d.r, - content: d.c, - })) as Message[], - }; - - it("toApi is applied when sending messages via apiUrl", async () => { - fetchSpy.mockResolvedValue(new Response(doneStream())); - - const toApi = vi.fn((msgs: Message[]) => - msgs.map((m) => ({ custom_role: m.role, custom_content: (m as any).content })), - ); - - const store = createChatStore({ - apiUrl: "/api/chat", - messageFormat: { toApi, fromApi: (d) => d as Message[] }, - }); - store.setState({ selectedThreadId: "t1" }); - - await store.getState().processMessage({ role: "user", content: "hello" }); - - expect(toApi).toHaveBeenCalled(); - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.messages[0]).toEqual({ custom_role: "user", custom_content: "hello" }); - expect(body.messages[0]).not.toHaveProperty("id"); - }); - - it("fromApi is applied when loading thread via threadApiUrl", async () => { - const serverPayload = [ - { r: "user", c: "hi" }, - { r: "assistant", c: "hello" }, - ]; - fetchSpy.mockResolvedValue(new Response(JSON.stringify(serverPayload))); - - const fromApi = vi.fn(customFormat.fromApi); - - const store = createChatStore({ - apiUrl: "/api/chat", - threadApiUrl: "/api/threads", - messageFormat: { toApi: (m) => m, fromApi }, - }); - - store.getState().selectThread("t1"); - await flushPromises(); - - expect(fromApi).toHaveBeenCalledWith(serverPayload); - expect(store.getState().messages).toHaveLength(2); - expect(store.getState().messages[0].role).toBe("user"); - expect((store.getState().messages[0] as any).content).toBe("hi"); - expect(store.getState().messages[1].role).toBe("assistant"); - expect((store.getState().messages[1] as any).content).toBe("hello"); - }); - - it("toApi is applied when creating thread via threadApiUrl", async () => { - const thread = makeThread("t-new"); - fetchSpy.mockResolvedValue(new Response(JSON.stringify(thread))); - - const toApi = vi.fn((msgs: Message[]) => - msgs.map((m) => ({ r: m.role, c: (m as any).content })), - ); - - const store = createChatStore({ - apiUrl: "/api/chat", - threadApiUrl: "/api/threads", - messageFormat: { toApi, fromApi: (d) => d as Message[] }, - }); - - await store.getState().createThread({ - id: "m1", - role: "user", - content: "hello", - } as UserMessage); - - expect(toApi).toHaveBeenCalled(); - const [url, opts] = fetchSpy.mock.calls[0]; - expect(url).toBe("/api/threads/create"); - const body = JSON.parse(opts.body); - expect(body.messages).toEqual([{ r: "user", c: "hello" }]); - }); - - it("round-trip: toApi then fromApi preserves data", async () => { - const thread = makeThread("t-rt"); - - fetchSpy - .mockResolvedValueOnce(new Response(JSON.stringify(thread))) - .mockResolvedValueOnce(new Response(doneStream())) - .mockResolvedValueOnce(new Response(JSON.stringify([{ r: "user", c: "round-trip" }]))); - - const store = createChatStore({ - apiUrl: "/api/chat", - threadApiUrl: "/api/threads", - messageFormat: customFormat, - }); - - await store.getState().processMessage({ role: "user", content: "round-trip" }); - - const createBody = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(createBody.messages[0]).toEqual({ r: "user", c: "round-trip" }); - - const sendBody = JSON.parse(fetchSpy.mock.calls[1][1].body); - expect(sendBody.messages[0]).toEqual({ r: "user", c: "round-trip" }); - - store.getState().selectThread("t-rt"); - await flushPromises(); - - expect(store.getState().messages).toHaveLength(1); - expect(store.getState().messages[0].role).toBe("user"); - expect((store.getState().messages[0] as any).content).toBe("round-trip"); - }); - - it("default identityMessageFormat passes through unchanged", async () => { - fetchSpy.mockResolvedValue(new Response(doneStream())); - - const store = createChatStore({ apiUrl: "/api/chat" }); - store.setState({ selectedThreadId: "t1" }); - - await store.getState().processMessage({ role: "user", content: "raw" }); - - const body = JSON.parse(fetchSpy.mock.calls[0][1].body); - expect(body.messages[0]).toHaveProperty("id"); - expect(body.messages[0].role).toBe("user"); - expect(body.messages[0].content).toBe("raw"); + expect(capturedSignal!.aborted).toBe(true); }); }); @@ -800,17 +392,17 @@ describe("createChatStore", () => { describe("selectThread while streaming", () => { it("cancels current stream and loads new thread", async () => { - let capturedAbort: AbortController; - const processMessage = vi.fn().mockImplementation(({ abortController }) => { - capturedAbort = abortController; + let capturedSignal: AbortSignal; + const send = vi.fn().mockImplementation(({ signal }) => { + capturedSignal = signal; return new Promise(() => {}); // never resolves }); const newMessages = [makeMessage("new-m1")]; - const loadThread = vi.fn().mockResolvedValue(newMessages); + const getMessages = vi.fn().mockResolvedValue(newMessages); - const store = createChatStore({ - processMessage, - loadThread, + const store = makeStore({ + send, + getMessages, streamProtocol: { parse: async function* () {} }, }); store.setState({ selectedThreadId: "t1" }); @@ -823,7 +415,7 @@ describe("createChatStore", () => { // Switch thread mid-stream store.getState().selectThread("t2"); - expect(capturedAbort!.signal.aborted).toBe(true); + expect(capturedSignal!.aborted).toBe(true); expect(store.getState().selectedThreadId).toBe("t2"); expect(store.getState().isLoadingMessages).toBe(true); diff --git a/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts b/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts index 859ff21d5..e261af996 100644 --- a/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts +++ b/packages/react-headless/src/store/__tests__/detailedViewThreadSwitch.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { createChatStore } from "../createChatStore"; import { createDetailedViewStore } from "../createDetailedViewStore"; +import { makeStore } from "./__helpers/makeStore"; const flushPromises = () => new Promise((r) => setTimeout(r, 0)); describe("detailed-view thread-switch cleanup", () => { const setupStores = () => { - const chatStore = createChatStore({ processMessage: vi.fn() }); + const chatStore = makeStore(); const detailedViewStore = createDetailedViewStore(); const unsubscribe = chatStore.subscribe( @@ -47,7 +47,7 @@ describe("detailed-view thread-switch cleanup", () => { it("clears active view when active thread is deleted", async () => { const deleteThread = vi.fn().mockResolvedValue(undefined); - const chatStore = createChatStore({ deleteThread, processMessage: vi.fn() }); + const chatStore = makeStore({ deleteThread }); const detailedViewStore = createDetailedViewStore(); const unsubscribe = chatStore.subscribe( @@ -94,8 +94,8 @@ describe("detailed-view thread-switch cleanup", () => { }); it("handles rapid thread switches cleanly", async () => { - const loadThread = vi.fn().mockResolvedValue([]); - const chatStore = createChatStore({ loadThread, processMessage: vi.fn() }); + const getMessages = vi.fn().mockResolvedValue([]); + const chatStore = makeStore({ getMessages }); const detailedViewStore = createDetailedViewStore(); const unsubscribe = chatStore.subscribe( diff --git a/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts b/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts index 1f08badfa..6ed6dc758 100644 --- a/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts +++ b/packages/react-headless/src/store/__tests__/threadContextSwitch.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; -import { createChatStore } from "../createChatStore"; import { createThreadContextStore } from "../createThreadContextStore"; +import { makeStore } from "./__helpers/makeStore"; const flushPromises = () => new Promise((r) => setTimeout(r, 0)); describe("thread-context thread-switch cleanup", () => { const setupStores = () => { - const chatStore = createChatStore({ processMessage: vi.fn() }); + const chatStore = makeStore(); const threadContextStore = createThreadContextStore(); const unsubscribe = chatStore.subscribe( @@ -56,7 +56,7 @@ describe("thread-context thread-switch cleanup", () => { it("clears thread context when active thread is deleted", async () => { const deleteThread = vi.fn().mockResolvedValue(undefined); - const chatStore = createChatStore({ deleteThread, processMessage: vi.fn() }); + const chatStore = makeStore({ deleteThread }); const threadContextStore = createThreadContextStore(); const unsubscribe = chatStore.subscribe( @@ -103,8 +103,8 @@ describe("thread-context thread-switch cleanup", () => { }); it("handles rapid thread switches cleanly", async () => { - const loadThread = vi.fn().mockResolvedValue([]); - const chatStore = createChatStore({ loadThread, processMessage: vi.fn() }); + const getMessages = vi.fn().mockResolvedValue([]); + const chatStore = makeStore({ getMessages }); const threadContextStore = createThreadContextStore(); const unsubscribe = chatStore.subscribe( diff --git a/packages/react-headless/src/store/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index 4d1df405e..542fc4328 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -1,105 +1,22 @@ import { createStore } from "zustand"; import { subscribeWithSelector } from "zustand/middleware"; +import type { ChatLLM, ChatStorage } from "../adapters/types"; import { processStreamedMessage } from "../stream/processStreamedMessage"; -import { identityMessageFormat } from "../types/messageFormat"; -import type { ChatProviderProps, ChatStore, Message, Thread, UserMessage } from "./types"; +import type { ChatStore, Message, Thread, UserMessage } from "./types"; -type StoreConfig = Omit; +export interface CreateChatStoreConfig { + storage: ChatStorage; + llm: ChatLLM; +} const mergeThreadList = (existing: Thread[], incoming: Thread[]): Thread[] => Array.from(new Map([...existing, ...incoming].map((t) => [t.id, t])).values()).sort( (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), ); -export const createChatStore = (config: StoreConfig) => { - const { - apiUrl, - threadApiUrl, - processMessage: userProcessMessage, - fetchThreadList: userFetchThreadList, - createThread: userCreateThread, - deleteThread: userDeleteThread, - updateThread: userUpdateThread, - loadThread: userLoadThread, - streamProtocol, - messageFormat = identityMessageFormat, - } = config; - - // ── Default implementations (when threadApiUrl is provided) ── - - const fetchThreadList = - userFetchThreadList ?? - (async (cursor?: any) => { - if (!threadApiUrl) return { threads: [] }; - const url = cursor ? `${threadApiUrl}/get?cursor=${cursor}` : `${threadApiUrl}/get`; - const res = await fetch(url); - return res.json(); - }); - - const createThread = - userCreateThread ?? - (async (firstMessage: UserMessage) => { - if (!threadApiUrl) throw new Error("threadApiUrl or createThread required"); - const res = await fetch(`${threadApiUrl}/create`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: messageFormat.toApi([firstMessage]) }), - }); - return res.json(); - }); - - const deleteThreadFn = - userDeleteThread ?? - (async (id: string) => { - if (!threadApiUrl) return; - await fetch(`${threadApiUrl}/delete/${id}`, { method: "DELETE" }); - }); - - const updateThreadFn = - userUpdateThread ?? - (async (updated: Thread) => { - if (!threadApiUrl) return updated; - const res = await fetch(`${threadApiUrl}/update/${updated.id}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(updated), - }); - return res.json(); - }); - - const loadThread = - userLoadThread ?? - (async (threadId: string): Promise => { - if (!threadApiUrl) return []; - const res = await fetch(`${threadApiUrl}/get/${threadId}`); - const raw: unknown = await res.json(); - return messageFormat.fromApi(raw); - }); - - const sendMessage = - userProcessMessage ?? - (async ({ - threadId, - messages, - abortController, - }: { - threadId: string; - messages: Message[]; - abortController: AbortController; - }) => { - if (!apiUrl) throw new Error("apiUrl or processMessage required"); - return fetch(apiUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - threadId, - messages: messageFormat.toApi(messages), - }), - signal: abortController.signal, - }); - }); - - // ── Store ── +export const createChatStore = (config: CreateChatStoreConfig) => { + const { storage, llm } = config; + const { thread: threadStorage } = storage; const store = createStore()( subscribeWithSelector((set, get) => ({ @@ -122,7 +39,8 @@ export const createChatStore = (config: StoreConfig) => { loadThreads: () => { set({ isLoadingThreads: true, threadListError: null }); - fetchThreadList(undefined) + threadStorage + .listThreads(undefined) .then(({ threads = [], nextCursor }) => { set({ threads, @@ -139,7 +57,8 @@ export const createChatStore = (config: StoreConfig) => { loadMoreThreads: () => { const cursor = get()._nextCursor; if (cursor === undefined) return; - fetchThreadList(cursor) + threadStorage + .listThreads(cursor) .then(({ threads = [], nextCursor }) => { set((s) => ({ threads: mergeThreadList(s.threads, threads), @@ -158,7 +77,7 @@ export const createChatStore = (config: StoreConfig) => { }, createThread: async (firstMessage: UserMessage) => { - const thread = await createThread(firstMessage); + const thread = await threadStorage.createThread(firstMessage); set((s) => ({ threads: mergeThreadList(s.threads, [thread]) })); return thread; }, @@ -171,7 +90,8 @@ export const createChatStore = (config: StoreConfig) => { isLoadingMessages: true, threadError: null, }); - loadThread(threadId) + threadStorage + .getMessages(threadId) .then((messages) => set({ messages, isLoadingMessages: false })) .catch((e) => set({ threadError: e, isLoadingMessages: false })); }, @@ -180,7 +100,8 @@ export const createChatStore = (config: StoreConfig) => { const setPending = (id: string, isPending: boolean) => set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) })); setPending(thread.id, true); - updateThreadFn(thread) + threadStorage + .updateThread(thread) .then((updated) => { set((s) => ({ threads: s.threads.map((t) => (t.id === updated.id ? updated : t)), @@ -193,7 +114,8 @@ export const createChatStore = (config: StoreConfig) => { const setPending = (id: string, isPending: boolean) => set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) })); setPending(threadId, true); - deleteThreadFn(threadId) + threadStorage + .deleteThread(threadId) .then(() => { const state = get(); set({ threads: state.threads.filter((t) => t.id !== threadId) }); @@ -228,19 +150,15 @@ export const createChatStore = (config: StoreConfig) => { let threadId = get().selectedThreadId; if (!threadId) { - if (userCreateThread || threadApiUrl) { - const created = await get().createThread(optimisticMessage); - threadId = created.id; - set({ selectedThreadId: threadId }); - } else { - threadId = "ephemeral"; - } + const created = await get().createThread(optimisticMessage); + threadId = created.id; + set({ selectedThreadId: threadId }); } - const response = await sendMessage({ + const response = await llm.send({ threadId, messages: get().messages, - abortController, + signal: abortController.signal, }); if (response instanceof Response && !response.ok) { @@ -254,7 +172,7 @@ export const createChatStore = (config: StoreConfig) => { set((s) => ({ messages: s.messages.map((m) => (m.id === msg.id ? msg : m)), })), - adapter: streamProtocol, + adapter: llm.streamProtocol, }); } catch (e) { if (!abortController.signal.aborted) { diff --git a/packages/react-headless/src/store/createDetailedViewStore.ts b/packages/react-headless/src/store/createDetailedViewStore.ts index 25566c5b9..e9f71c3a6 100644 --- a/packages/react-headless/src/store/createDetailedViewStore.ts +++ b/packages/react-headless/src/store/createDetailedViewStore.ts @@ -24,7 +24,8 @@ export const createDetailedViewStore = () => { _detailedViewPanelNode: null, _setDetailedViewPanelNode: (node) => { if ( - process.env["NODE_ENV"] !== "production" && + typeof process !== "undefined" && + process.env?.["NODE_ENV"] !== "production" && node && get()._detailedViewPanelNode && get()._detailedViewPanelNode !== node diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 7bbce0fa5..0c494f769 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -1,6 +1,5 @@ +import type { ChatLLM, ChatStorage } from "../adapters/types"; import type { Message, UserMessage } from "../types/message"; -import type { MessageFormat } from "../types/messageFormat"; -import type { StreamProtocolAdapter } from "../types/stream"; import type { AppRendererConfig } from "./appRendererTypes"; export type { Message, UserMessage } from "../types/message"; @@ -65,47 +64,17 @@ export type ChatStore = ThreadListState & // ── Provider props ── -type ThreadApiConfig = - | { - threadApiUrl: string; - fetchThreadList?: never; - createThread?: never; - deleteThread?: never; - updateThread?: never; - loadThread?: never; - } - | { - threadApiUrl?: never; - fetchThreadList?: (cursor?: any) => Promise<{ threads: Thread[]; nextCursor?: any }>; - createThread?: (firstMessage: UserMessage) => Promise; - deleteThread?: (id: string) => Promise; - updateThread?: (updated: Thread) => Promise; - loadThread?: (threadId: string) => Promise; - }; - -type ChatApiConfig = - | { - apiUrl: string; - processMessage?: never; - } - | { - apiUrl?: never; - processMessage: (params: { - threadId: string; - messages: Message[]; - abortController: AbortController; - }) => Promise; - }; - -export type ChatProviderProps = ThreadApiConfig & - ChatApiConfig & { - streamProtocol?: StreamProtocolAdapter; - messageFormat?: MessageFormat; - /** - * App renderers matched against tool calls in the conversation. - * Captured at mount; subsequent prop changes are ignored (dev warning). - * Order is priority: first match wins on duplicate `toolName`. - */ - appRenderers?: ReadonlyArray>; - children: React.ReactNode; - }; +export interface ChatProviderProps { + /** Optional — defaults to an internal in-memory storage (no persistence). */ + storage?: ChatStorage; + /** Required — drives message sending and stream parsing. */ + llm: ChatLLM; + /** + * App renderers matched against tool calls in the conversation. + * Captured at mount; subsequent prop changes are ignored (dev warning). + * Order is priority: first match wins on duplicate `toolName`. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + appRenderers?: ReadonlyArray>; + children: React.ReactNode; +} diff --git a/packages/react-ui/src/__test-helpers/mockChat.ts b/packages/react-ui/src/__test-helpers/mockChat.ts new file mode 100644 index 000000000..ed1dc018f --- /dev/null +++ b/packages/react-ui/src/__test-helpers/mockChat.ts @@ -0,0 +1,62 @@ +import { agUIAdapter } from "@openuidev/react-headless"; +import type { ChatLLM, ChatStorage, ThreadStorage } from "@openuidev/react-headless"; + +/** + * Test helpers for stories — construct ChatStorage / ChatLLM objects + * with sensible defaults and per-story overrides. + */ + +export interface MockStorageOverrides extends Partial { + pinning?: ChatStorage["pinning"]; + share?: ChatStorage["share"]; +} + +export function makeMockStorage(overrides: MockStorageOverrides = {}): ChatStorage { + const { pinning, share, ...threadOverrides } = overrides; + return { + thread: { + listThreads: async () => ({ threads: [] }), + createThread: async () => ({ + id: crypto.randomUUID(), + title: "New Chat", + createdAt: Date.now(), + }), + getMessages: async () => [], + updateThread: async (t) => t, + deleteThread: async () => {}, + ...threadOverrides, + }, + pinning, + share, + }; +} + +export interface MockLLMOverrides { + send?: ChatLLM["send"]; +} + +export function makeMockLLM(overrides: MockLLMOverrides = {}): ChatLLM { + return { + send: overrides.send ?? (async () => new Response("data: [DONE]\n\n")), + streamProtocol: agUIAdapter(), + }; +} + +/** + * Build a streaming mock Response that emits a single TEXT_MESSAGE_CONTENT + * delta followed by [DONE]. Useful for stories simulating the LLM reply. + */ +export function mockSSEResponse(text: string, delayMs = 500): Promise { + return new Promise((resolve) => { + setTimeout(() => { + const events = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: text })}\n\ndata: [DONE]\n\n`; + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(events)); + controller.close(); + }, + }); + resolve(new Response(stream)); + }, delayMs); + }); +} diff --git a/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx b/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx index e05841b88..ce5bc7f8c 100644 --- a/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx +++ b/packages/react-ui/src/components/BottomTray/stories/BottomTray.stories.tsx @@ -1,6 +1,7 @@ import { ChatProvider, Message } from "@openuidev/react-headless"; import { MessageSquare, Sparkles, Zap } from "lucide-react"; import { useState } from "react"; +import { makeMockLLM, makeMockStorage, mockSSEResponse } from "../../../__test-helpers/mockChat"; import { Composer, Container, @@ -17,20 +18,77 @@ import { import styles from "./style.module.scss"; import logoUrl from "./thesysdev_logo.jpeg"; -function mockSSEResponse(text: string, delayMs = 500): Promise { - return new Promise((resolve) => { - setTimeout(() => { - const events = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: text })}\n\ndata: [DONE]\n\n`; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(events)); - controller.close(); - }, - }); - resolve(new Response(stream)); - }, delayMs); - }); -} +const populatedStorage3 = makeMockStorage({ + listThreads: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + threads: [ + { id: "1", title: "test", createdAt: Date.now() }, + { id: "2", title: "test 2", createdAt: Date.now() }, + { id: "3", title: "test 3", createdAt: Date.now() }, + ], + }; + }, + getMessages: async (threadId) => { + if (!threadId) return []; + return [ + { id: crypto.randomUUID(), role: "user", content: "Hello" }, + { + id: crypto.randomUUID(), + role: "assistant", + content: "Hello! How can I help you today?", + }, + ] as Message[]; + }, +}); + +const populatedStorage2 = makeMockStorage({ + listThreads: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + threads: [ + { id: "1", title: "test", createdAt: Date.now() }, + { id: "2", title: "test 2", createdAt: Date.now() }, + ], + }; + }, + getMessages: async (threadId) => { + if (!threadId) return []; + return [ + { id: crypto.randomUUID(), role: "user", content: "Hello" }, + { + id: crypto.randomUUID(), + role: "assistant", + content: "Hello! How can I help you today?", + }, + ] as Message[]; + }, +}); + +const emptyStorage = makeMockStorage({}); + +const trayLLM = makeMockLLM({ + send: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return mockSSEResponse("This is a response from the bottom tray assistant!", 0); + }, +}); + +const supportLLM = makeMockLLM({ + send: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return mockSSEResponse("This is a response from the assistant!", 0); + }, +}); + +const echoLLM = makeMockLLM({ + send: async ({ messages }) => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const lastUser = messages.filter((m) => m.role === "user").pop(); + const content = typeof lastUser?.content === "string" ? lastUser.content : ""; + return mockSSEResponse(`You asked: "${content}"`, 0); + }, +}); export default { title: "Components/BottomTray", @@ -74,48 +132,9 @@ const BottomTrayStory = ({
    - { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return mockSSEResponse("This is a response from the bottom tray assistant!", 0); - }} - fetchThreadList={async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return { - threads: [ - { id: "1", title: "test", createdAt: Date.now() }, - { id: "2", title: "test 2", createdAt: Date.now() }, - { id: "3", title: "test 3", createdAt: Date.now() }, - ], - }; - }} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "test", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async (threadId) => { - if (!threadId) return []; - return [ - { - id: crypto.randomUUID(), - role: "user", - content: "Hello", - }, - { - id: crypto.randomUUID(), - role: "assistant", - content: "Hello! How can I help you today?", - }, - ] as Message[]; - }} - > - {/* Trigger is always visible - toggles the tray (hidden on mobile when open) */} + setIsOpen(!isOpen)} isOpen={isOpen} /> - {/* Container is controlled externally */}
    setIsOpen(false)} /> @@ -135,12 +154,11 @@ const BottomTrayStory = ({ displayText: "Who is the president of Venezuela and where is he currently located (icon was not passed)", prompt: "Who is the president of Venezuela and where is he currently located?", - // icon undefined = shows default lightbulb }, { displayText: "Tell me about major stock (no icon with empty fragment)", prompt: "Tell me about major stock", - icon: <>, // Empty fragment = no icon + icon: <>, }, ]} /> @@ -176,7 +194,6 @@ export const LongVariant = { render: (args: any) => , }; -// Example with custom trigger const CustomTriggerStory = ({ defaultOpen = false, variant = "short", @@ -193,40 +210,7 @@ const CustomTriggerStory = ({

    Use a fully custom trigger with your own styling and content.

    - { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return mockSSEResponse("This is a response from the assistant!", 0); - }} - fetchThreadList={async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return { - threads: [ - { id: "1", title: "test", createdAt: Date.now() }, - { id: "2", title: "test 2", createdAt: Date.now() }, - ], - }; - }} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "test", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async (threadId) => { - if (!threadId) return []; - return [ - { id: crypto.randomUUID(), role: "user", content: "Hello" }, - { - id: crypto.randomUUID(), - role: "assistant", - content: "Hello! How can I help you today?", - }, - ] as Message[]; - }} - > - {/* Custom trigger - always visible, toggles tray (hidden on mobile when open) */} + setIsOpen(!isOpen)} isOpen={isOpen} @@ -260,7 +244,7 @@ const CustomTriggerStory = ({ { displayText: "No icon example - this is a shorter prompt", prompt: "No icon example - this is a shorter prompt", - icon: <>, // Empty fragment = no icon + icon: <>, }, ]} /> @@ -288,7 +272,6 @@ export const CustomTriggerLongVariant = { render: (args: any) => , }; -// Example with WelcomeScreen const WelcomeScreenStory = ({ defaultOpen = true, variant = "short", @@ -305,23 +288,7 @@ const WelcomeScreenStory = ({

    This example shows the WelcomeScreen component with title, description, and logo.

    - { - await new Promise((resolve) => setTimeout(resolve, 1000)); - const lastUser = messages.filter((m) => m.role === "user").pop(); - const content = typeof lastUser?.content === "string" ? lastUser.content : ""; - return mockSSEResponse(`You asked: "${content}"`, 0); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async () => []} - > + setIsOpen(!isOpen)} isOpen={isOpen} /> @@ -375,7 +342,6 @@ export const WithWelcomeScreenLongVariant = { render: (args: any) => , }; -// Example with custom children in WelcomeScreen const CustomWelcomeScreenStory = ({ defaultOpen = true, variant = "short", @@ -392,23 +358,7 @@ const CustomWelcomeScreenStory = ({

    This example shows WelcomeScreen with custom children instead of props.

    - { - await new Promise((resolve) => setTimeout(resolve, 1000)); - const lastUser = messages.filter((m) => m.role === "user").pop(); - const content = typeof lastUser?.content === "string" ? lastUser.content : ""; - return mockSSEResponse(`You asked: "${content}"`, 0); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async () => []} - > + setIsOpen(!isOpen)} isOpen={isOpen} /> diff --git a/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx b/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx index 260bc1b9a..f5d3718dc 100644 --- a/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx +++ b/packages/react-ui/src/components/CopilotShell/stories/Shell.stories.tsx @@ -1,5 +1,6 @@ import { ChatProvider, Message } from "@openuidev/react-headless"; import { Sparkles } from "lucide-react"; +import { makeMockLLM, makeMockStorage, mockSSEResponse } from "../../../__test-helpers/mockChat"; import { Composer, Container, @@ -15,20 +16,43 @@ import { import styles from "./style.module.scss"; import logoUrl from "./thesysdev_logo.jpeg"; -function mockSSEResponse(text: string, delayMs = 500): Promise { - return new Promise((resolve) => { - setTimeout(() => { - const events = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: text })}\n\ndata: [DONE]\n\n`; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(events)); - controller.close(); - }, - }); - resolve(new Response(stream)); - }, delayMs); - }); -} +const populatedStorage = makeMockStorage({ + listThreads: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + threads: [ + { id: "1", title: "test", createdAt: Date.now() }, + { id: "2", title: "test 2", createdAt: Date.now() }, + { id: "3", title: "test 3", createdAt: Date.now() }, + ], + }; + }, + getMessages: async (threadId) => { + if (!threadId) return []; + return [ + { id: crypto.randomUUID(), role: "user", content: "Hello" } as Message, + { + id: crypto.randomUUID(), + role: "assistant", + content: "Hello! How can I help you today?", + } as Message, + ]; + }, +}); + +const emptyStorage = makeMockStorage({}); + +const defaultLLM = makeMockLLM({ + send: async () => mockSSEResponse("This is a response from the AI assistant.", 1000), +}); + +const echoLLM = makeMockLLM({ + send: async ({ messages }) => { + const lastMsg = messages[messages.length - 1]; + const content = lastMsg && typeof lastMsg.content === "string" ? lastMsg.content : ""; + return mockSSEResponse(`You asked: "${content}"`, 1000); + }, +}); export default { title: "Components/CopilotShell", @@ -51,12 +75,11 @@ const SAMPLE_STARTERS = [ { displayText: "Who is the president of Venezuela and where is he currently located?", prompt: "Who is the president of Venezuela and where is he currently located?", - // icon undefined = shows default lightbulb }, { displayText: "Tell me about major stock (no icon)", prompt: "Tell me about major stock", - icon: <>, // Empty fragment = no icon + icon: <>, }, ]; @@ -67,43 +90,7 @@ export const Default = { render: ({ variant }: { variant: "short" | "long" }) => (
    - - mockSSEResponse("This is a response from the AI assistant.", 1000) - } - fetchThreadList={async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return { - threads: [ - { id: "1", title: "test", createdAt: Date.now() }, - { id: "2", title: "test 2", createdAt: Date.now() }, - { id: "3", title: "test 3", createdAt: Date.now() }, - ], - }; - }} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "test", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async (threadId) => { - if (!threadId) return []; - return [ - { - id: crypto.randomUUID(), - role: "user", - content: "Hello", - } as Message, - { - id: crypto.randomUUID(), - role: "assistant", - content: "Hello! How can I help you today?", - } as Message, - ]; - }} - > +
    @@ -126,22 +113,7 @@ export const LongVariant = { render: ({ variant }: { variant: "short" | "long" }) => (
    - { - const lastMsg = messages[messages.length - 1]; - const content = lastMsg && typeof lastMsg.content === "string" ? lastMsg.content : ""; - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async () => []} - > +
    @@ -157,7 +129,6 @@ export const LongVariant = { ), }; -// Example with WelcomeScreen export const WithWelcomeScreen = { args: { variant: "short", @@ -165,22 +136,7 @@ export const WithWelcomeScreen = { render: ({ variant }: { variant: "short" | "long" }) => (
    - { - const lastMsg = messages[messages.length - 1]; - const content = lastMsg && typeof lastMsg.content === "string" ? lastMsg.content : ""; - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async () => []} - > +
    @@ -201,7 +157,6 @@ export const WithWelcomeScreen = { ), }; -// Example with custom children in WelcomeScreen export const WithCustomWelcomeScreen = { args: { variant: "short", @@ -209,22 +164,7 @@ export const WithCustomWelcomeScreen = { render: ({ variant }: { variant: "short" | "long" }) => (
    - { - const lastMsg = messages[messages.length - 1]; - const content = lastMsg && typeof lastMsg.content === "string" ? lastMsg.content : ""; - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t) => t} - loadThread={async () => []} - > +
    diff --git a/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.stories.tsx b/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.stories.tsx index a94d7be66..536b12ae2 100644 --- a/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.stories.tsx +++ b/packages/react-ui/src/components/OpenUIChat/stories/OpenUIChat.stories.tsx @@ -1,6 +1,7 @@ import type { Message } from "@openuidev/react-headless"; import { ChevronDown, Download, Share, Sparkles, ThumbsUp, Zap } from "lucide-react"; import { useState } from "react"; +import { makeMockLLM, makeMockStorage, mockSSEResponse } from "../../../__test-helpers/mockChat"; import logoUrl from "../../BottomTray/stories/thesysdev_logo.jpeg"; import { Button } from "../../Button"; import { IconButton } from "../../IconButton"; @@ -14,21 +15,6 @@ export default { tags: ["dev"], }; -function mockSSEResponse(text: string, delayMs = 500): Promise { - return new Promise((resolve) => { - setTimeout(() => { - const events = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: text })}\n\ndata: [DONE]\n\n`; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(events)); - controller.close(); - }, - }); - resolve(new Response(stream)); - }, delayMs); - }); -} - const SAMPLE_WELCOME_MESSAGE: WelcomeMessageConfig = { title: "Hi, I'm OpenUI Assistant", description: "I can help you with questions about your account, products, and more.", @@ -80,30 +66,24 @@ const mockGenerateShareLink = async (threadId: string) => { return `https://example.com/shared/${threadId}`; }; -const mockProcessMessage = async ({ messages }: { messages: Message[] }) => { - const lastMsg = messages[messages.length - 1]; - const content = - lastMsg?.role === "user" && typeof lastMsg.content === "string" ? lastMsg.content : ""; - return mockSSEResponse(`You said: "${content}". This is a response from the AI assistant.`); -}; +const mockLLM = makeMockLLM({ + send: async ({ messages }) => { + const lastMsg = messages[messages.length - 1]; + const content = + lastMsg?.role === "user" && typeof lastMsg.content === "string" ? lastMsg.content : ""; + return mockSSEResponse(`You said: "${content}". This is a response from the AI assistant.`); + }, +}); -const sharedProps = { - processMessage: mockProcessMessage, - fetchThreadList: async () => ({ +const mockStorage = makeMockStorage({ + listThreads: async () => ({ threads: [ { id: "1", title: "Previous Chat 1", createdAt: Date.now() }, { id: "2", title: "Previous Chat 2", createdAt: Date.now() }, { id: "3", title: "Previous Chat 3", createdAt: Date.now() }, ], }), - createThread: async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - }), - deleteThread: async () => {}, - updateThread: async (t: any) => t, - loadThread: async (threadId: string) => { + getMessages: async (threadId: string) => { if (!threadId) return []; return [ { id: crypto.randomUUID(), role: "user", content: "Hello" }, @@ -114,6 +94,11 @@ const sharedProps = { }, ] as Message[]; }, +}); + +const sharedProps = { + storage: mockStorage, + llm: mockLLM, logoUrl, agentName: "OpenUI Assistant", }; diff --git a/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx b/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx index f2955cee0..9556102cf 100644 --- a/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx +++ b/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx @@ -1,4 +1,8 @@ -import type { AssistantMessage, ChatProviderProps, UserMessage } from "@openuidev/react-headless"; +import type { + AssistantMessage, + ChatProviderProps, + UserMessage, +} from "@openuidev/react-headless"; import { ChatProvider } from "@openuidev/react-headless"; import type { Library } from "@openuidev/react-lang"; import { useMemo } from "react"; @@ -17,46 +21,23 @@ export type ChatLayoutProps = Omit & ThemeWrapperProps & Extra; -const DummyThemeProvider = ({ children }: { children: React.ReactNode }) => { - return children; -}; - -const CHAT_PROVIDER_PROP_KEYS: Set> = new Set([ - "apiUrl", - "processMessage", - "threadApiUrl", - "fetchThreadList", - "createThread", - "deleteThread", - "updateThread", - "loadThread", - "streamProtocol", - "messageFormat", - "appRenderers", -]); +const DummyThemeProvider = ({ children }: { children: React.ReactNode }) => children; export function withChatProvider(WrappedComponent: React.ComponentType) { const WithChatProvider = (props: ChatLayoutProps) => { - const innerProps: Record = {}; - const chatProviderProps: Record = {}; - let theme: ThemeProps | undefined; - let disableThemeProvider = false; - - for (const [key, value] of Object.entries(props)) { - if (key === "theme") { - theme = value as ThemeProps; - } else if (key === "disableThemeProvider") { - disableThemeProvider = value as boolean; - } else if (CHAT_PROVIDER_PROP_KEYS.has(key as keyof Omit)) { - chatProviderProps[key] = value; - } else { - innerProps[key] = value; - } - } + const { + storage, + llm, + appRenderers, + theme, + disableThemeProvider, + ...innerProps + } = props as ChatLayoutProps; - const componentLibrary = innerProps["componentLibrary"] as Library | undefined; - const customAssistantMessage = innerProps["assistantMessage"]; - const customUserMessage = innerProps["userMessage"]; + const sharedUIProps = innerProps as SharedChatUIProps; + const componentLibrary = sharedUIProps.componentLibrary as Library | undefined; + const customAssistantMessage = sharedUIProps.assistantMessage; + const customUserMessage = sharedUIProps.userMessage; const genUIAssistantMessage = useMemo(() => { if (customAssistantMessage || !componentLibrary) return undefined; @@ -70,19 +51,20 @@ export function withChatProvider(WrappedComponent: React.Compon return ({ message }: { message: UserMessage }) => ; }, [customUserMessage, componentLibrary]); + const finalInnerProps: Record = { ...innerProps }; if (genUIAssistantMessage && !customAssistantMessage) { - innerProps["assistantMessage"] = genUIAssistantMessage; + finalInnerProps["assistantMessage"] = genUIAssistantMessage; } if (genUIUserMessage && !customUserMessage) { - innerProps["userMessage"] = genUIUserMessage; + finalInnerProps["userMessage"] = genUIUserMessage; } const ThemeProviderComponent = disableThemeProvider ? DummyThemeProvider : ThemeProvider; return ( - - + + ); diff --git a/packages/react-ui/src/components/Shell/stories/Shell.stories.tsx b/packages/react-ui/src/components/Shell/stories/Shell.stories.tsx index e689f4437..6bd353118 100644 --- a/packages/react-ui/src/components/Shell/stories/Shell.stories.tsx +++ b/packages/react-ui/src/components/Shell/stories/Shell.stories.tsx @@ -1,5 +1,6 @@ -import { ChatProvider, Message, type Thread } from "@openuidev/react-headless"; +import { ChatProvider, Message } from "@openuidev/react-headless"; import { MessageSquare, Share, Sparkles, Zap } from "lucide-react"; +import { makeMockLLM, makeMockStorage, mockSSEResponse } from "../../../__test-helpers/mockChat"; import { Button } from "../../Button"; import { IconButton } from "../../IconButton"; import { Container } from "../Container"; @@ -19,27 +20,54 @@ import { ThreadList } from "../ThreadList"; import { WelcomeScreen } from "../WelcomeScreen"; import logoUrl from "./thesysdev_logo.jpeg"; -function mockSSEResponse(text: string, delayMs = 500): Promise { - return new Promise((resolve) => { - setTimeout(() => { - const events = `data: ${JSON.stringify({ type: "TEXT_MESSAGE_CONTENT", delta: text })}\n\ndata: [DONE]\n\n`; - const stream = new ReadableStream({ - start(controller) { - controller.enqueue(new TextEncoder().encode(events)); - controller.close(); - }, - }); - resolve(new Response(stream)); - }, delayMs); - }); -} - function getLastUserContent(messages: Message[]): string { const lastUser = [...messages].reverse().find((m) => m.role === "user"); if (!lastUser) return ""; return typeof lastUser.content === "string" ? lastUser.content : ""; } +// ── Reusable adapter setups ── + +const populatedStorage = makeMockStorage({ + listThreads: async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return { + threads: [ + { id: "1", title: "test", createdAt: Date.now() }, + { id: "2", title: "test 2", createdAt: Date.now() }, + { id: "3", title: "test 3", createdAt: Date.now() }, + ], + }; + }, + getMessages: async (threadId) => { + if (!threadId) return []; + return [ + { id: crypto.randomUUID(), role: "user", content: "Hello" }, + { + id: crypto.randomUUID(), + role: "assistant", + content: "Hello! How can I help you today?", + }, + ] as Message[]; + }, +}); + +const emptyStorage = makeMockStorage({}); + +const defaultLLM = makeMockLLM({ + send: async () => { + await new Promise((r) => setTimeout(r, 100)); + return mockSSEResponse("This is a response from the AI assistant.", 1000); + }, +}); + +const echoLLM = makeMockLLM({ + send: async ({ messages }) => { + const content = getLastUserContent(messages); + return mockSSEResponse(`You asked: "${content}"`, 1000); + }, +}); + export default { title: "Components/Shell", tags: ["dev"], @@ -96,40 +124,7 @@ export const Default = { variant: "short", }, render: ({ variant }: { variant: "short" | "long" }) => ( - { - await new Promise((r) => setTimeout(r, 100)); - return mockSSEResponse("This is a response from the AI assistant.", 1000); - }} - fetchThreadList={async () => { - await new Promise((resolve) => setTimeout(resolve, 1000)); - return { - threads: [ - { id: "1", title: "test", createdAt: Date.now() }, - { id: "2", title: "test 2", createdAt: Date.now() }, - { id: "3", title: "test 3", createdAt: Date.now() }, - ], - }; - }} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "test", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t: Thread) => t} - loadThread={async (threadId) => { - if (!threadId) return []; - return [ - { id: crypto.randomUUID(), role: "user", content: "Hello" }, - { - id: crypto.randomUUID(), - role: "assistant", - content: "Hello! How can I help you today?", - }, - ]; - }} - > + @@ -172,21 +167,7 @@ export const WithThreadHeader = { variant: "short", }, render: ({ variant }: { variant: "short" | "long" }) => ( - { - const content = getLastUserContent(messages); - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t: Thread) => t} - loadThread={async () => []} - > + @@ -233,21 +214,7 @@ export const WithConversationStarter = { variant: "short", }, render: ({ variant }: { variant: "short" | "long" }) => ( - { - const content = getLastUserContent(messages); - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t: Thread) => t} - loadThread={async () => []} - > + @@ -285,21 +252,7 @@ export const LongVariant = { variant: "long", }, render: ({ variant }: { variant: "short" | "long" }) => ( - { - const content = getLastUserContent(messages); - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t: Thread) => t} - loadThread={async () => []} - > + @@ -337,21 +290,7 @@ export const WithWelcomeScreen = { variant: "short", }, render: ({ variant }: { variant: "short" | "long" }) => ( - { - const content = getLastUserContent(messages); - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t: Thread) => t} - loadThread={async () => []} - > + @@ -396,21 +335,7 @@ export const WithCustomWelcomeScreen = { variant: "short", }, render: ({ variant }: { variant: "short" | "long" }) => ( - { - const content = getLastUserContent(messages); - return mockSSEResponse(`You asked: "${content}"`, 1000); - }} - fetchThreadList={async () => ({ threads: [] })} - createThread={async () => ({ - id: crypto.randomUUID(), - title: "New Chat", - createdAt: Date.now(), - })} - deleteThread={async () => {}} - updateThread={async (t: Thread) => t} - loadThread={async () => []} - > + From ff1367e636b6d4fe51e34d72877360250a807e94 Mon Sep 17 00:00:00 2001 From: abhithesys Date: Mon, 18 May 2026 20:10:54 +0530 Subject: [PATCH 08/88] typecheck fix --- .../src/stream/adapters/openai-responses.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/react-headless/src/stream/adapters/openai-responses.ts b/packages/react-headless/src/stream/adapters/openai-responses.ts index 8fc0ffc5e..8763658b8 100644 --- a/packages/react-headless/src/stream/adapters/openai-responses.ts +++ b/packages/react-headless/src/stream/adapters/openai-responses.ts @@ -1,4 +1,7 @@ -import type { ResponseStreamEvent } from "openai/resources/responses/responses"; +import type { + ResponseFunctionToolCallOutputItem, + ResponseStreamEvent, +} from "openai/resources/responses/responses"; import { AGUIEvent, EventType, StreamProtocolAdapter } from "../../types"; export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ @@ -27,7 +30,10 @@ export const openAIResponsesAdapter = (): StreamProtocolAdapter => ({ switch (event.type) { case "response.output_item.added": { - const item = event.item; + // OpenAI's Conversations API surfaces function_call_output as an + // output item even though the SDK's ResponseOutputItem union does + // not declare it. Widen the type so we can branch on it below. + const item = event.item as typeof event.item | ResponseFunctionToolCallOutputItem; if (item.type === "message" && item.role === "assistant") { yield { type: EventType.TEXT_MESSAGE_START, From c9d54c983c8485918929bc1be3d9394757433f7d Mon Sep 17 00:00:00 2001 From: abhithesys Date: Thu, 28 May 2026 21:37:08 +0530 Subject: [PATCH 09/88] agent interface --- examples/supabase-chat/tsconfig.json | 31 +- .../AgentInterface/AgentInterface.tsx | 300 ++++++++ .../components/AgentInterface/Composer.tsx | 51 ++ .../components/AgentInterface/Container.tsx | 40 ++ .../AgentInterface/ConversationStarter.tsx | 139 ++++ .../AgentInterface/MobileHeader.tsx | 94 +++ .../AgentInterface/NewChatButton.tsx | 38 + .../AgentInterface/ResizableSeparator.tsx | 77 ++ .../src/components/AgentInterface/Route.tsx | 21 + .../src/components/AgentInterface/Sidebar.tsx | 167 +++++ .../components/AgentInterface/SidebarItem.tsx | 78 +++ .../components/AgentInterface/SidebarSlot.tsx | 19 + .../src/components/AgentInterface/Thread.tsx | 378 ++++++++++ .../components/AgentInterface/ThreadList.tsx | 164 +++++ .../AgentInterface/WelcomeScreen.tsx | 142 ++++ .../detailed-view/DetailedViewOverlay.tsx | 73 ++ .../detailed-view/DetailedViewPanel.tsx | 134 ++++ .../DetailedViewPortalTarget.tsx | 43 ++ .../detailed-view/detailedViewOverlay.scss | 40 ++ .../detailed-view/detailedViewPanel.scss | 46 ++ .../_shared/detailed-view/index.ts | 3 + .../AgentInterface/_shared/index.ts | 5 + .../AgentInterface/_shared/navContext.tsx | 74 ++ .../AgentInterface/_shared/shared.scss | 2 + .../_shared/startersContext.tsx | 20 + .../AgentInterface/_shared/store/index.ts | 1 + .../AgentInterface/_shared/store/store.tsx | 57 ++ .../tool-renderer/RendererInstance.tsx | 96 +++ .../tool-renderer/ToolMessageRenderer.tsx | 57 ++ .../_shared/tool-renderer/index.ts | 1 + .../AgentInterface/_shared/types/index.ts | 34 + .../AgentInterface/_shared/utils/index.ts | 11 + .../AgentInterface/agentInterface.scss | 37 + .../AgentInterface/components/Composer.tsx | 82 +++ .../components/DesktopWelcomeComposer.tsx | 77 ++ .../AgentInterface/components/composer.scss | 73 ++ .../components/desktopWelcomeComposer.scss | 57 ++ .../AgentInterface/components/index.ts | 2 + .../AgentInterface/conversationStarter.scss | 156 +++++ .../components/AgentInterface/dependencies.ts | 5 + .../src/components/AgentInterface/index.ts | 10 + .../AgentInterface/mobileHeader.scss | 40 ++ .../AgentInterface/resizableSeparator.scss | 53 ++ .../components/AgentInterface/sidebar.scss | 194 +++++ .../stories/AgentInterface.stories.tsx | 663 ++++++++++++++++++ .../stories/thesysdev_logo.jpeg | Bin 0 -> 2429 bytes .../src/components/AgentInterface/thread.scss | 351 ++++++++++ .../components/AgentInterface/threadlist.scss | 94 +++ .../AgentInterface/useDetailedViewResize.ts | 89 +++ .../AgentInterface/welcomeScreen.scss | 158 +++++ packages/react-ui/src/components/index.scss | 1 + packages/react-ui/src/index.ts | 1 + 52 files changed, 4575 insertions(+), 4 deletions(-) create mode 100644 packages/react-ui/src/components/AgentInterface/AgentInterface.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/Composer.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/Container.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/ConversationStarter.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/MobileHeader.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/NewChatButton.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/ResizableSeparator.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/Route.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/Sidebar.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/SidebarItem.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/SidebarSlot.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/Thread.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/ThreadList.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/WelcomeScreen.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewOverlay.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPanel.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPortalTarget.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewOverlay.scss create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewPanel.scss create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/detailed-view/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/navContext.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/shared.scss create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/startersContext.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/store/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/store/store.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/RendererInstance.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/ToolMessageRenderer.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/types/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/_shared/utils/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/agentInterface.scss create mode 100644 packages/react-ui/src/components/AgentInterface/components/Composer.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/components/DesktopWelcomeComposer.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/components/composer.scss create mode 100644 packages/react-ui/src/components/AgentInterface/components/desktopWelcomeComposer.scss create mode 100644 packages/react-ui/src/components/AgentInterface/components/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/conversationStarter.scss create mode 100644 packages/react-ui/src/components/AgentInterface/dependencies.ts create mode 100644 packages/react-ui/src/components/AgentInterface/index.ts create mode 100644 packages/react-ui/src/components/AgentInterface/mobileHeader.scss create mode 100644 packages/react-ui/src/components/AgentInterface/resizableSeparator.scss create mode 100644 packages/react-ui/src/components/AgentInterface/sidebar.scss create mode 100644 packages/react-ui/src/components/AgentInterface/stories/AgentInterface.stories.tsx create mode 100644 packages/react-ui/src/components/AgentInterface/stories/thesysdev_logo.jpeg create mode 100644 packages/react-ui/src/components/AgentInterface/thread.scss create mode 100644 packages/react-ui/src/components/AgentInterface/threadlist.scss create mode 100644 packages/react-ui/src/components/AgentInterface/useDetailedViewResize.ts create mode 100644 packages/react-ui/src/components/AgentInterface/welcomeScreen.scss diff --git a/examples/supabase-chat/tsconfig.json b/examples/supabase-chat/tsconfig.json index 93e220ad6..1894d9ffc 100644 --- a/examples/supabase-chat/tsconfig.json +++ b/examples/supabase-chat/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -13,7 +17,26 @@ "isolatedModules": true, "jsx": "react-jsx", "incremental": true, - "plugins": [{ "name": "next" }], - "paths": { "@/*": ["./src/*"] } - } + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + } + }, + "include": [ + "next-env.d.ts", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts", + "**/*.mts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] } diff --git a/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx b/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx new file mode 100644 index 000000000..949118a4c --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx @@ -0,0 +1,300 @@ +import { + ChatProvider, + type AssistantMessage, + type ChatProviderProps, + type UserMessage, +} from "@openuidev/react-headless"; +import type { Library } from "@openuidev/react-lang"; +import { + Children, + isValidElement, + useMemo, + type FC, + type ReactElement, + type ReactNode, +} from "react"; +import type { ConversationStarterProps } from "../../types/ConversationStarter"; +import { GenUIAssistantMessage } from "../OpenUIChat/GenUIAssistantMessage"; +import { GenUIUserMessage } from "../OpenUIChat/GenUIUserMessage"; +import { ThemeProvider, type ThemeProps } from "../ThemeProvider"; +import { NavProvider, useNav } from "./_shared/navContext"; +import type { AssistantMessageComponent, UserMessageComponent } from "./_shared/types"; +import { StartersProvider } from "./_shared/startersContext"; +import { Composer } from "./Composer"; +import { type ConversationStarterVariant } from "./ConversationStarter"; +import { Container } from "./Container"; +import { MobileHeader } from "./MobileHeader"; +import { NewChatButton } from "./NewChatButton"; +import { Route } from "./Route"; +import { + SidebarContainer, + SidebarContent, + SidebarHeader, + SidebarSeparator, +} from "./Sidebar"; +import { SidebarItem } from "./SidebarItem"; +import { SidebarSlot } from "./SidebarSlot"; +import { + MessageLoading, + Messages, + ScrollArea, + ThreadContainer, + ThreadHeader, +} from "./Thread"; +import { ThreadList } from "./ThreadList"; +import { WelcomeScreen } from "./WelcomeScreen"; + +export interface AgentInterfaceComponents { + AssistantMessage?: AssistantMessageComponent; + UserMessage?: UserMessageComponent; +} + +export interface AgentInterfaceProps extends Omit { + /** Component library for auto-GenUI rendering when `components.AssistantMessage` is not provided. */ + componentLibrary?: Library; + /** Explicit component overrides. Takes precedence over GenUI auto-derivation. */ + components?: AgentInterfaceComponents; + /** Theme props passed to . */ + theme?: ThemeProps; + /** When true, skips wrapping in . */ + disableThemeProvider?: boolean; + /** Brand logo shown in default SidebarHeader + MobileHeader. */ + logoUrl?: string; + /** Agent display name. */ + agentName?: string; + /** Global starters inherited by Welcome (when active) or Composer. */ + starters?: ConversationStarterProps[]; + /** Layout variant for inherited starters. */ + starterVariant?: ConversationStarterVariant; + /** Controlled current path. Pair with `onNavigate`. `undefined` = thread view. */ + path?: string; + /** Initial path for uncontrolled mode. Ignored when `onNavigate` is provided. */ + defaultPath?: string; + /** Called when navigation occurs. Presence selects controlled mode. */ + onNavigate?: (next: string | undefined) => void; + children?: ReactNode; +} + +interface ExtractedSlots { + sidebar?: ReactElement; + sidebarHeader?: ReactElement; + mobileHeader?: ReactElement; + threadHeader?: ReactElement; + welcome?: ReactElement; + composer?: ReactElement; + routes: ReactElement[]; + rest: ReactNode[]; +} + +type SingleSlotKey = Exclude; + +const SLOT_KEY_BY_TYPE = new Map([ + [SidebarSlot, "sidebar"], + [SidebarHeader, "sidebarHeader"], + [MobileHeader, "mobileHeader"], + [ThreadHeader, "threadHeader"], + [WelcomeScreen, "welcome"], + [Composer, "composer"], +]); + +const isDev = () => + typeof process !== "undefined" && process.env?.["NODE_ENV"] !== "production"; + +function extractSlots(children: ReactNode): ExtractedSlots { + const result: ExtractedSlots = { routes: [], rest: [] }; + Children.forEach(children, (child) => { + if (!isValidElement(child)) { + result.rest.push(child); + return; + } + if (child.type === Route) { + result.routes.push(child); + return; + } + const key = SLOT_KEY_BY_TYPE.get(child.type); + if (!key) { + result.rest.push(child); + return; + } + if (result[key]) { + if (isDev()) { + console.warn( + `[AgentInterface] Multiple slot children — using the first; ignoring the rest.`, + ); + } + return; + } + result[key] = child; + }); + return result; +} + +const DummyThemeProvider = ({ children }: { children: ReactNode }) => <>{children}; + +interface AgentInterfaceComponent extends FC { + Sidebar: typeof SidebarSlot; + SidebarHeader: typeof SidebarHeader; + SidebarContent: typeof SidebarContent; + SidebarSeparator: typeof SidebarSeparator; + SidebarItem: typeof SidebarItem; + Route: typeof Route; + MobileHeader: typeof MobileHeader; + ThreadHeader: typeof ThreadHeader; + Welcome: typeof WelcomeScreen; + Composer: typeof Composer; + NewChatButton: typeof NewChatButton; + ThreadList: typeof ThreadList; + Messages: typeof Messages; + MessageLoading: typeof MessageLoading; + ScrollArea: typeof ScrollArea; +} + +export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfaceProps) => { + const { + storage, + llm, + appRenderers, + componentLibrary, + components, + theme, + disableThemeProvider, + logoUrl, + agentName, + starters, + starterVariant, + path, + defaultPath, + onNavigate, + children, + } = props; + + const slots = useMemo(() => extractSlots(children), [children]); + + if (slots.sidebar && slots.sidebarHeader) { + if (isDev()) { + console.warn( + "[AgentInterface] at top level is ignored because is provided. Put SidebarHeader inside Sidebar instead.", + ); + } + slots.sidebarHeader = undefined; + } + + const resolvedAssistantMessage = useMemo(() => { + if (components?.AssistantMessage) return components.AssistantMessage; + if (componentLibrary) { + const Cmp = ({ message }: { message: AssistantMessage }) => ( + + ); + return Cmp; + } + return undefined; + }, [components?.AssistantMessage, componentLibrary]); + + const resolvedUserMessage = useMemo(() => { + if (components?.UserMessage) return components.UserMessage; + if (componentLibrary) { + const Cmp = ({ message }: { message: UserMessage }) => ; + return Cmp; + } + return undefined; + }, [components?.UserMessage, componentLibrary]); + + const ThemeProviderComponent = disableThemeProvider ? DummyThemeProvider : ThemeProvider; + + return ( + + + + + + + + + + ); +}) as AgentInterfaceComponent; + +interface AgentInterfaceBodyProps { + slots: ExtractedSlots; + logoUrl: string; + agentName: string; + resolvedAssistantMessage: AssistantMessageComponent | undefined; + resolvedUserMessage: UserMessageComponent | undefined; +} + +const AgentInterfaceBody = ({ + slots, + logoUrl, + agentName, + resolvedAssistantMessage, + resolvedUserMessage, +}: AgentInterfaceBodyProps) => { + const { path } = useNav(); + + const activeRoute = useMemo(() => { + if (path === undefined) return undefined; + return slots.routes.find( + (route) => (route.props as { path: string }).path === path, + ); + }, [path, slots.routes]); + + return ( + + + {slots.sidebar ? ( + (slots.sidebar.props as { children?: ReactNode }).children + ) : ( + <> + {slots.sidebarHeader ?? } + + + + + + )} + + {activeRoute ? ( + + {(activeRoute.props as { children?: ReactNode }).children} + + ) : ( + + {slots.mobileHeader ?? } + {slots.threadHeader} + {slots.welcome} + + } + assistantMessage={resolvedAssistantMessage} + userMessage={resolvedUserMessage} + /> + + {slots.composer ?? } + + )} + {slots.rest} + + ); +}; + +AgentInterface.Sidebar = SidebarSlot; +AgentInterface.SidebarHeader = SidebarHeader; +AgentInterface.SidebarContent = SidebarContent; +AgentInterface.SidebarSeparator = SidebarSeparator; +AgentInterface.SidebarItem = SidebarItem; +AgentInterface.Route = Route; +AgentInterface.MobileHeader = MobileHeader; +AgentInterface.ThreadHeader = ThreadHeader; +AgentInterface.Welcome = WelcomeScreen; +AgentInterface.Composer = Composer; +AgentInterface.NewChatButton = NewChatButton; +AgentInterface.ThreadList = ThreadList; +AgentInterface.Messages = Messages; +AgentInterface.MessageLoading = MessageLoading; +AgentInterface.ScrollArea = ScrollArea; diff --git a/packages/react-ui/src/components/AgentInterface/Composer.tsx b/packages/react-ui/src/components/AgentInterface/Composer.tsx new file mode 100644 index 000000000..c2e269fcc --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/Composer.tsx @@ -0,0 +1,51 @@ +import { useThread } from "@openuidev/react-headless"; +import clsx from "clsx"; +import type { ReactNode } from "react"; +import type { ConversationStarterProps } from "../../types/ConversationStarter"; +import { Composer as ComposerInput } from "./components/Composer"; +import { ConversationStarter, type ConversationStarterVariant } from "./ConversationStarter"; +import { useStartersFromContext } from "./_shared/startersContext"; +import { isChatEmpty } from "./_shared/utils"; + +export interface ComposerProps { + className?: string; + placeholder?: string; + /** Starters chips shown above the input when chat is empty. Inherits from . */ + starters?: ConversationStarterProps[]; + /** Layout variant for starters. Inherits from . */ + starterVariant?: ConversationStarterVariant; + /** Mode C — fully replaces the composer area. When provided, auto-starters rendering is disabled. */ + children?: ReactNode; +} + +export const Composer = ({ + className, + placeholder, + starters: ownStarters, + starterVariant: ownVariant, + children, +}: ComposerProps) => { + const fromCtx = useStartersFromContext(); + const messages = useThread((s) => s.messages); + const isLoadingMessages = useThread((s) => s.isLoadingMessages); + + if (children != null) { + return
    {children}
    ; + } + + const effectiveStarters = ownStarters ?? fromCtx.starters; + const effectiveVariant = ownVariant ?? fromCtx.starterVariant ?? "short"; + const showStarters = + isChatEmpty({ isLoadingMessages, messages }) && + effectiveStarters !== undefined && + effectiveStarters.length > 0; + + return ( +
    + {showStarters && ( + + )} + +
    + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/Container.tsx b/packages/react-ui/src/components/AgentInterface/Container.tsx new file mode 100644 index 000000000..551ad29d7 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/Container.tsx @@ -0,0 +1,40 @@ +import clsx from "clsx"; +import { useRef } from "react"; +import { LayoutContextProvider } from "../../context/LayoutContext"; +import { useElementSize } from "../../hooks/useElementSize"; +import { AgentInterfaceStoreProvider } from "./_shared/store"; + +interface ContainerProps { + children?: React.ReactNode; + logoUrl: string; + agentName: string; + className?: string; +} + +export const Container = ({ children, logoUrl, agentName, className }: ContainerProps) => { + const ref = useRef(null); + const { width } = useElementSize({ ref }) || {}; + // TODO: revisit this logic + const isMobile = width > 0 && width < 768; + const isFullScreen = width > 768; + const layout = isMobile ? "mobile" : isFullScreen ? "fullscreen" : "tray"; + + return ( + + +
    + {children} +
    +
    +
    + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/ConversationStarter.tsx b/packages/react-ui/src/components/AgentInterface/ConversationStarter.tsx new file mode 100644 index 000000000..2bfb3c9c9 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/ConversationStarter.tsx @@ -0,0 +1,139 @@ +import { useThread } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { ArrowUp, Lightbulb } from "lucide-react"; +import { Fragment, ReactNode } from "react"; +import { ConversationStarterIcon, ConversationStarterProps } from "../../types/ConversationStarter"; +import { isChatEmpty } from "./_shared/utils"; +import { Separator } from "../Separator"; + +export type ConversationStarterVariant = "short" | "long"; + +interface ConversationStarterItemProps extends ConversationStarterProps { + onClick: (prompt: string) => void; + variant: ConversationStarterVariant; +} + +/** + * Renders the appropriate icon based on the icon prop value + * - undefined: Show default lightbulb icon + * - ReactNode: Show the provided icon (use <> or React.Fragment for no icon) + */ +const renderIcon = (icon: ConversationStarterIcon | undefined): ReactNode => { + if (icon === undefined) { + return ; + } + return icon; +}; + +const ConversationStarterItem = ({ + displayText, + prompt, + onClick, + variant, + icon, +}: ConversationStarterItemProps) => { + const renderedIcon = renderIcon(icon); + + if (variant === "short") { + return ( + + ); + } + + // Long variant (detailed list style) + return ( + + ); +}; + +export interface ConversationStarterContainerProps { + starters: ConversationStarterProps[]; + className?: string; + /** + * Variant of the conversation starter + * - "short": Pill-style buttons that wrap (default) + * - "long": Vertical list items with icons and hover arrow + */ + variant?: ConversationStarterVariant; +} + +export const ConversationStarter = ({ + starters, + className, + variant = "short", +}: ConversationStarterContainerProps) => { + const processMessage = useThread((s) => s.processMessage); + const isRunning = useThread((s) => s.isRunning); + const messages = useThread((s) => s.messages); + const isLoadingMessages = useThread((s) => s.isLoadingMessages); + + const handleClick = (prompt: string) => { + if (isRunning) return; + processMessage({ + role: "user", + content: prompt, + }); + }; + + // Only show when there are no messages + if (!isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + if (starters.length === 0) { + return null; + } + + return ( +
    + {starters.map((item, index) => ( + + + {/* Add separator between items in long variant */} + {variant === "long" && index < starters.length - 1 && ( +
    + +
    + )} +
    + ))} +
    + ); +}; + +export default ConversationStarter; diff --git a/packages/react-ui/src/components/AgentInterface/MobileHeader.tsx b/packages/react-ui/src/components/AgentInterface/MobileHeader.tsx new file mode 100644 index 000000000..e1beafd6c --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/MobileHeader.tsx @@ -0,0 +1,94 @@ +import { useThreadList } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { Menu, Plus } from "lucide-react"; +import type { ReactNode } from "react"; +import { IconButton } from "../IconButton"; +import { useAgentInterfaceStore } from "./_shared/store"; + +export interface MobileHeaderProps { + className?: string; + logo?: ReactNode; + agentName?: ReactNode; + menuButton?: ReactNode | false; + newChatButton?: ReactNode | false; + actions?: ReactNode; + children?: ReactNode; +} + +export const MobileHeader = ({ + className, + logo, + agentName: agentNameProp, + menuButton, + newChatButton, + actions, + children, +}: MobileHeaderProps) => { + const switchToNewThread = useThreadList((s) => s.switchToNewThread); + const { logoUrl, agentName: ctxAgentName, setIsSidebarOpen } = useAgentInterfaceStore( + (state) => ({ + logoUrl: state.logoUrl, + agentName: state.agentName, + setIsSidebarOpen: state.setIsSidebarOpen, + }), + ); + + if (children != null) { + if ( + typeof process !== "undefined" && + process.env?.["NODE_ENV"] !== "production" && + (logo !== undefined || + agentNameProp !== undefined || + menuButton !== undefined || + newChatButton !== undefined || + actions !== undefined) + ) { + console.warn( + "[AgentInterface] received both children and override props; children win.", + ); + } + return
    {children}
    ; + } + + const defaultMenuButton = ( + } + onClick={() => setIsSidebarOpen(true)} + variant="secondary" + aria-label="Open sidebar" + /> + ); + + const defaultLogo = ( + Logo + ); + + const defaultAgentName = ( + {ctxAgentName} + ); + + const defaultNewChatButton = ( + } + onClick={switchToNewThread} + variant="secondary" + aria-label="New chat" + /> + ); + + return ( +
    + {menuButton === false ? null : (menuButton ?? defaultMenuButton)} +
    + {logo ?? defaultLogo} + {agentNameProp ?? defaultAgentName} +
    +
    + {actions} + {newChatButton === false ? null : (newChatButton ?? defaultNewChatButton)} +
    +
    + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/NewChatButton.tsx b/packages/react-ui/src/components/AgentInterface/NewChatButton.tsx new file mode 100644 index 000000000..927f44b3d --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/NewChatButton.tsx @@ -0,0 +1,38 @@ +import { useThreadList } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { Plus, SquarePen } from "lucide-react"; +import { Button } from "../Button"; +import { IconButton } from "../IconButton"; +import { useAgentInterfaceStore } from "./_shared/store"; + +export const NewChatButton = ({ className }: { className?: string }) => { + const switchToNewThread = useThreadList((s) => s.switchToNewThread); + const { isSidebarOpen } = useAgentInterfaceStore((state) => ({ + isSidebarOpen: state.isSidebarOpen, + })); + + if (!isSidebarOpen) { + return ( + } + onClick={switchToNewThread} + variant="primary" + size="small" + aria-label="New chat" + className={clsx("openui-agent-new-chat-button_collapsed", className)} + /> + ); + } + + return ( + + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/ResizableSeparator.tsx b/packages/react-ui/src/components/AgentInterface/ResizableSeparator.tsx new file mode 100644 index 000000000..f73393811 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/ResizableSeparator.tsx @@ -0,0 +1,77 @@ +import clsx from "clsx"; +import { useEffect, useRef } from "react"; + +interface ResizableSeparatorProps { + onResize: (clientX: number) => void; + onDragStart: () => void; + onDragEnd: () => void; + className?: string; +} + +/** + * A draggable vertical separator for resizing panels. + * Used between chat and detailed-view panels in desktop mode. + */ +export const ResizableSeparator = ({ + onResize, + onDragStart, + onDragEnd, + className, +}: ResizableSeparatorProps) => { + const isDraggingRef = useRef(false); + const onResizeRef = useRef(onResize); + const onDragStartRef = useRef(onDragStart); + const onDragEndRef = useRef(onDragEnd); + + // Keep callback refs up to date without triggering effect re-runs + useEffect(() => { + onResizeRef.current = onResize; + onDragStartRef.current = onDragStart; + onDragEndRef.current = onDragEnd; + }, [onResize, onDragStart, onDragEnd]); + + // Global mouse event handlers for drag behavior + // Uses refs instead of dependencies to avoid re-creating listeners + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (isDraggingRef.current) { + e.preventDefault(); + onResizeRef.current(e.clientX); + } + }; + + const handleMouseUp = () => { + if (isDraggingRef.current) { + isDraggingRef.current = false; + onDragEndRef.current(); + // Reset cursor styles + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }; + + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, []); // Empty deps - handlers use refs to access latest callbacks + + const handleMouseDown = () => { + isDraggingRef.current = true; + onDragStartRef.current(); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }; + + return ( +
    +
    +
    + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/Route.tsx b/packages/react-ui/src/components/AgentInterface/Route.tsx new file mode 100644 index 000000000..28c67231e --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/Route.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from "react"; + +export interface RouteProps { + /** Exact path match (no wildcards or params in v1). */ + path: string; + /** Content shown in the thread region when this route is active. */ + children?: ReactNode; +} + +/** + * Slot marker for a routable view. Never rendered directly — the parent + * extracts all Routes from its children, finds the one + * whose `path` matches the current nav state, and renders that Route's + * children in place of the entire thread region (MobileHeader, ThreadHeader, + * ScrollArea/Messages, Composer all hidden). + * + * Use multiple siblings to define separate views. + * When no Route matches, the thread region renders normally. + */ +export const Route = (_props: RouteProps) => null; +Route.displayName = "AgentInterface.Route"; diff --git a/packages/react-ui/src/components/AgentInterface/Sidebar.tsx b/packages/react-ui/src/components/AgentInterface/Sidebar.tsx new file mode 100644 index 000000000..4b6b6b672 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/Sidebar.tsx @@ -0,0 +1,167 @@ +import { useActiveDetailedView } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { ArrowLeftFromLine, ArrowRightFromLine } from "lucide-react"; +import { useEffect } from "react"; +import { useLayoutContext } from "../../context/LayoutContext"; +import { IconButton } from "../IconButton"; +import { useAgentInterfaceStore } from "./_shared/store"; + +export const SidebarContainer = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + const { isSidebarOpen, setIsSidebarOpen } = useAgentInterfaceStore((state) => ({ + isSidebarOpen: state.isSidebarOpen, + setIsSidebarOpen: state.setIsSidebarOpen, + })); + const { isDetailedViewActive } = useActiveDetailedView(); + const { layout } = useLayoutContext() || {}; + const isMobile = layout === "mobile"; + + useEffect(() => { + if (isMobile) { + setIsSidebarOpen(false); + } else { + setIsSidebarOpen(true); + } + }, [isMobile]); + + return ( + <> + {isMobile && ( +
    { + setIsSidebarOpen(false); + }} + /> + )} +
    + {children} +
    + + ); +}; + +export interface SidebarHeaderProps { + className?: string; + logo?: React.ReactNode; + agentName?: React.ReactNode; + collapseButton?: React.ReactNode | false; + children?: React.ReactNode; +} + +export const SidebarHeader = ({ + className, + logo, + agentName: agentNameProp, + collapseButton, + children, +}: SidebarHeaderProps) => { + const { agentName: ctxAgentName, logoUrl, setIsSidebarOpen, isSidebarOpen } = useAgentInterfaceStore( + (state) => ({ + agentName: state.agentName, + logoUrl: state.logoUrl, + setIsSidebarOpen: state.setIsSidebarOpen, + isSidebarOpen: state.isSidebarOpen, + }), + ); + + if (children != null) { + if ( + typeof process !== "undefined" && + process.env?.["NODE_ENV"] !== "production" && + (logo !== undefined || agentNameProp !== undefined || collapseButton !== undefined) + ) { + console.warn( + "[AgentInterface] received both children and override props; children win.", + ); + } + return ( +
    + {children} +
    + ); + } + + const defaultLogo = ( + {ctxAgentName} + ); + const defaultAgentName = ( +
    {ctxAgentName}
    + ); + const defaultCollapseButton = ( + : } + onClick={() => { + setIsSidebarOpen(!isSidebarOpen); + }} + size="small" + variant="secondary" + aria-label={isSidebarOpen ? "Collapse sidebar" : "Expand sidebar"} + className="openui-agent-sidebar-header__toggle-button" + /> + ); + + return ( +
    +
    + {logo ?? defaultLogo} + {agentNameProp ?? defaultAgentName} + {collapseButton === false ? null : (collapseButton ?? defaultCollapseButton)} +
    +
    + ); +}; + +export const SidebarContent = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + const { isSidebarOpen } = useAgentInterfaceStore((state) => ({ + isSidebarOpen: state.isSidebarOpen, + })); + + return ( +
    + {children} +
    + ); +}; + +export const SidebarSeparator = () => { + return
    ; +}; diff --git a/packages/react-ui/src/components/AgentInterface/SidebarItem.tsx b/packages/react-ui/src/components/AgentInterface/SidebarItem.tsx new file mode 100644 index 000000000..05f8080c3 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/SidebarItem.tsx @@ -0,0 +1,78 @@ +import clsx from "clsx"; +import type { ComponentPropsWithoutRef, MouseEventHandler, ReactNode } from "react"; +import { useLayoutContext } from "../../context/LayoutContext"; +import { useOptionalNav } from "./_shared/navContext"; +import { useAgentInterfaceStore } from "./_shared/store"; + +export interface SidebarItemProps + extends Omit, "children"> { + /** Leading icon. */ + icon?: ReactNode; + /** Trailing content — badges, counts, etc. Rendered right-aligned. */ + trailing?: ReactNode; + /** + * Selected/active state. Defaults to `currentPath === path` when `path` is + * provided. Pass explicitly to override the auto-derivation. + */ + selected?: boolean; + /** + * Path this item navigates to. When provided, clicking the item calls + * `navigate(path)` and the item auto-selects when current path matches. + * Works in both controlled and uncontrolled . + */ + path?: string; + children: ReactNode; +} + +/** + * Styled clickable item for use inside . Visually + * matches the ThreadList row so custom nav items blend with the default + * thread list. + */ +export const SidebarItem = ({ + icon, + trailing, + selected, + path, + className, + children, + onClick, + ...rest +}: SidebarItemProps) => { + const nav = useOptionalNav(); + const layoutCtx = useLayoutContext(); + const setIsSidebarOpen = useAgentInterfaceStore((s) => s.setIsSidebarOpen); + + const isActive = + selected !== undefined ? selected : path !== undefined && nav?.path === path; + + const handleClick: MouseEventHandler = (e) => { + onClick?.(e); + if (e.defaultPrevented) return; + if (path !== undefined && nav) { + nav.navigate(path); + if (layoutCtx?.layout === "mobile") { + setIsSidebarOpen(false); + } + } + }; + + return ( + + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/SidebarSlot.tsx b/packages/react-ui/src/components/AgentInterface/SidebarSlot.tsx new file mode 100644 index 000000000..7f7189699 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/SidebarSlot.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from "react"; + +export interface SidebarSlotProps { + children?: ReactNode; +} + +/** + * Slot marker for the entire sidebar region. Never rendered directly — the + * parent extracts its children and arranges them inside the + * SidebarContainer in place of the default sidebar arrangement. + * + * Mode A: omitted → default sidebar renders (SidebarHeader + SidebarContent + * with SidebarSeparator + ThreadList). + * Mode C: provided with children → children replace the entire sidebar's + * inner content (user composes SidebarHeader, SidebarSeparator, + * ThreadList, etc. as needed). + */ +export const SidebarSlot = (_props: SidebarSlotProps) => null; +SidebarSlot.displayName = "AgentInterface.Sidebar"; diff --git a/packages/react-ui/src/components/AgentInterface/Thread.tsx b/packages/react-ui/src/components/AgentInterface/Thread.tsx new file mode 100644 index 000000000..542d292ef --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/Thread.tsx @@ -0,0 +1,378 @@ +import type { AssistantMessage, Message, ToolMessage } from "@openuidev/react-headless"; +import { MessageProvider, useActiveDetailedView, useThread } from "@openuidev/react-headless"; +import clsx from "clsx"; +import React, { memo, useRef } from "react"; +import { useLayoutContext } from "../../context/LayoutContext"; +import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; +import { separateContentAndContext } from "../../utils/contentParser"; +import { DetailedViewOverlay, DetailedViewPortalTarget } from "./_shared/detailed-view"; +import { useAgentInterfaceStore } from "./_shared/store"; +import { ToolMessageRenderer } from "./_shared/tool-renderer"; +import type { AssistantMessageComponent, UserMessageComponent } from "./_shared/types"; +import { Callout } from "../Callout"; +import { MarkDownRenderer } from "../MarkDownRenderer"; +import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; +import { ToolCallComponent } from "../ToolCall"; +import { ToolResult } from "../ToolResult"; +import { ResizableSeparator } from "./ResizableSeparator"; +import { useDetailedViewResize } from "./useDetailedViewResize"; + +export const ThreadContainer = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + const { layout } = useLayoutContext(); + const isMobile = layout === "mobile"; + const { isDetailedViewActive } = useActiveDetailedView(); + + const { setIsSidebarOpen } = useAgentInterfaceStore((state) => ({ + setIsSidebarOpen: state.setIsSidebarOpen, + })); + + const isLoadingMessages = useThread((s) => s.isLoadingMessages); + + const { + containerRef, + chatPanelRef, + detailedViewPanelRef, + isDragging, + handleResize, + handleDragStart, + handleDragEnd, + } = useDetailedViewResize({ + isDetailedViewActive, + isMobile, + setIsSidebarOpen, + }); + + return ( +
    +
    + {/* Chat panel - always visible */} +
    + {children} + {isMobile && } +
    + + {/* Desktop only: Resizable separator and detailed-view panel */} + {!isMobile && isDetailedViewActive && ( + <> + +
    + +
    + + )} +
    +
    + ); +}; + +export const ScrollArea = ({ + children, + className, + scrollVariant = "user-message-anchor", + userMessageSelector, +}: { + children?: React.ReactNode; + className?: string; + /** + * Scroll to bottom once the last message is added + */ + scrollVariant?: ScrollVariant; + /** + * Selector for the user message + */ + userMessageSelector?: string; +}) => { + const ref = useRef(null); + + const messages = useThread((s) => s.messages); + const isRunning = useThread((s) => s.isRunning); + const isLoadingMessages = useThread((s) => s.isLoadingMessages); + + useScrollToBottom({ + ref, + lastMessage: messages[messages.length - 1] || { id: "" }, + scrollVariant, + userMessageSelector, + isRunning, + isLoadingMessages, + }); + + return ( +
    +
    + {children} +
    +
    + ); +}; + +export const AssistantMessageContainer = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + const { logoUrl } = useAgentInterfaceStore((store) => ({ + logoUrl: store.logoUrl, + })); + + return ( +
    + Assistant +
    {children}
    +
    + ); +}; + +export const UserMessageContainer = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + return ( +
    +
    {children}
    +
    + ); +}; + +const AssistantMessageContent = ({ + message, + allMessages, +}: { + message: AssistantMessage; + allMessages: Message[]; +}) => { + // Collect tool messages that follow this assistant message + const toolMessages: ToolMessage[] = []; + const msgIndex = allMessages.findIndex((m) => m.id === message.id); + if (msgIndex !== -1) { + for (let i = msgIndex + 1; i < allMessages.length; i++) { + const m = allMessages[i]; + if (m && m.role === "tool") { + toolMessages.push(m as ToolMessage); + } else { + break; + } + } + } + + return ( + <> + {message.content && ( + + )} + {message.toolCalls?.map((toolCall) => ( + + ))} + {toolMessages.map((tm) => { + const toolCall = message.toolCalls?.find((tc) => tc.id === tm.toolCallId); + const fallback = ; + if (!toolCall) return {fallback}; + return ( + + ); + })} + + ); +}; + +const UserMessageContent = ({ message }: { message: Message }) => { + if (message.role !== "user") return null; + const content = message.content; + if (typeof content === "string") { + // Strip XML wrapper tags (, ) so the bubble shows clean text + const { content: humanText } = separateContentAndContext(content); + return <>{humanText}; + } + // InputContent[] — render text parts + return ( + <> + {content?.map((part, i) => { + if (part.type === "text") { + return {part.text}; + } + // Binary content — could be image, file, etc. + if (part.type === "binary" && part.url) { + return ( + + ); + } + return null; + })} + + ); +}; + +export const RenderMessage = memo( + ({ + message, + className, + allMessages, + assistantMessage: CustomAssistantMessage, + userMessage: CustomUserMessage, + isStreaming, + }: { + message: Message; + className?: string; + allMessages: Message[]; + assistantMessage?: AssistantMessageComponent; + userMessage?: UserMessageComponent; + isStreaming: boolean; + }) => { + if (message.role === "tool") { + // Tool messages are rendered inline with their parent assistant message + return null; + } + + if (message.role === "assistant") { + if (CustomAssistantMessage) { + return ; + } + return ( + + + + ); + } + + if (message.role === "user") { + if (CustomUserMessage) { + return ; + } + return ( + + + + ); + } + + // Other roles (system, developer, reasoning, activity) — skip by default + return null; + }, +); + +export const MessageLoading = () => { + return ( +
    + +
    + ); +}; + +export const ThreadError = () => { + const threadError = useThread((s) => s.threadError); + if (!threadError) return null; + + return ( +
    + +
    + ); +}; + +export const Messages = ({ + className, + loader, + assistantMessage, + userMessage, +}: { + className?: string; + loader?: React.ReactNode; + assistantMessage?: AssistantMessageComponent; + userMessage?: UserMessageComponent; +}) => { + const messages = useThread((s) => s.messages); + const isRunning = useThread((s) => s.isRunning); + const threadError = useThread((s) => s.threadError); + + return ( +
    + {messages.map((message, i) => { + return ( + + + + ); + })} + {isRunning &&
    {loader}
    } + {!isRunning && threadError && } +
    + ); +}; + +export const ThreadHeader = ({ + children, + className, +}: { + children?: React.ReactNode; + className?: string; +}) => { + return
    {children}
    ; +}; + +// Re-export Composer from components +export { Composer } from "./components"; diff --git a/packages/react-ui/src/components/AgentInterface/ThreadList.tsx b/packages/react-ui/src/components/AgentInterface/ThreadList.tsx new file mode 100644 index 000000000..6a248e61f --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/ThreadList.tsx @@ -0,0 +1,164 @@ +import type { Thread } from "@openuidev/react-headless"; +import { useThreadList } from "@openuidev/react-headless"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import clsx from "clsx"; +import { EllipsisVerticalIcon, Trash2Icon } from "lucide-react"; +import { Fragment, useEffect } from "react"; +import { useLayoutContext } from "../../context/LayoutContext"; +import { useOptionalNav } from "./_shared/navContext"; +import { useAgentInterfaceStore } from "./_shared/store"; + +export const ThreadButton = ({ + id, + title, + className, +}: { + id: string; + title: string; + className?: string; +}) => { + const selectThread = useThreadList((s) => s.selectThread); + const deleteThread = useThreadList((s) => s.deleteThread); + const selectedThreadId = useThreadList((s) => s.selectedThreadId); + const { isSidebarOpen, setIsSidebarOpen } = useAgentInterfaceStore((state) => ({ + isSidebarOpen: state.isSidebarOpen, + setIsSidebarOpen: state.setIsSidebarOpen, + })); + const { layout } = useLayoutContext(); + const nav = useOptionalNav(); + + return ( +
    + + + + + + + + { + deleteThread(id); + }} + > + + Delete + + + + +
    + ); +}; + +export const ThreadList = ({ className }: { className?: string }) => { + const threads = useThreadList((s) => s.threads); + const loadThreads = useThreadList((s) => s.loadThreads); + + useEffect(() => { + loadThreads(); + }, []); + + const groupThreads = () => { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + const last7Days = new Date(today); + last7Days.setDate(last7Days.getDate() - 7); + const last30Days = new Date(today); + last30Days.setDate(last30Days.getDate() - 30); + const thisYear = new Date(today); + thisYear.setMonth(0, 1); + + return threads.reduce( + (groups, thread) => { + const threadDate = new Date(thread.createdAt); + + if (threadDate >= today) { + groups.today = [...(groups.today || []), thread]; + } else if (threadDate >= yesterday) { + groups.yesterday = [...(groups.yesterday || []), thread]; + } else if (threadDate >= last7Days) { + groups.last7Days = [...(groups.last7Days || []), thread]; + } else if (threadDate >= last30Days) { + groups.last30Days = [...(groups.last30Days || []), thread]; + } else if (threadDate >= thisYear) { + groups.thisYear = [...(groups.thisYear || []), thread]; + } else { + groups.older = [...(groups.older || []), thread]; + } + + return groups; + }, + { + today: [] as Thread[], + yesterday: [] as Thread[], + last7Days: [] as Thread[], + last30Days: [] as Thread[], + thisYear: [] as Thread[], + older: [] as Thread[], + }, + ); + }; + + const groupedThreads = groupThreads(); + const groupLabels: { [key in keyof typeof groupedThreads]: string } = { + today: "Today", + yesterday: "Yesterday", + last7Days: "Previous 7 Days", + last30Days: "Previous 30 Days", + thisYear: "This Year", + older: "Older", + }; + + return ( +
    + {Object.entries(groupedThreads) + .filter(([_, groupThreads]) => groupThreads.length > 0) + .map(([group, groupThreads]) => ( + +
    + {groupLabels[group as keyof typeof groupLabels]} +
    + {groupThreads.map((thread) => ( + + ))} +
    + ))} +
    + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/WelcomeScreen.tsx b/packages/react-ui/src/components/AgentInterface/WelcomeScreen.tsx new file mode 100644 index 000000000..b201085cb --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/WelcomeScreen.tsx @@ -0,0 +1,142 @@ +import { useThread } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { ReactNode } from "react"; +import { ConversationStarterProps } from "../../types/ConversationStarter"; +import { isChatEmpty } from "./_shared/utils"; +import { useStartersFromContext } from "./_shared/startersContext"; +import { DesktopWelcomeComposer } from "./components"; +import { ConversationStarter, ConversationStarterVariant } from "./ConversationStarter"; + +interface WelcomeScreenBaseProps { + /** + * Additional CSS class name + */ + className?: string; +} + +interface WelcomeScreenWithContentProps extends WelcomeScreenBaseProps { + /** + * The greeting/title text to display + */ + title?: string; + /** + * Optional description text to add more context + */ + description?: string; + /** + * Image to display - can be a URL object or a ReactNode + * - { url: string }: Renders an tag with default styling (64x64, object-fit: cover, rounded) + * - ReactNode: Renders the provided element directly (for custom icons, styled images, etc.) + */ + image?: { url: string } | ReactNode; + /** + * Conversation starters to show below the composer + */ + starters?: ConversationStarterProps[]; + /** + * Variant of the conversation starters + */ + starterVariant?: ConversationStarterVariant; + /** + * Children are not allowed when using props-based content + */ + children?: never; +} + +interface WelcomeScreenWithChildrenProps extends WelcomeScreenBaseProps { + /** + * Custom content to render inside the welcome screen + * When children are provided, title, description, and image are ignored + */ + children: ReactNode; + title?: never; + description?: never; + image?: never; + starters?: never; + starterVariant?: never; +} + +export type WelcomeScreenProps = WelcomeScreenWithContentProps | WelcomeScreenWithChildrenProps; + +/** + * Type guard to check if image is a URL object + */ +const isImageUrl = (image: { url: string } | ReactNode): image is { url: string } => { + return typeof image === "object" && image !== null && "url" in image; +}; + +export const WelcomeScreen = (props: WelcomeScreenProps) => { + const { className } = props; + const fromCtx = useStartersFromContext(); + + const ownStarters = "starters" in props ? props.starters : undefined; + const ownVariant = "starterVariant" in props ? props.starterVariant : undefined; + const starters = ownStarters ?? fromCtx.starters ?? []; + const starterVariant = ownVariant ?? fromCtx.starterVariant ?? "long"; + + const messages = useThread((s) => s.messages); + const isLoadingMessages = useThread((s) => s.isLoadingMessages); + + // Only show when there are no messages + if (!isChatEmpty({ isLoadingMessages, messages })) { + return null; + } + + // Check if children are provided + if ("children" in props && props.children) { + return
    {props.children}
    ; + } + + // Props-based content + const { title, description, image } = props as WelcomeScreenWithContentProps; + + const renderImage = () => { + if (!image) return null; + + if (isImageUrl(image)) { + return ( + {title + ); + } + + return image; + }; + + return ( +
    +
    + {image && ( +
    {renderImage()}
    + )} + {(title || description) && ( +
    + {title &&

    {title}

    } + {description && ( +

    {description}

    + )} +
    + )} +
    + {/* Desktop-only welcome composer */} +
    +
    + +
    + {/* Desktop-only conversation starters */} + {starters.length > 0 && ( +
    + +
    + )} +
    +
    + ); +}; + +export default WelcomeScreen; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewOverlay.tsx b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewOverlay.tsx new file mode 100644 index 000000000..c4c70012e --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewOverlay.tsx @@ -0,0 +1,73 @@ +import { useActiveDetailedView } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { forwardRef, useCallback, useEffect, useRef, useState } from "react"; +import { useMultipleRefs } from "../../../../hooks/useMultipleRefs"; +import { DetailedViewPortalTarget } from "./DetailedViewPortalTarget"; + +/** + * Props for {@link DetailedViewOverlay}. + * + * @category Components + */ +export type DetailedViewOverlayProps = { + /** Additional CSS class name(s) applied to the overlay container. */ + className?: string; +}; + +/** + * Shared overlay wrapper for the detailed-view portal target. + * Used by CopilotShell, BottomTray, and Shell (mobile) layouts. + * Renders an absolute-positioned overlay with slide-in/slide-out animations. + * + * @category Components + */ +export const DetailedViewOverlay = forwardRef( + ({ className }, ref) => { + const { isDetailedViewActive } = useActiveDetailedView(); + const [shouldRender, setShouldRender] = useState(isDetailedViewActive); + const [isExiting, setIsExiting] = useState(false); + const internalRef = useRef(null); + const mergedRef = useMultipleRefs(ref, internalRef); + + useEffect(() => { + if (isDetailedViewActive) { + // Opening: mount immediately, cancel any in-progress exit + setShouldRender(true); + setIsExiting(false); + } else if (shouldRender) { + // Closing: start exit animation, defer unmount + setIsExiting(true); + } + }, [isDetailedViewActive]); // eslint-disable-line react-hooks/exhaustive-deps + + const handleAnimationEnd = useCallback( + (e: React.AnimationEvent) => { + // Only react to our own animation, not children's animations bubbling up + if (e.target !== internalRef.current) return; + if (isExiting) { + setShouldRender(false); + setIsExiting(false); + } + }, + [isExiting], + ); + + if (!shouldRender) return null; + + return ( +
    + +
    + ); + }, +); + +DetailedViewOverlay.displayName = "DetailedViewOverlay"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPanel.tsx b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPanel.tsx new file mode 100644 index 000000000..2bc2e17b0 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPanel.tsx @@ -0,0 +1,134 @@ +import { useDetailedView, useDetailedViewPortalTarget } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { X } from "lucide-react"; +import { Component, forwardRef, useEffect, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { useTheme } from "../../../ThemeProvider/ThemeProvider"; + +/** @internal */ +type DetailedViewErrorBoundaryProps = { + children: ReactNode; + fallback?: ReactNode; +}; + +type DetailedViewErrorBoundaryState = { + hasError: boolean; +}; + +/** @internal */ +class DetailedViewErrorBoundary extends Component< + DetailedViewErrorBoundaryProps, + DetailedViewErrorBoundaryState +> { + constructor(props: DetailedViewErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): DetailedViewErrorBoundaryState { + return { hasError: true }; + } + + override render() { + if (this.state.hasError) { + return this.props.fallback ?? null; + } + return this.props.children; + } +} + +/** + * Props for {@link DetailedViewPanel}. + * + * @category Components + */ +export type DetailedViewPanelProps = { + /** Detailed-view id this panel renders content for. Must match the id passed to `useDetailedView(viewId)`. */ + viewId: string; + /** Content rendered inside the panel when this view is active. */ + children: ReactNode; + /** Display title for the panel header and aria-label. Defaults to `"Detailed view"`. */ + title?: string; + /** Additional CSS class name(s) applied to the panel container. */ + className?: string; + /** Fallback UI rendered if children throw during rendering. Defaults to `null`. */ + errorFallback?: ReactNode; + /** + * Controls the panel header. + * - `true` (default): built-in header with title + close button + * - `false`: no header, raw children only + * - `ReactNode`: custom header replacing the built-in one + */ + header?: boolean | ReactNode; +}; + +/** @internal */ +const DefaultHeader = ({ title, onClose }: { title: string; onClose: () => void }) => ( +
    + {title} + +
    +); + +/** + * Portals detailed-view content into the nearest {@link DetailedViewPortalTarget}. + * + * Renders nothing when the view is inactive or no portal target is mounted. + * Wraps children in an error boundary and applies theme-scoped class names. + * + * Requires `` to be mounted in the layout. + * + * @category Components + */ +export const DetailedViewPanel = forwardRef( + ({ viewId, children, title, className, errorFallback, header = true }, ref) => { + const { isActive, close } = useDetailedView(viewId); + const { node: panelNode } = useDetailedViewPortalTarget(); + const { portalThemeClassName } = useTheme(); + + useEffect(() => { + if (!isActive || panelNode) return; + + const timer = setTimeout(() => { + console.warn( + "[OpenUI] DetailedViewPanel: view is active but no render target is mounted. " + + "Ensure is rendered in your layout.", + ); + }, 100); + return () => clearTimeout(timer); + }, [isActive, panelNode]); + + if (!isActive || !panelNode) return null; + + const handleClose = () => close(); + + let headerContent: ReactNode = null; + if (header === true) { + headerContent = ; + } else if (header !== false) { + headerContent = header; + } + + return createPortal( +
    + {headerContent} + {children} +
    , + panelNode, + ); + }, +); + +DetailedViewPanel.displayName = "DetailedViewPanel"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPortalTarget.tsx b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPortalTarget.tsx new file mode 100644 index 000000000..256bb05eb --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/DetailedViewPortalTarget.tsx @@ -0,0 +1,43 @@ +import { useDetailedViewPortalTarget } from "@openuidev/react-headless"; +import { forwardRef, useCallback, useRef } from "react"; + +/** + * Props for {@link DetailedViewPortalTarget}. + */ +export type DetailedViewPortalTargetProps = { + /** Additional CSS class name(s) applied to the container element. */ + className?: string; +}; + +/** + * Registers a DOM node as the render target for {@link DetailedViewPanel} portals. + * + * Mount exactly one instance in your layout. Renders a `
    ` with + * `display: contents` so it doesn't affect layout flow. + * + * @category Components + */ +export const DetailedViewPortalTarget = forwardRef( + ({ className }, ref) => { + const { setNode } = useDetailedViewPortalTarget(); + const forwardedRef = useRef(ref); + forwardedRef.current = ref; + + const callbackRef = useCallback( + (node: HTMLDivElement | null) => { + setNode(node); + const fRef = forwardedRef.current; + if (typeof fRef === "function") { + fRef(node); + } else if (fRef) { + fRef.current = node; + } + }, + [setNode], + ); + + return
    ; + }, +); + +DetailedViewPortalTarget.displayName = "DetailedViewPortalTarget"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewOverlay.scss b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewOverlay.scss new file mode 100644 index 000000000..5a6d565c8 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewOverlay.scss @@ -0,0 +1,40 @@ +@use "../../../../cssUtils" as cssUtils; + +.openui-detailed-view-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + background-color: cssUtils.$foreground; + animation: openui-detailed-view-overlay-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &--exiting { + animation: openui-detailed-view-overlay-slide-out 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } +} + +@keyframes openui-detailed-view-overlay-slide-in { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes openui-detailed-view-overlay-slide-out { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(20px); + } +} diff --git a/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewPanel.scss b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewPanel.scss new file mode 100644 index 000000000..13d5c0156 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/detailedViewPanel.scss @@ -0,0 +1,46 @@ +@use "../../../../cssUtils" as cssUtils; + +.openui-detailed-view-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: cssUtils.$space-s; + padding: cssUtils.$space-s cssUtils.$space-m; + border-bottom: 1px solid cssUtils.$border-default; + background-color: cssUtils.$foreground; + position: sticky; + top: 0; + z-index: 1; +} + +.openui-detailed-view-panel__title { + @include cssUtils.typography(body, default); + color: cssUtils.$text-neutral-primary; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.openui-detailed-view-panel__close { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: cssUtils.$radius-s; + background: transparent; + color: cssUtils.$text-neutral-secondary; + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: cssUtils.$highlight; + color: cssUtils.$text-neutral-primary; + } +} diff --git a/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/index.ts b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/index.ts new file mode 100644 index 000000000..7e986aebb --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/detailed-view/index.ts @@ -0,0 +1,3 @@ +export * from "./DetailedViewOverlay"; +export * from "./DetailedViewPanel"; +export * from "./DetailedViewPortalTarget"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/index.ts b/packages/react-ui/src/components/AgentInterface/_shared/index.ts new file mode 100644 index 000000000..f1cab8d7d --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/index.ts @@ -0,0 +1,5 @@ +export * from "./detailed-view"; +export * from "./store"; +export * from "./tool-renderer"; +export * from "./types"; +export * from "./utils"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/navContext.tsx b/packages/react-ui/src/components/AgentInterface/_shared/navContext.tsx new file mode 100644 index 000000000..e659184f7 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/navContext.tsx @@ -0,0 +1,74 @@ +import { + createContext, + useCallback, + useContext, + useMemo, + useState, + type ReactNode, +} from "react"; + +export interface NavContextValue { + /** Current path. `undefined` means the thread region is active (no Route matched). */ + path: string | undefined; + /** Switch path. Pass `undefined` to clear and return to the thread view. */ + navigate: (next: string | undefined) => void; +} + +const NavContext = createContext(null); + +export interface NavProviderProps { + /** Controlled current path. Provide together with `onNavigate`. */ + path?: string; + /** Initial path for uncontrolled mode. Ignored when `onNavigate` is provided. */ + defaultPath?: string; + /** Called when navigation occurs. Presence determines controlled mode. */ + onNavigate?: (next: string | undefined) => void; + children: ReactNode; +} + +/** + * Standard controlled/uncontrolled split: + * - `onNavigate` provided → controlled. Parent owns state; `path` prop is the source of truth. + * - `onNavigate` absent → uncontrolled. Internal state starts at `defaultPath`. + */ +export const NavProvider = ({ path, defaultPath, onNavigate, children }: NavProviderProps) => { + const isControlled = onNavigate !== undefined; + const [internalPath, setInternalPath] = useState(defaultPath); + + const currentPath = isControlled ? path : internalPath; + + const navigate = useCallback( + (next: string | undefined) => { + if (isControlled) { + onNavigate?.(next); + } else { + setInternalPath(next); + } + }, + [isControlled, onNavigate], + ); + + const value = useMemo( + () => ({ path: currentPath, navigate }), + [currentPath, navigate], + ); + + return {children}; +}; + +/** + * Read the current navigation state from inside . + * + * Returns `{ path, navigate }`. Call `navigate(undefined)` to return to the + * thread view (clears any active route). + */ +export const useNav = (): NavContextValue => { + const ctx = useContext(NavContext); + if (!ctx) { + throw new Error("useNav() must be used inside "); + } + return ctx; +}; + +/** Returns the nav context if mounted, otherwise null. Internal use. */ +export const useOptionalNav = (): NavContextValue | null => useContext(NavContext); diff --git a/packages/react-ui/src/components/AgentInterface/_shared/shared.scss b/packages/react-ui/src/components/AgentInterface/_shared/shared.scss new file mode 100644 index 000000000..b7532c8e4 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/shared.scss @@ -0,0 +1,2 @@ +@forward "./detailed-view/detailedViewOverlay.scss"; +@forward "./detailed-view/detailedViewPanel.scss"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/startersContext.tsx b/packages/react-ui/src/components/AgentInterface/_shared/startersContext.tsx new file mode 100644 index 000000000..2ffa62c8c --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/startersContext.tsx @@ -0,0 +1,20 @@ +import { createContext, useContext, type ReactNode } from "react"; +import type { ConversationStarterProps } from "../../../types/ConversationStarter"; +import type { ConversationStarterVariant } from "../ConversationStarter"; + +export interface StartersContextValue { + starters?: ConversationStarterProps[]; + starterVariant?: ConversationStarterVariant; +} + +const StartersContext = createContext({}); + +export const StartersProvider = ({ + starters, + starterVariant, + children, +}: StartersContextValue & { children: ReactNode }) => ( + {children} +); + +export const useStartersFromContext = () => useContext(StartersContext); diff --git a/packages/react-ui/src/components/AgentInterface/_shared/store/index.ts b/packages/react-ui/src/components/AgentInterface/_shared/store/index.ts new file mode 100644 index 000000000..f5990c259 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/store/index.ts @@ -0,0 +1 @@ +export * from "./store"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/store/store.tsx b/packages/react-ui/src/components/AgentInterface/_shared/store/store.tsx new file mode 100644 index 000000000..0bebb4226 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/store/store.tsx @@ -0,0 +1,57 @@ +import { createContext, useContext, useEffect, useMemo } from "react"; +import { create } from "zustand"; +import { useShallow } from "zustand/react/shallow"; + +interface AgentInterfaceState { + isSidebarOpen: boolean; + isWorkspaceOpen: boolean; + agentName: string; + logoUrl: string; + setIsSidebarOpen: (isOpen: boolean) => void; + setIsWorkspaceOpen: (isOpen: boolean) => void; + setAgentName: (name: string) => void; + setLogoUrl: (url: string) => void; +} + +export const createAgentInterfaceStore = ({ logoUrl, agentName }: { logoUrl: string; agentName: string }) => + create((set) => ({ + isSidebarOpen: true, + isWorkspaceOpen: true, + agentName: agentName, + logoUrl: logoUrl, + setIsSidebarOpen: (isOpen: boolean) => set({ isSidebarOpen: isOpen }), + setIsWorkspaceOpen: (isOpen: boolean) => set({ isWorkspaceOpen: isOpen }), + setAgentName: (name: string) => set({ agentName: name }), + setLogoUrl: (url: string) => set({ logoUrl: url }), + })); + +export const AgentInterfaceStoreContext = createContext | null>(null); + +export const useAgentInterfaceStore = (selector: (state: AgentInterfaceState) => T): T => { + const store = useContext(AgentInterfaceStoreContext); + if (!store) { + throw new Error("useAgentInterfaceStore must be used within AgentInterfaceStoreProvider"); + } + + return store(useShallow(selector)); +}; + +export const AgentInterfaceStoreProvider = ({ + children, + agentName, + logoUrl, +}: { + children: React.ReactNode; + logoUrl: string; + agentName: string; +}) => { + const shellStore = useMemo(() => createAgentInterfaceStore({ agentName, logoUrl }), []); + + useEffect(() => { + const { setAgentName, setLogoUrl } = shellStore.getState(); + setAgentName(agentName); + setLogoUrl(logoUrl); + }, [agentName, logoUrl]); + + return {children}; +}; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/RendererInstance.tsx b/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/RendererInstance.tsx new file mode 100644 index 000000000..181a832ef --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/RendererInstance.tsx @@ -0,0 +1,96 @@ +import { + useDetailedView, + useThreadContextStore, + type AppRendererConfig, + type AppRendererControls, +} from "@openuidev/react-headless"; +import { useEffect, useId, useMemo } from "react"; +import { DetailedViewPanel } from "../detailed-view"; + +/** + * Renders a matched renderer (app or artifact) for a single tool call/response. + * + * Lifecycle: + * 1. Run `parser({ args, response })` to derive Props. + * 2. Run `meta(props, ctx)` to derive ThreadContext entry. + * 3. If meta returns non-null, register the entry on mount; unregister on unmount. + * The `kind` field on the renderer routes to apps (default) or artifacts. + * 4. Render `preview(props, controls)` inline + `` containing + * `actual(props, controls)` for the side panel. + * + * If `parser` returns `null`, renders nothing (caller should fall back). + * If `meta` returns `null`, renders inline + panel but skips ThreadContext registration — + * a fallback `viewId` derived from `useId()` is used so `controls` remain functional. + * + * The same component instance is reused as a tool call transitions from + * streaming (args partial, response null, isStreaming true) to completed + * (args full, response present, isStreaming false). Parser + meta + render + * functions are re-invoked on each update; ThreadContext registration stays + * stable as long as `meta`'s `(id, version)` does not change. + * + * Internal — consumers should use {@link ToolMessageRenderer}. + * + * @internal + */ +export function RendererInstance({ + renderer, + args, + response, + isStreaming = false, +}: { + renderer: AppRendererConfig; + args: unknown; + response: unknown; + isStreaming?: boolean; +}) { + const fallbackId = useId(); + const tcStore = useThreadContextStore(); + + const props = useMemo(() => renderer.parser({ args, response }), [renderer, args, response]); + + const meta = useMemo(() => { + if (props === null) return null; + return renderer.meta(props, { isStreaming }); + }, [renderer, props, isStreaming]); + + // viewId derives from meta when present, otherwise from React's useId + // so `controls.open` still works for an inline-only renderer. + const viewId = meta ? `${meta.id}:${meta.version}` : fallbackId; + + // Register entry on mount; unregister on unmount or when (id, version) changes. + // The `kind` field on the renderer routes to the correct ThreadContext slice + // (apps for `defineAppRenderer`, artifacts for `defineArtifactRenderer`). + // Heading-only changes upsert via the store's idempotent register* actions. + const kind = renderer.kind ?? "app"; + useEffect(() => { + if (!meta) return; + const state = tcStore.getState(); + if (kind === "artifact") { + state.registerArtifact(meta); + return () => tcStore.getState().unregisterArtifact(meta.id, meta.version); + } + state.registerApp(meta); + return () => tcStore.getState().unregisterApp(meta.id, meta.version); + }, [tcStore, kind, meta?.id, meta?.version, meta?.heading]); // eslint-disable-line react-hooks/exhaustive-deps + + const { isActive, open, close, toggle } = useDetailedView(viewId); + + if (props === null) return null; + + const controls: AppRendererControls = { + isActive, + open, + close, + toggle, + isStreaming, + }; + + return ( + <> + {renderer.preview(props, controls)} + + {renderer.actual(props, controls)} + + + ); +} diff --git a/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/ToolMessageRenderer.tsx b/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/ToolMessageRenderer.tsx new file mode 100644 index 000000000..9aabbd794 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/ToolMessageRenderer.tsx @@ -0,0 +1,57 @@ +import { useAppRenderer, type ToolCall, type ToolMessage } from "@openuidev/react-headless"; +import type { ReactNode } from "react"; +import { RendererInstance } from "./RendererInstance"; + +/** + * Props for {@link ToolMessageRenderer}. + * + * @category Components + */ +export type ToolMessageRendererProps = { + /** + * The tool message containing the response payload, or `null` while the tool + * call is still streaming (args have not finished arriving and no result yet). + * The matched renderer is rendered with `controls.isStreaming = true` in that case. + */ + toolMessage: ToolMessage | null; + /** The matching tool call from the parent assistant message (provides `name` + `arguments`). */ + toolCall: ToolCall; + /** Rendered when no AppRenderer matches `toolCall.function.name`. */ + fallback: ReactNode; +}; + +/** + * Dispatches a tool call (streaming or completed) to a matching AppRenderer if + * one is registered, otherwise renders `fallback` (typically the default + * ``). + * + * Looks up `toolCall.function.name` against the AppRenderer registry provided + * by ``. On match, hands off to + * {@link RendererInstance} which runs the parser, registers in ThreadContext, + * and renders the inline preview + detailed-view panel. + * + * Tool args (`toolCall.function.arguments`) and response (`toolMessage.content`, + * or `null` while streaming) are passed to the renderer's `parser` raw — the + * SDK does not pre-parse JSON. The renderer's `parser` is responsible for + * handling partial-JSON args during streaming. + * + * @category Components + */ +export const ToolMessageRenderer = ({ + toolMessage, + toolCall, + fallback, +}: ToolMessageRendererProps) => { + const renderer = useAppRenderer(toolCall.function.name); + + if (!renderer) return <>{fallback}; + + return ( + + ); +}; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/index.ts b/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/index.ts new file mode 100644 index 000000000..f5645ca88 --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/tool-renderer/index.ts @@ -0,0 +1 @@ +export { ToolMessageRenderer, type ToolMessageRendererProps } from "./ToolMessageRenderer"; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/types/index.ts b/packages/react-ui/src/components/AgentInterface/_shared/types/index.ts new file mode 100644 index 000000000..869f4ea5f --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/types/index.ts @@ -0,0 +1,34 @@ +import type { AssistantMessage, UserMessage } from "@openuidev/react-headless"; + +/** + * Custom component for rendering assistant messages. + * When provided, replaces the default assistant message rendering entirely + * (including the container with avatar). + * + * @example + * const MyAssistantMessage: AssistantMessageComponent = ({ message }) => ( + *
    + * {message.content ?? ""} + *
    + * ); + */ +export type AssistantMessageComponent = React.ComponentType<{ + message: AssistantMessage; + isStreaming: boolean; +}>; + +/** + * Custom component for rendering user messages. + * When provided, replaces the default user message rendering entirely + * (including the container). + * + * @example + * const MyUserMessage: UserMessageComponent = ({ message }) => ( + *
    + * {typeof message.content === "string" ? message.content : "..."} + *
    + * ); + */ +export type UserMessageComponent = React.ComponentType<{ + message: UserMessage; +}>; diff --git a/packages/react-ui/src/components/AgentInterface/_shared/utils/index.ts b/packages/react-ui/src/components/AgentInterface/_shared/utils/index.ts new file mode 100644 index 000000000..9a588ec6c --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/_shared/utils/index.ts @@ -0,0 +1,11 @@ +import { Message } from "@openuidev/react-headless"; + +export const isChatEmpty = ({ + isLoadingMessages, + messages, +}: { + isLoadingMessages: boolean | undefined; + messages: Message[]; +}) => { + return !isLoadingMessages && messages.length === 0; +}; diff --git a/packages/react-ui/src/components/AgentInterface/agentInterface.scss b/packages/react-ui/src/components/AgentInterface/agentInterface.scss new file mode 100644 index 000000000..1613639fd --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/agentInterface.scss @@ -0,0 +1,37 @@ +@use "../../cssUtils" as cssUtils; +@use "./sidebar.scss"; +@use "./threadlist.scss"; +@use "./thread.scss"; +@use "./mobileHeader.scss"; +@use "./resizableSeparator.scss"; +@use "./conversationStarter.scss"; +@use "./welcomeScreen.scss"; +@use "./components/desktopWelcomeComposer.scss"; +@use "./components/composer.scss"; + +.openui-agent-container { + display: flex; + position: relative; + height: 100dvh; + width: 100dvw; + overflow: hidden; + + background: cssUtils.$chat-container-bg; + box-sizing: border-box; + & * { + box-sizing: border-box; + } + + &.openui-agent-container--mobile { + padding: 0; + } +} + +.openui-agent-new-chat-button { + width: 100%; + justify-content: space-between; + + .openui-agent-sidebar-header--collapsed & { + width: auto; + } +} diff --git a/packages/react-ui/src/components/AgentInterface/components/Composer.tsx b/packages/react-ui/src/components/AgentInterface/components/Composer.tsx new file mode 100644 index 000000000..2b2a5e47c --- /dev/null +++ b/packages/react-ui/src/components/AgentInterface/components/Composer.tsx @@ -0,0 +1,82 @@ +import { useThread } from "@openuidev/react-headless"; +import clsx from "clsx"; +import { ArrowUp, Square } from "lucide-react"; +import { useLayoutEffect, useRef } from "react"; +import { useComposerState } from "../../../hooks/useComposerState"; +import { IconButton } from "../../IconButton"; + +export interface ComposerProps { + className?: string; + placeholder?: string; +} + +export const Composer = ({ className, placeholder = "Type your query here" }: ComposerProps) => { + const { textContent, setTextContent } = useComposerState(); + const processMessage = useThread((s) => s.processMessage); + const cancelMessage = useThread((s) => s.cancelMessage); + const isRunning = useThread((s) => s.isRunning); + const isLoadingMessages = useThread((s) => s.isLoadingMessages); + const inputRef = useRef(null); + + const handleSubmit = () => { + if (!textContent.trim() || isRunning || isLoadingMessages) { + return; + } + + processMessage({ + role: "user", + content: textContent, + }); + + setTextContent(""); + }; + + useLayoutEffect(() => { + const input = inputRef.current; + if (!input) return; + + // Reset to 0 (not "auto") so scrollHeight reflects content, not container + input.style.height = "0px"; + input.style.height = `${Math.max(input.scrollHeight, 24)}px`; + }, [textContent]); + + return ( +
    { + if (!(e.target as HTMLElement).closest("button, a, [role='button']")) { + inputRef.current?.focus(); + } + }} + > +
    +