diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 6bd05b30a..286384dd4 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 { artifactViewId, parseArtifactViewId } from "./store/artifactViewId"; 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/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index 76f708a26..91c4403c7 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, type FC } from "react"; import { createDefaultInMemoryStorage } from "../adapters/_defaultStorage"; +import { useArtifactAutoOpenWatcher } from "./artifactAutoOpenWatcher"; import { ArtifactCategoriesContext } from "./ArtifactCategoriesContext"; import { ArtifactRenderersContext, @@ -22,6 +23,7 @@ export const ChatProvider: FC = ({ llm, artifactRenderers, artifactCategories, + artifactAutoOpen, }) => { const [resolvedStorage] = useState(() => storage ?? createDefaultInMemoryStorage()); const [chatStore] = useState(() => createChatStore({ storage: resolvedStorage, llm })); @@ -64,6 +66,13 @@ export const ChatProvider: FC = ({ return unsubscribe; }, [chatStore, detailedViewStore, threadContextStore]); + useArtifactAutoOpenWatcher( + artifactAutoOpen ?? true, + chatStore, + threadContextStore, + detailedViewStore, + ); + return ( 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..fb2078272 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts @@ -0,0 +1,115 @@ +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("opens a newly registered artifact while the stream runs", () => { + const store = createDetailedViewStore(); + const claimed = new Set(); + const opened = evaluateRegisteredArtifacts(registry(entry("art")), true, claimed, store); + expect(opened).toBe(true); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + expect(claimed.has("art:1")).toBe(true); + }); + + it("a user close sticks across re-registrations", () => { + const store = createDetailedViewStore(); + const claimed = new Set(); + const arts = registry(entry("art")); + evaluateRegisteredArtifacts(arts, true, claimed, store); + store.getState().setActiveDetailedView(null); + const opened = evaluateRegisteredArtifacts(arts, true, claimed, store); + expect(opened).toBe(false); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("an edit re-opens: a new version gets a fresh chance, even after a close", () => { + const store = createDetailedViewStore(); + const claimed = new Set(); + evaluateRegisteredArtifacts(registry(entry("art", 1)), true, claimed, store); + store.getState().setActiveDetailedView(null); + const opened = evaluateRegisteredArtifacts( + registry(entry("art", 1), entry("art", 2)), + true, + claimed, + store, + ); + expect(opened).toBe(true); + expect(store.getState().activeDetailedViewId).toBe("art:2"); + }); + + it("the same version never re-opens after a close", () => { + const store = createDetailedViewStore(); + const claimed = new Set(); + const arts = registry(entry("art", 2)); + evaluateRegisteredArtifacts(arts, true, claimed, store); + expect(store.getState().activeDetailedViewId).toBe("art:2"); + store.getState().setActiveDetailedView(null); + expect(evaluateRegisteredArtifacts(arts, true, claimed, store)).toBe(false); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("opens the latest registered version of an id", () => { + const store = createDetailedViewStore(); + evaluateRegisteredArtifacts(registry(entry("art", 1), entry("art", 3)), true, new Set(), store); + expect(store.getState().activeDetailedViewId).toBe("art:3"); + }); + + it("mayOpen=false (nothing streaming): never opens, still claims", () => { + const store = createDetailedViewStore(); + const claimed = new Set(); + const arts = registry(entry("old")); + const opened = evaluateRegisteredArtifacts(arts, false, claimed, store); + expect(opened).toBe(false); + expect(store.getState().activeDetailedViewId).toBeNull(); + expect(claimed.has("old:1")).toBe(true); + evaluateRegisteredArtifacts(arts, true, claimed, store); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("only the first artifact of a pass opens; the second is claimed but ignored", () => { + const store = createDetailedViewStore(); + const claimed = new Set(); + const arts = registry(entry("a1"), entry("a2")); + const opened = evaluateRegisteredArtifacts(arts, true, claimed, store); + expect(opened).toBe(true); + expect(store.getState().activeDetailedViewId).toBe("a1:1"); + expect(claimed.has("a2:1")).toBe(true); + store.getState().setActiveDetailedView(null); + expect(evaluateRegisteredArtifacts(arts, true, claimed, store)).toBe(false); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("a new artifact takes over an already-open panel", () => { + const store = createDetailedViewStore(); + const claimed = new Set(); + store.getState().setActiveDetailedView("user-panel"); + const opened = evaluateRegisteredArtifacts(registry(entry("art")), true, claimed, store); + expect(opened).toBe(true); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + }); + + it("thread switch (fresh claim set) re-arms for the next thread", () => { + const store = createDetailedViewStore(); + const arts = registry(entry("art")); + evaluateRegisteredArtifacts(arts, true, new Set(), store); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + store.getState().reset(); + evaluateRegisteredArtifacts(arts, true, new Set(), store); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + }); +}); diff --git a/packages/react-headless/src/store/__tests__/artifactViewId.test.ts b/packages/react-headless/src/store/__tests__/artifactViewId.test.ts new file mode 100644 index 000000000..a381052f3 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/artifactViewId.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { artifactViewId, parseArtifactViewId } from "../artifactViewId"; + +describe("artifactViewId", () => { + it("builds id:version", () => { + expect(artifactViewId("art-1", 3)).toBe("art-1:3"); + }); + + it("round-trips through parse", () => { + expect(parseArtifactViewId(artifactViewId("art-1", 3))).toEqual({ id: "art-1", version: 3 }); + }); + + it("handles artifact ids that contain colons", () => { + expect(parseArtifactViewId(artifactViewId("ns:art", 2))).toEqual({ id: "ns:art", version: 2 }); + }); + + it("returns null for non-artifact view ids", () => { + expect(parseArtifactViewId("custom-panel")).toBeNull(); + expect(parseArtifactViewId("art:not-a-number")).toBeNull(); + expect(parseArtifactViewId(":r1:")).toBeNull(); + }); +}); diff --git a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts new file mode 100644 index 000000000..4138bc7b9 --- /dev/null +++ b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts @@ -0,0 +1,105 @@ +import { useEffect, useRef } from "react"; +import { artifactViewId } from "./artifactViewId"; +import type { createChatStore } from "./createChatStore"; +import type { createDetailedViewStore } from "./createDetailedViewStore"; +import type { createThreadContextStore } from "./createThreadContextStore"; +import type { ArtifactEntry } from "./threadContextTypes"; + +/** + * One pass over every artifact registered in the thread. Each artifact + * version (`id:version`) gets a single chance to auto-open — recorded in + * `claimedIds` the first time we see it, whether or not it opens — so an + * edit's new version qualifies again. Returns true when a panel opened. + */ +export function evaluateRegisteredArtifacts( + artifacts: Record, // all artifacts in the thread context: id → versions, ascending + mayOpen: boolean, // "is opening allowed right now" — streaming and nothing opened yet this stream + claimedIds: Set, // "id:version" keys that already used their chance (cleared per thread) + detailedViewStore: ReturnType, +): boolean { + let opened = false; + // artifacts = { + // "report-abc": [ {id:"report-abc", version:1, …}, {id:"report-abc", version:2, …} ], + // "deck-xyz": [ {id:"deck-xyz", version:1, …} ], + // } + // artifactsWithVersions = [ + // [ {id:"report-abc", version:1, …}, {id:"report-abc", version:2, …} ], + // [ {id:"deck-xyz", version:1, …} ], + // ] + const artifactsWithVersions = Object.values(artifacts); + for (let i = 0; i < artifactsWithVersions.length; i++) { + const versions = artifactsWithVersions[i]; + // versions = [ {id:"report-abc", version:1, …}, {id:"report-abc", version:2, …} ] + // no list at this index (can't happen; strict TS guard) + if (!versions) continue; + // versions are sorted ascending, so the last entry is the newest one + // latest = {id:"report-abc", version:2, …} + const latest = versions[versions.length - 1]; + // no versions at all (can't happen in practice; keeps strict TS happy) + if (!latest) continue; + // claim keyed by id:version so an EDIT (new version, same id) gets a + // fresh chance to open, while re-registrations of the same version are + // ignored (a user's close sticks) + const key = artifactViewId(latest.id, latest.version); + if (claimedIds.has(key)) continue; + claimedIds.add(key); + // allowed to open right now? and only one open per pass + if (!mayOpen || opened) continue; + // takes over the panel even if it is already open: a new stream's first + // artifact (or a streaming edit) re-points an open panel to itself + detailedViewStore.getState().setActiveDetailedView(key); + opened = true; + } + return opened; +} + +/** + * Runs inside ChatProvider: re-evaluates whenever an artifact registers in + * the thread context, opening at most one artifact per stream. + */ +export function useArtifactAutoOpenWatcher( + artifactAutoOpen: boolean, + chatStore: ReturnType, + threadContextStore: ReturnType, + detailedViewStore: ReturnType, +): void { + // "did this stream already auto-open something" — re-armed on each new stream + const openedThisRunRef = useRef(false); + // id:version keys that used their one auto-open chance — cleared on thread switch + const claimedIdsRef = useRef(new Set()); + + useEffect(() => { + if (!artifactAutoOpen) return; + const unsubscribeThread = chatStore.subscribe( + (s) => s.selectedThreadId, + () => { + claimedIdsRef.current.clear(); + openedThisRunRef.current = false; + }, + ); + const unsubscribeRun = chatStore.subscribe( + (s) => s.isRunning, + (isRunning) => { + if (isRunning) openedThisRunRef.current = false; + }, + ); + const unsubscribeArtifacts = threadContextStore.subscribe( + (s) => s.artifacts, + (artifacts) => { + const mayOpen = chatStore.getState().isRunning && !openedThisRunRef.current; + if ( + evaluateRegisteredArtifacts(artifacts, mayOpen, claimedIdsRef.current, detailedViewStore) + ) { + openedThisRunRef.current = true; + } + }, + // also evaluate artifacts that registered before this watcher mounted + { fireImmediately: true }, + ); + return () => { + unsubscribeThread(); + unsubscribeRun(); + unsubscribeArtifacts(); + }; + }, [artifactAutoOpen, chatStore, threadContextStore, detailedViewStore]); +} diff --git a/packages/react-headless/src/store/artifactViewId.ts b/packages/react-headless/src/store/artifactViewId.ts new file mode 100644 index 000000000..fe84c3ddb --- /dev/null +++ b/packages/react-headless/src/store/artifactViewId.ts @@ -0,0 +1,21 @@ +/** + * Builds the detailed-view id for an artifact version. This is the contract + * between everything that opens artifact panels (auto-open watcher, workspace + * rail) and the renderer that registers them — always build/read the id + * through these helpers, never hand-roll the string. + */ +export function artifactViewId(id: string, version: number): string { + return `${id}:${version}`; +} + +/** + * Splits a detailed-view id back into artifact id + version. Returns null + * when the string isn't an artifact view id (e.g. a useId fallback). + */ +export function parseArtifactViewId(viewId: string): { id: string; version: number } | null { + const sep = viewId.lastIndexOf(":"); + if (sep <= 0) return null; + const versionPart = viewId.slice(sep + 1); + if (!/^\d+$/.test(versionPart)) return null; + return { id: viewId.slice(0, sep), version: Number(versionPart) }; +} diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 4ddc3c870..8f673ecad 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -91,5 +91,6 @@ export interface ChatProviderProps { * artifact browser's pre-applied filters, and workspace section grouping. */ artifactCategories?: ArtifactCategory[]; + artifactAutoOpen?: boolean; 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..56f1dd3be 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, + artifactAutoOpen, componentLibrary, components, theme, @@ -240,6 +241,7 @@ export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfacePr llm={llm} artifactRenderers={artifactRenderers} artifactCategories={artifactCategories} + artifactAutoOpen={artifactAutoOpen} > diff --git a/packages/react-ui/src/components/AgentInterface/Workspace.tsx b/packages/react-ui/src/components/AgentInterface/Workspace.tsx index 3d9c0bc85..90de5ef32 100644 --- a/packages/react-ui/src/components/AgentInterface/Workspace.tsx +++ b/packages/react-ui/src/components/AgentInterface/Workspace.tsx @@ -1,4 +1,5 @@ import { + artifactViewId, useActiveDetailedView, useArtifactCategories, useArtifactList, @@ -156,7 +157,7 @@ const WorkspaceSection = ({ }; const WorkspaceItem = ({ entry }: { entry: ArtifactEntry }) => { - const viewId = `${entry.id}:${entry.version}`; + const viewId = artifactViewId(entry.id, entry.version); const { isActive } = useDetailedView(viewId); const store = useDetailedViewStore(); const onClick = () => store.getState().setActiveDetailedView(viewId); diff --git a/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx b/packages/react-ui/src/components/OpenUIChat/withChatProvider.tsx index ab216af63..5b1490627 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, + artifactAutoOpen, theme, disableThemeProvider, ...innerProps @@ -65,6 +66,7 @@ export function withChatProvider(WrappedComponent: React.Compon llm={llm} artifactRenderers={artifactRenderers} artifactCategories={artifactCategories} + artifactAutoOpen={artifactAutoOpen} > diff --git a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx index 20a74aea1..778d278a1 100644 --- a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx @@ -1,4 +1,6 @@ import { + artifactViewId, + parseArtifactViewId, useDetailedView, useDetailedViewStore, useThreadContextStore, @@ -89,7 +91,7 @@ export function ToolActivityRenderer({ // viewId derives from meta when present, otherwise from React's useId so // `controls.open` still works for an inline-only renderer. - const viewId = meta ? `${meta.id}:${meta.version}` : fallbackId; + const viewId = meta ? artifactViewId(meta.id, meta.version) : fallbackId; // Register entry on mount; unregister on unmount or when (id, version) changes. useEffect(() => { @@ -126,9 +128,10 @@ export function ToolActivityRenderer({ if (!meta) return; const dv = dvStore.getState(); const active = dv.activeDetailedViewId; - if (!active || active === viewId || !active.startsWith(`${meta.id}:`)) return; - const activeVersion = Number(active.slice(meta.id.length + 1)); - if (!Number.isFinite(activeVersion) || meta.version > activeVersion) { + if (!active || active === viewId) return; + const parsed = parseArtifactViewId(active); + if (!parsed || parsed.id !== meta.id) return; + if (meta.version > parsed.version) { dv.setActiveDetailedView(viewId); } // eslint-disable-next-line react-hooks/exhaustive-deps