Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
016d0df
feat: artifactViewMode — opt-in auto-open for artifact detail panels
ankit-thesys Jul 28, 2026
4dd3c4c
docs: latch keys are tool-call ids, not artifact id:version
ankit-thesys Jul 28, 2026
12758b7
refactor: move the auto-open behavior into a react-headless hook
ankit-thesys Jul 28, 2026
00a8164
refactor: drive artifactViewMode from a ChatProvider watcher — zero U…
ankit-thesys Jul 29, 2026
a4ec353
refactor: present-once auto-open driven by artifact registration
ankit-thesys Jul 30, 2026
bdc7a5b
refactor: types-only public surface; once-latch on the store for all …
ankit-thesys Jul 31, 2026
834d0a2
refactor: strip comments from artifact view mode files
ankit-thesys Jul 31, 2026
a87874d
refactor: drop unused useArtifactAutoOpen hook, fold shouldAutoOpen i…
ankit-thesys Aug 4, 2026
813be89
refactor: drop unused ArtifactViewModeContext
ankit-thesys Aug 5, 2026
0b751b9
refactor!: replace artifactViewMode with boolean autoOpenArtifact prop
ankit-thesys Aug 5, 2026
eb58c9d
refactor!: rename prop to artifactAutoOpen, default on
ankit-thesys Aug 5, 2026
12b86ae
docs: human-readable comments on the auto-open watcher
ankit-thesys Aug 5, 2026
ba3f647
refactor: move the auto-open latch into the watcher; store untouched
ankit-thesys Aug 6, 2026
18a279b
style: indexed for loop in evaluateRegisteredArtifacts
ankit-thesys Aug 6, 2026
97c8717
docs: annotate watcher guards and registry shape
ankit-thesys Aug 6, 2026
eeab0ba
style: rename allVersionLists to artifactsWithVersions
ankit-thesys Aug 6, 2026
5644e40
refactor: single source of truth for the artifact view-id format
ankit-thesys Aug 6, 2026
fd85a15
feat: a new stream's artifact takes over an already-open panel
ankit-thesys Aug 6, 2026
3d6bde6
feat: edits re-open the panel — claim keyed by id:version
ankit-thesys Aug 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/react-headless/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export {
} from "./store/ArtifactRenderersContext";
export { defineArtifactRenderer } from "./store/artifactRendererTypes";
export { useArtifactStorage } from "./store/ArtifactStorageContext";
export { artifactViewId, parseArtifactViewId } from "./store/artifactViewId";
export { ChatProvider } from "./store/ChatProvider";
export { DetailedViewContext, useDetailedViewStore } from "./store/DetailedViewContext";
export { ThreadContextContext, useThreadContextStore } from "./store/ThreadContextContext";
Expand Down
9 changes: 9 additions & 0 deletions packages/react-headless/src/store/ChatProvider.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,6 +23,7 @@ export const ChatProvider: FC<ChatProviderProps> = ({
llm,
artifactRenderers,
artifactCategories,
artifactAutoOpen,
}) => {
const [resolvedStorage] = useState(() => storage ?? createDefaultInMemoryStorage());
const [chatStore] = useState(() => createChatStore({ storage: resolvedStorage, llm }));
Expand Down Expand Up @@ -64,6 +66,13 @@ export const ChatProvider: FC<ChatProviderProps> = ({
return unsubscribe;
}, [chatStore, detailedViewStore, threadContextStore]);

useArtifactAutoOpenWatcher(
artifactAutoOpen ?? true,
chatStore,
threadContextStore,
detailedViewStore,
);

return (
<ChatContext.Provider value={chatStore}>
<DetailedViewContext.Provider value={detailedViewStore}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, expect, it } from "vitest";
import { evaluateRegisteredArtifacts } from "../artifactAutoOpenWatcher";
import { createDetailedViewStore } from "../createDetailedViewStore";
import type { ArtifactEntry } from "../threadContextTypes";

const entry = (id: string, version = 1): ArtifactEntry => ({
id,
version,
heading: `${id} v${version}`,
type: "test_artifact",
});

const registry = (...entries: ArtifactEntry[]): Record<string, ArtifactEntry[]> => {
const out: Record<string, ArtifactEntry[]> = {};
for (const e of entries) (out[e.id] ??= []).push(e);
return out;
};

describe("evaluateRegisteredArtifacts", () => {
it("opens a newly registered artifact while the stream runs", () => {
const store = createDetailedViewStore();
const claimed = new Set<string>();
const opened = evaluateRegisteredArtifacts(registry(entry("art")), true, claimed, store);
expect(opened).toBe(true);
expect(store.getState().activeDetailedViewId).toBe("art:1");
expect(claimed.has("art:1")).toBe(true);
});

it("a user close sticks across re-registrations", () => {
const store = createDetailedViewStore();
const claimed = new Set<string>();
const arts = registry(entry("art"));
evaluateRegisteredArtifacts(arts, true, claimed, store);
store.getState().setActiveDetailedView(null);
const opened = evaluateRegisteredArtifacts(arts, true, claimed, store);
expect(opened).toBe(false);
expect(store.getState().activeDetailedViewId).toBeNull();
});

it("an edit re-opens: a new version gets a fresh chance, even after a close", () => {
const store = createDetailedViewStore();
const claimed = new Set<string>();
evaluateRegisteredArtifacts(registry(entry("art", 1)), true, claimed, store);
store.getState().setActiveDetailedView(null);
const opened = evaluateRegisteredArtifacts(
registry(entry("art", 1), entry("art", 2)),
true,
claimed,
store,
);
expect(opened).toBe(true);
expect(store.getState().activeDetailedViewId).toBe("art:2");
});

it("the same version never re-opens after a close", () => {
const store = createDetailedViewStore();
const claimed = new Set<string>();
const arts = registry(entry("art", 2));
evaluateRegisteredArtifacts(arts, true, claimed, store);
expect(store.getState().activeDetailedViewId).toBe("art:2");
store.getState().setActiveDetailedView(null);
expect(evaluateRegisteredArtifacts(arts, true, claimed, store)).toBe(false);
expect(store.getState().activeDetailedViewId).toBeNull();
});

it("opens the latest registered version of an id", () => {
const store = createDetailedViewStore();
evaluateRegisteredArtifacts(registry(entry("art", 1), entry("art", 3)), true, new Set(), store);
expect(store.getState().activeDetailedViewId).toBe("art:3");
});

it("mayOpen=false (nothing streaming): never opens, still claims", () => {
const store = createDetailedViewStore();
const claimed = new Set<string>();
const arts = registry(entry("old"));
const opened = evaluateRegisteredArtifacts(arts, false, claimed, store);
expect(opened).toBe(false);
expect(store.getState().activeDetailedViewId).toBeNull();
expect(claimed.has("old:1")).toBe(true);
evaluateRegisteredArtifacts(arts, true, claimed, store);
expect(store.getState().activeDetailedViewId).toBeNull();
});

it("only the first artifact of a pass opens; the second is claimed but ignored", () => {
const store = createDetailedViewStore();
const claimed = new Set<string>();
const arts = registry(entry("a1"), entry("a2"));
const opened = evaluateRegisteredArtifacts(arts, true, claimed, store);
expect(opened).toBe(true);
expect(store.getState().activeDetailedViewId).toBe("a1:1");
expect(claimed.has("a2:1")).toBe(true);
store.getState().setActiveDetailedView(null);
expect(evaluateRegisteredArtifacts(arts, true, claimed, store)).toBe(false);
expect(store.getState().activeDetailedViewId).toBeNull();
});

it("a new artifact takes over an already-open panel", () => {
const store = createDetailedViewStore();
const claimed = new Set<string>();
store.getState().setActiveDetailedView("user-panel");
const opened = evaluateRegisteredArtifacts(registry(entry("art")), true, claimed, store);
expect(opened).toBe(true);
expect(store.getState().activeDetailedViewId).toBe("art:1");
});

it("thread switch (fresh claim set) re-arms for the next thread", () => {
const store = createDetailedViewStore();
const arts = registry(entry("art"));
evaluateRegisteredArtifacts(arts, true, new Set(), store);
expect(store.getState().activeDetailedViewId).toBe("art:1");
store.getState().reset();
evaluateRegisteredArtifacts(arts, true, new Set(), store);
expect(store.getState().activeDetailedViewId).toBe("art:1");
});
});
22 changes: 22 additions & 0 deletions packages/react-headless/src/store/__tests__/artifactViewId.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { artifactViewId, parseArtifactViewId } from "../artifactViewId";

describe("artifactViewId", () => {
it("builds id:version", () => {
expect(artifactViewId("art-1", 3)).toBe("art-1:3");
});

it("round-trips through parse", () => {
expect(parseArtifactViewId(artifactViewId("art-1", 3))).toEqual({ id: "art-1", version: 3 });
});

it("handles artifact ids that contain colons", () => {
expect(parseArtifactViewId(artifactViewId("ns:art", 2))).toEqual({ id: "ns:art", version: 2 });
});

it("returns null for non-artifact view ids", () => {
expect(parseArtifactViewId("custom-panel")).toBeNull();
expect(parseArtifactViewId("art:not-a-number")).toBeNull();
expect(parseArtifactViewId(":r1:")).toBeNull();
});
});
105 changes: 105 additions & 0 deletions packages/react-headless/src/store/artifactAutoOpenWatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useEffect, useRef } from "react";
import { artifactViewId } from "./artifactViewId";
import type { createChatStore } from "./createChatStore";
import type { createDetailedViewStore } from "./createDetailedViewStore";
import type { createThreadContextStore } from "./createThreadContextStore";
import type { ArtifactEntry } from "./threadContextTypes";

/**
* One pass over every artifact registered in the thread. Each artifact
* version (`id:version`) gets a single chance to auto-open — recorded in
* `claimedIds` the first time we see it, whether or not it opens — so an
* edit's new version qualifies again. Returns true when a panel opened.
*/
export function evaluateRegisteredArtifacts(
artifacts: Record<string, ArtifactEntry[]>, // all artifacts in the thread context: id → versions, ascending
mayOpen: boolean, // "is opening allowed right now" — streaming and nothing opened yet this stream
claimedIds: Set<string>, // "id:version" keys that already used their chance (cleared per thread)
detailedViewStore: ReturnType<typeof createDetailedViewStore>,
): boolean {
let opened = false;
// artifacts = {
// "report-abc": [ {id:"report-abc", version:1, …}, {id:"report-abc", version:2, …} ],
// "deck-xyz": [ {id:"deck-xyz", version:1, …} ],
// }
// artifactsWithVersions = [
// [ {id:"report-abc", version:1, …}, {id:"report-abc", version:2, …} ],
// [ {id:"deck-xyz", version:1, …} ],
// ]
const artifactsWithVersions = Object.values(artifacts);
for (let i = 0; i < artifactsWithVersions.length; i++) {
const versions = artifactsWithVersions[i];
// versions = [ {id:"report-abc", version:1, …}, {id:"report-abc", version:2, …} ]
// no list at this index (can't happen; strict TS guard)
if (!versions) continue;
// versions are sorted ascending, so the last entry is the newest one
// latest = {id:"report-abc", version:2, …}
const latest = versions[versions.length - 1];
// no versions at all (can't happen in practice; keeps strict TS happy)
if (!latest) continue;
// claim keyed by id:version so an EDIT (new version, same id) gets a
// fresh chance to open, while re-registrations of the same version are
// ignored (a user's close sticks)
const key = artifactViewId(latest.id, latest.version);
if (claimedIds.has(key)) continue;
claimedIds.add(key);
// allowed to open right now? and only one open per pass
if (!mayOpen || opened) continue;
// takes over the panel even if it is already open: a new stream's first
// artifact (or a streaming edit) re-points an open panel to itself
detailedViewStore.getState().setActiveDetailedView(key);
opened = true;
}
return opened;
}

/**
* Runs inside ChatProvider: re-evaluates whenever an artifact registers in
* the thread context, opening at most one artifact per stream.
*/
export function useArtifactAutoOpenWatcher(
artifactAutoOpen: boolean,
chatStore: ReturnType<typeof createChatStore>,
threadContextStore: ReturnType<typeof createThreadContextStore>,
detailedViewStore: ReturnType<typeof createDetailedViewStore>,
): void {
// "did this stream already auto-open something" — re-armed on each new stream
const openedThisRunRef = useRef(false);
// id:version keys that used their one auto-open chance — cleared on thread switch
const claimedIdsRef = useRef(new Set<string>());

useEffect(() => {
if (!artifactAutoOpen) return;
const unsubscribeThread = chatStore.subscribe(
(s) => s.selectedThreadId,
() => {
claimedIdsRef.current.clear();
openedThisRunRef.current = false;
},
);
const unsubscribeRun = chatStore.subscribe(
(s) => s.isRunning,
(isRunning) => {
if (isRunning) openedThisRunRef.current = false;
},
);
const unsubscribeArtifacts = threadContextStore.subscribe(
(s) => s.artifacts,
(artifacts) => {
const mayOpen = chatStore.getState().isRunning && !openedThisRunRef.current;
if (
evaluateRegisteredArtifacts(artifacts, mayOpen, claimedIdsRef.current, detailedViewStore)
) {
openedThisRunRef.current = true;
}
},
// also evaluate artifacts that registered before this watcher mounted
{ fireImmediately: true },
);
return () => {
unsubscribeThread();
unsubscribeRun();
unsubscribeArtifacts();
};
}, [artifactAutoOpen, chatStore, threadContextStore, detailedViewStore]);
}
21 changes: 21 additions & 0 deletions packages/react-headless/src/store/artifactViewId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* Builds the detailed-view id for an artifact version. This is the contract
* between everything that opens artifact panels (auto-open watcher, workspace
* rail) and the renderer that registers them — always build/read the id
* through these helpers, never hand-roll the string.
*/
export function artifactViewId(id: string, version: number): string {
return `${id}:${version}`;
}

/**
* Splits a detailed-view id back into artifact id + version. Returns null
* when the string isn't an artifact view id (e.g. a useId fallback).
*/
export function parseArtifactViewId(viewId: string): { id: string; version: number } | null {
const sep = viewId.lastIndexOf(":");
if (sep <= 0) return null;
const versionPart = viewId.slice(sep + 1);
if (!/^\d+$/.test(versionPart)) return null;
return { id: viewId.slice(0, sep), version: Number(versionPart) };
}
1 change: 1 addition & 0 deletions packages/react-headless/src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,5 +91,6 @@ export interface ChatProviderProps {
* artifact browser's pre-applied filters, and workspace section grouping.
*/
artifactCategories?: ArtifactCategory[];
artifactAutoOpen?: boolean;
children: React.ReactNode;
}
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfacePr
llm,
artifactRenderers,
artifactCategories,
artifactAutoOpen,
componentLibrary,
components,
theme,
Expand Down Expand Up @@ -240,6 +241,7 @@ export const AgentInterface: AgentInterfaceComponent = ((props: AgentInterfacePr
llm={llm}
artifactRenderers={artifactRenderers}
artifactCategories={artifactCategories}
artifactAutoOpen={artifactAutoOpen}
>
<NavProvider path={path} defaultPath={defaultPath} onNavigate={onNavigate}>
<StartersProvider starters={starters} starterVariant={starterVariant}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
artifactViewId,
useActiveDetailedView,
useArtifactCategories,
useArtifactList,
Expand Down Expand Up @@ -156,7 +157,7 @@ const WorkspaceSection = ({
};

const WorkspaceItem = ({ entry }: { entry: ArtifactEntry }) => {
const viewId = `${entry.id}:${entry.version}`;
const viewId = artifactViewId(entry.id, entry.version);
const { isActive } = useDetailedView(viewId);
const store = useDetailedViewStore();
const onClick = () => store.getState().setActiveDetailedView(viewId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function withChatProvider<ExtraProps = {}>(WrappedComponent: React.Compon
llm,
artifactRenderers,
artifactCategories,
artifactAutoOpen,
theme,
disableThemeProvider,
...innerProps
Expand Down Expand Up @@ -65,6 +66,7 @@ export function withChatProvider<ExtraProps = {}>(WrappedComponent: React.Compon
llm={llm}
artifactRenderers={artifactRenderers}
artifactCategories={artifactCategories}
artifactAutoOpen={artifactAutoOpen}
>
<WrappedComponent {...finalInnerProps} />
</ChatProvider>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import {
artifactViewId,
parseArtifactViewId,
useDetailedView,
useDetailedViewStore,
useThreadContextStore,
Expand Down Expand Up @@ -89,7 +91,7 @@ export function ToolActivityRenderer<Props>({

// viewId derives from meta when present, otherwise from React's useId so
// `controls.open` still works for an inline-only renderer.
const viewId = meta ? `${meta.id}:${meta.version}` : fallbackId;
const viewId = meta ? artifactViewId(meta.id, meta.version) : fallbackId;

// Register entry on mount; unregister on unmount or when (id, version) changes.
useEffect(() => {
Expand Down Expand Up @@ -126,9 +128,10 @@ export function ToolActivityRenderer<Props>({
if (!meta) return;
const dv = dvStore.getState();
const active = dv.activeDetailedViewId;
if (!active || active === viewId || !active.startsWith(`${meta.id}:`)) return;
const activeVersion = Number(active.slice(meta.id.length + 1));
if (!Number.isFinite(activeVersion) || meta.version > activeVersion) {
if (!active || active === viewId) return;
const parsed = parseArtifactViewId(active);
if (!parsed || parsed.id !== meta.id) return;
if (meta.version > parsed.version) {
dv.setActiveDetailedView(viewId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
Expand Down
Loading