feat: artifactAutoOpen — auto-open artifact detail panels (default on) - #892
feat: artifactAutoOpen — auto-open artifact detail panels (default on)#892ankit-thesys wants to merge 19 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.
…nto 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 <cursoragent@cursor.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
| <ArtifactViewModeContext.Provider | ||
| value={artifactViewMode ?? DEFAULT_ARTIFACT_VIEW_MODE} | ||
| > |
| @@ -0,0 +1,53 @@ | |||
| import { useEffect } from "react"; | |||
There was a problem hiding this comment.
only this file plus updating props to pass to useArtifactAutoOpen is needed.
- while streaming first artifact should open in that stream.
- if another artifact comes in same stream, we should ignore it.
- no need for artifact open on mount. replace it with boolean prop:
autoOpenArtifact
There was a problem hiding this comment.
Done in 0b751b9 — exactly this file plus prop plumbing:
- ✅ While streaming, the stream's first artifact opens (registration-driven, while
isRunning). - ✅ Another artifact in the same stream is ignored — the watcher latches one open per run (
isRunningtransition resets it), on top of the per-artifact once-latch. - ✅
open-on-mountremoved; the prop is now booleanautoOpenArtifact(defaultfalse) on ChatProvider/AgentInterface/withChatProvider. TheArtifactViewModetype and mode context are deleted — no new barrel exports at all.
(useArtifactAutoOpen itself was already removed earlier as unused — a87874d.)
120 tests green incl. a new first-of-stream-only case; re-verified live on the genui-sdk bench: settled thread loads stay quiet, live generation auto-opens its first artifact mid-stream, mid-stream close sticks through stream end.
There was a problem hiding this comment.
Update per the Linear thread on TH-2454 (eb58c9d8): final prop name is artifactAutoOpen and it now defaults to true — hosts pass false to keep click-to-open. Re-verified live on the bench with no prop set: settled thread quiet, live generation auto-opens its first artifact, mid-stream close sticks.
The context's only reader was the useArtifactAutoOpen hook removed in a87874d. The file shrinks to the ArtifactViewMode type + default (renamed artifactViewMode.ts) and ChatProvider loses the dead provider layer. Reintroduce alongside the hook if the SDK integration needs it. Co-authored-by: Cursor <cursoragent@cursor.com>
Per review: drop the open-on-mount mode and the mode union entirely. autoOpenArtifact (default false) opens the stream's FIRST artifact while it streams; further artifacts in the same stream are ignored (claimed but never opened). Close-sticks, edit-quiet, reload-quiet, and first-wins semantics unchanged. Public surface is now just the boolean prop - the ArtifactViewMode type export is gone. Co-authored-by: Cursor <cursoragent@cursor.com>
Per Linear review decisions on TH-2454: the boolean prop is artifactAutoOpen and auto-open is the default — the stream's first artifact presents itself unless the host passes false. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
With the escape-hatch hook gone the watcher is the latch's only consumer, so it no longer needs to live on the detailed-view store. createDetailedViewStore/detailedViewTypes revert to main; the claimed set is a hook ref cleared on thread switch. The feature is now a single file plus prop plumbing. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
The `${id}:${version}` string was hand-rolled in four places across
two packages with nothing enforcing agreement. New artifactViewId /
parseArtifactViewId helpers in react-headless (exported — react-ui
consumes the public barrel) now build and parse it everywhere: the
auto-open watcher, ToolActivityRenderer (build + edit-follow parse),
and the Workspace rail.
Co-authored-by: Cursor <cursoragent@cursor.com>
E2E demo — final design (
|
| # | Behavior | Result |
|---|---|---|
| 1 | Loading an old thread — nothing streaming → panel stays closed | ✓ |
| 2 | Live generation — the stream's first artifact opens by itself mid-stream | ✓ |
| 3 | Close mid-stream — must not re-open for the rest of the stream | ✓ |
(The earlier demo comments showed the since-removed mode-based API and have been deleted.) Full-quality mp4: gist · Linear: TH-2454
Drop the "never steal an open panel" guard: when a new generation streams in a new artifact, the open panel re-points to it. Close-sticks (per-artifact claim) and one-open-per-stream are unchanged. Verified live: deck panel open -> report requested -> panel re-pointed mid-stream; close mid-stream stayed closed. Co-authored-by: Cursor <cursoragent@cursor.com>
An edit's new version now gets its own auto-open chance (old-c1 parity): the claim set is keyed by id:version instead of artifact id. Same-version re-registrations stay quiet, so a user's close still sticks. Verified live: deck generated, panel closed, edit requested -> panel re-opened on the new version mid-stream; closing during the edit stream stuck. Co-authored-by: Cursor <cursoragent@cursor.com>
Two late behavior changes — recorded and live-verifiedBoth requested during review of the running build; commits 1. Panel take-over (
|



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 boolean prop
artifactAutoOpen(defaulttrue, per review) onChatProvider,AgentInterface, and the OpenUIChatwithChatProviderfamily.When enabled:
id:version, so an edit's new version gets its own chance and re-points the panel as it streams. Re-registrations of the same version stay quiet, so a close still sticks.Pass
artifactAutoOpen={false}for pure click-to-open — the watcher then subscribes to nothing.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).Design notes:
isRunningtransitions; after the stream's first auto-open, later artifacts in the same run are claimed but ignored.Artifact sources that bypass the registry (an SDK's chat-library artifacts) are out of scope: a
useArtifactAutoOpenescape-hatch hook was prototyped and then removed as unused (a87874dd; archived in workspace notes). When the chat-shell integration lands it will either register those artifacts in the ThreadContext (watcher covers them) or reintroduce the hook.Core files
react-headless/store/artifactAutoOpenWatcher.tsevaluateRegisteredArtifacts(one pass: claim once → allowed now → open/take over the panel, at most one per pass) and theChatProviderhook that subscribes it to the artifact registry, holds both latches (per-artifact claims, cleared on thread switch; per-stream open flag, re-armed on each run), and enforces one open per stream.react-headless/store/ChatProvider.tsxreact-headless/store/types.tsartifactAutoOpen?: booleanprop (defaulttrue) onChatProviderProps.react-headless/store/artifactViewId.tsartifactViewId/parseArtifactViewId— single source of truth for theid:versionview-id format (exported; previously hand-rolled in four places across both packages).react-ui: AgentInterface.tsx+withChatProvider.tsxChatProvider.ToolActivityRenderer+Workspacenow build/parse view ids via the shared helpers instead of hand-rolled strings.Verification
testing/openui-cloudbench (PR head injected, no prop set — exercising the default), asserted programmatically and recorded: settled thread load stays quiet; a live generation auto-opens the stream's first artifact mid-stream; a second generation re-points the open panel to its new artifact; an edit re-opens a closed panel on its new version; a mid-stream close sticks through stream end. Labeled recording in the PR comments below.🤖 Generated with Claude Code
https://claude.ai/code/session_011nPH2Rn2XGXETgx5Yu5seX