feat: artifactViewMode — opt-in auto-open for artifact detail panels - #892
feat: artifactViewMode — opt-in auto-open for artifact detail panels#892ankit-thesys wants to merge 7 commits into
Conversation
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019hnic4tGtRh3MSSaCdGJYE
| set({ activeDetailedViewId: null, _autoOpenedArtifactKeys: new Set() }); | ||
| }, | ||
|
|
||
| _autoOpenedArtifactKeys: new Set<string>(), | ||
| _markAutoOpened: (key) => { | ||
| const keys = get()._autoOpenedArtifactKeys; | ||
| if (keys.has(key)) return false; | ||
| set({ _autoOpenedArtifactKeys: new Set(keys).add(key) }); | ||
| return true; | ||
| }, |
There was a problem hiding this comment.
Explain this part?
There was a problem hiding this comment.
This is the auto-open latch's check-and-claim, done as one atomic call so there's no gap between "has it fired?" and "mark it fired":
keys.has(key)→ already claimed → returnfalse, and the caller skips opening. This is what makes a user's mid-stream close stick: if the renderer host remounts during streaming (it does — React StrictMode and streaming re-renders both remount it), the fresh instance asks again, getsfalse, and does NOT re-open over the close.- Otherwise
new Set(keys).add(key)— a copy, notkeys.add(key)in place, because zustand subscribers compare by reference; mutating the existing Set would be an invisible state change. Copying keeps the store honest. reset()(one line up) clears the Set together with the active view on thread switch, so a fresh thread starts with a clean slate.
| /** | ||
| * 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<string>; | ||
| /** | ||
| * 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; |
There was a problem hiding this comment.
Can you also explain this part?
There was a problem hiding this comment.
This is the type contract for that latch: _markAutoOpened(key) returns a boolean that IS the decision — true = this call claimed the key, go ahead and open; false = something already auto-opened for this key, do nothing. The caller never reads the Set directly (it's exposed as ReadonlySet and underscore-internal, so it's not public API surface).
One thing your comment flushed out: the docstring still said keys are id:version from an earlier iteration. The final implementation latches by tool-call id — a streamed edit often carries no version in its args, so id:version collides with the generate's key and would swallow the edit's auto-open (caught in live testing). Tool-call id is unique per generate/edit call and stable across remounts, which is exactly the "once per call" semantics we want. Doc fixed in 4dd3c4c.
| 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]); | ||
|
|
There was a problem hiding this comment.
Can you tell me why we are doing it here? We can do this in react-headless
Just trying to understand what is happening ?
There was a problem hiding this comment.
Fair question — the split is deliberate, and react-headless does own everything it can own:
In react-headless already: the artifactViewMode value (ChatProvider prop → context) and the latch (_markAutoOpened on the detailed-view store, reset on thread switch). That's the policy + state.
Why the trigger effect lives here and not in react-headless: the open decision needs per-activity render context that a renderless package doesn't have —
parsed/activity.isError— a failed or unparseable tool call renders only the fallback card and must never open a panel. Whether a parse succeeded is only known here, where the renderer's parser actually ran.viewId— derived in this component, including theuseId()fallback for early frames beforemetalands.useIdis a React render-tree concern; headless can't produce it.isStreamingfor this mounted activity — the reload-safety gate (historical activities mount ascomplete).
ToolActivityRenderer is the one place every artifact renderer is mounted and bound to a viewId — the same place that already runs the two open-preserving effects (viewId migration, edit-version follow) this one must cooperate with. If we moved the effect into a react-headless hook, react-ui would still have to call that hook from this exact spot with these exact inputs — same coupling, one extra layer of indirection. So: state/policy headless, render-coupled trigger here.
There was a problem hiding this comment.
Update: done your way — 12758b7 moves the whole behavior into react-headless. New exported hook useArtifactAutoOpen({ viewId, latchKey, isStreaming, enabled }) owns the mode context, the shouldAutoOpen predicate, the per-mount latch, and the store-level auto-open latch; ToolActivityRenderer shrinks to a single call feeding it the render-derived facts (viewId, tool-call id, streaming, error/parse eligibility) — the only things a renderless package can't know. Bonus: any headless consumer building its own thread UI now gets auto-open from the same hook, and the c1 companion PR deletes its local copy and calls this one (−90 lines there). Predicate matrix test moved into react-headless (118 tests green); full auto-open suite re-verified live on the linked pair after the refactor.
There was a problem hiding this comment.
Follow-through: 00a81646 moves the invocation too — this file is back to zero auto-open code.
ChatProvider now runs a store-level watcher (react-headless artifactAutoOpenWatcher): it subscribes to the thread's messages, derives each artifact tool call's status from the store, parses via a shared runArtifactRenderer envelope (now also the single definition this renderer uses — the two sides can never disagree on what a parser sees), and opens once per tool call via the existing latch. react-ui's entire diff is the 2+2-line prop pass-through.
Deliberate semantics with the move:
open-on-mount= once per tool call per thread session (loading a thread opens its newest artifact) — a store watcher can't observe mounts.- Renderers that never yield
meta(useId-fallback hosts) can't be auto-opened from the store;useArtifactAutoOpenstays public for those and for non-tool-call artifact sources (the SDK's chat-library path). - Cost is latch-bounded: already-opened (or user-closed) calls are skipped before any parsing.
140 tests green (11 new watcher tests); full live matrix re-verified on a linked build: auto-open hands-off, mid-stream close sticks, reload quiet, edit re-opens, presentation, and open-on-mount opening a settled thread.
| llm={llm} | ||
| artifactRenderers={artifactRenderers} | ||
| artifactCategories={artifactCategories} | ||
| artifactViewMode={artifactViewMode} |
There was a problem hiding this comment.
This looks good.
There was a problem hiding this comment.
👍 — and note it's Omit<ChatProviderProps, "children"> inheritance, so the prop rides the existing type: no separate AgentInterface-side type to keep in sync.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hnic4tGtRh3MSSaCdGJYE
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hnic4tGtRh3MSSaCdGJYE
…I wiring 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nPH2Rn2XGXETgx5Yu5seX
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011nPH2Rn2XGXETgx5Yu5seX
|
Semantics + architecture update ( What changed vs the previous revision:
Trade-off made deliberately: this drops old c1's "edit re-opens the panel" behavior in exchange for a much simpler mechanism and a calmer UX. Everything re-verified live: auto-open on first registration, close-sticks, edit stays quiet, reload quiet, new-id opens, open-on-mount presents a settled thread. 128 headless tests green. |
…modes 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 <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
| _detailedViewPanelNode: HTMLElement | null; | ||
| /** @internal */ | ||
| _setDetailedViewPanelNode: (node: HTMLElement | null) => void; | ||
| /** @internal */ | ||
| _autoOpenedArtifactKeys: ReadonlySet<string>; | ||
| /** @internal */ | ||
| _markAutoOpened: (key: string) => boolean; | ||
| }; | ||
|
|
||
| /** Combined detailed-view store type (state + actions + internals). */ |
There was a problem hiding this comment.
What is happening here and why are we changing this ?
There was a problem hiding this comment.
This is the state behind the new artifactViewMode prop on ChatProvider. The policy is "present once": an artifact may auto-open the side panel exactly once, when its id first registers — _autoOpenedArtifactKeys is the set of keys that already used that chance, and _markAutoOpened is the atomic check-and-claim (returns false when the key is already burned, so the caller skips opening). It lives on the detailed-view store rather than component state so the latch survives host remounts and StrictMode double-effects, and reset() clears it on thread switch so a new thread starts clean. The underscore + @internal marking follows this file's existing convention (_detailedViewPanelNode) — nothing here is public API; the barrel only exports the ArtifactViewMode type.
Problem
Artifact detail panels only open on an explicit user click. There is no way for a chat shell to open the panel automatically while an artifact streams in — the behavior the original c1 chat shipped.
Change
New
artifactViewModeprop —'auto-open' | 'open-on-mount' | 'overview'(default'overview') — onChatProvider,AgentInterface, and the OpenUIChatwithChatProviderfamily.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.
overview(default): today's click-to-open behavior. Inert unless set.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 regardless of running — loading a thread presents its first artifact (deep-link / kiosk).Edits never force a panel open: an edit shares its generate's artifact id, whose one chance is already spent. An open panel still follows edit versions live via the pre-existing follow effect — the user just isn't interrupted if they closed it. An open panel is never stolen (first wins), whether the user opened it or an earlier artifact did.
Architecture: registration-driven, entirely in react-headless
ChatProviderruns a store-level watcher (artifactAutoOpenWatcher) subscribed to the ThreadContext artifact registry. Registration is the trigger — whoever renders an artifact registers it, and that registration is the moment it may present itself. The store layer needs no renderer or parsing knowledge, and react-ui's entire footprint is the prop pass-through (2 lines inAgentInterface, 2 inwithChatProvider;ToolActivityRendereris untouched).Design notes:
useArtifactAutoOpenexists as an internal hook for artifact sources that bypass the registry (an SDK's chat-library artifacts, custom hosts), adopting the same present-once policy — its once-latch lives on the detailed-view store for all modes, so a user's close sticks across host remounts. It is deliberately NOT exported yet; the public surface is theartifactViewModeprop plus theArtifactViewModetype, and the hook goes public in the PR that consumes it.Known narrow edges (accepted, documented): an artifact whose header only arrives in the run's final flush (buffered/one-chunk transports) registers after
isRunningclears and quietly misses its auto-open; a custom composer that sends while a thread's history is still loading can let a historical artifact register mid-run and present itself once.Verification
Real generations against the workspace example (
artifactViewModeset locally), Playwright-driven with zero artifact clicks:open-on-mount: a settled thread presented its artifact on load; close stuck; a non-artifact thread stayed quiet.🤖 Generated with Claude Code
https://claude.ai/code/session_011nPH2Rn2XGXETgx5Yu5seX