diff --git a/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts new file mode 100644 index 000000000..bccbaa0dd --- /dev/null +++ b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "vitest"; +import { shouldAutoOpen } from "../useArtifactAutoOpen"; + +describe("shouldAutoOpen", () => { + it.each([ + ["open-on-mount", true, true], + ["open-on-mount", false, true], + ["auto-open", true, true], + ["auto-open", false, false], + ["overview", true, false], + ["overview", false, false], + ] as const)("mode %s, streaming %s → %s", (mode, isStreaming, expected) => { + expect(shouldAutoOpen(mode, isStreaming)).toBe(expected); + }); +}); diff --git a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts new file mode 100644 index 000000000..298ae07ab --- /dev/null +++ b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { useArtifactViewMode, type ArtifactViewMode } from "../store/ArtifactViewModeContext"; +import { useDetailedViewStore } from "../store/DetailedViewContext"; + +export function shouldAutoOpen(mode: ArtifactViewMode, isStreaming: boolean): boolean { + return mode === "open-on-mount" || (mode === "auto-open" && isStreaming); +} + +export interface UseArtifactAutoOpenOptions { + viewId: string; + latchKey: string; + isStreaming: boolean; + enabled?: boolean; +} + +export function useArtifactAutoOpen({ + viewId, + latchKey, + isStreaming, + enabled = true, +}: UseArtifactAutoOpenOptions): void { + const viewMode = useArtifactViewMode(); + const store = useDetailedViewStore(); + + useEffect(() => { + if (!enabled || !shouldAutoOpen(viewMode, isStreaming)) return; + const dv = store.getState(); + if (!dv._markAutoOpened(latchKey)) return; + const active = dv.activeDetailedViewId; + if (active !== null && active !== viewId) return; + dv.setActiveDetailedView(viewId); + }, [viewMode, enabled, isStreaming, latchKey, viewId, store]); +} diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 6bd05b30a..f5aee7b62 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -19,6 +19,7 @@ export { } from "./store/ArtifactRenderersContext"; export { defineArtifactRenderer } from "./store/artifactRendererTypes"; export { useArtifactStorage } from "./store/ArtifactStorageContext"; +export type { ArtifactViewMode } from "./store/ArtifactViewModeContext"; export { ChatProvider } from "./store/ChatProvider"; export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext"; export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext"; diff --git a/packages/react-headless/src/store/ArtifactViewModeContext.ts b/packages/react-headless/src/store/ArtifactViewModeContext.ts new file mode 100644 index 000000000..84c1bf178 --- /dev/null +++ b/packages/react-headless/src/store/ArtifactViewModeContext.ts @@ -0,0 +1,11 @@ +import { createContext, useContext } from "react"; + +export type ArtifactViewMode = "auto-open" | "open-on-mount" | "overview"; + +export const DEFAULT_ARTIFACT_VIEW_MODE: ArtifactViewMode = "overview"; + +export const ArtifactViewModeContext = createContext(DEFAULT_ARTIFACT_VIEW_MODE); + +export function useArtifactViewMode(): ArtifactViewMode { + return useContext(ArtifactViewModeContext); +} diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index 76f708a26..96ce76197 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -1,11 +1,13 @@ import { useEffect, useRef, useState, type FC } from "react"; import { createDefaultInMemoryStorage } from "../adapters/_defaultStorage"; +import { useArtifactAutoOpenWatcher } from "./artifactAutoOpenWatcher"; import { ArtifactCategoriesContext } from "./ArtifactCategoriesContext"; import { ArtifactRenderersContext, buildArtifactRendererRegistry, } from "./ArtifactRenderersContext"; import { ArtifactStorageContext } from "./ArtifactStorageContext"; +import { ArtifactViewModeContext, DEFAULT_ARTIFACT_VIEW_MODE } from "./ArtifactViewModeContext"; import { ChatContext } from "./ChatContext"; import { createChatStore } from "./createChatStore"; import { createDetailedViewStore } from "./createDetailedViewStore"; @@ -22,6 +24,7 @@ export const ChatProvider: FC = ({ llm, artifactRenderers, artifactCategories, + artifactViewMode, }) => { const [resolvedStorage] = useState(() => storage ?? createDefaultInMemoryStorage()); const [chatStore] = useState(() => createChatStore({ storage: resolvedStorage, llm })); @@ -64,6 +67,13 @@ export const ChatProvider: FC = ({ return unsubscribe; }, [chatStore, detailedViewStore, threadContextStore]); + useArtifactAutoOpenWatcher( + artifactViewMode ?? DEFAULT_ARTIFACT_VIEW_MODE, + chatStore, + threadContextStore, + detailedViewStore, + ); + return ( @@ -71,7 +81,11 @@ export const ChatProvider: FC = ({ - {children} + + {children} + diff --git a/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts new file mode 100644 index 000000000..d3dc022d5 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vitest"; +import { evaluateRegisteredArtifacts } from "../artifactAutoOpenWatcher"; +import { createDetailedViewStore } from "../createDetailedViewStore"; +import type { ArtifactEntry } from "../threadContextTypes"; + +const entry = (id: string, version = 1): ArtifactEntry => ({ + id, + version, + heading: `${id} v${version}`, + type: "test_artifact", +}); + +const registry = (...entries: ArtifactEntry[]): Record => { + const out: Record = {}; + for (const e of entries) (out[e.id] ??= []).push(e); + return out; +}; + +describe("evaluateRegisteredArtifacts", () => { + it("auto-open: a newly registered artifact opens while the thread runs", () => { + const store = createDetailedViewStore(); + evaluateRegisteredArtifacts("auto-open", registry(entry("art")), true, store); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + expect(store.getState()._autoOpenedArtifactKeys.has("art")).toBe(true); + }); + + it("presents once: a user close sticks across re-registrations", () => { + const store = createDetailedViewStore(); + const arts = registry(entry("art")); + evaluateRegisteredArtifacts("auto-open", arts, true, store); + store.getState().setActiveDetailedView(null); + evaluateRegisteredArtifacts("auto-open", arts, true, store); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("edits never re-open: a new version shares the claimed id", () => { + const store = createDetailedViewStore(); + evaluateRegisteredArtifacts("auto-open", registry(entry("art", 1)), true, store); + store.getState().setActiveDetailedView(null); + evaluateRegisteredArtifacts( + "auto-open", + registry(entry("art", 1), entry("art", 2)), + true, + store, + ); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("opens the latest registered version of an id", () => { + const store = createDetailedViewStore(); + evaluateRegisteredArtifacts( + "open-on-mount", + registry(entry("art", 1), entry("art", 3)), + false, + store, + ); + expect(store.getState().activeDetailedViewId).toBe("art:3"); + }); + + it("auto-open: historical registrations (thread not running) never open — and stay claimed", () => { + const store = createDetailedViewStore(); + const arts = registry(entry("old")); + evaluateRegisteredArtifacts("auto-open", arts, false, store); + expect(store.getState().activeDetailedViewId).toBeNull(); + evaluateRegisteredArtifacts("auto-open", arts, true, store); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("open-on-mount: opens on thread load with nothing running", () => { + const store = createDetailedViewStore(); + evaluateRegisteredArtifacts("open-on-mount", registry(entry("art")), false, store); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + }); + + it("first wins: an open panel is never stolen by another artifact", () => { + const store = createDetailedViewStore(); + evaluateRegisteredArtifacts("auto-open", registry(entry("a1"), entry("a2")), true, store); + expect(store.getState().activeDetailedViewId).toBe("a1:1"); + expect(store.getState()._autoOpenedArtifactKeys.has("a2")).toBe(true); + store.getState().setActiveDetailedView(null); + evaluateRegisteredArtifacts("auto-open", registry(entry("a1"), entry("a2")), true, store); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("first wins: a user-opened panel blocks auto-open the same way", () => { + const store = createDetailedViewStore(); + store.getState().setActiveDetailedView("user-panel"); + evaluateRegisteredArtifacts("auto-open", registry(entry("art")), true, store); + expect(store.getState().activeDetailedViewId).toBe("user-panel"); + expect(store.getState()._autoOpenedArtifactKeys.has("art")).toBe(true); + }); + + it("overview: never opens and never claims", () => { + const store = createDetailedViewStore(); + evaluateRegisteredArtifacts("overview", registry(entry("art")), true, store); + expect(store.getState().activeDetailedViewId).toBeNull(); + expect(store.getState()._autoOpenedArtifactKeys.size).toBe(0); + }); + + it("thread switch (reset) re-arms for the next thread", () => { + const store = createDetailedViewStore(); + const arts = registry(entry("art")); + evaluateRegisteredArtifacts("open-on-mount", arts, false, store); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + store.getState().reset(); + evaluateRegisteredArtifacts("open-on-mount", arts, false, store); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + }); +}); diff --git a/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts b/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts new file mode 100644 index 000000000..b0a502b1b --- /dev/null +++ b/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { createDetailedViewStore } from "../createDetailedViewStore"; + +describe("detailed-view auto-open latch", () => { + it("claims a key exactly once", () => { + const store = createDetailedViewStore(); + + expect(store.getState()._markAutoOpened("a1:1")).toBe(true); + expect(store.getState()._markAutoOpened("a1:1")).toBe(false); + }); + + it("treats a new version (edit) as a fresh key", () => { + const store = createDetailedViewStore(); + + expect(store.getState()._markAutoOpened("a1:1")).toBe(true); + expect(store.getState()._markAutoOpened("a1:2")).toBe(true); + }); + + it("clears claimed keys on reset (thread switch)", () => { + const store = createDetailedViewStore(); + + expect(store.getState()._markAutoOpened("a1:1")).toBe(true); + store.getState().reset(); + expect(store.getState()._markAutoOpened("a1:1")).toBe(true); + }); +}); diff --git a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts new file mode 100644 index 000000000..195861fb5 --- /dev/null +++ b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts @@ -0,0 +1,50 @@ +import { useEffect } from "react"; +import { shouldAutoOpen } from "../hooks/useArtifactAutoOpen"; +import type { ArtifactViewMode } from "./ArtifactViewModeContext"; +import type { createChatStore } from "./createChatStore"; +import type { createDetailedViewStore } from "./createDetailedViewStore"; +import type { createThreadContextStore } from "./createThreadContextStore"; +import type { ArtifactEntry } from "./threadContextTypes"; + +export function evaluateRegisteredArtifacts( + viewMode: ArtifactViewMode, + artifacts: Record, + isThreadRunning: boolean, + detailedViewStore: ReturnType, +): void { + if (viewMode === "overview") return; + const mayOpen = shouldAutoOpen(viewMode, isThreadRunning); + + for (const versions of Object.values(artifacts)) { + const latest = versions[versions.length - 1]; + if (!latest) continue; + const dv = detailedViewStore.getState(); + if (!dv._markAutoOpened(latest.id)) continue; + if (!mayOpen) continue; + if (dv.activeDetailedViewId !== null) continue; + dv.setActiveDetailedView(`${latest.id}:${latest.version}`); + } +} + +export function useArtifactAutoOpenWatcher( + viewMode: ArtifactViewMode, + chatStore: ReturnType, + threadContextStore: ReturnType, + detailedViewStore: ReturnType, +): void { + useEffect(() => { + if (viewMode === "overview") return; + return threadContextStore.subscribe( + (s) => s.artifacts, + (artifacts) => { + evaluateRegisteredArtifacts( + viewMode, + artifacts, + chatStore.getState().isRunning, + detailedViewStore, + ); + }, + { fireImmediately: true }, + ); + }, [viewMode, chatStore, threadContextStore, detailedViewStore]); +} diff --git a/packages/react-headless/src/store/createDetailedViewStore.ts b/packages/react-headless/src/store/createDetailedViewStore.ts index e9f71c3a6..5067fca8f 100644 --- a/packages/react-headless/src/store/createDetailedViewStore.ts +++ b/packages/react-headless/src/store/createDetailedViewStore.ts @@ -18,7 +18,15 @@ export const createDetailedViewStore = () => { }, reset: () => { - set({ activeDetailedViewId: null }); + set({ activeDetailedViewId: null, _autoOpenedArtifactKeys: new Set() }); + }, + + _autoOpenedArtifactKeys: new Set(), + _markAutoOpened: (key) => { + const keys = get()._autoOpenedArtifactKeys; + if (keys.has(key)) return false; + set({ _autoOpenedArtifactKeys: new Set(keys).add(key) }); + return true; }, _detailedViewPanelNode: null, diff --git a/packages/react-headless/src/store/detailedViewTypes.ts b/packages/react-headless/src/store/detailedViewTypes.ts index a8b0f52f8..9345f63d4 100644 --- a/packages/react-headless/src/store/detailedViewTypes.ts +++ b/packages/react-headless/src/store/detailedViewTypes.ts @@ -35,6 +35,10 @@ export type DetailedViewInternals = { _detailedViewPanelNode: HTMLElement | null; /** @internal */ _setDetailedViewPanelNode: (node: HTMLElement | null) => void; + /** @internal */ + _autoOpenedArtifactKeys: ReadonlySet; + /** @internal */ + _markAutoOpened: (key: string) => boolean; }; /** Combined detailed-view store type (state + actions + internals). */ diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 4ddc3c870..2b9f83086 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -1,6 +1,7 @@ import type { ArtifactCategory, ChatLLM, ChatStorage } from "../adapters/types"; import type { Message, UserMessage } from "../types/message"; import type { ArtifactRendererConfig } from "./artifactRendererTypes"; +import type { ArtifactViewMode } from "./ArtifactViewModeContext"; export type { Message, UserMessage } from "../types/message"; export type CreateMessage = Omit; @@ -91,5 +92,6 @@ export interface ChatProviderProps { * artifact browser's pre-applied filters, and workspace section grouping. */ artifactCategories?: ArtifactCategory[]; + artifactViewMode?: ArtifactViewMode; children: React.ReactNode; } diff --git a/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx b/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx index 99dab597a..a1b258139 100644 --- a/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx +++ b/packages/react-ui/src/components/AgentInterface/AgentInterface.tsx @@ -183,6 +183,7 @@ export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfacePr llm, artifactRenderers, artifactCategories, + artifactViewMode, componentLibrary, components, theme, @@ -240,6 +241,7 @@ export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfacePr llm={llm} artifactRenderers={artifactRenderers} artifactCategories={artifactCategories} + artifactViewMode={artifactViewMode} > diff --git a/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx b/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx index ab216af63..b5e4d1481 100644 --- a/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx +++ b/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx @@ -26,6 +26,7 @@ export function withChatProvider(WrappedComponent: React.Compon llm, artifactRenderers, artifactCategories, + artifactViewMode, theme, disableThemeProvider, ...innerProps @@ -65,6 +66,7 @@ export function withChatProvider(WrappedComponent: React.Compon llm={llm} artifactRenderers={artifactRenderers} artifactCategories={artifactCategories} + artifactViewMode={artifactViewMode} >