diff --git a/.gitignore b/.gitignore index 82e85c083..628e6e7b1 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ Claude.md .claude/ CLAUDE.local.md +.workspace/ # Dependencies node_modules diff --git a/packages/react-headless/package.json b/packages/react-headless/package.json index c752f553b..d60cd2829 100644 --- a/packages/react-headless/package.json +++ b/packages/react-headless/package.json @@ -21,7 +21,6 @@ }, "peerDependencies": { "react": ">=19.0.0", - "react-dom": ">=19.0.0", "zustand": "^4.5.5" }, "devDependencies": { diff --git a/packages/react-headless/src/hooks/useActiveArtifact.ts b/packages/react-headless/src/hooks/useActiveArtifact.ts new file mode 100644 index 000000000..b5d48b082 --- /dev/null +++ b/packages/react-headless/src/hooks/useActiveArtifact.ts @@ -0,0 +1,46 @@ +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/useArtifact.ts b/packages/react-headless/src/hooks/useArtifact.ts new file mode 100644 index 000000000..ff1775958 --- /dev/null +++ b/packages/react-headless/src/hooks/useArtifact.ts @@ -0,0 +1,69 @@ +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/useArtifactPortalTarget.ts b/packages/react-headless/src/hooks/useArtifactPortalTarget.ts new file mode 100644 index 000000000..f7abfec65 --- /dev/null +++ b/packages/react-headless/src/hooks/useArtifactPortalTarget.ts @@ -0,0 +1,49 @@ +import { useCallback } from "react"; +import { useStore } from "zustand"; +import { useArtifactStore } from "../store/ArtifactContext"; + +/** + * Provides access to the artifact 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. + * 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()`. + * + * Must be called within a ``. + * + * @category Hooks + * @returns `{ setNode, node }` — setter for registration, getter for portal rendering + * + * @example + * ```tsx + * // Registering a portal target + * function MyPortalTarget() { + * const { setNode } = useArtifactPortalTarget(); + * return
; + * } + * + * // Building a custom artifact panel + * function MyArtifactPanel({ artifactId, children }) { + * const { isActive } = useArtifact(artifactId); + * const { node } = useArtifactPortalTarget(); + * if (!isActive || !node) return null; + * return createPortal(
{children}
, node); + * } + * ``` + */ +export function useArtifactPortalTarget() { + const store = useArtifactStore(); + const node = useStore(store, (s) => s._artifactPanelNode); + + const setNode = useCallback( + (node: HTMLElement | null) => { + store.getState()._setArtifactPanelNode(node); + }, + [store], + ); + + return { setNode, node }; +} diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index ddacb1ba4..c37984d7c 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -1,6 +1,10 @@ +export { useActiveArtifact } from "./hooks/useActiveArtifact"; +export { useArtifact } from "./hooks/useArtifact"; +export { useArtifactPortalTarget } from "./hooks/useArtifactPortalTarget"; 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 { agUIAdapter, @@ -11,6 +15,8 @@ export { export { openAIConversationMessageFormat, openAIMessageFormat } from "./stream/formats"; export { processStreamedMessage } from "./stream/processStreamedMessage"; +export type { ArtifactActions, ArtifactState } from "./store/artifactTypes"; + export type { ChatProviderProps, ChatStore, diff --git a/packages/react-headless/src/store/ArtifactContext.ts b/packages/react-headless/src/store/ArtifactContext.ts new file mode 100644 index 000000000..153a39334 --- /dev/null +++ b/packages/react-headless/src/store/ArtifactContext.ts @@ -0,0 +1,24 @@ +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 5404bda18..3ec368d64 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -1,10 +1,27 @@ -import { useState, type FC } from "react"; +import { useEffect, useState, type FC } from "react"; +import { ArtifactContext } from "./ArtifactContext"; import { ChatContext } from "./ChatContext"; +import { createArtifactStore } from "./createArtifactStore"; import { createChatStore } from "./createChatStore"; import type { ChatProviderProps } from "./types"; export const ChatProvider: FC = ({ children, ...config }) => { - const [store] = useState(() => createChatStore(config)); + const [chatStore] = useState(() => createChatStore(config)); + const [artifactStore] = useState(() => createArtifactStore()); - return {children}; + // Cross-store subscription: reset artifacts 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(), + ); + return unsubscribe; + }, [chatStore, artifactStore]); + + return ( + + {children} + + ); }; diff --git a/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts b/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts new file mode 100644 index 000000000..56ebf2487 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/artifactThreadSwitch.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, vi } from "vitest"; +import { createArtifactStore } from "../createArtifactStore"; +import { createChatStore } from "../createChatStore"; + +const flushPromises = () => new Promise((r) => setTimeout(r, 0)); + +describe("artifact thread-switch cleanup", () => { + const setupStores = () => { + const chatStore = createChatStore({ processMessage: vi.fn() }); + const artifactStore = createArtifactStore(); + + const unsubscribe = chatStore.subscribe( + (state) => state.selectedThreadId, + () => artifactStore.getState().resetArtifacts(), + ); + + return { chatStore, artifactStore, unsubscribe }; + }; + + it("clears active artifact when selectThread is called", async () => { + const { chatStore, artifactStore, unsubscribe } = setupStores(); + + artifactStore.getState().openArtifact("art-1"); + expect(artifactStore.getState().activeArtifactId).toBe("art-1"); + + chatStore.getState().selectThread("thread-2"); + await flushPromises(); + + expect(artifactStore.getState().activeArtifactId).toBeNull(); + + unsubscribe(); + }); + + it("clears active artifact when switchToNewThread is called", async () => { + const { chatStore, artifactStore, unsubscribe } = setupStores(); + + chatStore.setState({ selectedThreadId: "thread-1" }); + artifactStore.getState().openArtifact("art-1"); + + chatStore.getState().switchToNewThread(); + await flushPromises(); + + expect(artifactStore.getState().activeArtifactId).toBeNull(); + + unsubscribe(); + }); + + it("clears active artifact when active thread is deleted", async () => { + const deleteThread = vi.fn().mockResolvedValue(undefined); + const chatStore = createChatStore({ deleteThread, processMessage: vi.fn() }); + const artifactStore = createArtifactStore(); + + const unsubscribe = chatStore.subscribe( + (state) => state.selectedThreadId, + () => artifactStore.getState().resetArtifacts(), + ); + + chatStore.setState({ + selectedThreadId: "thread-1", + threads: [ + { + id: "thread-1", + title: "Test", + createdAt: new Date().toISOString(), + }, + ], + }); + + artifactStore.getState().openArtifact("art-1"); + + chatStore.getState().deleteThread("thread-1"); + await flushPromises(); + + expect(artifactStore.getState().activeArtifactId).toBeNull(); + + unsubscribe(); + }); + + it("does not clear active artifact when re-selecting the same thread", async () => { + const { chatStore, artifactStore, unsubscribe } = setupStores(); + + chatStore.setState({ selectedThreadId: "thread-1" }); + await flushPromises(); + + artifactStore.getState().openArtifact("art-1"); + expect(artifactStore.getState().activeArtifactId).toBe("art-1"); + + chatStore.getState().selectThread("thread-1"); + await flushPromises(); + + expect(artifactStore.getState().activeArtifactId).toBe("art-1"); + + unsubscribe(); + }); + + it("handles rapid thread switches cleanly", async () => { + const loadThread = vi.fn().mockResolvedValue([]); + const chatStore = createChatStore({ loadThread, processMessage: vi.fn() }); + const artifactStore = createArtifactStore(); + + const unsubscribe = chatStore.subscribe( + (state) => state.selectedThreadId, + () => artifactStore.getState().resetArtifacts(), + ); + + artifactStore.getState().openArtifact("art-1"); + + chatStore.getState().selectThread("thread-1"); + chatStore.getState().selectThread("thread-2"); + chatStore.getState().selectThread("thread-3"); + await flushPromises(); + + expect(artifactStore.getState().activeArtifactId).toBeNull(); + expect(chatStore.getState().selectedThreadId).toBe("thread-3"); + + unsubscribe(); + }); +}); diff --git a/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts b/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts new file mode 100644 index 000000000..40bf14379 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/createArtifactStore.test.ts @@ -0,0 +1,94 @@ +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/artifactTypes.ts b/packages/react-headless/src/store/artifactTypes.ts new file mode 100644 index 000000000..014dd5ad7 --- /dev/null +++ b/packages/react-headless/src/store/artifactTypes.ts @@ -0,0 +1,44 @@ +/** + * 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 new file mode 100644 index 000000000..070c7571a --- /dev/null +++ b/packages/react-headless/src/store/createArtifactStore.ts @@ -0,0 +1,47 @@ +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/createChatStore.ts b/packages/react-headless/src/store/createChatStore.ts index 6811f0f6f..95e7d7fb8 100644 --- a/packages/react-headless/src/store/createChatStore.ts +++ b/packages/react-headless/src/store/createChatStore.ts @@ -1,4 +1,5 @@ import { createStore } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; import { processStreamedMessage } from "../stream/processStreamedMessage"; import { identityMessageFormat } from "../types/messageFormat"; import type { ChatProviderProps, ChatStore, Message, Thread, UserMessage } from "./types"; @@ -100,200 +101,195 @@ export const createChatStore = (config: StoreConfig) => { // ── Store ── - const store = createStore((set, get) => ({ - // Thread List State - threads: [], - isLoadingThreads: false, - threadListError: null, - selectedThreadId: null, - hasMoreThreads: false, - _nextCursor: undefined, - - // Thread State - messages: [], - isRunning: false, - isLoadingMessages: false, - threadError: null, - _abortController: null, - - // ── Thread List Actions ── - - loadThreads: () => { - set({ isLoadingThreads: true, threadListError: null }); - fetchThreadList(undefined) - .then(({ threads = [], nextCursor }) => { - set({ - threads, - isLoadingThreads: false, - _nextCursor: nextCursor, - hasMoreThreads: nextCursor !== undefined, + const store = createStore()( + subscribeWithSelector((set, get) => ({ + // Thread List State + threads: [], + isLoadingThreads: false, + threadListError: null, + selectedThreadId: null, + hasMoreThreads: false, + _nextCursor: undefined, + + // Thread State + messages: [], + isRunning: false, + isLoadingMessages: false, + threadError: null, + _abortController: null, + + // ── Thread List Actions ── + + loadThreads: () => { + set({ isLoadingThreads: true, threadListError: null }); + fetchThreadList(undefined) + .then(({ threads = [], nextCursor }) => { + set({ + threads, + isLoadingThreads: false, + _nextCursor: nextCursor, + hasMoreThreads: nextCursor !== undefined, + }); + }) + .catch((e) => { + set({ isLoadingThreads: false, threadListError: e }); }); - }) - .catch((e) => { - set({ isLoadingThreads: false, threadListError: e }); + }, + + loadMoreThreads: () => { + const cursor = get()._nextCursor; + if (cursor === undefined) return; + fetchThreadList(cursor) + .then(({ threads = [], nextCursor }) => { + set((s) => ({ + threads: mergeThreadList(s.threads, threads), + _nextCursor: nextCursor, + hasMoreThreads: nextCursor !== undefined, + })); + }) + .catch((e) => { + set({ threadListError: e }); + }); + }, + + switchToNewThread: () => { + get().cancelMessage(); + set({ selectedThreadId: null, messages: [], threadError: null }); + }, + + createThread: async (firstMessage: UserMessage) => { + const thread = await createThread(firstMessage); + set((s) => ({ threads: mergeThreadList(s.threads, [thread]) })); + return thread; + }, + + selectThread: (threadId: string) => { + get().cancelMessage(); + set({ + selectedThreadId: threadId, + messages: [], + isLoadingMessages: true, + threadError: null, }); - }, - - loadMoreThreads: () => { - const cursor = get()._nextCursor; - if (cursor === undefined) return; - fetchThreadList(cursor) - .then(({ threads = [], nextCursor }) => { - set((s) => ({ - threads: mergeThreadList(s.threads, threads), - _nextCursor: nextCursor, - hasMoreThreads: nextCursor !== undefined, - })); - }) - .catch((e) => { - set({ threadListError: e }); + loadThread(threadId) + .then((messages) => set({ messages, isLoadingMessages: false })) + .catch((e) => set({ threadError: e, isLoadingMessages: false })); + }, + + updateThread: (thread: Thread) => { + 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) + .then((updated) => { + set((s) => ({ + threads: s.threads.map((t) => (t.id === updated.id ? updated : t)), + })); + }) + .catch(() => setPending(thread.id, false)); + }, + + deleteThread: (threadId: string) => { + const setPending = (id: string, isPending: boolean) => + set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) })); + setPending(threadId, true); + deleteThreadFn(threadId) + .then(() => { + const state = get(); + set({ threads: state.threads.filter((t) => t.id !== threadId) }); + if (state.selectedThreadId === threadId) { + state.switchToNewThread(); + } + }) + .catch(() => setPending(threadId, false)); + }, + + // ── Thread Actions ── + + processMessage: async (message) => { + const state = get(); + if (state.isRunning) return; + + const abortController = new AbortController(); + const optimisticMessage: UserMessage = { + ...message, + id: crypto.randomUUID(), + role: "user", + }; + + set({ _abortController: abortController, isRunning: true, threadError: null }); + set((s) => ({ messages: [...s.messages, optimisticMessage] })); + + abortController.signal.addEventListener("abort", () => { + set({ _abortController: null, isRunning: false }); }); - }, - - switchToNewThread: () => { - get().cancelMessage(); - set({ selectedThreadId: null, messages: [], threadError: null }); - }, - - createThread: async (firstMessage: UserMessage) => { - const thread = await createThread(firstMessage); - set((s) => ({ threads: mergeThreadList(s.threads, [thread]) })); - return thread; - }, - - selectThread: (threadId: string) => { - get().cancelMessage(); - set({ - selectedThreadId: threadId, - messages: [], - isLoadingMessages: true, - threadError: null, - }); - loadThread(threadId) - .then((messages) => set({ messages, isLoadingMessages: false })) - .catch((e) => set({ threadError: e, isLoadingMessages: false })); - }, - - updateThread: (thread: Thread) => { - 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) - .then((updated) => { - set((s) => ({ - threads: s.threads.map((t) => (t.id === updated.id ? updated : t)), - })); - }) - .catch(() => setPending(thread.id, false)); - }, - - deleteThread: (threadId: string) => { - const setPending = (id: string, isPending: boolean) => - set((s) => ({ threads: s.threads.map((t) => (t.id === id ? { ...t, isPending } : t)) })); - setPending(threadId, true); - deleteThreadFn(threadId) - .then(() => { - const state = get(); - set({ threads: state.threads.filter((t) => t.id !== threadId) }); - if (state.selectedThreadId === threadId) { - state.switchToNewThread(); - } - }) - .catch(() => setPending(threadId, false)); - }, - - // ── Thread Actions ── - processMessage: async (message) => { - const state = get(); - if (state.isRunning) return; - - const abortController = new AbortController(); - const optimisticMessage: UserMessage = { - ...message, - id: crypto.randomUUID(), - role: "user", - }; - - set({ _abortController: abortController, isRunning: true, threadError: null }); - set((s) => ({ messages: [...s.messages, optimisticMessage] })); - - abortController.signal.addEventListener("abort", () => { - set({ _abortController: null, isRunning: false }); - }); - - try { - let threadId = get().selectedThreadId; - - if (!threadId) { - if (userCreateThread || threadApiUrl) { - const created = await get().createThread(optimisticMessage); - threadId = created.id; - set({ selectedThreadId: threadId }); - } else { - threadId = "ephemeral"; + try { + 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 response = await sendMessage({ - threadId, - messages: get().messages, - abortController, - }); - - if (response instanceof Response && !response.ok) { - throw new Error(`Request failed: ${response.status} ${response.statusText}`); - } - - const messageCountBefore = get().messages.length; + const response = await sendMessage({ + threadId, + messages: get().messages, + abortController, + }); - await processStreamedMessage({ - response, - createMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), - updateMessage: (msg) => - 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, - }); + if (response instanceof Response && !response.ok) { + throw new Error(`Request failed: ${response.status} ${response.statusText}`); + } - if (get().messages.length === messageCountBefore) { - throw new Error("Failed to get a response. Please check your connection and try again."); - } - } catch (e) { - if (!abortController.signal.aborted) { - const error = e instanceof Error ? e : new Error(String(e)); - console.error("[OpenUI] processMessage failed:", error); - set({ threadError: error }); + await processStreamedMessage({ + response, + createMessage: (msg) => set((s) => ({ messages: [...s.messages, msg] })), + updateMessage: (msg) => + 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) { + if (!abortController.signal.aborted) { + set({ threadError: e instanceof Error ? e : new Error(String(e)) }); + } + } finally { + set({ _abortController: null, isRunning: false }); } - } finally { - set({ _abortController: null, isRunning: false }); - } - }, - - appendMessages: (...newMessages: Message[]) => { - set((s) => ({ messages: [...s.messages, ...newMessages] })); - }, - - updateMessage: (message: Message) => { - set((s) => ({ - messages: s.messages.map((m) => (m.id === message.id ? message : m)), - })); - }, - - setMessages: (messages: Message[]) => { - set({ messages }); - }, - - deleteMessage: (messageId: string) => { - set((s) => ({ messages: s.messages.filter((m) => m.id !== messageId) })); - }, - - cancelMessage: () => { - get()._abortController?.abort(); - }, - })); + }, + + appendMessages: (...newMessages: Message[]) => { + set((s) => ({ messages: [...s.messages, ...newMessages] })); + }, + + updateMessage: (message: Message) => { + set((s) => ({ + messages: s.messages.map((m) => (m.id === message.id ? message : m)), + })); + }, + + setMessages: (messages: Message[]) => { + set({ messages }); + }, + + deleteMessage: (messageId: string) => { + set((s) => ({ messages: s.messages.filter((m) => m.id !== messageId) })); + }, + + cancelMessage: () => { + get()._abortController?.abort(); + }, + })), + ); return store; }; diff --git a/packages/react-ui/src/components/BottomTray/Container.tsx b/packages/react-ui/src/components/BottomTray/Container.tsx index 438a5b3e7..d92521399 100644 --- a/packages/react-ui/src/components/BottomTray/Container.tsx +++ b/packages/react-ui/src/components/BottomTray/Container.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { LayoutContextProvider } from "../../context/LayoutContext"; -import { ShellStoreProvider } from "../Shell/store"; +import { ShellStoreProvider } from "../_shared/store"; interface ContainerProps { children?: React.ReactNode; diff --git a/packages/react-ui/src/components/BottomTray/ConversationStarter.tsx b/packages/react-ui/src/components/BottomTray/ConversationStarter.tsx index 6d060c8bd..5cba770b1 100644 --- a/packages/react-ui/src/components/BottomTray/ConversationStarter.tsx +++ b/packages/react-ui/src/components/BottomTray/ConversationStarter.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { ArrowUp, Lightbulb } from "lucide-react"; import { Fragment, ReactNode } from "react"; import { ConversationStarterIcon, ConversationStarterProps } from "../../types/ConversationStarter"; -import { isChatEmpty } from "../OpenUIChat/utils"; +import { isChatEmpty } from "../_shared/utils"; import { Separator } from "../Separator"; export type ConversationStarterVariant = "short" | "long"; diff --git a/packages/react-ui/src/components/BottomTray/Header.tsx b/packages/react-ui/src/components/BottomTray/Header.tsx index a4ff2dfa1..55b71561a 100644 --- a/packages/react-ui/src/components/BottomTray/Header.tsx +++ b/packages/react-ui/src/components/BottomTray/Header.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { SquarePen, X } from "lucide-react"; import { ReactNode } from "react"; import { IconButton } from "../IconButton"; -import { useShellStore } from "../Shell/store"; +import { useShellStore } from "../_shared/store"; import { ThreadListContainer } from "./ThreadListContainer"; export const BottomTrayNewChatButton = () => { diff --git a/packages/react-ui/src/components/BottomTray/Thread.tsx b/packages/react-ui/src/components/BottomTray/Thread.tsx index e4d62ae6d..68f67925d 100644 --- a/packages/react-ui/src/components/BottomTray/Thread.tsx +++ b/packages/react-ui/src/components/BottomTray/Thread.tsx @@ -1,36 +1,22 @@ import type { AssistantMessage, Message, ToolMessage } from "@openuidev/react-headless"; import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; -import React, { memo, useEffect, useRef } from "react"; +import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; +import { ArtifactOverlay } from "../_shared/artifact"; +import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; -import type { AssistantMessageComponent, UserMessageComponent } from "../OpenUIChat/types"; -import { useShellStore } from "../Shell/store"; import { ToolCallComponent } from "../ToolCall"; import { ToolResult } from "../ToolResult"; export const ThreadContainer = ({ children, className, - isArtifactActive = false, - renderArtifact = () => null, }: { children?: React.ReactNode; className?: string; - isArtifactActive?: boolean; - renderArtifact?: () => React.ReactNode; }) => { - const { setIsArtifactActive, setArtifactRenderer } = useShellStore((state) => ({ - setIsArtifactActive: state.setIsArtifactActive, - setArtifactRenderer: state.setArtifactRenderer, - })); - - useEffect(() => { - setIsArtifactActive(isArtifactActive); - setArtifactRenderer(renderArtifact); - }, [isArtifactActive, renderArtifact, setIsArtifactActive, setArtifactRenderer]); - const isLoadingMessages = useThread((s) => s.isLoadingMessages); return ( @@ -41,6 +27,7 @@ export const ThreadContainer = ({ }} > {children} +
); }; @@ -67,10 +54,6 @@ export const ScrollArea = ({ const messages = useThread((s) => s.messages); const isRunning = useThread((s) => s.isRunning); const isLoadingMessages = useThread((s) => s.isLoadingMessages); - const { isArtifactActive, artifactRenderer } = useShellStore((store) => ({ - isArtifactActive: store.isArtifactActive, - artifactRenderer: store.artifactRenderer, - })); useScrollToBottom({ ref, @@ -98,9 +81,6 @@ export const ScrollArea = ({ {/* Gradient to hide the bottom of the scroll area */}
- {isArtifactActive && ( -
{artifactRenderer()}
- )}
); }; diff --git a/packages/react-ui/src/components/BottomTray/WelcomeScreen.tsx b/packages/react-ui/src/components/BottomTray/WelcomeScreen.tsx index 0495bc016..b1a1afe98 100644 --- a/packages/react-ui/src/components/BottomTray/WelcomeScreen.tsx +++ b/packages/react-ui/src/components/BottomTray/WelcomeScreen.tsx @@ -1,7 +1,7 @@ import { useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import { ReactNode } from "react"; -import { isChatEmpty } from "../OpenUIChat/utils"; +import { isChatEmpty } from "../_shared/utils"; interface WelcomeScreenBaseProps { /** diff --git a/packages/react-ui/src/components/BottomTray/thread.scss b/packages/react-ui/src/components/BottomTray/thread.scss index 969994983..6cc739987 100644 --- a/packages/react-ui/src/components/BottomTray/thread.scss +++ b/packages/react-ui/src/components/BottomTray/thread.scss @@ -5,6 +5,7 @@ flex: 1; overflow: hidden; flex-direction: column; + position: relative; } .openui-bottom-tray-thread-scroll-container { @@ -37,29 +38,6 @@ } } -// Artifact panel (overlay style) -.openui-bottom-tray-thread-artifact-panel--mobile { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 10; - background-color: cssUtils.$foreground; - animation: openui-bottom-tray-slide-in-from-bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -@keyframes openui-bottom-tray-slide-in-from-bottom { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - .openui-bottom-tray-thread-messages { margin: 0 auto; display: flex; diff --git a/packages/react-ui/src/components/ChartsV2/ARCHITECTURE.md b/packages/react-ui/src/components/ChartsV2/ARCHITECTURE.md new file mode 100644 index 000000000..74711db49 --- /dev/null +++ b/packages/react-ui/src/components/ChartsV2/ARCHITECTURE.md @@ -0,0 +1,466 @@ +# ChartsV2 Architecture + +> Auto-maintained by architect agent. Last updated: 2026-03-18 (rev 4) + +## Overview + +ChartsV2 is a D3-based charting system within OpenUI's React component library. It provides 7 chart types across 3 topologies (cartesian-scrollable, cartesian-condensed, and polar). D3 is used strictly for math (scales, path generation, stacking); React owns the DOM via JSX. The system replaces the original Recharts-based `Charts/` package. + +The design follows a **hook-orchestrated architecture**: each chart delegates all shared state management (data, dimensions, hover, scroll, legend, tooltip) to a single "orchestrator" hook, then supplies only chart-specific rendering (series geometry, crosshairs, axis variant). + +## System Diagram + +``` + ChartsV2/index.ts (public API) + | + +----------+-----------+-----------+----------+----------+ + | | | | | | + D3AreaChart D3BarChart D3LineChart D3PieChart D3RadialChart D3RadarChart D3ScatterChart + | | | | | | | + | Cartesian (X/Y) | Polar (categorical) Polar (radar) Scatter + |__________|___________| |__________| | | + | | | | + +-----------+----------+ +----------+ +-------+ +-------+ + | | | | | + useChartScrollable useChartCondensed useCategorical useRadarChart useScatterChart + Orchestrator Orchestrator ChartOrchestrator Orchestrator Orchestrator + | | | | | + +----------+-----------+ +-------+----------+ | + | | | + hooks/cartesian/ hooks/polar/ hooks/cartesian/ + | | | + hooks/core/ <--- shared by all chart types ------> hooks/core/ + | + utils/ + types/ + | + shared/core/ <--- all chart types + shared/cartesian/ <--- Area, Bar, Line, Scatter +``` + +### Data Flow (Cartesian Scrollable -- most complex path) + +``` +Props (data, categoryKey, theme, variant, stacked, ...) + | + v +[1] useChartScrollableOrchestrator + |-- useChartData --> dataKeys, colors, hiddenSeries, toggleSeries, legendItems, chartConfig, colorMap + |-- useChartDimensions --> containerWidth, yAxisWidth, xAxisHeight, chartInnerHeight, svgWidth, needsScroll + |-- useChartHover --> hoveredIndex, mousePos, createMouseHandlers(findIndex) + |-- useChartScroll --> canScrollLeft, canScrollRight, handleScroll, scrollTo + |-- useTooltipPayload --> tooltipPayload | null + | + v +[2] Chart-specific hooks (in the chart component, not the orchestrator) + |-- useXScale / useXBandScale --> xScale (D3 ScalePoint or ScaleBand) + |-- useYScale --> yScale (D3 ScaleLinear) + |-- useStackedData --> stackedData | null (D3 stack generator) + | + v +[3] ScrollableChartLayout (shared layout component) + |-- YAxis (separate fixed SVG) + |-- Scrollable container with main SVG + | |-- Grid (horizontal lines from yScale.ticks()) + | |-- [Chart-specific Series] renders SVG paths/rects using scales + | |-- [Chart-specific Crosshair/Hover indicator] + | |-- XAxis (foreignObject labels from scale.domain()) + |-- ScrollButtonsHorizontal (snap navigation) + |-- DefaultLegend (expand/collapse, series toggle) + |-- ChartTooltip (portal via @floating-ui, positioned at mouse viewport coords) +``` + +## Module Map + +| Module | Responsibility | Key Dependencies | +| ------------------------------ | ------------------------------------------------------------------------------------------- | ------------------------------ | +| `types/common.ts` | Base types: `ChartData`, `BaseChartProps`, `LegendItem`, `XAxisTickVariant` | paletteUtils (PaletteName) | +| `utils/dataUtils.ts` | Extract data keys, build chart config, legend items, color lookup | types | +| `utils/paletteUtils.ts` | 6 color palettes (11 colors each), `useChartPalette` hook, color distribution | ThemeProvider | +| `utils/mouseUtils.ts` | `findNearestDataIndex` (point scale), `findBandIndex` (band scale) | d3-scale | +| `utils/scrollUtils.ts` | Data width calculation, snap positions, density spacing | -- | +| `utils/styleUtils.ts` | `numberTickFormatter` (K/M/B/T abbreviations), `measureYAxisWidth` (tick-based axis sizing) | -- | +| `utils/polarUtils.ts` | Polar helpers: sort, slice hover style, percentage format, radar angles | types | +| `utils/buildContainerStyle.ts` | Merge chart CSS vars with fixed width/height overrides | -- | +| `hooks/core/` | Coordinate-agnostic hooks shared by all chart types | d3-selection, ThemeProvider | +| `hooks/cartesian/` | Cartesian-specific: orchestrators, scales, axes, scroll, stacking | d3-scale, d3-shape, core hooks | +| `hooks/polar/` | Polar-specific: categorical orchestrator, radar orchestrator, radar hover | core hooks | +| `shared/core/` | Topology-agnostic components used by all chart types: legend, tooltips, truncation hook | @floating-ui, @radix-ui | +| `shared/cartesian/` | Cartesian-only components: axes, grid, clip, crosshair, layouts, scroll buttons, base SCSS | @floating-ui, lucide-react | +| `D3AreaChart/` | Area chart: gradient fills, stacked/unstacked, natural/linear/step curves | hooks, shared, d3-shape | +| `D3BarChart/` | Bar chart: grouped/stacked, bar radius, internal line, band scale | hooks, shared, d3-scale | +| `D3LineChart/` | Line chart: dot display, natural/linear/step curves | hooks, shared, d3-shape | +| `D3PieChart/` | Pie/donut chart: circular/semicircular, corner radius, padding angle | hooks, shared, d3-shape | +| `D3RadialChart/` | Radial bar chart: circular/semicircular, proportional arc bars | hooks, shared, d3-shape | +| `D3RadarChart/` | Radar chart: polygon/circle grid, multi-series overlay, axis labels | hooks, shared, d3-scale | +| `D3ScatterChart/` | Scatter plot: numeric X/Y, multi-dataset, 2D nearest-point hover | hooks, shared, d3-scale | + +## Hook Architecture + +### Three-Layer Hook System + +``` +hooks/ + core/ -- coordinate-agnostic (all chart types) + useChartData -- multi-series data: keys, colors, hidden series, legend (delegates to useSeriesVisibility) + useCategoricalChartData -- single-series categorical: slices, totals, percentages + useChartHover -- hover state + createMouseHandlers factory (1D: mouseX) + useContainerSize -- ResizeObserver with fixed-size bypass + useLegendHeight -- ResizeObserver on legend element + usePrintContext -- matchMedia("print") detection + useSeriesVisibility -- toggle hidden series with "keep at least one" guard (shared by useChartData + scatter) + useTooltipPayload -- builds tooltip content from hovered row + useTransformedKeys -- stable tk-N IDs for CSS vars (ref-cached) + useCanvasContextForLabelSize -- memoized canvas 2D context for text measurement (SSR-safe stub) + + cartesian/ -- X/Y charts only + useChartScrollableOrchestrator -- composes: data + dimensions + hover + scroll + tooltip + useChartCondensedOrchestrator -- composes: data + hover + tooltip (angled labels, no scroll) + useScatterChartOrchestrator -- composes: useSeriesVisibility + useChartPalette + measureYAxisWidth (numeric X/Y, 2D hover) + useChartDimensions -- all layout math: sizing, axis, scroll detection + useChartScroll -- scroll state + snap navigation + useXScale -- D3 scalePoint (area, line) + useXBandScale -- D3 scaleBand (bar) + useYScale -- D3 scaleLinear (all cartesian) + useStackedData -- D3 stack generator (area stacked, bar stacked) + useXAxisHeight -- canvas-based label height calculation (useMemo) + useYAxisWidth -- Canvas measurement of tick width + useMaxLabelWidth -- Canvas measurement of category labels + useAutoAngleCalculation -- Trig-based label rotation for condensed mode + + polar/ -- Pie, Radial, Radar + useCategoricalChartOrchestrator -- composes: categorical data + container + legend + hover + useRadarChartOrchestrator -- composes: chart data + radial scale + container + hover + useRadarHover -- hover state + createMouseHandlers factory (2D: mouseX, mouseY) +``` + +### Orchestrator Pattern + +Each orchestrator is a "mega-hook" that composes lower-level hooks and returns a structured result object with consistent sections: + +```typescript +return { + refs: { containerRef, legendRef, [mainContainerRef] }, + identity: { chartId }, + data: { catKey, dataKeys, colorMap, chartConfig, ... }, + dimensions: { containerWidth, chartInnerHeight, totalHeight, ... }, + hover: { hoveredIndex, mousePos, createMouseHandlers | handleMouseMove }, + scroll: { canScrollLeft, canScrollRight, handleScroll, scrollTo }, // cartesian scrollable only + legend: { legendItems, hiddenSeries, toggleSeries, isLegendExpanded, setIsLegendExpanded }, + tooltip: { tooltipPayload }, + style: { containerStyle }, +}; +``` + +This pattern means chart components are thin -- they destructure the orchestrator result, create chart-specific scales, and pass everything to a shared layout component. + +## Chart Classification + +### Cartesian Charts (Area, Bar, Line) + +Each has 3 files: + +- `D3[Type]Chart.tsx` -- empty-state guard + condensed/scrollable routing +- `D3[Type]ChartCondensed.tsx` -- uses `useChartCondensedOrchestrator` + `CondensedChartLayout` +- `D3[Type]ChartScrollable.tsx` -- uses `useChartScrollableOrchestrator` + `ScrollableChartLayout` + +The `condensed` prop (from `BaseChartProps`) selects the mode. Condensed mode fits all data in the container width with angled X-axis labels. Scrollable mode (default) uses fixed spacing per data point and enables horizontal scrolling when data overflows. + +**Scale selection:** + +- Area/Line use `useXScale` (ScalePoint) -- points centered within group width +- Bar uses `useXBandScale` (ScaleBand) -- discrete bands with padding + +### Polar Charts (Pie, Radial) + +Both use `useCategoricalChartOrchestrator` which wraps `useCategoricalChartData`. They share: + +- Single-series data model (categoryKey + dataKey) +- Slice-based hover (per-element `onMouseMove`) +- Responsive sizing constrained to min/max chart size +- Semi-circular appearance option + +Pie adds: D3 `pie()` + `arc()` generators, donut variant (inner radius), padding angle, corner radius. +Radial adds: proportional arc bars (each slice = one ring), radial grid. + +### Radar Chart + +Uses `useRadarChartOrchestrator` which wraps `useChartData` (multi-series aware). Unique because: + +- Multi-series data on a polar coordinate system +- Polygon/circle grid with spokes +- Axis labels positioned by trigonometry +- 2D hover via `useRadarHover` (angle-based nearest-axis detection) + +### Scatter Chart + +Uses `useScatterChartOrchestrator` which composes shared hooks (`useSeriesVisibility`, `useChartPalette`, `useContainerSize`, `useLegendHeight`, `usePrintContext`, `useCanvasContextForLabelSize`) and the shared `measureYAxisWidth` utility. `D3ScatterChart` uses `forwardRef` + `displayName` per project convention, and renders shared components (`Grid`, `VerticalGrid`, `YAxis`, `DefaultLegend`, `ChartTooltip`). Unique because: + +- Different data model: `ScatterDataset[]` with `{name, data: ScatterPoint[]}` (not `ChartData`) +- Numeric X and Y axes (both `scaleLinear`) +- 2D nearest-point hover with snap radius (30px) +- Vertical + horizontal grid (both `Grid` and `VerticalGrid` from `shared/cartesian/`) +- Does not extend `BaseChartProps` -- has its own props interface +- No condensed/scrollable bifurcation (the continuous-axis nature means all data always fits) + +## Shared Component Architecture + +### Topology-Tiered Directory Layout + +`shared/` is organized into tiers that mirror the `hooks/` core/cartesian/polar pattern: + +``` +shared/ + core/ -- used by ALL chart types + DefaultLegend/ -- expand/collapse legend with series toggle + LabelTooltip/ -- Radix tooltip for truncated axis labels + PortalTooltip/ -- @floating-ui portal tooltip for chart data + useIsTruncated.ts -- ResizeObserver-based text truncation detection + cartesian/ -- used by Area, Bar, Line (+ Scatter for Grid/YAxis) + axes/ -- XAxis, AngledXAxis, XAxisLabel, YAxis + layouts/ -- ScrollableChartLayout, CondensedChartLayout + ScrollButtonsHorizontal/ -- snap-scroll navigation arrows + ClipDefs.tsx -- SVG clip-path definitions + Grid.tsx -- horizontal grid lines from yScale + VerticalGrid.tsx -- vertical grid lines from xScale (linear, used by scatter) + LineDotCrosshair.tsx -- hover crosshair + active dot (line/area) + chartBase.scss -- SCSS mixins (chart-base, crosshair-styles) + index.ts -- barrel re-exports from both tiers +``` + +A `shared/polar/` tier should be created when the first reusable polar component appears that is shared by 2+ polar chart types. + +### Placement Rules for shared/ Components + +| Condition | Location | +| ------------------------------------------------ | ------------------------------------------ | +| Used by all chart topologies (cartesian + polar) | `shared/core/` | +| Used by 2+ cartesian charts only | `shared/cartesian/` | +| Used by 2+ polar charts only | `shared/polar/` (create when first needed) | +| Crosses topologies but not all | `shared/core/` (err toward core) | +| Used by exactly 1 chart | That chart's `parts/` directory | + +Components graduate from `parts/` to `shared/` when a second chart needs them. They never start in `shared/`. + +### Layout Components + +`ScrollableChartLayout` and `CondensedChartLayout` (in `shared/cartesian/layouts/`) are the two rendering templates for cartesian charts. They accept: + +- The orchestrator result (typed as `ReturnType`) +- A yScale for the Y-axis +- Mouse handlers (created from `orch.hover.createMouseHandlers`) +- Slot props: `defs`, `series`, `xAxis` (chart-specific SVG content) + +This slot-based approach lets charts inject their unique SVG elements while sharing all layout, axes, grid, legend, and tooltip rendering. + +### Tooltip System + +Two tooltip mechanisms: + +1. **ChartTooltip** -- portal-based data tooltip using `@floating-ui/react-dom`. Positioned via virtual element at viewport coordinates. Truncates to 5 items if >10, with "Click to view all" message. Portals to `document.body` with theme class. +2. **LabelTooltip** -- Radix UI tooltip for truncated axis labels. Wraps the chart root in `LabelTooltipProvider`. + +### Legend System + +`DefaultLegend` is a `forwardRef` + `memo` component that: + +- Measures available width to determine how many items fit in one row +- Shows "N more" toggle button when items overflow +- Supports expand/collapse state +- Dims hidden series (opacity 0.3) +- Displays optional X/Y axis labels below the legend + +Uses `useDefaultLegend` hook for intelligent width-based item fitting via canvas text measurement. + +## Styling Architecture + +### SCSS Mixin System + +`shared/cartesian/chartBase.scss` provides two SCSS mixins: + +- `chart-base($prefix)` -- generates container, inner, Y-axis container, main container, grid, and tick styles for a given chart prefix +- `crosshair-styles($prefix)` -- generates crosshair line and active dot styles + +Each cartesian chart SCSS file includes these mixins: + +```scss +@use "../shared/cartesian/chartBase" as base; +@include base.chart-base("area-chart"); +@include base.crosshair-styles("area-chart"); +``` + +### CSS Class Convention + +All classes follow `openui-d3-{chart-type}-{element}` naming: + +- `openui-d3-area-chart-container` +- `openui-d3-bar-chart-hover-highlight` +- `openui-d3-line-chart-line--animated` + +Shared components use `openui-chart-{component}`: + +- `openui-chart-legend-container` +- `openui-chart-tooltip` +- `openui-portal-tooltip` + +### Animation + +Each chart type defines its own CSS animations: + +- **Area/Line**: stroke-dasharray draw (`openui-d3-draw-line`) + area fade-in +- **Bar**: scaleY grow from bottom (`openui-d3-bar-grow`) +- **Pie**: scale + fade (`openui-d3-pie-appear`) +- **Radial/Radar**: fade (`openui-d3-radial-bar-appear`, `openui-d3-radar-polygon-appear`) +- **Scatter**: dot appear (`openui-d3-scatter-dot-appear`) + +All animations respect `isAnimationActive` prop and `usePrintContext` (disabled during print). + +### Design Token Usage + +All SCSS files use `cssUtils` tokens (per OpenUI convention): + +- Colors: `cssUtils.$text-neutral-primary`, `cssUtils.$border-default`, `cssUtils.$foreground` +- Spacing: `cssUtils.$space-m`, `cssUtils.$space-xs` +- Typography: `@include cssUtils.typography(label, extra-small)` +- Radius: `cssUtils.$radius-l`, `cssUtils.$radius-2xs` +- Shadow: `cssUtils.$shadow-s` + +Exception: `paletteUtils.ts` defines chart-specific color palettes as hex values (not design tokens), since they are data visualization colors rather than UI chrome. + +## Type System + +### Data Models + +``` +ChartData = Array> -- cartesian + radar + pie + radial + +ScatterDataset = { name: string; data: ScatterPoint[] } -- scatter only +ScatterPoint = { x: number; y: number; [key]: string | number | undefined } +D3ScatterChartData = ScatterDataset[] +``` + +### Props Hierarchy + +``` +BaseChartProps -- shared by Area, Bar, Line + |-- data, categoryKey, theme, customPalette + |-- tickVariant, grid, legend, icons + |-- isAnimationActive, showYAxis + |-- xAxisLabel, yAxisLabel, className + |-- height, width, fitLegendInHeight + |-- condensed, density + | + +-- D3AreaChartProps extends BaseChartProps + variant, stacked, onClick + +-- D3BarChartProps extends BaseChartProps + variant, barRadius, maxBarWidth, internalLine*, onClick + +-- D3LineChartProps extends BaseChartProps + variant, showDots, dotRadius, onClick + +D3PieChartProps -- independent (single-series: categoryKey + dataKey) +D3RadialChartProps -- independent (single-series: categoryKey + dataKey) +D3RadarChartProps -- independent (multi-series: categoryKey, no dataKey) +D3ScatterChartProps -- independent (dataset array, no categoryKey) +``` + +## Dependency Rules + +### Allowed Imports + +``` +D3[Chart]/ --> hooks/, shared/core/, shared/cartesian/, utils/, types/ +hooks/cartesian/ --> hooks/core/, utils/, types/ +hooks/polar/ --> hooks/core/, utils/, types/, shared/core/PortalTooltip (for TooltipItem type) +hooks/core/ --> utils/, types/, ThemeProvider +shared/core/ --> utils/, types/, ThemeProvider, hooks/ (for type inference only) +shared/cartesian/ --> shared/core/, utils/, types/, ThemeProvider, hooks/ (for type inference only) +shared/polar/ --> shared/core/, utils/, types/, ThemeProvider, hooks/ (for type inference only) +utils/ --> types/, ThemeProvider (paletteUtils only) +types/ --> utils/ (PaletteName only) +``` + +**Tier rule**: `shared/cartesian/` may import from `shared/core/` but never the reverse. `shared/core/` must remain topology-agnostic. + +### Forbidden Imports + +- No chart type imports another chart type +- No ChartsV2 file imports from the legacy `Charts/` directory +- No hook imports a component (except type-only imports for ReturnType inference) +- `hooks/core/` must not import from `hooks/cartesian/` or `hooks/polar/` + +## Design Patterns + +### 1. Orchestrator + Slot Layout + +The primary composition pattern. Orchestrators handle all shared state; layout components provide the DOM structure with slots for chart-specific SVG content. + +### 2. Factory-Based Mouse Handlers + +`useChartHover.createMouseHandlers(findIndex)` is a factory that accepts a chart-specific index-finding function. This decouples hover mechanics from scale type: + +- Area/Line pass `(mouseX) => findNearestDataIndex(xScale, mouseX)` (nearest point) +- Bar passes `(mouseX) => findBandIndex(xScale, mouseX)` (band position) +- Radar passes `(mouseX, mouseY) => angleBasedIndex(mouseX, mouseY)` (2D angle) + +### 3. Canvas-Based Text Measurement (SSR-Safe) + +All text measurement hooks (`useXAxisHeight`, `useYAxisWidth`, `useMaxLabelWidth`, `useDefaultLegend`) use a shared `useCanvasContextForLabelSize` hook that creates a memoized `CanvasRenderingContext2D` configured with the theme font. In SSR environments (`typeof document === "undefined"`), it returns a typed stub with zero-width `measureText()` but a valid `font` string, so font-parsing logic (e.g., `parseLineHeight`) still produces correct line-height values on the server. This avoids DOM measurement overhead for text sizing and eliminates layout thrashing. + +### 4. Condensed/Scrollable Bifurcation + +Each cartesian chart has a single entry point that routes to either a Condensed or Scrollable sub-component based on the `condensed` prop. The two modes use different orchestrators, different X-axis components (AngledXAxis vs XAxis), and different layout components. + +### 5. Series Visibility Toggle + +The shared `useSeriesVisibility` hook (in `hooks/core/`) implements a "must keep at least one visible" constraint: `if (next.size >= seriesKeys.length - 1) return prev`. This is consumed by: + +- `useChartData` (multi-series cartesian + radar) -- delegates visibility to `useSeriesVisibility` +- `useScatterChartOrchestrator` -- calls `useSeriesVisibility` directly +- `useCategoricalChartData` (single-series pie/radial) -- has its own inline implementation (single-series model differs) + +## Architectural Strengths + +1. **Strong separation of concerns**: D3 for math, React for DOM, CSS for animation. No `d3.select().append()` fighting React's virtual DOM. + +2. **High code reuse via orchestrators**: Adding a new cartesian chart requires only ~100 lines of chart-specific code; the orchestrator handles 80% of the logic. + +3. **Consistent API surface**: All cartesian charts share `BaseChartProps`. Legend, tooltip, and hover behavior are identical across charts. + +4. **Progressive enhancement**: CSS `transition` on SVG `d` attribute works in modern browsers; gracefully degrades to instant updates in older ones. + +5. **Responsive by default**: `useContainerSize` with ResizeObserver + automatic scroll/condensed adaptation. + +6. **Print-aware**: `usePrintContext` disables all animations during print, ensuring static output. + +7. **Shared layout components**: `ScrollableChartLayout` and `CondensedChartLayout` (in `shared/cartesian/layouts/`) eliminate layout code duplication across 3 chart types. + +8. **Topology-tiered shared/**: The `shared/core/` + `shared/cartesian/` split mirrors the hook tier system, making it clear which components are topology-agnostic and preventing accidental coupling between cartesian and polar concerns. + +## Architectural Weaknesses and Risks + +### 1. Scatter Chart Structural Differences (Low Risk -- Largely Resolved) + +`D3ScatterChart` now shares core infrastructure with other chart types (`useSeriesVisibility`, `useChartPalette`, `measureYAxisWidth`, shared `Grid`/`VerticalGrid`/`YAxis`/`DefaultLegend`/`ChartTooltip` components, `forwardRef` + `displayName`). Remaining differences are justified by the fundamentally different data model: + +- Does not extend `BaseChartProps` (scatter uses `ScatterDataset[]`, not `ChartData` with `categoryKey`) +- No condensed/scrollable bifurcation (continuous-axis data always fits without scrolling) +- Tooltip construction is inline (2 items: X/Y values, not N series values per row) + +The ThemeProvider palette bypass bug is resolved -- scatter now routes through `useChartPalette`. + +### 2. Orchestrator Return Type Coupling (Low Risk) + +`CondensedChartLayout` and `ScrollableChartLayout` type their `orch` prop as `ReturnType` / `ReturnType`. This creates tight coupling between the layout components and the exact shape of the orchestrator return value. Any change to the orchestrator return shape requires updating the layout component and all chart components simultaneously. + +### 3. Color Palette Hardcoded Hex Values (Low Risk) + +`paletteUtils.ts` defines all palette colors as hex strings rather than oklch/CSS custom properties. This means chart data colors do not participate in the ThemeProvider's oklch color system. The `useChartPalette` hook does check `theme[themePaletteName]` first (allowing theme-level override), so this is mitigated for users who provide custom palettes via ThemeProvider. + +### 4. No Unit Tests (High Risk) + +The ChartsV2 directory has no test files. All validation is via Storybook visual testing only. The hooks contain significant logic (dimension calculation, scale construction, hover detection, scroll state) that would benefit from unit tests. The utility functions (`scrollUtils`, `mouseUtils`, `dataUtils`, `polarUtils`) are pure functions and trivially testable. + +### 5. Missing Accessibility (Medium Risk) + +Charts have `role="img"` and `aria-label` on the SVG element, but: + +- No keyboard navigation for data points +- No screen reader announcements for hover/tooltip content +- Legend items are clickable divs without `role="button"` or keyboard handlers +- No ARIA live region for dynamic tooltip content diff --git a/packages/react-ui/src/components/CopilotShell/Container.tsx b/packages/react-ui/src/components/CopilotShell/Container.tsx index ae4a46e76..13e820d5c 100644 --- a/packages/react-ui/src/components/CopilotShell/Container.tsx +++ b/packages/react-ui/src/components/CopilotShell/Container.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { LayoutContextProvider } from "../../context/LayoutContext"; -import { ShellStoreProvider } from "../Shell/store"; +import { ShellStoreProvider } from "../_shared/store"; interface ContainerProps { children?: React.ReactNode; diff --git a/packages/react-ui/src/components/CopilotShell/ConversationStarter.tsx b/packages/react-ui/src/components/CopilotShell/ConversationStarter.tsx index afced9b83..b16c63f19 100644 --- a/packages/react-ui/src/components/CopilotShell/ConversationStarter.tsx +++ b/packages/react-ui/src/components/CopilotShell/ConversationStarter.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { ArrowUp, Lightbulb } from "lucide-react"; import { Fragment, ReactNode } from "react"; import { ConversationStarterIcon, ConversationStarterProps } from "../../types/ConversationStarter"; -import { isChatEmpty } from "../OpenUIChat/utils"; +import { isChatEmpty } from "../_shared/utils"; import { Separator } from "../Separator"; export type ConversationStarterVariant = "short" | "long"; diff --git a/packages/react-ui/src/components/CopilotShell/Header.tsx b/packages/react-ui/src/components/CopilotShell/Header.tsx index d8327e9fc..ce4f0ccfc 100644 --- a/packages/react-ui/src/components/CopilotShell/Header.tsx +++ b/packages/react-ui/src/components/CopilotShell/Header.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import { ReactNode } from "react"; -import { useShellStore } from "../Shell/store"; +import { useShellStore } from "../_shared/store"; interface HeaderProps { className?: string; diff --git a/packages/react-ui/src/components/CopilotShell/Thread.tsx b/packages/react-ui/src/components/CopilotShell/Thread.tsx index 211cd4845..99e70bdc3 100644 --- a/packages/react-ui/src/components/CopilotShell/Thread.tsx +++ b/packages/react-ui/src/components/CopilotShell/Thread.tsx @@ -1,36 +1,22 @@ import type { AssistantMessage, Message, ToolMessage } from "@openuidev/react-headless"; import { MessageProvider, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; -import React, { memo, useEffect, useRef } from "react"; +import React, { memo, useRef } from "react"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; +import { ArtifactOverlay } from "../_shared/artifact"; +import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; -import type { AssistantMessageComponent, UserMessageComponent } from "../OpenUIChat/types"; -import { useShellStore } from "../Shell/store"; import { ToolCallComponent } from "../ToolCall"; import { ToolResult } from "../ToolResult"; export const ThreadContainer = ({ children, className, - isArtifactActive = false, - renderArtifact = () => null, }: { children?: React.ReactNode; className?: string; - isArtifactActive?: boolean; - renderArtifact?: () => React.ReactNode; }) => { - const { setIsArtifactActive, setArtifactRenderer } = useShellStore((state) => ({ - setIsArtifactActive: state.setIsArtifactActive, - setArtifactRenderer: state.setArtifactRenderer, - })); - - useEffect(() => { - setIsArtifactActive(isArtifactActive); - setArtifactRenderer(renderArtifact); - }, [isArtifactActive, setIsArtifactActive]); - const isLoadingMessages = useThread((s) => s.isLoadingMessages); return ( @@ -41,6 +27,7 @@ export const ThreadContainer = ({ }} > {children} + ); }; @@ -67,10 +54,6 @@ export const ScrollArea = ({ const messages = useThread((s) => s.messages); const isRunning = useThread((s) => s.isRunning); const isLoadingMessages = useThread((s) => s.isLoadingMessages); - const { isArtifactActive, artifactRenderer } = useShellStore((store) => ({ - isArtifactActive: store.isArtifactActive, - artifactRenderer: store.artifactRenderer, - })); useScrollToBottom({ ref, @@ -96,11 +79,6 @@ export const ScrollArea = ({ > {children} - {isArtifactActive && ( -
- {artifactRenderer()} -
- )} ); }; diff --git a/packages/react-ui/src/components/CopilotShell/WelcomeScreen.tsx b/packages/react-ui/src/components/CopilotShell/WelcomeScreen.tsx index 1236198cc..9fc19c133 100644 --- a/packages/react-ui/src/components/CopilotShell/WelcomeScreen.tsx +++ b/packages/react-ui/src/components/CopilotShell/WelcomeScreen.tsx @@ -1,7 +1,7 @@ import { useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import { ReactNode } from "react"; -import { isChatEmpty } from "../OpenUIChat/utils"; +import { isChatEmpty } from "../_shared/utils"; interface WelcomeScreenBaseProps { /** diff --git a/packages/react-ui/src/components/CopilotShell/thread.scss b/packages/react-ui/src/components/CopilotShell/thread.scss index 3a129518c..12b1c29ce 100644 --- a/packages/react-ui/src/components/CopilotShell/thread.scss +++ b/packages/react-ui/src/components/CopilotShell/thread.scss @@ -5,6 +5,7 @@ flex: 1; overflow: hidden; flex-direction: column; + position: relative; } .openui-copilot-shell-thread-scroll-container { @@ -26,29 +27,6 @@ } } -// Artifact panel (overlay style) -.openui-copilot-shell-thread-artifact-panel--mobile { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 10; - background-color: cssUtils.$foreground; - animation: slideInFromBottom 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - -@keyframes slideInFromBottom { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - .openui-copilot-shell-thread-messages { margin: 0 auto; display: flex; diff --git a/packages/react-ui/src/components/OpenUIChat/ComposedBottomTray.tsx b/packages/react-ui/src/components/OpenUIChat/ComposedBottomTray.tsx index c31ff5d4f..0200ee24d 100644 --- a/packages/react-ui/src/components/OpenUIChat/ComposedBottomTray.tsx +++ b/packages/react-ui/src/components/OpenUIChat/ComposedBottomTray.tsx @@ -73,8 +73,6 @@ const BottomTrayInner = ({ agentName = "My Agent", messageLoading: MessageLoadingComponent = MessageLoading, scrollVariant = "user-message-anchor", - isArtifactActive, - renderArtifact, isOpen: controlledIsOpen, onOpenChange, defaultOpen = false, @@ -105,7 +103,7 @@ const BottomTrayInner = ({ - +
handleOpenChange(false)} rightChildren={headerActions} /> diff --git a/packages/react-ui/src/components/OpenUIChat/ComposedCopilot.tsx b/packages/react-ui/src/components/OpenUIChat/ComposedCopilot.tsx index 3dd2c2c58..a59f44874 100644 --- a/packages/react-ui/src/components/OpenUIChat/ComposedCopilot.tsx +++ b/packages/react-ui/src/components/OpenUIChat/ComposedCopilot.tsx @@ -68,8 +68,6 @@ const CopilotInner = ({ agentName = "My Agent", messageLoading: MessageLoadingComponent = MessageLoading, scrollVariant = "user-message-anchor", - isArtifactActive, - renderArtifact, welcomeMessage, conversationStarters, assistantMessage, @@ -79,7 +77,7 @@ const CopilotInner = ({ }: CopilotSpecificProps) => { return ( - +
diff --git a/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx b/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx index 85fe70ced..edc381298 100644 --- a/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx +++ b/packages/react-ui/src/components/OpenUIChat/ComposedStandalone.tsx @@ -81,8 +81,6 @@ const FullScreenInner = ({ agentName = "My Agent", messageLoading: MessageLoadingComponent = MessageLoading, scrollVariant = "user-message-anchor", - isArtifactActive, - renderArtifact, welcomeMessage, conversationStarters, assistantMessage, @@ -101,7 +99,7 @@ const FullScreenInner = ({ - + {threadHeader && {threadHeader}} ( - *
- * {message.content ?? ""} - *
- * ); - */ -export type AssistantMessageComponent = React.ComponentType<{ - message: AssistantMessage; -}>; - -/** - * 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; -}>; +export type { AssistantMessageComponent, UserMessageComponent }; /** * Welcome message configuration for OpenUIChat. @@ -124,8 +94,6 @@ export interface SharedChatUIProps { agentName?: string; messageLoading?: React.ComponentType; scrollVariant?: ScrollVariant; - isArtifactActive?: boolean; - renderArtifact?: () => React.ReactNode; welcomeMessage?: WelcomeMessageConfig; conversationStarters?: ConversationStartersConfig; assistantMessage?: AssistantMessageComponent; diff --git a/packages/react-ui/src/components/OpenUIChat/utils/index.ts b/packages/react-ui/src/components/OpenUIChat/utils/index.ts index 59328138c..35ea635b7 100644 --- a/packages/react-ui/src/components/OpenUIChat/utils/index.ts +++ b/packages/react-ui/src/components/OpenUIChat/utils/index.ts @@ -1,4 +1,3 @@ -import { Message } from "@openuidev/react-headless"; import { WelcomeMessageConfig } from "../types"; /** @@ -26,12 +25,4 @@ export const isWelcomeComponent = ( return typeof config === "function"; }; -export const isChatEmpty = ({ - isLoadingMessages, - messages, -}: { - isLoadingMessages: boolean | undefined; - messages: Message[]; -}) => { - return !isLoadingMessages && messages.length === 0; -}; +export { isChatEmpty } from "../../_shared/utils"; diff --git a/packages/react-ui/src/components/Shell/Container.tsx b/packages/react-ui/src/components/Shell/Container.tsx index eba5a2229..728b80e38 100644 --- a/packages/react-ui/src/components/Shell/Container.tsx +++ b/packages/react-ui/src/components/Shell/Container.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import { useRef } from "react"; import { LayoutContextProvider } from "../../context/LayoutContext"; import { useElementSize } from "../../hooks/useElementSize"; -import { ShellStoreProvider } from "./store"; +import { ShellStoreProvider } from "../_shared/store"; interface ContainerProps { children?: React.ReactNode; diff --git a/packages/react-ui/src/components/Shell/ConversationStarter.tsx b/packages/react-ui/src/components/Shell/ConversationStarter.tsx index 2fee3f1fb..20ea4dde7 100644 --- a/packages/react-ui/src/components/Shell/ConversationStarter.tsx +++ b/packages/react-ui/src/components/Shell/ConversationStarter.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { ArrowUp, Lightbulb } from "lucide-react"; import { Fragment, ReactNode } from "react"; import { ConversationStarterIcon, ConversationStarterProps } from "../../types/ConversationStarter"; -import { isChatEmpty } from "../OpenUIChat/utils"; +import { isChatEmpty } from "../_shared/utils"; import { Separator } from "../Separator"; export type ConversationStarterVariant = "short" | "long"; diff --git a/packages/react-ui/src/components/Shell/MobileHeader.tsx b/packages/react-ui/src/components/Shell/MobileHeader.tsx index b1b7e3da3..16596285f 100644 --- a/packages/react-ui/src/components/Shell/MobileHeader.tsx +++ b/packages/react-ui/src/components/Shell/MobileHeader.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { Menu, Plus } from "lucide-react"; import { ReactNode } from "react"; import { IconButton } from "../IconButton"; -import { useShellStore } from "./store"; +import { useShellStore } from "../_shared/store"; interface MobileHeaderProps { className?: string; diff --git a/packages/react-ui/src/components/Shell/NewChatButton.tsx b/packages/react-ui/src/components/Shell/NewChatButton.tsx index bb1ced6d5..a17ef7067 100644 --- a/packages/react-ui/src/components/Shell/NewChatButton.tsx +++ b/packages/react-ui/src/components/Shell/NewChatButton.tsx @@ -3,7 +3,7 @@ import clsx from "clsx"; import { Plus, SquarePen } from "lucide-react"; import { Button } from "../Button"; import { IconButton } from "../IconButton"; -import { useShellStore } from "./store"; +import { useShellStore } from "../_shared/store"; export const NewChatButton = ({ className }: { className?: string }) => { const switchToNewThread = useThreadList((s) => s.switchToNewThread); diff --git a/packages/react-ui/src/components/Shell/Sidebar.tsx b/packages/react-ui/src/components/Shell/Sidebar.tsx index 65d5ec897..9cde32be7 100644 --- a/packages/react-ui/src/components/Shell/Sidebar.tsx +++ b/packages/react-ui/src/components/Shell/Sidebar.tsx @@ -1,9 +1,10 @@ +import { useActiveArtifact } 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 { useShellStore } from "./store"; +import { useShellStore } from "../_shared/store"; export const SidebarContainer = ({ children, @@ -12,11 +13,11 @@ export const SidebarContainer = ({ children?: React.ReactNode; className?: string; }) => { - const { isSidebarOpen, setIsSidebarOpen, isArtifactActive } = useShellStore((state) => ({ + const { isSidebarOpen, setIsSidebarOpen } = useShellStore((state) => ({ isSidebarOpen: state.isSidebarOpen, setIsSidebarOpen: state.setIsSidebarOpen, - isArtifactActive: state.isArtifactActive, })); + const { isArtifactActive } = useActiveArtifact(); const { layout } = useLayoutContext() || {}; const isMobile = layout === "mobile"; diff --git a/packages/react-ui/src/components/Shell/Thread.tsx b/packages/react-ui/src/components/Shell/Thread.tsx index 254e37615..bbc6c2e25 100644 --- a/packages/react-ui/src/components/Shell/Thread.tsx +++ b/packages/react-ui/src/components/Shell/Thread.tsx @@ -1,45 +1,35 @@ import type { AssistantMessage, Message, ToolMessage } from "@openuidev/react-headless"; -import { MessageProvider, useThread } from "@openuidev/react-headless"; +import { MessageProvider, useActiveArtifact, useThread } from "@openuidev/react-headless"; import clsx from "clsx"; -import React, { memo, useEffect, useRef } from "react"; +import React, { memo, useRef } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; import { ScrollVariant, useScrollToBottom } from "../../hooks/useScrollToBottom"; +import { ArtifactOverlay, ArtifactPortalTarget } from "../_shared/artifact"; +import { useShellStore } from "../_shared/store"; +import type { AssistantMessageComponent, UserMessageComponent } from "../_shared/types"; import { Callout } from "../Callout"; import { MarkDownRenderer } from "../MarkDownRenderer"; import { MessageLoading as MessageLoadingComponent } from "../MessageLoading"; -import type { AssistantMessageComponent, UserMessageComponent } from "../OpenUIChat/types"; import { ToolCallComponent } from "../ToolCall"; import { ToolResult } from "../ToolResult"; import { ResizableSeparator } from "./ResizableSeparator"; -import { useShellStore } from "./store"; import { useArtifactResize } from "./useArtifactResize"; export const ThreadContainer = ({ children, className, - isArtifactActive = false, - renderArtifact = () => null, }: { children?: React.ReactNode; className?: string; - isArtifactActive?: boolean; - renderArtifact?: () => React.ReactNode; }) => { const { layout } = useLayoutContext(); const isMobile = layout === "mobile"; + const { isArtifactActive } = useActiveArtifact(); - const { setIsSidebarOpen, setIsArtifactActive, setArtifactRenderer } = useShellStore((state) => ({ + const { setIsSidebarOpen } = useShellStore((state) => ({ setIsSidebarOpen: state.setIsSidebarOpen, - setIsArtifactActive: state.setIsArtifactActive, - setArtifactRenderer: state.setArtifactRenderer, })); - // Sync artifact state and renderer with store - useEffect(() => { - setIsArtifactActive(isArtifactActive); - setArtifactRenderer(renderArtifact); - }, [isArtifactActive, renderArtifact, setIsArtifactActive, setArtifactRenderer]); - const isLoadingMessages = useThread((s) => s.isLoadingMessages); const { @@ -74,6 +64,7 @@ export const ThreadContainer = ({ })} > {children} + {isMobile && } {/* Desktop only: Resizable separator and artifact panel */} @@ -90,7 +81,7 @@ export const ThreadContainer = ({ "openui-shell-thread-artifact-panel--animating": !isDragging, })} > - {renderArtifact?.()} + )} @@ -117,16 +108,10 @@ export const ScrollArea = ({ userMessageSelector?: string; }) => { const ref = useRef(null); - const { layout } = useLayoutContext(); - const isMobile = layout === "mobile"; const messages = useThread((s) => s.messages); const isRunning = useThread((s) => s.isRunning); const isLoadingMessages = useThread((s) => s.isLoadingMessages); - const { isArtifactActive, artifactRenderer } = useShellStore((store) => ({ - isArtifactActive: store.isArtifactActive, - artifactRenderer: store.artifactRenderer, - })); useScrollToBottom({ ref, @@ -152,9 +137,6 @@ export const ScrollArea = ({ > {children} - {isMobile && isArtifactActive && ( -
{artifactRenderer()}
- )} ); }; diff --git a/packages/react-ui/src/components/Shell/ThreadList.tsx b/packages/react-ui/src/components/Shell/ThreadList.tsx index a6ee1301a..e663580a3 100644 --- a/packages/react-ui/src/components/Shell/ThreadList.tsx +++ b/packages/react-ui/src/components/Shell/ThreadList.tsx @@ -5,7 +5,7 @@ import clsx from "clsx"; import { EllipsisVerticalIcon, Trash2Icon } from "lucide-react"; import { Fragment, useEffect } from "react"; import { useLayoutContext } from "../../context/LayoutContext"; -import { useShellStore } from "./store"; +import { useShellStore } from "../_shared/store"; export const ThreadButton = ({ id, diff --git a/packages/react-ui/src/components/Shell/WelcomeScreen.tsx b/packages/react-ui/src/components/Shell/WelcomeScreen.tsx index d0c87e247..edff6e183 100644 --- a/packages/react-ui/src/components/Shell/WelcomeScreen.tsx +++ b/packages/react-ui/src/components/Shell/WelcomeScreen.tsx @@ -2,7 +2,7 @@ import { useThread } from "@openuidev/react-headless"; import clsx from "clsx"; import { ReactNode } from "react"; import { ConversationStarterProps } from "../../types/ConversationStarter"; -import { isChatEmpty } from "../OpenUIChat/utils"; +import { isChatEmpty } from "../_shared/utils"; import { DesktopWelcomeComposer } from "./components"; import { ConversationStarter, ConversationStarterVariant } from "./ConversationStarter"; diff --git a/packages/react-ui/src/components/Shell/components/composer.scss b/packages/react-ui/src/components/Shell/components/composer.scss index 1ff0fec4d..bfe4431d2 100644 --- a/packages/react-ui/src/components/Shell/components/composer.scss +++ b/packages/react-ui/src/components/Shell/components/composer.scss @@ -11,7 +11,6 @@ $center-align-spacing: calc(32px + cssUtils.$space-s); .openui-shell-container--mobile & { margin: 0; padding: cssUtils.$space-m 14px; - background-color: cssUtils.$foreground; } .openui-shell-thread-container--artifact-active & { diff --git a/packages/react-ui/src/components/Shell/index.ts b/packages/react-ui/src/components/Shell/index.ts index 5d9e046e4..2ea9bb301 100644 --- a/packages/react-ui/src/components/Shell/index.ts +++ b/packages/react-ui/src/components/Shell/index.ts @@ -1,10 +1,11 @@ +export * from "../_shared/artifact"; +export * from "../_shared/store"; export * from "./components"; export * from "./Container"; export * from "./ConversationStarter"; export * from "./MobileHeader"; export * from "./NewChatButton"; export * from "./Sidebar"; -export * from "./store"; export * from "./Thread"; export * from "./ThreadList"; export * from "./WelcomeScreen"; diff --git a/packages/react-ui/src/components/Shell/thread.scss b/packages/react-ui/src/components/Shell/thread.scss index df3f3bf12..3ef323fef 100644 --- a/packages/react-ui/src/components/Shell/thread.scss +++ b/packages/react-ui/src/components/Shell/thread.scss @@ -73,17 +73,6 @@ } } -// Mobile artifact panel (absolute overlay) -.openui-shell-thread-artifact-panel--mobile { - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - z-index: 10; - animation: slideInFromBottom 0.3s cubic-bezier(0.4, 0, 0.2, 1); -} - // ============================================================================= // Animations // ============================================================================= @@ -99,17 +88,6 @@ } } -@keyframes slideInFromBottom { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - // ============================================================================= // Scroll Area // ============================================================================= diff --git a/packages/react-ui/src/components/_shared/artifact/ArtifactOverlay.tsx b/packages/react-ui/src/components/_shared/artifact/ArtifactOverlay.tsx new file mode 100644 index 000000000..803041324 --- /dev/null +++ b/packages/react-ui/src/components/_shared/artifact/ArtifactOverlay.tsx @@ -0,0 +1,73 @@ +import { useActiveArtifact } 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"; + +/** + * Props for {@link ArtifactOverlay}. + * + * @category Components + */ +export type ArtifactOverlayProps = { + /** Additional CSS class name(s) applied to the overlay container. */ + className?: string; +}; + +/** + * Shared overlay wrapper for the artifact 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( + ({ className }, ref) => { + const { isArtifactActive } = useActiveArtifact(); + const [shouldRender, setShouldRender] = useState(isArtifactActive); + const [isExiting, setIsExiting] = useState(false); + const internalRef = useRef(null); + const mergedRef = useMultipleRefs(ref, internalRef); + + useEffect(() => { + if (isArtifactActive) { + // Opening: mount immediately, cancel any in-progress exit + setShouldRender(true); + setIsExiting(false); + } else if (shouldRender) { + // Closing: start exit animation, defer unmount + setIsExiting(true); + } + }, [isArtifactActive]); // 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 ( +
+ +
+ ); + }, +); + +ArtifactOverlay.displayName = "ArtifactOverlay"; diff --git a/packages/react-ui/src/components/_shared/artifact/ArtifactPanel.tsx b/packages/react-ui/src/components/_shared/artifact/ArtifactPanel.tsx new file mode 100644 index 000000000..07dac2b5c --- /dev/null +++ b/packages/react-ui/src/components/_shared/artifact/ArtifactPanel.tsx @@ -0,0 +1,134 @@ +import { useArtifact, useArtifactPortalTarget } 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 ArtifactErrorBoundaryProps = { + children: ReactNode; + fallback?: ReactNode; +}; + +type ArtifactErrorBoundaryState = { + hasError: boolean; +}; + +/** @internal */ +class ArtifactErrorBoundary extends Component< + ArtifactErrorBoundaryProps, + ArtifactErrorBoundaryState +> { + constructor(props: ArtifactErrorBoundaryProps) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): ArtifactErrorBoundaryState { + return { hasError: true }; + } + + override render() { + if (this.state.hasError) { + return this.props.fallback ?? null; + } + return this.props.children; + } +} + +/** + * Props for {@link ArtifactPanel}. + * + * @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. */ + children: ReactNode; + /** Display title for the panel header and aria-label. Defaults to `"Artifact"`. */ + 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 artifact content into the nearest {@link ArtifactPortalTarget}. + * + * Renders nothing when the artifact 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 ArtifactPanel = forwardRef( + ({ artifactId, children, title, className, errorFallback, header = true }, ref) => { + const { isActive, close } = useArtifact(artifactId); + const { node: panelNode } = useArtifactPortalTarget(); + const { portalThemeClassName } = useTheme(); + + useEffect(() => { + if (!isActive || panelNode) return; + + const timer = setTimeout(() => { + console.warn( + "[OpenUI] ArtifactPanel: artifact 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, + ); + }, +); + +ArtifactPanel.displayName = "ArtifactPanel"; diff --git a/packages/react-ui/src/components/_shared/artifact/ArtifactPortalTarget.tsx b/packages/react-ui/src/components/_shared/artifact/ArtifactPortalTarget.tsx new file mode 100644 index 000000000..f768fcbda --- /dev/null +++ b/packages/react-ui/src/components/_shared/artifact/ArtifactPortalTarget.tsx @@ -0,0 +1,43 @@ +import { useArtifactPortalTarget } from "@openuidev/react-headless"; +import { forwardRef, useCallback, useRef } from "react"; + +/** + * Props for {@link ArtifactPortalTarget}. + */ +export type ArtifactPortalTargetProps = { + /** Additional CSS class name(s) applied to the container element. */ + className?: string; +}; + +/** + * Registers a DOM node as the render target for {@link ArtifactPanel} 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( + ({ className }, ref) => { + const { setNode } = useArtifactPortalTarget(); + 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
; + }, +); + +ArtifactPortalTarget.displayName = "ArtifactPortalTarget"; diff --git a/packages/react-ui/src/components/_shared/artifact/artifactOverlay.scss b/packages/react-ui/src/components/_shared/artifact/artifactOverlay.scss new file mode 100644 index 000000000..af9a729ce --- /dev/null +++ b/packages/react-ui/src/components/_shared/artifact/artifactOverlay.scss @@ -0,0 +1,40 @@ +@use "../../../cssUtils" as cssUtils; + +.openui-artifact-overlay { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 10; + background-color: cssUtils.$foreground; + animation: openui-artifact-overlay-slide-in 0.3s cubic-bezier(0.4, 0, 0.2, 1); + + &--exiting { + animation: openui-artifact-overlay-slide-out 0.2s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } +} + +@keyframes openui-artifact-overlay-slide-in { + from { + opacity: 0; + transform: translateY(20px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes openui-artifact-overlay-slide-out { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(20px); + } +} diff --git a/packages/react-ui/src/components/_shared/artifact/artifactPanel.scss b/packages/react-ui/src/components/_shared/artifact/artifactPanel.scss new file mode 100644 index 000000000..cbc0bb181 --- /dev/null +++ b/packages/react-ui/src/components/_shared/artifact/artifactPanel.scss @@ -0,0 +1,42 @@ +@use "../../../cssUtils" as cssUtils; + +.openui-artifact-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; +} + +.openui-artifact-panel__title { + @include cssUtils.typography(body, default); + color: cssUtils.$text-neutral-primary; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.openui-artifact-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/_shared/artifact/index.ts b/packages/react-ui/src/components/_shared/artifact/index.ts new file mode 100644 index 000000000..010430867 --- /dev/null +++ b/packages/react-ui/src/components/_shared/artifact/index.ts @@ -0,0 +1,3 @@ +export * from "./ArtifactOverlay"; +export * from "./ArtifactPanel"; +export * from "./ArtifactPortalTarget"; diff --git a/packages/react-ui/src/components/_shared/index.ts b/packages/react-ui/src/components/_shared/index.ts new file mode 100644 index 000000000..60d968b92 --- /dev/null +++ b/packages/react-ui/src/components/_shared/index.ts @@ -0,0 +1,4 @@ +export * from "./artifact"; +export * from "./store"; +export * from "./types"; +export * from "./utils"; diff --git a/packages/react-ui/src/components/_shared/shared.scss b/packages/react-ui/src/components/_shared/shared.scss new file mode 100644 index 000000000..1e4098651 --- /dev/null +++ b/packages/react-ui/src/components/_shared/shared.scss @@ -0,0 +1,2 @@ +@forward "./artifact/artifactOverlay.scss"; +@forward "./artifact/artifactPanel.scss"; diff --git a/packages/react-ui/src/components/_shared/store/index.ts b/packages/react-ui/src/components/_shared/store/index.ts new file mode 100644 index 000000000..f5990c259 --- /dev/null +++ b/packages/react-ui/src/components/_shared/store/index.ts @@ -0,0 +1 @@ +export * from "./store"; diff --git a/packages/react-ui/src/components/Shell/store.tsx b/packages/react-ui/src/components/_shared/store/store.tsx similarity index 79% rename from packages/react-ui/src/components/Shell/store.tsx rename to packages/react-ui/src/components/_shared/store/store.tsx index 4d5e0a6e7..cab14e1c2 100644 --- a/packages/react-ui/src/components/Shell/store.tsx +++ b/packages/react-ui/src/components/_shared/store/store.tsx @@ -6,13 +6,9 @@ interface ShellState { isSidebarOpen: boolean; agentName: string; logoUrl: string; - isArtifactActive: boolean; - artifactRenderer: () => React.ReactNode; setIsSidebarOpen: (isOpen: boolean) => void; setAgentName: (name: string) => void; setLogoUrl: (url: string) => void; - setIsArtifactActive: (isActive: boolean) => void; - setArtifactRenderer: (renderer: () => React.ReactNode) => void; } export const createShellStore = ({ logoUrl, agentName }: { logoUrl: string; agentName: string }) => @@ -20,13 +16,9 @@ export const createShellStore = ({ logoUrl, agentName }: { logoUrl: string; agen isSidebarOpen: true, agentName: agentName, logoUrl: logoUrl, - isArtifactActive: false, - artifactRenderer: () => null, setIsSidebarOpen: (isOpen: boolean) => set({ isSidebarOpen: isOpen }), setAgentName: (name: string) => set({ agentName: name }), setLogoUrl: (url: string) => set({ logoUrl: url }), - setIsArtifactActive: (isActive: boolean) => set({ isArtifactActive: isActive }), - setArtifactRenderer: (renderer: () => React.ReactNode) => set({ artifactRenderer: renderer }), })); export const ShellStoreContext = createContext | null>(null); diff --git a/packages/react-ui/src/components/_shared/types/index.ts b/packages/react-ui/src/components/_shared/types/index.ts new file mode 100644 index 000000000..9fd648af9 --- /dev/null +++ b/packages/react-ui/src/components/_shared/types/index.ts @@ -0,0 +1,33 @@ +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; +}>; + +/** + * 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/_shared/utils/index.ts b/packages/react-ui/src/components/_shared/utils/index.ts new file mode 100644 index 000000000..9a588ec6c --- /dev/null +++ b/packages/react-ui/src/components/_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/index.scss b/packages/react-ui/src/components/index.scss index d7f452eed..e2d3d5808 100644 --- a/packages/react-ui/src/components/index.scss +++ b/packages/react-ui/src/components/index.scss @@ -55,3 +55,4 @@ @forward "./ToggleItem/toggleItem.scss"; @forward "./ToolCall/toolCall.scss"; @forward "./ToolResult/toolResult.scss"; +@forward "./_shared/shared.scss"; diff --git a/packages/react-ui/src/index.ts b/packages/react-ui/src/index.ts index 463ba3773..fac42b290 100644 --- a/packages/react-ui/src/index.ts +++ b/packages/react-ui/src/index.ts @@ -1,6 +1,20 @@ "use client"; export * from "./components/Accordion"; + +// Artifact exports (ArtifactPanel/ArtifactPortalTarget also available as Shell.*) +export { useActiveArtifact, useArtifact } from "@openuidev/react-headless"; +export { + ArtifactOverlay, + ArtifactPanel, + ArtifactPortalTarget, +} from "./components/_shared/artifact"; +export type { + ArtifactOverlayProps, + ArtifactPanelProps, + ArtifactPortalTargetProps, +} from "./components/_shared/artifact"; + export * from "./components/Button"; export * from "./components/Buttons"; export * from "./components/Calendar"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6eddd5f3..4ca735df6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -509,9 +509,6 @@ importers: react: specifier: '>=19.0.0' version: 19.2.4 - react-dom: - specifier: '>=19.0.0' - version: 19.2.4(react@19.2.4) zustand: specifier: ^4.5.5 version: 4.5.7(@types/react@19.2.14)(react@19.2.4)