From 016d0df199bdbcf7d370af68a9677b94d9ad8e6a Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Tue, 28 Jul 2026 16:50:41 +0530 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20artifactViewMode=20=E2=80=94=20opt-?= =?UTF-8?q?in=20auto-open=20for=20artifact=20detail=20panels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an artifactViewMode prop ('auto-open' | 'open-on-mount' | 'overview', default 'overview') to ChatProvider, AgentInterface, and the OpenUIChat withChatProvider family. In 'auto-open' the detail panel opens by itself while an artifact's tool call streams live; 'open-on-mount' opens on every artifact mount (deep-link/kiosk); the default keeps today's click-to-open behavior, so the change is inert for existing consumers. One effect in ToolActivityRenderer serves every artifact renderer. It re-gates the error/parsed-null shapes (those hooks sit above the fallback early-return), only fires for live streams (historical activities mount as 'complete', so thread reloads stay quiet), and latches on the detailed-view store keyed by TOOL-CALL id — not meta id:version, because a streamed edit often carries no version in its args and would collide with the generate's key, swallowing the edit's auto-open. The store latch survives host remounts mid-stream (a per-instance ref would re-fire over a user's mid-stream close) and resets on thread switch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019hnic4tGtRh3MSSaCdGJYE --- packages/react-headless/src/index.ts | 2 ++ .../src/store/ArtifactViewModeContext.ts | 35 +++++++++++++++++++ .../react-headless/src/store/ChatProvider.tsx | 8 ++++- .../detailedViewAutoOpenLatch.test.ts | 28 +++++++++++++++ .../src/store/createDetailedViewStore.ts | 10 +++++- .../src/store/detailedViewTypes.ts | 14 ++++++++ packages/react-headless/src/store/types.ts | 8 +++++ .../AgentInterface/AgentInterface.tsx | 2 ++ .../OpenUIChat/withChatProvider.tsx | 2 ++ .../tool-renderer/ToolActivityRenderer.tsx | 35 +++++++++++++++++++ 10 files changed, 142 insertions(+), 2 deletions(-) create mode 100644 packages/react-headless/src/store/ArtifactViewModeContext.ts create mode 100644 packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 6bd05b30a..7ef7062b0 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -19,6 +19,8 @@ export { } from "./store/ArtifactRenderersContext"; export { defineArtifactRenderer } from "./store/artifactRendererTypes"; export { useArtifactStorage } from "./store/ArtifactStorageContext"; +export { ArtifactViewModeContext, useArtifactViewMode } from "./store/ArtifactViewModeContext"; +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..4d6f45f0b --- /dev/null +++ b/packages/react-headless/src/store/ArtifactViewModeContext.ts @@ -0,0 +1,35 @@ +import { createContext, useContext } from "react"; + +/** + * How artifact detail panels open. + * + * - `"overview"` (default) — panels only open on an explicit user action + * (clicking an artifact preview's open control). + * - `"auto-open"` — a panel opens by itself the moment its artifact is + * observed streaming live. A user's mid-stream close sticks; historical + * artifacts mounted on a thread reload never fire; a new artifact version + * (an edit) auto-opens again. + * - `"open-on-mount"` — a panel opens whenever its artifact mounts, streaming + * or not. Deep-link / kiosk embeds; on a thread reload the last-mounted + * artifact wins. + * + * @category Types + */ +export type ArtifactViewMode = "auto-open" | "open-on-mount" | "overview"; + +export const DEFAULT_ARTIFACT_VIEW_MODE: ArtifactViewMode = "overview"; + +/** + * Carries the artifact view mode from `ChatProvider` (set via its + * `artifactViewMode` prop) to the renderer host. Unlike the renderer + * registry, the value is live — prop changes apply on the next render. + */ +export const ArtifactViewModeContext = createContext(DEFAULT_ARTIFACT_VIEW_MODE); + +/** + * The active {@link ArtifactViewMode}. Defaults to `"overview"` outside a + * `ChatProvider` (or when the prop is unset), i.e. never auto-open. + */ +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..f5286a1b0 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -6,6 +6,7 @@ import { 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 +23,7 @@ export const ChatProvider: FC = ({ llm, artifactRenderers, artifactCategories, + artifactViewMode, }) => { const [resolvedStorage] = useState(() => storage ?? createDefaultInMemoryStorage()); const [chatStore] = useState(() => createChatStore({ storage: resolvedStorage, llm })); @@ -71,7 +73,11 @@ export const ChatProvider: FC = ({ - {children} + + {children} + 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..bbbf624f3 --- /dev/null +++ b/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts @@ -0,0 +1,28 @@ +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); + // A remounted host asking again for the same artifact version must not + // re-open (a user's mid-stream close sticks). + 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/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..ab49e60e4 100644 --- a/packages/react-headless/src/store/detailedViewTypes.ts +++ b/packages/react-headless/src/store/detailedViewTypes.ts @@ -35,6 +35,20 @@ export type DetailedViewInternals = { _detailedViewPanelNode: HTMLElement | null; /** @internal */ _setDetailedViewPanelNode: (node: HTMLElement | null) => void; + /** + * Artifact keys (`id:version`) that already auto-opened once, so a user's + * mid-stream close sticks even when the renderer host remounts during + * streaming. Cleared by `reset()` (thread switch). + * @internal + */ + _autoOpenedArtifactKeys: ReadonlySet; + /** + * Atomically records an auto-open for `key`. Returns `false` when the key + * already fired (the caller must not open again), `true` when this call + * claimed it. + * @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..30d96ce65 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,12 @@ export interface ChatProviderProps { * artifact browser's pre-applied filters, and workspace section grouping. */ artifactCategories?: ArtifactCategory[]; + /** + * How artifact detail panels open (default `"overview"` — only on user + * action). `"auto-open"` opens a panel by itself while its artifact streams + * live; `"open-on-mount"` opens on every artifact mount (deep-link/kiosk). + * Live — prop changes apply on the next render. + */ + 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} > 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..454f16511 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,5 @@ import { + useArtifactViewMode, useDetailedView, useDetailedViewStore, useThreadContextStore, @@ -134,6 +135,40 @@ export function ToolActivityRenderer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dvStore, viewId, meta?.id, meta?.version]); + // Artifact view mode: "auto-open" opens the panel by itself while this + // activity streams live; "open-on-mount" opens once per mount regardless. + // Hooks sit ABOVE the error/parsed early-return below, so both failure + // shapes must be re-gated here — a failed tool call renders only the + // fallback card and must never open a panel. Reload safety comes from the + // isStreaming gate (historical activities mount as "complete"). The + // auto-open latch lives on the detailed-view store (reset on thread + // switch), keyed by TOOL-CALL id — not `meta.id:meta.version`, because a + // streamed edit often carries no version in its args (numericVersion + // defaults it to the generate's), which would collide and swallow the + // edit's auto-open. activity.id is unique per generate/edit call and + // stable across host remounts, so a user's mid-stream close sticks while + // every new call (an edit) auto-opens again. Opening early on the useId + // fallback viewId is safe: the migration effect above carries the open + // state to the real key when meta lands. + const viewMode = useArtifactViewMode(); + const openedThisMountRef = useRef(false); + const isError = activity.isError ?? false; + const parsedIsNull = parsed === null; + useEffect(() => { + if (viewMode === "overview") return; + if (isError || parsedIsNull) return; + const dv = dvStore.getState(); + if (viewMode === "auto-open") { + if (!isStreaming) return; + if (!dv._markAutoOpened(activity.id)) return; + } else { + if (openedThisMountRef.current) return; + } + openedThisMountRef.current = true; + dv.setActiveDetailedView(viewId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [viewMode, isStreaming, isError, parsedIsNull, viewId, activity.id, dvStore]); + const { isActive, open, close, toggle } = useDetailedView(viewId); if (error) { From 4dd3c4c101831c6a92374e3e06412bb1f9dbb5e5 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Tue, 28 Jul 2026 20:01:39 +0530 Subject: [PATCH 2/8] docs: latch keys are tool-call ids, not artifact id:version Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019hnic4tGtRh3MSSaCdGJYE --- packages/react-headless/src/store/detailedViewTypes.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/react-headless/src/store/detailedViewTypes.ts b/packages/react-headless/src/store/detailedViewTypes.ts index ab49e60e4..24a88e484 100644 --- a/packages/react-headless/src/store/detailedViewTypes.ts +++ b/packages/react-headless/src/store/detailedViewTypes.ts @@ -36,9 +36,11 @@ export type DetailedViewInternals = { /** @internal */ _setDetailedViewPanelNode: (node: HTMLElement | null) => void; /** - * Artifact keys (`id:version`) that already auto-opened once, so a user's + * Latch keys (tool-call ids) that already auto-opened once, so a user's * mid-stream close sticks even when the renderer host remounts during - * streaming. Cleared by `reset()` (thread switch). + * streaming. Keyed per tool call — not per artifact version — because a + * streamed edit often carries no version in its args and would collide + * with the generate's key. Cleared by `reset()` (thread switch). * @internal */ _autoOpenedArtifactKeys: ReadonlySet; From 12758b71c6f934dfdbb41b56f423d355aed2e616 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Tue, 28 Jul 2026 22:16:07 +0530 Subject: [PATCH 3/8] refactor: move the auto-open behavior into a react-headless hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback: keep the logic in react-headless. useArtifactAutoOpen now owns the complete behavior — mode context, shouldAutoOpen predicate, open-on-mount per-mount latch, and the store-level auto-open latch — and is exported for any headless consumer building its own thread UI. ToolActivityRenderer shrinks to one call supplying the render-derived facts (viewId, tool-call latch key, streaming, error/parse eligibility). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_019hnic4tGtRh3MSSaCdGJYE --- .../__tests__/useArtifactAutoOpen.test.ts | 22 ++++++ .../src/hooks/useArtifactAutoOpen.ts | 76 +++++++++++++++++++ packages/react-headless/src/index.ts | 2 + .../tool-renderer/ToolActivityRenderer.tsx | 50 ++++-------- 4 files changed, 116 insertions(+), 34 deletions(-) create mode 100644 packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts create mode 100644 packages/react-headless/src/hooks/useArtifactAutoOpen.ts 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..bd84fa810 --- /dev/null +++ b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { shouldAutoOpen } from "../useArtifactAutoOpen"; + +// The auto-open decision matrix. The hook is a once-latch around this pure +// predicate (plus the store-level _markAutoOpened latch, tested in +// store/__tests__/detailedViewAutoOpenLatch.test.ts), so the matrix is the +// behavior: +// open-on-mount → open on mount, streaming or not (deep-link / kiosk). +// auto-open → open only while the artifact streams live. +// overview → never (the click-to-open default). +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..56b7ce8f1 --- /dev/null +++ b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef } from "react"; +import { useArtifactViewMode, type ArtifactViewMode } from "../store/ArtifactViewModeContext"; +import { useDetailedViewStore } from "../store/DetailedViewContext"; + +/** + * The open decision, pure: open on mount always for `"open-on-mount"`, only + * while observed live-streaming for `"auto-open"`, never for `"overview"`. + */ +export function shouldAutoOpen(mode: ArtifactViewMode, isStreaming: boolean): boolean { + return mode === "open-on-mount" || (mode === "auto-open" && isStreaming); +} + +/** + * Options for {@link useArtifactAutoOpen}. + * + * @category Types + */ +export interface UseArtifactAutoOpenOptions { + /** The detailed-view id to open. */ + viewId: string; + /** + * Identity of the artifact-producing unit for the `"auto-open"` once-latch — + * a tool-call id for tool-call artifacts, a statement id for chat-library + * artifacts. Must be unique per generate/edit and stable across host + * remounts, so a user's mid-stream close sticks while every new call (an + * edit) auto-opens again. Deliberately NOT `artifactId:version`: a streamed + * edit often carries no version in its args and would collide with the + * generate's key. + */ + latchKey: string; + /** Whether the artifact is streaming live right now. `"auto-open"` only + * fires while true, which keeps thread reloads quiet (historical + * artifacts mount settled). */ + isStreaming: boolean; + /** Gate for host-known failure shapes (parse failed, tool call errored) — + * pass `false` and nothing ever opens. Defaults to `true`. */ + enabled?: boolean; +} + +/** + * The artifact auto-open behavior, complete: reads the `artifactViewMode` + * set on `ChatProvider` and opens `viewId` per its semantics — + * + * - `"overview"` (default): never. + * - `"auto-open"`: once per `latchKey`, only while `isStreaming`. The latch + * lives on the detailed-view store (cleared on thread switch), so it + * survives host remounts mid-stream. + * - `"open-on-mount"`: once per mounted host instance, streaming or not. + * + * The host (react-ui's tool renderer, a custom thread UI, an SDK's artifact + * component) supplies only the render-derived facts: which view to open, + * the latch identity, streaming state, and eligibility. + */ +export function useArtifactAutoOpen({ + viewId, + latchKey, + isStreaming, + enabled = true, +}: UseArtifactAutoOpenOptions): void { + const viewMode = useArtifactViewMode(); + const store = useDetailedViewStore(); + const openedThisMountRef = useRef(false); + + useEffect(() => { + if (!enabled) return; + if (!shouldAutoOpen(viewMode, isStreaming)) return; + const dv = store.getState(); + if (viewMode === "auto-open") { + if (!dv._markAutoOpened(latchKey)) return; + } else if (openedThisMountRef.current) { + return; + } + openedThisMountRef.current = true; + 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 7ef7062b0..08f36fbc3 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -1,4 +1,6 @@ export { useActiveDetailedView } from "./hooks/useActiveDetailedView"; +export { shouldAutoOpen, useArtifactAutoOpen } from "./hooks/useArtifactAutoOpen"; +export type { UseArtifactAutoOpenOptions } from "./hooks/useArtifactAutoOpen"; export { useArtifactList } from "./hooks/useArtifactList"; export type { ArtifactListFilter } from "./hooks/useArtifactList"; export { useArtifactRenderer } from "./hooks/useArtifactRenderer"; 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 454f16511..20258b793 100644 --- a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx @@ -1,5 +1,5 @@ import { - useArtifactViewMode, + useArtifactAutoOpen, useDetailedView, useDetailedViewStore, useThreadContextStore, @@ -135,39 +135,21 @@ export function ToolActivityRenderer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dvStore, viewId, meta?.id, meta?.version]); - // Artifact view mode: "auto-open" opens the panel by itself while this - // activity streams live; "open-on-mount" opens once per mount regardless. - // Hooks sit ABOVE the error/parsed early-return below, so both failure - // shapes must be re-gated here — a failed tool call renders only the - // fallback card and must never open a panel. Reload safety comes from the - // isStreaming gate (historical activities mount as "complete"). The - // auto-open latch lives on the detailed-view store (reset on thread - // switch), keyed by TOOL-CALL id — not `meta.id:meta.version`, because a - // streamed edit often carries no version in its args (numericVersion - // defaults it to the generate's), which would collide and swallow the - // edit's auto-open. activity.id is unique per generate/edit call and - // stable across host remounts, so a user's mid-stream close sticks while - // every new call (an edit) auto-opens again. Opening early on the useId - // fallback viewId is safe: the migration effect above carries the open - // state to the real key when meta lands. - const viewMode = useArtifactViewMode(); - const openedThisMountRef = useRef(false); - const isError = activity.isError ?? false; - const parsedIsNull = parsed === null; - useEffect(() => { - if (viewMode === "overview") return; - if (isError || parsedIsNull) return; - const dv = dvStore.getState(); - if (viewMode === "auto-open") { - if (!isStreaming) return; - if (!dv._markAutoOpened(activity.id)) return; - } else { - if (openedThisMountRef.current) return; - } - openedThisMountRef.current = true; - dv.setActiveDetailedView(viewId); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [viewMode, isStreaming, isError, parsedIsNull, viewId, activity.id, dvStore]); + // Artifact view mode ("auto-open" / "open-on-mount" / default "overview"): + // the behavior lives in react-headless (useArtifactAutoOpen — mode context, + // gates, remount-proof latch on the detailed-view store). This host only + // feeds it the render-derived facts. `enabled` re-gates the error/parsed + // failure shapes because these hooks sit ABOVE the fallback early-return + // below — a failed tool call renders only the fallback card and must never + // open a panel. Opening early on the useId fallback viewId is safe: the + // migration effect above carries the open state to the real key when meta + // lands. + useArtifactAutoOpen({ + viewId, + latchKey: activity.id, + isStreaming, + enabled: !activity.isError && parsed !== null, + }); const { isActive, open, close, toggle } = useDetailedView(viewId); From 00a81646504f4d28e5d2e0a81ce602c6d94de646 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Thu, 30 Jul 2026 04:47:28 +0530 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20drive=20artifactViewMode=20from?= =?UTF-8?q?=20a=20ChatProvider=20watcher=20=E2=80=94=20zero=20UI=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool-call auto-open now lives entirely in react-headless: ChatProvider subscribes to the thread's messages and opens an artifact's detailed view per the mode, once per tool call, latched on the detailed-view store. The parser envelope moves to a shared runArtifactRenderer export so the watcher and react-ui's tool renderer can never disagree on what a parser sees. react-ui keeps only the artifactViewMode prop pass-through; its tool renderer loses the hook call and its local envelope copy (net deletion). open-on-mount is redefined store-side — once per tool call per thread session, so loading a thread opens its newest artifact — since a store watcher cannot observe mounts. useArtifactAutoOpen stays public for artifact sources the chat store can't see (SDK chat-library paths, custom hosts). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011nPH2Rn2XGXETgx5Yu5seX --- .../src/hooks/useArtifactAutoOpen.ts | 12 +- packages/react-headless/src/index.ts | 2 + .../src/store/ArtifactViewModeContext.ts | 24 +-- .../react-headless/src/store/ChatProvider.tsx | 12 ++ .../__tests__/artifactAutoOpenWatcher.test.ts | 152 ++++++++++++++++++ .../src/store/artifactAutoOpenWatcher.ts | 132 +++++++++++++++ .../src/store/runArtifactRenderer.ts | 42 +++++ packages/react-headless/src/store/types.ts | 7 +- .../tool-renderer/ToolActivityRenderer.tsx | 35 +--- 9 files changed, 368 insertions(+), 50 deletions(-) create mode 100644 packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts create mode 100644 packages/react-headless/src/store/artifactAutoOpenWatcher.ts create mode 100644 packages/react-headless/src/store/runArtifactRenderer.ts diff --git a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts index 56b7ce8f1..bc32af32c 100644 --- a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts +++ b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts @@ -38,8 +38,8 @@ export interface UseArtifactAutoOpenOptions { } /** - * The artifact auto-open behavior, complete: reads the `artifactViewMode` - * set on `ChatProvider` and opens `viewId` per its semantics — + * Applies the `artifactViewMode` set on `ChatProvider` from a rendering host, + * opening `viewId` per its semantics — * * - `"overview"` (default): never. * - `"auto-open"`: once per `latchKey`, only while `isStreaming`. The latch @@ -47,9 +47,11 @@ export interface UseArtifactAutoOpenOptions { * survives host remounts mid-stream. * - `"open-on-mount"`: once per mounted host instance, streaming or not. * - * The host (react-ui's tool renderer, a custom thread UI, an SDK's artifact - * component) supplies only the render-derived facts: which view to open, - * the latch identity, streaming state, and eligibility. + * Tool-call artifacts need none of this — `ChatProvider` drives them itself + * from the chat store. This hook is for artifact sources the store can't + * see (an SDK's chat-library artifacts, custom renderer hosts): the host + * supplies the render-derived facts — which view to open, the latch + * identity, streaming state, and eligibility. */ export function useArtifactAutoOpen({ viewId, diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 08f36fbc3..8f196806d 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -25,6 +25,8 @@ export { ArtifactViewModeContext, useArtifactViewMode } from "./store/ArtifactVi export type { ArtifactViewMode } from "./store/ArtifactViewModeContext"; export { ChatProvider } from "./store/ChatProvider"; export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext"; +export { runArtifactRenderer } from "./store/runArtifactRenderer"; +export type { ArtifactParseSource } from "./store/runArtifactRenderer"; export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext"; export { pairToolActivity, partialJSONParse } from "./store/toolActivity"; export { diff --git a/packages/react-headless/src/store/ArtifactViewModeContext.ts b/packages/react-headless/src/store/ArtifactViewModeContext.ts index 4d6f45f0b..8038fad77 100644 --- a/packages/react-headless/src/store/ArtifactViewModeContext.ts +++ b/packages/react-headless/src/store/ArtifactViewModeContext.ts @@ -1,17 +1,20 @@ import { createContext, useContext } from "react"; /** - * How artifact detail panels open. + * How artifact detail panels open. For tool-call artifacts the behavior is + * driven entirely inside `ChatProvider` (a store-level watcher) — rendering + * hosts need no wiring. Non-tool-call artifact sources apply the same mode + * via `useArtifactAutoOpen`. * * - `"overview"` (default) — panels only open on an explicit user action * (clicking an artifact preview's open control). - * - `"auto-open"` — a panel opens by itself the moment its artifact is - * observed streaming live. A user's mid-stream close sticks; historical - * artifacts mounted on a thread reload never fire; a new artifact version - * (an edit) auto-opens again. - * - `"open-on-mount"` — a panel opens whenever its artifact mounts, streaming - * or not. Deep-link / kiosk embeds; on a thread reload the last-mounted - * artifact wins. + * - `"auto-open"` — a panel opens by itself as soon as its artifact's header + * parses from the live stream, once per tool call: a user's mid-stream + * close sticks; historical artifacts on a thread reload never fire; an + * edit (a new call) auto-opens again. + * - `"open-on-mount"` — every artifact opens once per thread session, + * streaming or not: loading a thread opens its newest artifact (last one + * wins). Deep-link / kiosk embeds. * * @category Types */ @@ -21,8 +24,9 @@ export const DEFAULT_ARTIFACT_VIEW_MODE: ArtifactViewMode = "overview"; /** * Carries the artifact view mode from `ChatProvider` (set via its - * `artifactViewMode` prop) to the renderer host. Unlike the renderer - * registry, the value is live — prop changes apply on the next render. + * `artifactViewMode` prop) to custom artifact hosts (`useArtifactAutoOpen`). + * Unlike the renderer registry, the value is live — prop changes apply on + * the next render. */ export const ArtifactViewModeContext = createContext(DEFAULT_ARTIFACT_VIEW_MODE); diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index f5286a1b0..42172cbec 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, @@ -66,6 +67,17 @@ export const ChatProvider: FC = ({ return unsubscribe; }, [chatStore, detailedViewStore, threadContextStore]); + // Drives artifactViewMode for tool-call artifacts entirely at the store + // layer — no rendering host involved. Declared AFTER the reset subscription + // so on a thread switch the latch clears before this pass sees the new + // thread's messages. + useArtifactAutoOpenWatcher( + artifactViewMode ?? DEFAULT_ARTIFACT_VIEW_MODE, + artifactRendererRegistry, + chatStore, + 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..b59ef1c0f --- /dev/null +++ b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts @@ -0,0 +1,152 @@ +import { describe, expect, it } from "vitest"; +import type { AssistantMessage, Message, ToolMessage } from "../../types"; +import { evaluateArtifactAutoOpen } from "../artifactAutoOpenWatcher"; +import { buildArtifactRendererRegistry } from "../ArtifactRenderersContext"; +import type { ArtifactRendererConfig } from "../artifactRendererTypes"; +import { createDetailedViewStore } from "../createDetailedViewStore"; + +// Test renderer: parses `{"id": "...", "version": n}` out of the (possibly +// partial) args and yields meta only once `id` is present — mirroring real +// parsers, whose header arrives a few tokens into the stream. +const artifactRenderer: ArtifactRendererConfig = { + type: "test_artifact", + toolName: "make_artifact", + parser: ({ args }) => { + if (typeof args !== "string") return null; + let input: { id?: string; version?: number; explode?: boolean }; + try { + input = JSON.parse(args) as typeof input; + } catch { + return { props: {}, meta: null }; // header not parseable yet + } + if (input.explode) throw new Error("parser exploded"); + if (!input.id) return { props: {}, meta: null }; + return { + props: {}, + meta: { id: input.id, version: input.version ?? 1, heading: "t" }, + }; + }, + preview: () => null, + actual: () => null, +}; + +const registry = buildArtifactRendererRegistry([artifactRenderer]); + +const assistant = (callId: string, args: string, toolName = "make_artifact"): AssistantMessage => ({ + id: `msg-${callId}`, + role: "assistant", + toolCalls: [{ id: callId, type: "function", function: { name: toolName, arguments: args } }], +}); + +const toolResult = (callId: string, error?: string): ToolMessage => ({ + id: `res-${callId}`, + role: "tool", + toolCallId: callId, + content: JSON.stringify({ id: "art", version: 1 }), + ...(error ? { error } : {}), +}); + +const run = ( + viewMode: "auto-open" | "open-on-mount" | "overview", + messages: Message[], + store = createDetailedViewStore(), + executing: ReadonlySet = new Set(), +) => { + evaluateArtifactAutoOpen( + viewMode, + registry, + { messages, executingToolCallIds: executing }, + store.getState(), + ); + return store; +}; + +describe("evaluateArtifactAutoOpen", () => { + it("auto-open: opens a streaming artifact once its header parses", () => { + const store = run("auto-open", [assistant("c1", '{"id": "art", "version": 1}')]); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + expect(store.getState()._autoOpenedArtifactKeys.has("c1")).toBe(true); + }); + + it("auto-open: a user close sticks — the same call never re-opens", () => { + const messages = [assistant("c1", '{"id": "art", "version": 1}')]; + const store = run("auto-open", messages); + store.getState().setActiveDetailedView(null); // user closes mid-stream + run("auto-open", messages, store); // next stream update + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("auto-open: does not burn the latch before the header arrives", () => { + const store = createDetailedViewStore(); + run("auto-open", [assistant("c1", '{"id": "ar')], store); // partial args + expect(store.getState().activeDetailedViewId).toBeNull(); + expect(store.getState()._autoOpenedArtifactKeys.has("c1")).toBe(false); + run("auto-open", [assistant("c1", '{"id": "art", "version": 2}')], store); + expect(store.getState().activeDetailedViewId).toBe("art:2"); + }); + + it("auto-open: settled (historical) artifacts stay quiet", () => { + const store = run("auto-open", [ + assistant("c1", '{"id": "art", "version": 1}'), + toolResult("c1"), + ]); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("auto-open: an executing call (args closed, no result) still opens", () => { + const store = run( + "auto-open", + [assistant("c1", '{"id": "art", "version": 1}')], + createDetailedViewStore(), + new Set(["c1"]), + ); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + }); + + it("open-on-mount: settled artifacts open, newest (last) wins", () => { + const store = run("open-on-mount", [ + assistant("c1", '{"id": "a1", "version": 1}'), + toolResult("c1"), + assistant("c2", '{"id": "a2", "version": 1}'), + toolResult("c2"), + ]); + expect(store.getState().activeDetailedViewId).toBe("a2:1"); + expect(store.getState()._autoOpenedArtifactKeys.has("c1")).toBe(true); + }); + + it("overview: never opens anything", () => { + const store = run("overview", [assistant("c1", '{"id": "art", "version": 1}')]); + expect(store.getState().activeDetailedViewId).toBeNull(); + expect(store.getState()._autoOpenedArtifactKeys.size).toBe(0); + }); + + it("skips errored tool calls", () => { + const store = run("open-on-mount", [ + assistant("c1", '{"id": "art", "version": 1}'), + toolResult("c1", "boom"), + ]); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("skips tool calls with no matching renderer", () => { + const store = run("auto-open", [assistant("c1", '{"id": "art"}', "unrelated_tool")]); + expect(store.getState().activeDetailedViewId).toBeNull(); + }); + + it("a throwing parser is skipped without claiming the latch", () => { + const store = createDetailedViewStore(); + run("auto-open", [assistant("c1", '{"id": "art", "explode": true}')], store); + expect(store.getState().activeDetailedViewId).toBeNull(); + expect(store.getState()._autoOpenedArtifactKeys.has("c1")).toBe(false); + }); + + it("thread switch (reset) re-arms open-on-mount for the next thread", () => { + const messages = [assistant("c1", '{"id": "art", "version": 1}'), toolResult("c1")]; + const store = run("open-on-mount", messages); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + store.getState().reset(); + expect(store.getState().activeDetailedViewId).toBeNull(); + run("open-on-mount", messages, store); + expect(store.getState().activeDetailedViewId).toBe("art:1"); + }); +}); diff --git a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts new file mode 100644 index 000000000..2083e3a43 --- /dev/null +++ b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts @@ -0,0 +1,132 @@ +import { useEffect } from "react"; +import { shouldAutoOpen } from "../hooks/useArtifactAutoOpen"; +import type { AssistantMessage, Message, ToolMessage } from "../types"; +import { lookupArtifactRenderer, type ArtifactRendererRegistry } from "./ArtifactRenderersContext"; +import type { ArtifactViewMode } from "./ArtifactViewModeContext"; +import type { createChatStore } from "./createChatStore"; +import type { createDetailedViewStore } from "./createDetailedViewStore"; +import type { DetailedViewStore } from "./detailedViewTypes"; +import { runArtifactRenderer } from "./runArtifactRenderer"; +import type { ToolCallStatus } from "./toolActivity"; + +/** The store data one auto-open pass reads. @internal */ +export interface AutoOpenSnapshot { + messages: ReadonlyArray; + executingToolCallIds: ReadonlySet; +} + +/** + * One auto-open pass over a thread's messages: for every artifact tool call + * that hasn't already auto-opened, decide per the view mode and open the + * artifact's detailed view. + * + * Pure with respect to its inputs (all effects go through `detailedView`), so + * it is unit-testable without React. Cost is bounded by the latch: a claimed + * (or user-closed) call is skipped by the `_autoOpenedArtifactKeys` pre-check + * before any parsing, so during streaming only the not-yet-opened call pays a + * parser run per update — and only until its header (`meta`) arrives. + * + * Deliberate limits: + * - A parser that never yields `meta` (inline-only renderers) cannot auto-open: + * its detailed-view id is minted by the rendering host (`useId`), which a + * store-level pass cannot know. Such hosts call `useArtifactAutoOpen`. + * - An errored tool call never opens; a call that errors *after* opening keeps + * whatever the user sees (same as the previous host-side behavior). + * + * @internal + */ +export function evaluateArtifactAutoOpen( + viewMode: ArtifactViewMode, + registry: ArtifactRendererRegistry, + snapshot: AutoOpenSnapshot, + detailedView: Pick< + DetailedViewStore, + "_autoOpenedArtifactKeys" | "_markAutoOpened" | "setActiveDetailedView" + >, +): void { + if (viewMode === "overview") return; + + // toolCallId → result message, built lazily only when an unlatched artifact + // call exists (the common steady state — everything latched — never builds it). + let resultsByCallId: Map | null = null; + + for (const message of snapshot.messages) { + if (message.role !== "assistant") continue; + for (const toolCall of (message as AssistantMessage).toolCalls ?? []) { + if (detailedView._autoOpenedArtifactKeys.has(toolCall.id)) continue; + const renderer = lookupArtifactRenderer(registry, toolCall.function.name); + if (!renderer) continue; + + if (resultsByCallId === null) { + resultsByCallId = new Map(); + for (const m of snapshot.messages) { + if (m.role === "tool") { + const tm = m as ToolMessage; + if (tm.toolCallId) resultsByCallId.set(tm.toolCallId, tm); + } + } + } + const toolMessage = resultsByCallId.get(toolCall.id) ?? null; + if (toolMessage?.error) continue; + + const status: ToolCallStatus = toolMessage + ? "complete" + : snapshot.executingToolCallIds.has(toolCall.id) + ? "executing" + : "streaming"; + if (!shouldAutoOpen(viewMode, status === "streaming" || status === "executing")) continue; + + let meta: { id: string; version: number } | null = null; + try { + meta = + runArtifactRenderer(renderer, { + toolCall, + result: toolMessage?.content ?? null, + status, + })?.meta ?? null; + } catch { + continue; // host renders the parse failure; nothing to open + } + // No header yet: leave the latch unclaimed so a later, fuller snapshot + // gets to open it. + if (!meta) continue; + + if (!detailedView._markAutoOpened(toolCall.id)) continue; + detailedView.setActiveDetailedView(`${meta.id}:${meta.version}`); + } + } +} + +/** + * ChatProvider-internal driver for {@link ArtifactViewMode} on the tool-call + * path: subscribes to the thread's messages and runs + * {@link evaluateArtifactAutoOpen} on every change (and once on mount, which + * is what opens the newest artifact of a freshly loaded thread in + * `"open-on-mount"`). `"overview"` subscribes to nothing. + * + * Lives at the store layer so no rendering host has to wire auto-open — + * any UI on top of ChatProvider gets it from the prop alone. Non-tool-call + * artifact sources (which the chat store can't see) use `useArtifactAutoOpen`. + * + * @internal + */ +export function useArtifactAutoOpenWatcher( + viewMode: ArtifactViewMode, + registry: ArtifactRendererRegistry, + chatStore: ReturnType, + detailedViewStore: ReturnType, +): void { + useEffect(() => { + if (viewMode === "overview") return; + const evaluate = () => { + const { messages, executingToolCallIds } = chatStore.getState(); + evaluateArtifactAutoOpen( + viewMode, + registry, + { messages, executingToolCallIds }, + detailedViewStore.getState(), + ); + }; + return chatStore.subscribe((s) => s.messages, evaluate, { fireImmediately: true }); + }, [viewMode, registry, chatStore, detailedViewStore]); +} diff --git a/packages/react-headless/src/store/runArtifactRenderer.ts b/packages/react-headless/src/store/runArtifactRenderer.ts new file mode 100644 index 000000000..3006127ed --- /dev/null +++ b/packages/react-headless/src/store/runArtifactRenderer.ts @@ -0,0 +1,42 @@ +import type { ToolCall } from "../types"; +import type { ArtifactRendererConfig, ParsedArtifact } from "./artifactRendererTypes"; +import type { ToolCallStatus } from "./toolActivity"; + +/** + * The pieces of a tool call a renderer's `parser` is fed from. Structurally a + * subset of {@link ToolActivity}, so an activity can be passed directly; the + * auto-open watcher builds one from raw messages instead. + * + * @category Types + */ +export interface ArtifactParseSource { + /** The tool call owning the (possibly still-streaming) arguments. */ + toolCall: ToolCall; + /** The tool result content, or null/undefined while it hasn't landed. */ + result?: string | null; + /** Lifecycle of the call — `streaming`/`executing` map to `isStreaming`. */ + status: ToolCallStatus; +} + +/** + * Runs a renderer's `parser` for one tool call via the `parser` contract: + * reconstruct the raw envelope from the typed pieces (`args` = the raw JSON + * string, `response` = the result or null) so parsers see exactly today's + * input. The single definition of that envelope — every caller (react-ui's + * tool renderer, the auto-open watcher, custom hosts) must build it here so + * they can never disagree on what a parser sees. + * + * Does NOT catch: a throwing parser propagates, so hosts choose their own + * failure rendering. + * + * @category Utilities + */ +export function runArtifactRenderer( + renderer: ArtifactRendererConfig, + source: ArtifactParseSource, +): ParsedArtifact | null { + return renderer.parser( + { args: source.toolCall.function.arguments, response: source.result ?? null }, + { isStreaming: source.status === "streaming" || source.status === "executing" }, + ); +} diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 30d96ce65..82729d170 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -95,8 +95,11 @@ export interface ChatProviderProps { /** * How artifact detail panels open (default `"overview"` — only on user * action). `"auto-open"` opens a panel by itself while its artifact streams - * live; `"open-on-mount"` opens on every artifact mount (deep-link/kiosk). - * Live — prop changes apply on the next render. + * live; `"open-on-mount"` opens each artifact once per thread session, + * streaming or not — loading a thread opens its newest artifact + * (deep-link/kiosk). Driven entirely inside ChatProvider for tool-call + * artifacts; no host wiring needed. Live — prop changes apply on the next + * render. */ artifactViewMode?: ArtifactViewMode; children: React.ReactNode; 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 20258b793..6578146a9 100644 --- a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx @@ -1,5 +1,5 @@ import { - useArtifactAutoOpen, + runArtifactRenderer, useDetailedView, useDetailedViewStore, useThreadContextStore, @@ -25,21 +25,6 @@ export type ToolDetailedViewPanel = ComponentType<{ children: ReactNode; }>; -/** - * Runs a matched renderer for one tool activity via the `parser` contract: - * reconstruct the raw envelope from the typed activity (`args` = the raw JSON - * string, `response` = the result or null) so parsers see exactly today's input. - */ -function runRenderer( - renderer: ArtifactRendererConfig, - activity: ToolActivity, -): ParsedArtifact | null { - return renderer.parser( - { args: activity.toolCall.function.arguments, response: activity.result ?? null }, - { isStreaming: activity.status === "streaming" || activity.status === "executing" }, - ); -} - /** * Renders a matched artifact renderer for a single {@link ToolActivity}. * @@ -72,7 +57,7 @@ export function ToolActivityRenderer({ const { parsed, error } = useMemo(() => { try { - return { parsed: runRenderer(renderer, activity), error: null as string | null }; + return { parsed: runArtifactRenderer(renderer, activity), error: null as string | null }; } catch (e) { return { parsed: null as ParsedArtifact | null, error: String(e) }; } @@ -135,22 +120,6 @@ export function ToolActivityRenderer({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [dvStore, viewId, meta?.id, meta?.version]); - // Artifact view mode ("auto-open" / "open-on-mount" / default "overview"): - // the behavior lives in react-headless (useArtifactAutoOpen — mode context, - // gates, remount-proof latch on the detailed-view store). This host only - // feeds it the render-derived facts. `enabled` re-gates the error/parsed - // failure shapes because these hooks sit ABOVE the fallback early-return - // below — a failed tool call renders only the fallback card and must never - // open a panel. Opening early on the useId fallback viewId is safe: the - // migration effect above carries the open state to the real key when meta - // lands. - useArtifactAutoOpen({ - viewId, - latchKey: activity.id, - isStreaming, - enabled: !activity.isError && parsed !== null, - }); - const { isActive, open, close, toggle } = useDetailedView(viewId); if (error) { From a4ec353758961b1e9308fdaa892c1894082db9a9 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Thu, 30 Jul 2026 17:08:23 +0530 Subject: [PATCH 5/8] refactor: present-once auto-open driven by artifact registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simpler trigger, simpler policy (per review): an artifact may auto-open exactly once — when its id first registers in the ThreadContext — and only into an empty panel. Edits share their generate's id, so they never force a panel open (an open panel still follows versions via the existing follow effect); an open panel is never stolen (first wins); historical registrations burn their one chance quietly, so reloads never fire. The watcher now subscribes to the artifact registry instead of walking messages: no parsing, no renderer knowledge at the store layer, and the shared parser envelope (runArtifactRenderer) is no longer needed — react-ui's tool renderer returns to exactly its pre-branch shape. auto-open gates on the thread running; open-on-mount opens regardless. useArtifactAutoOpen (non-registry sources) adopts the same first-wins policy. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011nPH2Rn2XGXETgx5Yu5seX --- .../src/hooks/useArtifactAutoOpen.ts | 28 +-- packages/react-headless/src/index.ts | 2 - .../src/store/ArtifactViewModeContext.ts | 24 +-- .../react-headless/src/store/ChatProvider.tsx | 11 +- .../__tests__/artifactAutoOpenWatcher.test.ts | 187 +++++++----------- .../src/store/artifactAutoOpenWatcher.ts | 161 ++++++--------- .../src/store/detailedViewTypes.ts | 11 +- .../src/store/runArtifactRenderer.ts | 42 ---- packages/react-headless/src/store/types.ts | 12 +- .../tool-renderer/ToolActivityRenderer.tsx | 18 +- 10 files changed, 198 insertions(+), 298 deletions(-) delete mode 100644 packages/react-headless/src/store/runArtifactRenderer.ts diff --git a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts index bc32af32c..6e0f254d8 100644 --- a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts +++ b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts @@ -19,13 +19,12 @@ export interface UseArtifactAutoOpenOptions { /** The detailed-view id to open. */ viewId: string; /** - * Identity of the artifact-producing unit for the `"auto-open"` once-latch — - * a tool-call id for tool-call artifacts, a statement id for chat-library - * artifacts. Must be unique per generate/edit and stable across host - * remounts, so a user's mid-stream close sticks while every new call (an - * edit) auto-opens again. Deliberately NOT `artifactId:version`: a streamed - * edit often carries no version in its args and would collide with the - * generate's key. + * Identity for the `"auto-open"` once-latch — e.g. a statement id or + * artifact id for chat-library artifacts. Must be stable across host + * remounts, so a user's mid-stream close sticks instead of re-opening + * when the host remounts. Lives on the detailed-view store's latch + * (cleared on thread switch), sharing a namespace with the registration + * path's artifact ids. */ latchKey: string; /** Whether the artifact is streaming live right now. `"auto-open"` only @@ -47,11 +46,12 @@ export interface UseArtifactAutoOpenOptions { * survives host remounts mid-stream. * - `"open-on-mount"`: once per mounted host instance, streaming or not. * - * Tool-call artifacts need none of this — `ChatProvider` drives them itself - * from the chat store. This hook is for artifact sources the store can't - * see (an SDK's chat-library artifacts, custom renderer hosts): the host - * supplies the render-derived facts — which view to open, the latch - * identity, streaming state, and eligibility. + * Artifacts that register in the ThreadContext need none of this — + * `ChatProvider` presents them itself on first registration. This hook is + * for artifact sources that bypass the registry (an SDK's chat-library + * artifacts, custom renderer hosts): the host supplies the render-derived + * facts — which view to open, the latch identity, streaming state, and + * eligibility. */ export function useArtifactAutoOpen({ viewId, @@ -73,6 +73,10 @@ export function useArtifactAutoOpen({ return; } openedThisMountRef.current = true; + // First wins, same policy as the registration path: never steal a panel + // that is already open (this view re-asserting itself is fine). + 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 8f196806d..08f36fbc3 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -25,8 +25,6 @@ export { ArtifactViewModeContext, useArtifactViewMode } from "./store/ArtifactVi export type { ArtifactViewMode } from "./store/ArtifactViewModeContext"; export { ChatProvider } from "./store/ChatProvider"; export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext"; -export { runArtifactRenderer } from "./store/runArtifactRenderer"; -export type { ArtifactParseSource } from "./store/runArtifactRenderer"; export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext"; export { pairToolActivity, partialJSONParse } from "./store/toolActivity"; export { diff --git a/packages/react-headless/src/store/ArtifactViewModeContext.ts b/packages/react-headless/src/store/ArtifactViewModeContext.ts index 8038fad77..c42e28cd5 100644 --- a/packages/react-headless/src/store/ArtifactViewModeContext.ts +++ b/packages/react-headless/src/store/ArtifactViewModeContext.ts @@ -1,20 +1,22 @@ import { createContext, useContext } from "react"; /** - * How artifact detail panels open. For tool-call artifacts the behavior is - * driven entirely inside `ChatProvider` (a store-level watcher) — rendering - * hosts need no wiring. Non-tool-call artifact sources apply the same mode - * via `useArtifactAutoOpen`. + * How artifact detail panels open. The policy is "present once": an artifact + * may open by itself exactly one time — when its id first registers in the + * ThreadContext — and only into an empty panel (an open panel is never + * stolen). Edits update in place and never force a panel open. Driven + * entirely inside `ChatProvider`; rendering hosts need no wiring. Artifact + * sources that bypass the registry apply the mode via `useArtifactAutoOpen`. * * - `"overview"` (default) — panels only open on an explicit user action * (clicking an artifact preview's open control). - * - `"auto-open"` — a panel opens by itself as soon as its artifact's header - * parses from the live stream, once per tool call: a user's mid-stream - * close sticks; historical artifacts on a thread reload never fire; an - * edit (a new call) auto-opens again. - * - `"open-on-mount"` — every artifact opens once per thread session, - * streaming or not: loading a thread opens its newest artifact (last one - * wins). Deep-link / kiosk embeds. + * - `"auto-open"` — a newly registered artifact opens while the thread is + * running (a live generation presenting its artifact). A user's close + * sticks; historical artifacts on a thread reload never fire. + * - `"open-on-mount"` — a newly registered artifact opens regardless of + * running: loading a thread presents its first artifact (message order; + * an id's panel still ends at its newest version). Deep-link / kiosk + * embeds. * * @category Types */ diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index 42172cbec..56bd5eae0 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -67,14 +67,15 @@ export const ChatProvider: FC = ({ return unsubscribe; }, [chatStore, detailedViewStore, threadContextStore]); - // Drives artifactViewMode for tool-call artifacts entirely at the store - // layer — no rendering host involved. Declared AFTER the reset subscription - // so on a thread switch the latch clears before this pass sees the new - // thread's messages. + // Drives artifactViewMode entirely at the store layer — an artifact may + // present itself once, when it first registers in the ThreadContext. + // Thread-switch safety comes from the reset subscription above running + // synchronously inside the selectThread set(): the latch and registry are + // both cleared before any new-thread registration can fire this watcher. useArtifactAutoOpenWatcher( artifactViewMode ?? DEFAULT_ARTIFACT_VIEW_MODE, - artifactRendererRegistry, chatStore, + threadContextStore, detailedViewStore, ); diff --git a/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts index b59ef1c0f..806cc03e9 100644 --- a/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts +++ b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts @@ -1,152 +1,113 @@ import { describe, expect, it } from "vitest"; -import type { AssistantMessage, Message, ToolMessage } from "../../types"; -import { evaluateArtifactAutoOpen } from "../artifactAutoOpenWatcher"; -import { buildArtifactRendererRegistry } from "../ArtifactRenderersContext"; -import type { ArtifactRendererConfig } from "../artifactRendererTypes"; +import { evaluateRegisteredArtifacts } from "../artifactAutoOpenWatcher"; import { createDetailedViewStore } from "../createDetailedViewStore"; +import type { ArtifactEntry } from "../threadContextTypes"; -// Test renderer: parses `{"id": "...", "version": n}` out of the (possibly -// partial) args and yields meta only once `id` is present — mirroring real -// parsers, whose header arrives a few tokens into the stream. -const artifactRenderer: ArtifactRendererConfig = { +const entry = (id: string, version = 1): ArtifactEntry => ({ + id, + version, + heading: `${id} v${version}`, type: "test_artifact", - toolName: "make_artifact", - parser: ({ args }) => { - if (typeof args !== "string") return null; - let input: { id?: string; version?: number; explode?: boolean }; - try { - input = JSON.parse(args) as typeof input; - } catch { - return { props: {}, meta: null }; // header not parseable yet - } - if (input.explode) throw new Error("parser exploded"); - if (!input.id) return { props: {}, meta: null }; - return { - props: {}, - meta: { id: input.id, version: input.version ?? 1, heading: "t" }, - }; - }, - preview: () => null, - actual: () => null, -}; - -const registry = buildArtifactRendererRegistry([artifactRenderer]); - -const assistant = (callId: string, args: string, toolName = "make_artifact"): AssistantMessage => ({ - id: `msg-${callId}`, - role: "assistant", - toolCalls: [{ id: callId, type: "function", function: { name: toolName, arguments: args } }], }); -const toolResult = (callId: string, error?: string): ToolMessage => ({ - id: `res-${callId}`, - role: "tool", - toolCallId: callId, - content: JSON.stringify({ id: "art", version: 1 }), - ...(error ? { error } : {}), -}); - -const run = ( - viewMode: "auto-open" | "open-on-mount" | "overview", - messages: Message[], - store = createDetailedViewStore(), - executing: ReadonlySet = new Set(), -) => { - evaluateArtifactAutoOpen( - viewMode, - registry, - { messages, executingToolCallIds: executing }, - store.getState(), - ); - return store; +// Registry shape mirrors ThreadContextState.artifacts: id → versions ascending. +const registry = (...entries: ArtifactEntry[]): Record => { + const out: Record = {}; + for (const e of entries) (out[e.id] ??= []).push(e); + return out; }; -describe("evaluateArtifactAutoOpen", () => { - it("auto-open: opens a streaming artifact once its header parses", () => { - const store = run("auto-open", [assistant("c1", '{"id": "art", "version": 1}')]); +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("c1")).toBe(true); - }); - - it("auto-open: a user close sticks — the same call never re-opens", () => { - const messages = [assistant("c1", '{"id": "art", "version": 1}')]; - const store = run("auto-open", messages); - store.getState().setActiveDetailedView(null); // user closes mid-stream - run("auto-open", messages, store); // next stream update - expect(store.getState().activeDetailedViewId).toBeNull(); + expect(store.getState()._autoOpenedArtifactKeys.has("art")).toBe(true); }); - it("auto-open: does not burn the latch before the header arrives", () => { + it("presents once: a user close sticks across re-registrations", () => { const store = createDetailedViewStore(); - run("auto-open", [assistant("c1", '{"id": "ar')], store); // partial args + const arts = registry(entry("art")); + evaluateRegisteredArtifacts("auto-open", arts, true, store); + store.getState().setActiveDetailedView(null); // user closes + evaluateRegisteredArtifacts("auto-open", arts, true, store); // remount re-register expect(store.getState().activeDetailedViewId).toBeNull(); - expect(store.getState()._autoOpenedArtifactKeys.has("c1")).toBe(false); - run("auto-open", [assistant("c1", '{"id": "art", "version": 2}')], store); - expect(store.getState().activeDetailedViewId).toBe("art:2"); }); - it("auto-open: settled (historical) artifacts stay quiet", () => { - const store = run("auto-open", [ - assistant("c1", '{"id": "art", "version": 1}'), - toolResult("c1"), - ]); + 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); // user closes the generate + // The edit registers v2 under the same id — still quiet. + evaluateRegisteredArtifacts( + "auto-open", + registry(entry("art", 1), entry("art", 2)), + true, + store, + ); expect(store.getState().activeDetailedViewId).toBeNull(); }); - it("auto-open: an executing call (args closed, no result) still opens", () => { - const store = run( - "auto-open", - [assistant("c1", '{"id": "art", "version": 1}')], - createDetailedViewStore(), - new Set(["c1"]), + 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:1"); + expect(store.getState().activeDetailedViewId).toBe("art:3"); }); - it("open-on-mount: settled artifacts open, newest (last) wins", () => { - const store = run("open-on-mount", [ - assistant("c1", '{"id": "a1", "version": 1}'), - toolResult("c1"), - assistant("c2", '{"id": "a2", "version": 1}'), - toolResult("c2"), - ]); - expect(store.getState().activeDetailedViewId).toBe("a2:1"); - expect(store.getState()._autoOpenedArtifactKeys.has("c1")).toBe(true); + 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); // thread load + expect(store.getState().activeDetailedViewId).toBeNull(); + // A later run starts and the host re-registers: the claim is already burned. + evaluateRegisteredArtifacts("auto-open", arts, true, store); + expect(store.getState().activeDetailedViewId).toBeNull(); }); - it("overview: never opens anything", () => { - const store = run("overview", [assistant("c1", '{"id": "art", "version": 1}')]); - expect(store.getState().activeDetailedViewId).toBeNull(); - expect(store.getState()._autoOpenedArtifactKeys.size).toBe(0); + 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("skips errored tool calls", () => { - const store = run("open-on-mount", [ - assistant("c1", '{"id": "art", "version": 1}'), - toolResult("c1", "boom"), - ]); + 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"); + // a2's chance is spent: even after the user closes, it stays quiet. + 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("skips tool calls with no matching renderer", () => { - const store = run("auto-open", [assistant("c1", '{"id": "art"}', "unrelated_tool")]); - 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("a throwing parser is skipped without claiming the latch", () => { + it("overview: never opens and never claims", () => { const store = createDetailedViewStore(); - run("auto-open", [assistant("c1", '{"id": "art", "explode": true}')], store); + evaluateRegisteredArtifacts("overview", registry(entry("art")), true, store); expect(store.getState().activeDetailedViewId).toBeNull(); - expect(store.getState()._autoOpenedArtifactKeys.has("c1")).toBe(false); + expect(store.getState()._autoOpenedArtifactKeys.size).toBe(0); }); - it("thread switch (reset) re-arms open-on-mount for the next thread", () => { - const messages = [assistant("c1", '{"id": "art", "version": 1}'), toolResult("c1")]; - const store = run("open-on-mount", messages); + 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(); - expect(store.getState().activeDetailedViewId).toBeNull(); - run("open-on-mount", messages, store); + evaluateRegisteredArtifacts("open-on-mount", arts, false, store); expect(store.getState().activeDetailedViewId).toBe("art:1"); }); }); diff --git a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts index 2083e3a43..ed87c69bd 100644 --- a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts +++ b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts @@ -1,132 +1,93 @@ import { useEffect } from "react"; import { shouldAutoOpen } from "../hooks/useArtifactAutoOpen"; -import type { AssistantMessage, Message, ToolMessage } from "../types"; -import { lookupArtifactRenderer, type ArtifactRendererRegistry } from "./ArtifactRenderersContext"; import type { ArtifactViewMode } from "./ArtifactViewModeContext"; import type { createChatStore } from "./createChatStore"; import type { createDetailedViewStore } from "./createDetailedViewStore"; -import type { DetailedViewStore } from "./detailedViewTypes"; -import { runArtifactRenderer } from "./runArtifactRenderer"; -import type { ToolCallStatus } from "./toolActivity"; - -/** The store data one auto-open pass reads. @internal */ -export interface AutoOpenSnapshot { - messages: ReadonlyArray; - executingToolCallIds: ReadonlySet; -} +import type { createThreadContextStore } from "./createThreadContextStore"; +import type { ArtifactEntry } from "./threadContextTypes"; /** - * One auto-open pass over a thread's messages: for every artifact tool call - * that hasn't already auto-opened, decide per the view mode and open the - * artifact's detailed view. + * One auto-open pass over the thread's registered artifacts, applying the + * "present once" policy: an artifact may open exactly one time — when its id + * first appears in the ThreadContext registry — and only into an empty panel. + * + * The rules, in evaluation order per artifact id: * - * Pure with respect to its inputs (all effects go through `detailedView`), so - * it is unit-testable without React. Cost is bounded by the latch: a claimed - * (or user-closed) call is skipped by the `_autoOpenedArtifactKeys` pre-check - * before any parsing, so during streaming only the not-yet-opened call pays a - * parser run per update — and only until its header (`meta`) arrives. + * 1. **Claim first, ask later.** The first pass that sees an id claims it via + * `_markAutoOpened(id)` whether or not it opens. This is what keeps + * historical artifacts (registered on thread load, nothing running) from + * ever popping open later, and what makes host remounts/StrictMode + * re-registrations invisible. + * 2. **Mode gate.** `"auto-open"` requires the thread to be running (a live + * generation is what's presenting the artifact); `"open-on-mount"` opens + * regardless — loading a thread presents its artifact. + * 3. **First wins.** If any panel is already open, nothing else opens over + * it. The user's (or an earlier artifact's) panel state is never stolen. * - * Deliberate limits: - * - A parser that never yields `meta` (inline-only renderers) cannot auto-open: - * its detailed-view id is minted by the rendering host (`useId`), which a - * store-level pass cannot know. Such hosts call `useArtifactAutoOpen`. - * - An errored tool call never opens; a call that errors *after* opening keeps - * whatever the user sees (same as the previous host-side behavior). + * Edits never re-open by construction: an edit shares the artifact id with + * its generate, and the id was claimed when the generate registered. The + * edit still updates in place — react-ui's version-follow effect re-points + * an OPEN panel to the newest version as it registers. + * + * Latch keys here are artifact ids (cleared on thread switch by `reset()`); + * `useArtifactAutoOpen` callers use their own key namespace on the same latch. * * @internal */ -export function evaluateArtifactAutoOpen( +export function evaluateRegisteredArtifacts( viewMode: ArtifactViewMode, - registry: ArtifactRendererRegistry, - snapshot: AutoOpenSnapshot, - detailedView: Pick< - DetailedViewStore, - "_autoOpenedArtifactKeys" | "_markAutoOpened" | "setActiveDetailedView" - >, + artifacts: Record, + isThreadRunning: boolean, + detailedViewStore: ReturnType, ): void { if (viewMode === "overview") return; - // toolCallId → result message, built lazily only when an unlatched artifact - // call exists (the common steady state — everything latched — never builds it). - let resultsByCallId: Map | null = null; - - for (const message of snapshot.messages) { - if (message.role !== "assistant") continue; - for (const toolCall of (message as AssistantMessage).toolCalls ?? []) { - if (detailedView._autoOpenedArtifactKeys.has(toolCall.id)) continue; - const renderer = lookupArtifactRenderer(registry, toolCall.function.name); - if (!renderer) continue; - - if (resultsByCallId === null) { - resultsByCallId = new Map(); - for (const m of snapshot.messages) { - if (m.role === "tool") { - const tm = m as ToolMessage; - if (tm.toolCallId) resultsByCallId.set(tm.toolCallId, tm); - } - } - } - const toolMessage = resultsByCallId.get(toolCall.id) ?? null; - if (toolMessage?.error) continue; - - const status: ToolCallStatus = toolMessage - ? "complete" - : snapshot.executingToolCallIds.has(toolCall.id) - ? "executing" - : "streaming"; - if (!shouldAutoOpen(viewMode, status === "streaming" || status === "executing")) continue; - - let meta: { id: string; version: number } | null = null; - try { - meta = - runArtifactRenderer(renderer, { - toolCall, - result: toolMessage?.content ?? null, - status, - })?.meta ?? null; - } catch { - continue; // host renders the parse failure; nothing to open - } - // No header yet: leave the latch unclaimed so a later, fuller snapshot - // gets to open it. - if (!meta) continue; - - if (!detailedView._markAutoOpened(toolCall.id)) continue; - detailedView.setActiveDetailedView(`${meta.id}:${meta.version}`); - } + for (const versions of Object.values(artifacts)) { + const latest = versions[versions.length - 1]; + if (!latest) continue; + // Fresh state per iteration: an open earlier in this same pass must make + // `activeDetailedViewId` non-null for the ids after it (first wins). + const dv = detailedViewStore.getState(); + if (!dv._markAutoOpened(latest.id)) continue; + if (!shouldAutoOpen(viewMode, isThreadRunning)) continue; + if (dv.activeDetailedViewId !== null) continue; + dv.setActiveDetailedView(`${latest.id}:${latest.version}`); } } /** - * ChatProvider-internal driver for {@link ArtifactViewMode} on the tool-call - * path: subscribes to the thread's messages and runs - * {@link evaluateArtifactAutoOpen} on every change (and once on mount, which - * is what opens the newest artifact of a freshly loaded thread in - * `"open-on-mount"`). `"overview"` subscribes to nothing. + * ChatProvider-internal driver for {@link ArtifactViewMode}: subscribes to + * the ThreadContext artifact registry and runs + * {@link evaluateRegisteredArtifacts} on every registration change (and once + * on mount). `"overview"` subscribes to nothing. * - * Lives at the store layer so no rendering host has to wire auto-open — - * any UI on top of ChatProvider gets it from the prop alone. Non-tool-call - * artifact sources (which the chat store can't see) use `useArtifactAutoOpen`. + * Registration is the trigger — not tool-call parsing — so the store layer + * needs no knowledge of renderers or streaming payloads: whoever renders an + * artifact registers it (react-ui's tool renderer, custom hosts), and that + * registration is the moment it may present itself. Artifact sources that + * bypass the registry apply the mode themselves via `useArtifactAutoOpen`. * * @internal */ export function useArtifactAutoOpenWatcher( viewMode: ArtifactViewMode, - registry: ArtifactRendererRegistry, chatStore: ReturnType, + threadContextStore: ReturnType, detailedViewStore: ReturnType, ): void { useEffect(() => { if (viewMode === "overview") return; - const evaluate = () => { - const { messages, executingToolCallIds } = chatStore.getState(); - evaluateArtifactAutoOpen( - viewMode, - registry, - { messages, executingToolCallIds }, - detailedViewStore.getState(), - ); - }; - return chatStore.subscribe((s) => s.messages, evaluate, { fireImmediately: true }); - }, [viewMode, registry, chatStore, detailedViewStore]); + 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/detailedViewTypes.ts b/packages/react-headless/src/store/detailedViewTypes.ts index 24a88e484..716690228 100644 --- a/packages/react-headless/src/store/detailedViewTypes.ts +++ b/packages/react-headless/src/store/detailedViewTypes.ts @@ -36,11 +36,12 @@ export type DetailedViewInternals = { /** @internal */ _setDetailedViewPanelNode: (node: HTMLElement | null) => void; /** - * Latch keys (tool-call ids) that already auto-opened once, so a user's - * mid-stream close sticks even when the renderer host remounts during - * streaming. Keyed per tool call — not per artifact version — because a - * streamed edit often carries no version in its args and would collide - * with the generate's key. Cleared by `reset()` (thread switch). + * Auto-open latch: keys that already had their one chance to auto-open, + * claimed whether or not a panel actually opened. Keyed by artifact id on + * the registration path (an edit shares its generate's id, so edits can + * never force a panel open; remounts/re-registrations are invisible) and + * by the caller's `latchKey` for `useArtifactAutoOpen` hosts. Cleared by + * `reset()` (thread switch). * @internal */ _autoOpenedArtifactKeys: ReadonlySet; diff --git a/packages/react-headless/src/store/runArtifactRenderer.ts b/packages/react-headless/src/store/runArtifactRenderer.ts deleted file mode 100644 index 3006127ed..000000000 --- a/packages/react-headless/src/store/runArtifactRenderer.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { ToolCall } from "../types"; -import type { ArtifactRendererConfig, ParsedArtifact } from "./artifactRendererTypes"; -import type { ToolCallStatus } from "./toolActivity"; - -/** - * The pieces of a tool call a renderer's `parser` is fed from. Structurally a - * subset of {@link ToolActivity}, so an activity can be passed directly; the - * auto-open watcher builds one from raw messages instead. - * - * @category Types - */ -export interface ArtifactParseSource { - /** The tool call owning the (possibly still-streaming) arguments. */ - toolCall: ToolCall; - /** The tool result content, or null/undefined while it hasn't landed. */ - result?: string | null; - /** Lifecycle of the call — `streaming`/`executing` map to `isStreaming`. */ - status: ToolCallStatus; -} - -/** - * Runs a renderer's `parser` for one tool call via the `parser` contract: - * reconstruct the raw envelope from the typed pieces (`args` = the raw JSON - * string, `response` = the result or null) so parsers see exactly today's - * input. The single definition of that envelope — every caller (react-ui's - * tool renderer, the auto-open watcher, custom hosts) must build it here so - * they can never disagree on what a parser sees. - * - * Does NOT catch: a throwing parser propagates, so hosts choose their own - * failure rendering. - * - * @category Utilities - */ -export function runArtifactRenderer( - renderer: ArtifactRendererConfig, - source: ArtifactParseSource, -): ParsedArtifact | null { - return renderer.parser( - { args: source.toolCall.function.arguments, response: source.result ?? null }, - { isStreaming: source.status === "streaming" || source.status === "executing" }, - ); -} diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 82729d170..b10519a55 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -94,12 +94,12 @@ export interface ChatProviderProps { artifactCategories?: ArtifactCategory[]; /** * How artifact detail panels open (default `"overview"` — only on user - * action). `"auto-open"` opens a panel by itself while its artifact streams - * live; `"open-on-mount"` opens each artifact once per thread session, - * streaming or not — loading a thread opens its newest artifact - * (deep-link/kiosk). Driven entirely inside ChatProvider for tool-call - * artifacts; no host wiring needed. Live — prop changes apply on the next - * render. + * action). An artifact may present itself once, when it first registers: + * `"auto-open"` does so only while the thread is running (live + * generation); `"open-on-mount"` regardless (loading a thread presents + * its artifact — deep-link/kiosk). An open panel is never stolen and + * edits never force a panel open. Driven entirely inside ChatProvider; + * no host wiring needed. Live — prop changes apply on the next render. */ artifactViewMode?: ArtifactViewMode; children: React.ReactNode; 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 6578146a9..20a74aea1 100644 --- a/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx +++ b/packages/react-ui/src/components/_shared/tool-renderer/ToolActivityRenderer.tsx @@ -1,5 +1,4 @@ import { - runArtifactRenderer, useDetailedView, useDetailedViewStore, useThreadContextStore, @@ -25,6 +24,21 @@ export type ToolDetailedViewPanel = ComponentType<{ children: ReactNode; }>; +/** + * Runs a matched renderer for one tool activity via the `parser` contract: + * reconstruct the raw envelope from the typed activity (`args` = the raw JSON + * string, `response` = the result or null) so parsers see exactly today's input. + */ +function runRenderer( + renderer: ArtifactRendererConfig, + activity: ToolActivity, +): ParsedArtifact | null { + return renderer.parser( + { args: activity.toolCall.function.arguments, response: activity.result ?? null }, + { isStreaming: activity.status === "streaming" || activity.status === "executing" }, + ); +} + /** * Renders a matched artifact renderer for a single {@link ToolActivity}. * @@ -57,7 +71,7 @@ export function ToolActivityRenderer({ const { parsed, error } = useMemo(() => { try { - return { parsed: runArtifactRenderer(renderer, activity), error: null as string | null }; + return { parsed: runRenderer(renderer, activity), error: null as string | null }; } catch (e) { return { parsed: null as ParsedArtifact | null, error: String(e) }; } From bdc7a5b909a4dfc3871e8c6bd7fb5cba433d162d Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Fri, 31 Jul 2026 06:10:31 +0530 Subject: [PATCH 6/8] refactor: types-only public surface; once-latch on the store for all modes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Post-review pass on artifactViewMode: - Trim the public barrel to types-only — `ArtifactViewMode` stays exported; `shouldAutoOpen`, `useArtifactAutoOpen`, `UseArtifactAutoOpenOptions`, `ArtifactViewModeContext`, and `useArtifactViewMode` are now internal. The hook goes public in the PR that consumes it. - Unify `useArtifactAutoOpen`'s once-latch on the detailed-view store for all modes — previously `open-on-mount` used a per-mount ref, so a host remount could re-open a panel the user had closed. - Condense JSDoc/comments. Co-authored-by: Cursor --- .../__tests__/useArtifactAutoOpen.test.ts | 10 +--- .../src/hooks/useArtifactAutoOpen.ts | 59 +++++++++---------- packages/react-headless/src/index.ts | 3 - .../src/store/ArtifactViewModeContext.ts | 29 ++++----- .../react-headless/src/store/ChatProvider.tsx | 10 ++-- .../src/store/artifactAutoOpenWatcher.ts | 51 ++++++---------- .../src/store/detailedViewTypes.ts | 15 ++--- packages/react-headless/src/store/types.ts | 11 ++-- 8 files changed, 76 insertions(+), 112 deletions(-) diff --git a/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts index bd84fa810..212781fc9 100644 --- a/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts +++ b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts @@ -1,13 +1,9 @@ import { describe, expect, it } from "vitest"; import { shouldAutoOpen } from "../useArtifactAutoOpen"; -// The auto-open decision matrix. The hook is a once-latch around this pure -// predicate (plus the store-level _markAutoOpened latch, tested in -// store/__tests__/detailedViewAutoOpenLatch.test.ts), so the matrix is the -// behavior: -// open-on-mount → open on mount, streaming or not (deep-link / kiosk). -// auto-open → open only while the artifact streams live. -// overview → never (the click-to-open default). +// The auto-open decision matrix — the hook is a once-latch around this +// predicate, so the matrix is the behavior. The latch itself is tested in +// store/__tests__/detailedViewAutoOpenLatch.test.ts. describe("shouldAutoOpen", () => { it.each([ ["open-on-mount", true, true], diff --git a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts index 6e0f254d8..3675d6ebf 100644 --- a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts +++ b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts @@ -1,10 +1,10 @@ -import { useEffect, useRef } from "react"; +import { useEffect } from "react"; import { useArtifactViewMode, type ArtifactViewMode } from "../store/ArtifactViewModeContext"; import { useDetailedViewStore } from "../store/DetailedViewContext"; /** - * The open decision, pure: open on mount always for `"open-on-mount"`, only - * while observed live-streaming for `"auto-open"`, never for `"overview"`. + * The open decision, pure: always for `"open-on-mount"`, only while + * live-streaming for `"auto-open"`, never for `"overview"`. */ export function shouldAutoOpen(mode: ArtifactViewMode, isStreaming: boolean): boolean { return mode === "open-on-mount" || (mode === "auto-open" && isStreaming); @@ -19,39 +19,41 @@ export interface UseArtifactAutoOpenOptions { /** The detailed-view id to open. */ viewId: string; /** - * Identity for the `"auto-open"` once-latch — e.g. a statement id or - * artifact id for chat-library artifacts. Must be stable across host - * remounts, so a user's mid-stream close sticks instead of re-opening - * when the host remounts. Lives on the detailed-view store's latch - * (cleared on thread switch), sharing a namespace with the registration - * path's artifact ids. + * Identity for the once-latch — e.g. a statement id or artifact id for + * chat-library artifacts. Must be stable across host remounts so a user's + * close sticks. Lives on the detailed-view store (cleared on thread + * switch) and shares a namespace with the registration path's artifact + * ids, so pick keys that can't collide with one. */ latchKey: string; - /** Whether the artifact is streaming live right now. `"auto-open"` only - * fires while true, which keeps thread reloads quiet (historical - * artifacts mount settled). */ + /** + * Whether the artifact is streaming live right now. `"auto-open"` only + * fires while true, which keeps thread reloads quiet. + */ isStreaming: boolean; - /** Gate for host-known failure shapes (parse failed, tool call errored) — - * pass `false` and nothing ever opens. Defaults to `true`. */ + /** + * Gate for host-known failure shapes (parse failed, tool call errored): + * pass `false` and nothing ever opens. Defaults to `true`. + */ enabled?: boolean; } /** * Applies the `artifactViewMode` set on `ChatProvider` from a rendering host, - * opening `viewId` per its semantics — + * opening `viewId` once per `latchKey`: * * - `"overview"` (default): never. - * - `"auto-open"`: once per `latchKey`, only while `isStreaming`. The latch - * lives on the detailed-view store (cleared on thread switch), so it - * survives host remounts mid-stream. - * - `"open-on-mount"`: once per mounted host instance, streaming or not. + * - `"auto-open"`: only while `isStreaming`. + * - `"open-on-mount"`: streaming or not. + * + * The latch lives on the detailed-view store, so it survives host remounts + * and a user's close sticks until the thread switches. * * Artifacts that register in the ThreadContext need none of this — * `ChatProvider` presents them itself on first registration. This hook is * for artifact sources that bypass the registry (an SDK's chat-library * artifacts, custom renderer hosts): the host supplies the render-derived - * facts — which view to open, the latch identity, streaming state, and - * eligibility. + * facts — which view to open, latch identity, streaming state, eligibility. */ export function useArtifactAutoOpen({ viewId, @@ -61,20 +63,13 @@ export function useArtifactAutoOpen({ }: UseArtifactAutoOpenOptions): void { const viewMode = useArtifactViewMode(); const store = useDetailedViewStore(); - const openedThisMountRef = useRef(false); useEffect(() => { - if (!enabled) return; - if (!shouldAutoOpen(viewMode, isStreaming)) return; + if (!enabled || !shouldAutoOpen(viewMode, isStreaming)) return; const dv = store.getState(); - if (viewMode === "auto-open") { - if (!dv._markAutoOpened(latchKey)) return; - } else if (openedThisMountRef.current) { - return; - } - openedThisMountRef.current = true; - // First wins, same policy as the registration path: never steal a panel - // that is already open (this view re-asserting itself is fine). + if (!dv._markAutoOpened(latchKey)) return; + // First wins, same as the registration path: never steal an open panel + // (re-asserting this same view is fine). const active = dv.activeDetailedViewId; if (active !== null && active !== viewId) return; dv.setActiveDetailedView(viewId); diff --git a/packages/react-headless/src/index.ts b/packages/react-headless/src/index.ts index 08f36fbc3..f5aee7b62 100644 --- a/packages/react-headless/src/index.ts +++ b/packages/react-headless/src/index.ts @@ -1,6 +1,4 @@ export { useActiveDetailedView } from "./hooks/useActiveDetailedView"; -export { shouldAutoOpen, useArtifactAutoOpen } from "./hooks/useArtifactAutoOpen"; -export type { UseArtifactAutoOpenOptions } from "./hooks/useArtifactAutoOpen"; export { useArtifactList } from "./hooks/useArtifactList"; export type { ArtifactListFilter } from "./hooks/useArtifactList"; export { useArtifactRenderer } from "./hooks/useArtifactRenderer"; @@ -21,7 +19,6 @@ export { } from "./store/ArtifactRenderersContext"; export { defineArtifactRenderer } from "./store/artifactRendererTypes"; export { useArtifactStorage } from "./store/ArtifactStorageContext"; -export { ArtifactViewModeContext, useArtifactViewMode } from "./store/ArtifactViewModeContext"; export type { ArtifactViewMode } from "./store/ArtifactViewModeContext"; export { ChatProvider } from "./store/ChatProvider"; export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext"; diff --git a/packages/react-headless/src/store/ArtifactViewModeContext.ts b/packages/react-headless/src/store/ArtifactViewModeContext.ts index c42e28cd5..bc342044e 100644 --- a/packages/react-headless/src/store/ArtifactViewModeContext.ts +++ b/packages/react-headless/src/store/ArtifactViewModeContext.ts @@ -3,20 +3,18 @@ import { createContext, useContext } from "react"; /** * How artifact detail panels open. The policy is "present once": an artifact * may open by itself exactly one time — when its id first registers in the - * ThreadContext — and only into an empty panel (an open panel is never - * stolen). Edits update in place and never force a panel open. Driven - * entirely inside `ChatProvider`; rendering hosts need no wiring. Artifact - * sources that bypass the registry apply the mode via `useArtifactAutoOpen`. + * ThreadContext — and only into an empty panel. Edits update in place and + * never force a panel open. Driven by `ChatProvider` for artifacts that + * register in the thread context; non-registered artifact sources are out + * of scope for this policy. * - * - `"overview"` (default) — panels only open on an explicit user action - * (clicking an artifact preview's open control). - * - `"auto-open"` — a newly registered artifact opens while the thread is + * - `"overview"` (default): panels open only on explicit user action. + * - `"auto-open"`: a newly registered artifact opens while the thread is * running (a live generation presenting its artifact). A user's close - * sticks; historical artifacts on a thread reload never fire. - * - `"open-on-mount"` — a newly registered artifact opens regardless of - * running: loading a thread presents its first artifact (message order; - * an id's panel still ends at its newest version). Deep-link / kiosk - * embeds. + * sticks; thread reloads stay quiet. + * - `"open-on-mount"`: a newly registered artifact opens whether or not the + * thread is running, so loading a thread presents its first artifact. + * For deep-link / kiosk embeds. * * @category Types */ @@ -25,10 +23,9 @@ export type ArtifactViewMode = "auto-open" | "open-on-mount" | "overview"; export const DEFAULT_ARTIFACT_VIEW_MODE: ArtifactViewMode = "overview"; /** - * Carries the artifact view mode from `ChatProvider` (set via its - * `artifactViewMode` prop) to custom artifact hosts (`useArtifactAutoOpen`). - * Unlike the renderer registry, the value is live — prop changes apply on - * the next render. + * Carries the mode from `ChatProvider`'s `artifactViewMode` prop to custom + * artifact hosts (`useArtifactAutoOpen`). Live, unlike the renderer + * registry: prop changes apply on the next render. */ export const ArtifactViewModeContext = createContext(DEFAULT_ARTIFACT_VIEW_MODE); diff --git a/packages/react-headless/src/store/ChatProvider.tsx b/packages/react-headless/src/store/ChatProvider.tsx index 56bd5eae0..ab7d1da5b 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -67,11 +67,11 @@ export const ChatProvider: FC = ({ return unsubscribe; }, [chatStore, detailedViewStore, threadContextStore]); - // Drives artifactViewMode entirely at the store layer — an artifact may - // present itself once, when it first registers in the ThreadContext. - // Thread-switch safety comes from the reset subscription above running - // synchronously inside the selectThread set(): the latch and registry are - // both cleared before any new-thread registration can fire this watcher. + // Store-level driver for artifactViewMode: an artifact may present itself + // once, when it first registers in the ThreadContext. Thread-switch safety + // comes from the reset subscription above running synchronously inside the + // selectThread set(): latch and registry clear before any new-thread + // registration can reach this watcher. useArtifactAutoOpenWatcher( artifactViewMode ?? DEFAULT_ARTIFACT_VIEW_MODE, chatStore, diff --git a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts index ed87c69bd..7b99e0ac2 100644 --- a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts +++ b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts @@ -7,30 +7,16 @@ import type { createThreadContextStore } from "./createThreadContextStore"; import type { ArtifactEntry } from "./threadContextTypes"; /** - * One auto-open pass over the thread's registered artifacts, applying the - * "present once" policy: an artifact may open exactly one time — when its id - * first appears in the ThreadContext registry — and only into an empty panel. + * One auto-open pass over the thread's registered artifacts. * - * The rules, in evaluation order per artifact id: - * - * 1. **Claim first, ask later.** The first pass that sees an id claims it via - * `_markAutoOpened(id)` whether or not it opens. This is what keeps - * historical artifacts (registered on thread load, nothing running) from - * ever popping open later, and what makes host remounts/StrictMode - * re-registrations invisible. - * 2. **Mode gate.** `"auto-open"` requires the thread to be running (a live - * generation is what's presenting the artifact); `"open-on-mount"` opens - * regardless — loading a thread presents its artifact. - * 3. **First wins.** If any panel is already open, nothing else opens over - * it. The user's (or an earlier artifact's) panel state is never stolen. - * - * Edits never re-open by construction: an edit shares the artifact id with - * its generate, and the id was claimed when the generate registered. The - * edit still updates in place — react-ui's version-follow effect re-points - * an OPEN panel to the newest version as it registers. - * - * Latch keys here are artifact ids (cleared on thread switch by `reset()`); - * `useArtifactAutoOpen` callers use their own key namespace on the same latch. + * Every first-seen id is claimed via `_markAutoOpened` whether or not it + * opens, so historical artifacts (registered on load, nothing running) and + * StrictMode re-registrations can never open later. An id opens only when + * the mode allows it right now and no panel is open — first wins. Edits + * never re-open: an edit shares its generate's id, already claimed when the + * generate registered (an OPEN panel still follows versions via react-ui's + * follow effect). Latch keys here are artifact ids; `useArtifactAutoOpen` + * hosts use their own `latchKey` namespace on the same latch. * * @internal */ @@ -41,6 +27,7 @@ export function evaluateRegisteredArtifacts( 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]; @@ -49,23 +36,19 @@ export function evaluateRegisteredArtifacts( // `activeDetailedViewId` non-null for the ids after it (first wins). const dv = detailedViewStore.getState(); if (!dv._markAutoOpened(latest.id)) continue; - if (!shouldAutoOpen(viewMode, isThreadRunning)) continue; + if (!mayOpen) continue; if (dv.activeDetailedViewId !== null) continue; dv.setActiveDetailedView(`${latest.id}:${latest.version}`); } } /** - * ChatProvider-internal driver for {@link ArtifactViewMode}: subscribes to - * the ThreadContext artifact registry and runs - * {@link evaluateRegisteredArtifacts} on every registration change (and once - * on mount). `"overview"` subscribes to nothing. - * - * Registration is the trigger — not tool-call parsing — so the store layer - * needs no knowledge of renderers or streaming payloads: whoever renders an - * artifact registers it (react-ui's tool renderer, custom hosts), and that - * registration is the moment it may present itself. Artifact sources that - * bypass the registry apply the mode themselves via `useArtifactAutoOpen`. + * Drives `artifactViewMode` from `ChatProvider`: runs + * `evaluateRegisteredArtifacts` on every ThreadContext registration change + * (and once on mount). Registration is the trigger — not tool-call parsing — + * so the store layer needs no renderer or streaming knowledge: whoever + * renders an artifact registers it, and that registration is the moment it + * may present itself. `"overview"` subscribes to nothing. * * @internal */ diff --git a/packages/react-headless/src/store/detailedViewTypes.ts b/packages/react-headless/src/store/detailedViewTypes.ts index 716690228..e4035d1f0 100644 --- a/packages/react-headless/src/store/detailedViewTypes.ts +++ b/packages/react-headless/src/store/detailedViewTypes.ts @@ -36,19 +36,16 @@ export type DetailedViewInternals = { /** @internal */ _setDetailedViewPanelNode: (node: HTMLElement | null) => void; /** - * Auto-open latch: keys that already had their one chance to auto-open, - * claimed whether or not a panel actually opened. Keyed by artifact id on - * the registration path (an edit shares its generate's id, so edits can - * never force a panel open; remounts/re-registrations are invisible) and - * by the caller's `latchKey` for `useArtifactAutoOpen` hosts. Cleared by - * `reset()` (thread switch). + * Auto-open latch: keys that already used their one chance to auto-open, + * claimed whether or not a panel opened. The registration path keys by + * artifact id (edits and re-registrations stay quiet); `useArtifactAutoOpen` + * hosts key by their own `latchKey`. Cleared by `reset()` on thread switch. * @internal */ _autoOpenedArtifactKeys: ReadonlySet; /** - * Atomically records an auto-open for `key`. Returns `false` when the key - * already fired (the caller must not open again), `true` when this call - * claimed it. + * Claims `key` for auto-open. Returns `false` if already claimed (the + * caller must not open), `true` if this call claimed it. * @internal */ _markAutoOpened: (key: string) => boolean; diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index b10519a55..5ae67087c 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -94,12 +94,11 @@ export interface ChatProviderProps { artifactCategories?: ArtifactCategory[]; /** * How artifact detail panels open (default `"overview"` — only on user - * action). An artifact may present itself once, when it first registers: - * `"auto-open"` does so only while the thread is running (live - * generation); `"open-on-mount"` regardless (loading a thread presents - * its artifact — deep-link/kiosk). An open panel is never stolen and - * edits never force a panel open. Driven entirely inside ChatProvider; - * no host wiring needed. Live — prop changes apply on the next render. + * action). `"auto-open"` presents a newly registered artifact while the + * thread runs (live generation); `"open-on-mount"` also on thread load + * (deep-link/kiosk). Each artifact presents at most once, into an empty + * panel; edits never force one open. Driven inside ChatProvider — no host + * wiring needed — and live: prop changes apply on the next render. */ artifactViewMode?: ArtifactViewMode; children: React.ReactNode; From 834d0a2712e17c87188141a2ed2a214a3dbdabc1 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Fri, 31 Jul 2026 15:43:04 +0530 Subject: [PATCH 7/8] refactor: strip comments from artifact view mode files Co-authored-by: Cursor --- .../__tests__/useArtifactAutoOpen.test.ts | 3 -- .../src/hooks/useArtifactAutoOpen.ts | 44 ------------------- .../src/store/ArtifactViewModeContext.ts | 27 ------------ .../react-headless/src/store/ChatProvider.tsx | 5 --- .../__tests__/artifactAutoOpenWatcher.test.ts | 12 ++--- .../detailedViewAutoOpenLatch.test.ts | 2 - .../src/store/artifactAutoOpenWatcher.ts | 26 ----------- .../src/store/detailedViewTypes.ts | 14 +----- packages/react-headless/src/store/types.ts | 8 ---- 9 files changed, 6 insertions(+), 135 deletions(-) diff --git a/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts index 212781fc9..bccbaa0dd 100644 --- a/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts +++ b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import { shouldAutoOpen } from "../useArtifactAutoOpen"; -// The auto-open decision matrix — the hook is a once-latch around this -// predicate, so the matrix is the behavior. The latch itself is tested in -// store/__tests__/detailedViewAutoOpenLatch.test.ts. describe("shouldAutoOpen", () => { it.each([ ["open-on-mount", true, true], diff --git a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts index 3675d6ebf..298ae07ab 100644 --- a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts +++ b/packages/react-headless/src/hooks/useArtifactAutoOpen.ts @@ -2,59 +2,17 @@ import { useEffect } from "react"; import { useArtifactViewMode, type ArtifactViewMode } from "../store/ArtifactViewModeContext"; import { useDetailedViewStore } from "../store/DetailedViewContext"; -/** - * The open decision, pure: always for `"open-on-mount"`, only while - * live-streaming for `"auto-open"`, never for `"overview"`. - */ export function shouldAutoOpen(mode: ArtifactViewMode, isStreaming: boolean): boolean { return mode === "open-on-mount" || (mode === "auto-open" && isStreaming); } -/** - * Options for {@link useArtifactAutoOpen}. - * - * @category Types - */ export interface UseArtifactAutoOpenOptions { - /** The detailed-view id to open. */ viewId: string; - /** - * Identity for the once-latch — e.g. a statement id or artifact id for - * chat-library artifacts. Must be stable across host remounts so a user's - * close sticks. Lives on the detailed-view store (cleared on thread - * switch) and shares a namespace with the registration path's artifact - * ids, so pick keys that can't collide with one. - */ latchKey: string; - /** - * Whether the artifact is streaming live right now. `"auto-open"` only - * fires while true, which keeps thread reloads quiet. - */ isStreaming: boolean; - /** - * Gate for host-known failure shapes (parse failed, tool call errored): - * pass `false` and nothing ever opens. Defaults to `true`. - */ enabled?: boolean; } -/** - * Applies the `artifactViewMode` set on `ChatProvider` from a rendering host, - * opening `viewId` once per `latchKey`: - * - * - `"overview"` (default): never. - * - `"auto-open"`: only while `isStreaming`. - * - `"open-on-mount"`: streaming or not. - * - * The latch lives on the detailed-view store, so it survives host remounts - * and a user's close sticks until the thread switches. - * - * Artifacts that register in the ThreadContext need none of this — - * `ChatProvider` presents them itself on first registration. This hook is - * for artifact sources that bypass the registry (an SDK's chat-library - * artifacts, custom renderer hosts): the host supplies the render-derived - * facts — which view to open, latch identity, streaming state, eligibility. - */ export function useArtifactAutoOpen({ viewId, latchKey, @@ -68,8 +26,6 @@ export function useArtifactAutoOpen({ if (!enabled || !shouldAutoOpen(viewMode, isStreaming)) return; const dv = store.getState(); if (!dv._markAutoOpened(latchKey)) return; - // First wins, same as the registration path: never steal an open panel - // (re-asserting this same view is fine). const active = dv.activeDetailedViewId; if (active !== null && active !== viewId) return; dv.setActiveDetailedView(viewId); diff --git a/packages/react-headless/src/store/ArtifactViewModeContext.ts b/packages/react-headless/src/store/ArtifactViewModeContext.ts index bc342044e..84c1bf178 100644 --- a/packages/react-headless/src/store/ArtifactViewModeContext.ts +++ b/packages/react-headless/src/store/ArtifactViewModeContext.ts @@ -1,38 +1,11 @@ import { createContext, useContext } from "react"; -/** - * How artifact detail panels open. The policy is "present once": an artifact - * may open by itself exactly one time — when its id first registers in the - * ThreadContext — and only into an empty panel. Edits update in place and - * never force a panel open. Driven by `ChatProvider` for artifacts that - * register in the thread context; non-registered artifact sources are out - * of scope for this policy. - * - * - `"overview"` (default): panels open only on explicit user action. - * - `"auto-open"`: a newly registered artifact opens while the thread is - * running (a live generation presenting its artifact). A user's close - * sticks; thread reloads stay quiet. - * - `"open-on-mount"`: a newly registered artifact opens whether or not the - * thread is running, so loading a thread presents its first artifact. - * For deep-link / kiosk embeds. - * - * @category Types - */ export type ArtifactViewMode = "auto-open" | "open-on-mount" | "overview"; export const DEFAULT_ARTIFACT_VIEW_MODE: ArtifactViewMode = "overview"; -/** - * Carries the mode from `ChatProvider`'s `artifactViewMode` prop to custom - * artifact hosts (`useArtifactAutoOpen`). Live, unlike the renderer - * registry: prop changes apply on the next render. - */ export const ArtifactViewModeContext = createContext(DEFAULT_ARTIFACT_VIEW_MODE); -/** - * The active {@link ArtifactViewMode}. Defaults to `"overview"` outside a - * `ChatProvider` (or when the prop is unset), i.e. never auto-open. - */ 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 ab7d1da5b..96ce76197 100644 --- a/packages/react-headless/src/store/ChatProvider.tsx +++ b/packages/react-headless/src/store/ChatProvider.tsx @@ -67,11 +67,6 @@ export const ChatProvider: FC = ({ return unsubscribe; }, [chatStore, detailedViewStore, threadContextStore]); - // Store-level driver for artifactViewMode: an artifact may present itself - // once, when it first registers in the ThreadContext. Thread-switch safety - // comes from the reset subscription above running synchronously inside the - // selectThread set(): latch and registry clear before any new-thread - // registration can reach this watcher. useArtifactAutoOpenWatcher( artifactViewMode ?? DEFAULT_ARTIFACT_VIEW_MODE, chatStore, diff --git a/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts index 806cc03e9..d3dc022d5 100644 --- a/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts +++ b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts @@ -10,7 +10,6 @@ const entry = (id: string, version = 1): ArtifactEntry => ({ type: "test_artifact", }); -// Registry shape mirrors ThreadContextState.artifacts: id → versions ascending. const registry = (...entries: ArtifactEntry[]): Record => { const out: Record = {}; for (const e of entries) (out[e.id] ??= []).push(e); @@ -29,16 +28,15 @@ describe("evaluateRegisteredArtifacts", () => { const store = createDetailedViewStore(); const arts = registry(entry("art")); evaluateRegisteredArtifacts("auto-open", arts, true, store); - store.getState().setActiveDetailedView(null); // user closes - evaluateRegisteredArtifacts("auto-open", arts, true, store); // remount re-register + 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); // user closes the generate - // The edit registers v2 under the same id — still quiet. + store.getState().setActiveDetailedView(null); evaluateRegisteredArtifacts( "auto-open", registry(entry("art", 1), entry("art", 2)), @@ -62,9 +60,8 @@ describe("evaluateRegisteredArtifacts", () => { 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); // thread load + evaluateRegisteredArtifacts("auto-open", arts, false, store); expect(store.getState().activeDetailedViewId).toBeNull(); - // A later run starts and the host re-registers: the claim is already burned. evaluateRegisteredArtifacts("auto-open", arts, true, store); expect(store.getState().activeDetailedViewId).toBeNull(); }); @@ -79,7 +76,6 @@ describe("evaluateRegisteredArtifacts", () => { const store = createDetailedViewStore(); evaluateRegisteredArtifacts("auto-open", registry(entry("a1"), entry("a2")), true, store); expect(store.getState().activeDetailedViewId).toBe("a1:1"); - // a2's chance is spent: even after the user closes, it stays quiet. expect(store.getState()._autoOpenedArtifactKeys.has("a2")).toBe(true); store.getState().setActiveDetailedView(null); evaluateRegisteredArtifacts("auto-open", registry(entry("a1"), entry("a2")), true, store); diff --git a/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts b/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts index bbbf624f3..b0a502b1b 100644 --- a/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts +++ b/packages/react-headless/src/store/__tests__/detailedViewAutoOpenLatch.test.ts @@ -6,8 +6,6 @@ describe("detailed-view auto-open latch", () => { const store = createDetailedViewStore(); expect(store.getState()._markAutoOpened("a1:1")).toBe(true); - // A remounted host asking again for the same artifact version must not - // re-open (a user's mid-stream close sticks). expect(store.getState()._markAutoOpened("a1:1")).toBe(false); }); diff --git a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts index 7b99e0ac2..195861fb5 100644 --- a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts +++ b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts @@ -6,20 +6,6 @@ import type { createDetailedViewStore } from "./createDetailedViewStore"; import type { createThreadContextStore } from "./createThreadContextStore"; import type { ArtifactEntry } from "./threadContextTypes"; -/** - * One auto-open pass over the thread's registered artifacts. - * - * Every first-seen id is claimed via `_markAutoOpened` whether or not it - * opens, so historical artifacts (registered on load, nothing running) and - * StrictMode re-registrations can never open later. An id opens only when - * the mode allows it right now and no panel is open — first wins. Edits - * never re-open: an edit shares its generate's id, already claimed when the - * generate registered (an OPEN panel still follows versions via react-ui's - * follow effect). Latch keys here are artifact ids; `useArtifactAutoOpen` - * hosts use their own `latchKey` namespace on the same latch. - * - * @internal - */ export function evaluateRegisteredArtifacts( viewMode: ArtifactViewMode, artifacts: Record, @@ -32,8 +18,6 @@ export function evaluateRegisteredArtifacts( for (const versions of Object.values(artifacts)) { const latest = versions[versions.length - 1]; if (!latest) continue; - // Fresh state per iteration: an open earlier in this same pass must make - // `activeDetailedViewId` non-null for the ids after it (first wins). const dv = detailedViewStore.getState(); if (!dv._markAutoOpened(latest.id)) continue; if (!mayOpen) continue; @@ -42,16 +26,6 @@ export function evaluateRegisteredArtifacts( } } -/** - * Drives `artifactViewMode` from `ChatProvider`: runs - * `evaluateRegisteredArtifacts` on every ThreadContext registration change - * (and once on mount). Registration is the trigger — not tool-call parsing — - * so the store layer needs no renderer or streaming knowledge: whoever - * renders an artifact registers it, and that registration is the moment it - * may present itself. `"overview"` subscribes to nothing. - * - * @internal - */ export function useArtifactAutoOpenWatcher( viewMode: ArtifactViewMode, chatStore: ReturnType, diff --git a/packages/react-headless/src/store/detailedViewTypes.ts b/packages/react-headless/src/store/detailedViewTypes.ts index e4035d1f0..9345f63d4 100644 --- a/packages/react-headless/src/store/detailedViewTypes.ts +++ b/packages/react-headless/src/store/detailedViewTypes.ts @@ -35,19 +35,9 @@ export type DetailedViewInternals = { _detailedViewPanelNode: HTMLElement | null; /** @internal */ _setDetailedViewPanelNode: (node: HTMLElement | null) => void; - /** - * Auto-open latch: keys that already used their one chance to auto-open, - * claimed whether or not a panel opened. The registration path keys by - * artifact id (edits and re-registrations stay quiet); `useArtifactAutoOpen` - * hosts key by their own `latchKey`. Cleared by `reset()` on thread switch. - * @internal - */ + /** @internal */ _autoOpenedArtifactKeys: ReadonlySet; - /** - * Claims `key` for auto-open. Returns `false` if already claimed (the - * caller must not open), `true` if this call claimed it. - * @internal - */ + /** @internal */ _markAutoOpened: (key: string) => boolean; }; diff --git a/packages/react-headless/src/store/types.ts b/packages/react-headless/src/store/types.ts index 5ae67087c..2b9f83086 100644 --- a/packages/react-headless/src/store/types.ts +++ b/packages/react-headless/src/store/types.ts @@ -92,14 +92,6 @@ export interface ChatProviderProps { * artifact browser's pre-applied filters, and workspace section grouping. */ artifactCategories?: ArtifactCategory[]; - /** - * How artifact detail panels open (default `"overview"` — only on user - * action). `"auto-open"` presents a newly registered artifact while the - * thread runs (live generation); `"open-on-mount"` also on thread load - * (deep-link/kiosk). Each artifact presents at most once, into an empty - * panel; edits never force one open. Driven inside ChatProvider — no host - * wiring needed — and live: prop changes apply on the next render. - */ artifactViewMode?: ArtifactViewMode; children: React.ReactNode; } From a87874dd49ea172744ba335cf93cf551906c9e09 Mon Sep 17 00:00:00 2001 From: ankit-thesys Date: Tue, 4 Aug 2026 19:28:08 +0530 Subject: [PATCH 8/8] refactor: drop unused useArtifactAutoOpen hook, fold shouldAutoOpen into watcher The hook had no callers: the SDK's artifact renderers go through the registry path (covered by the watcher), and the chat-shell integration that might need a non-registry path is deferred. Archived for later reintroduction if needed. Its shouldAutoOpen predicate moves into artifactAutoOpenWatcher.ts; the decision-matrix test folds into the watcher suite (still 128 tests). Co-authored-by: Cursor --- .../__tests__/useArtifactAutoOpen.test.ts | 15 --------- .../src/hooks/useArtifactAutoOpen.ts | 33 ------------------- .../__tests__/artifactAutoOpenWatcher.test.ts | 15 ++++++++- .../src/store/artifactAutoOpenWatcher.ts | 5 ++- 4 files changed, 18 insertions(+), 50 deletions(-) delete mode 100644 packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts delete mode 100644 packages/react-headless/src/hooks/useArtifactAutoOpen.ts diff --git a/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts b/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts deleted file mode 100644 index bccbaa0dd..000000000 --- a/packages/react-headless/src/hooks/__tests__/useArtifactAutoOpen.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index 298ae07ab..000000000 --- a/packages/react-headless/src/hooks/useArtifactAutoOpen.ts +++ /dev/null @@ -1,33 +0,0 @@ -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/store/__tests__/artifactAutoOpenWatcher.test.ts b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts index d3dc022d5..e57f34dc4 100644 --- a/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts +++ b/packages/react-headless/src/store/__tests__/artifactAutoOpenWatcher.test.ts @@ -1,8 +1,21 @@ import { describe, expect, it } from "vitest"; -import { evaluateRegisteredArtifacts } from "../artifactAutoOpenWatcher"; +import { evaluateRegisteredArtifacts, shouldAutoOpen } from "../artifactAutoOpenWatcher"; import { createDetailedViewStore } from "../createDetailedViewStore"; import type { ArtifactEntry } from "../threadContextTypes"; +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); + }); +}); + const entry = (id: string, version = 1): ArtifactEntry => ({ id, version, diff --git a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts index 195861fb5..9127e4687 100644 --- a/packages/react-headless/src/store/artifactAutoOpenWatcher.ts +++ b/packages/react-headless/src/store/artifactAutoOpenWatcher.ts @@ -1,11 +1,14 @@ 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 shouldAutoOpen(mode: ArtifactViewMode, isStreaming: boolean): boolean { + return mode === "open-on-mount" || (mode === "auto-open" && isStreaming); +} + export function evaluateRegisteredArtifacts( viewMode: ArtifactViewMode, artifacts: Record,