Skip to content

feat: artifactViewMode — opt-in auto-open for artifact detail panels - #892

Open
ankit-thesys wants to merge 7 commits into
mainfrom
ankit/artifact-view-mode
Open

feat: artifactViewMode — opt-in auto-open for artifact detail panels#892
ankit-thesys wants to merge 7 commits into
mainfrom
ankit/artifact-view-mode

Conversation

@ankit-thesys

@ankit-thesys ankit-thesys commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

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 artifactViewMode prop — 'auto-open' | 'open-on-mount' | 'overview' (default 'overview') — on ChatProvider, AgentInterface, and the OpenUIChat withChatProvider family.

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

ChatProvider runs 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 in AgentInterface, 2 in withChatProvider; ToolActivityRenderer is untouched).

Design notes:

  • Claim first, ask later: every first-seen id burns its latch claim whether or not it opens. Historical artifacts (registered on load, nothing running) can therefore never pop open later, and host remounts / StrictMode re-registrations are invisible.
  • The latch lives on the detailed-view store, keyed by artifact id, cleared on thread switch.
  • useArtifactAutoOpen exists 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 the artifactViewMode prop plus the ArtifactViewMode type, and the hook goes public in the PR that consumes it.
  • Evaluation is O(registered ids) with zero parsing; already-claimed ids short-circuit.

Known narrow edges (accepted, documented): an artifact whose header only arrives in the run's final flush (buffered/one-chunk transports) registers after isRunning clears 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 (artifactViewMode set locally), Playwright-driven with zero artifact clicks:

  • Report auto-opened on first registration mid-stream; closed mid-stream → stayed closed through stream end.
  • Edit stayed quiet (mid-stream and at completion) — panel content still reflects the edit when opened.
  • Reload + reopening the artifact thread → quiet; a new artifact (new id) in the same thread auto-opened.
  • open-on-mount: a settled thread presented its artifact on load; close stuck; a non-artifact thread stayed quiet.
  • react-headless: 128 tests green (10 registration-trigger tests: claim-once, edit-quiet, historical-burn, first-wins ×2, latest-version, overview-inert, reset-re-arms); typecheck + eslint clean. react-ui: typecheck, 27 tests, build clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_011nPH2Rn2XGXETgx5Yu5seX

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
Comment on lines +21 to 30
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;
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain this part?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 → return false, 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, gets false, and does NOT re-open over the close.
  • Otherwise new Set(keys).add(key) — a copy, not keys.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.

Comment on lines +38 to +51
/**
* 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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you also explain this part?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +153 to +171
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]);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you tell me why we are doing it here? We can do this in react-headless

Just trying to understand what is happening ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 —

  1. 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.
  2. viewId — derived in this component, including the useId() fallback for early frames before meta lands. useId is a React render-tree concern; headless can't produce it.
  3. isStreaming for this mounted activity — the reload-safety gate (historical activities mount as complete).

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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; useArtifactAutoOpen stays 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}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 — and note it's Omit<ChatProviderProps, "children"> inheritance, so the prop rides the existing type: no separate AgentInterface-side type to keep in sync.

ankit-thesys and others added 4 commits July 28, 2026 20:01
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
@ankit-thesys

ankit-thesys commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Semantics + architecture update (a4ec3537), decided in review: the trigger is now artifact registration, and the policy is "present once" — an artifact may auto-open exactly one time, when its id first registers, and only into an empty panel.

What changed vs the previous revision:

  • Edits no longer re-open. An edit shares its generate's artifact id, whose one chance is already spent. An open panel still follows edit versions live (existing follow effect); a closed one stays closed — the user is never interrupted twice by the same artifact.
  • First wins. An open panel is never stolen — not by a second artifact in the same turn, not by anything.
  • Simpler machinery. The watcher subscribes to the ThreadContext registry instead of walking messages: no parsing at the store layer, no shared parser envelope needed (runArtifactRenderer deleted), and ToolActivityRenderer is back to exactly its pre-branch shape. Net −100 lines.

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.

ankit-thesys and others added 2 commits July 31, 2026 06:10
…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>
Comment on lines 35 to 44
_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). */

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is happening here and why are we changing this ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant