No artifacts yet. Files the agent writes to{" "}
@@ -407,7 +757,17 @@ export function ArtifactsPanel(props: { workspaceId: string; inDialog?: boolean
body = (
<>
{artbar}
- {listing.truncated && (
+ {actionError != null && (
+
+ {actionError}
+
+ )}
+ {listError != null && (
+
+ {listError}
+
+ )}
+ {listing?.available === true && listing.truncated && (
Some files are not shown.
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/MarkdownArtifact.tsx b/src/browser/features/RightSidebar/ArtifactsTab/MarkdownArtifact.tsx
index 53d2c98bc18..ee571c8bb03 100644
--- a/src/browser/features/RightSidebar/ArtifactsTab/MarkdownArtifact.tsx
+++ b/src/browser/features/RightSidebar/ArtifactsTab/MarkdownArtifact.tsx
@@ -10,7 +10,12 @@ import { useArtifactAssetReader } from "./useArtifactAssetReader";
* through the artifacts API (base64 -> data: URL); remote images keep MarkdownRenderer's usual
* handling. Images that cannot be read are left as written.
*/
-export function MarkdownArtifact(props: { content: string; path: string; workspaceId: string }) {
+export function MarkdownArtifact(props: {
+ content: string;
+ path: string;
+ /** null: leave relative images as written (see useArtifactAssetReader). */
+ workspaceId: string | null;
+}) {
const read = useArtifactAssetReader(props.workspaceId);
const relativeImages = findRelativeMarkdownImages(props.content);
const hasRelativeImages = relativeImages.length > 0;
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/OpenAsArtifactButton.tsx b/src/browser/features/RightSidebar/ArtifactsTab/OpenAsArtifactButton.tsx
new file mode 100644
index 00000000000..c6010aacbff
--- /dev/null
+++ b/src/browser/features/RightSidebar/ArtifactsTab/OpenAsArtifactButton.tsx
@@ -0,0 +1,53 @@
+import React from "react";
+import { PanelRight } from "lucide-react";
+import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip";
+import { useOptionalAPI } from "@/browser/contexts/API";
+import { useChatHostContext } from "@/browser/contexts/ChatHostContext";
+import { useExperimentValue } from "@/browser/hooks/useExperiments";
+import { EXPERIMENT_IDS } from "@/common/constants/experiments";
+import { cn } from "@/common/lib/utils";
+import { pinAndOpenArtifact } from "./openArtifact";
+
+/**
+ * "Open as artifact" for a checkout file (Review headers, file_read/file_edit cards): pins the
+ * file and shows it live in the Artifacts tab. Renders nothing while the experiment is off.
+ */
+export function OpenAsArtifactButton(props: {
+ workspaceId: string | undefined;
+ /** Absolute inside the checkout, or relative to it (or to the tool cwd, see `relativeTo`). */
+ path: string;
+ /**
+ * File tool cards pass "tool-cwd": their relative paths resolve from the tool's cwd (a
+ * sub-project dir), not the checkout root that Review paths use.
+ */
+ relativeTo?: "tool-cwd" | "checkout";
+ className?: string;
+}) {
+ // Hosts without an Artifacts surface (VS Code) cannot open it.
+ const experimentOn = useExperimentValue(EXPERIMENT_IDS.ARTIFACTS);
+ const panelSupported = useChatHostContext().uiSupport.artifactsPanel === "supported";
+ const enabled = experimentOn && panelSupported;
+ // Optional: tool cards and Review hunks also render in isolated stories and tests.
+ const api = useOptionalAPI()?.api;
+ const workspaceId = props.workspaceId;
+ if (!enabled || !api || !workspaceId || props.path.length === 0) return null;
+ return (
+
+
+
+ );
+}
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/SandboxedArtifactFrame.tsx b/src/browser/features/RightSidebar/ArtifactsTab/SandboxedArtifactFrame.tsx
index 2267a7595ac..350c1bdf785 100644
--- a/src/browser/features/RightSidebar/ArtifactsTab/SandboxedArtifactFrame.tsx
+++ b/src/browser/features/RightSidebar/ArtifactsTab/SandboxedArtifactFrame.tsx
@@ -47,7 +47,8 @@ interface BuiltDocument {
* artifactDocument.ts and artifactBridge.ts for the layered rules.
*/
export function SandboxedArtifactFrame(props: {
- workspaceId: string;
+ /** null: no relative assets (see useArtifactAssetReader). */
+ workspaceId: string | null;
path: string;
kind: "html" | "svg";
content: string;
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/artifactSelection.ts b/src/browser/features/RightSidebar/ArtifactsTab/artifactSelection.ts
index a91a574842f..292acbff49d 100644
--- a/src/browser/features/RightSidebar/ArtifactsTab/artifactSelection.ts
+++ b/src/browser/features/RightSidebar/ArtifactsTab/artifactSelection.ts
@@ -9,25 +9,38 @@ import {
ARTIFACTS_SELECTION_MAX_WORKSPACES,
} from "@/common/constants/storage";
-/** The Artifacts tab selection of one workspace: an artifacts-relative path. */
+/** What the Artifacts tab's selected path points at. */
+export type ArtifactSelectionScope = "artifact" | "pinned";
+
+/**
+ * The Artifacts tab selection of one workspace: an artifacts-relative path, or a
+ * checkout-relative path when `pinned`; `version` null means "Latest (live)".
+ */
export interface ArtifactSelection {
+ scope: ArtifactSelectionScope;
path: string | null;
+ version: number | null;
}
/** Workspace id -> selection; key order is least to most recently written. */
type ArtifactSelectionMap = Record
;
-const DEFAULT_SELECTION: ArtifactSelection = { path: null };
+const DEFAULT_SELECTION: ArtifactSelection = { scope: "artifact", path: null, version: null };
const EMPTY_MAP: ArtifactSelectionMap = {};
-// One global most-recently-used map instead of a key per workspace: per-workspace keys
+// One global most-recently-used map instead of three keys per workspace: per-workspace keys
// multiply by every workspace the user ever opened and overflowed the localStorage budget
// (persistedStateBudget.test.ts).
function sanitize(raw: unknown): ArtifactSelection {
if (typeof raw !== "object" || raw === null) return DEFAULT_SELECTION;
const value = raw as Partial>;
+ const scope = value.scope === "pinned" ? value.scope : ("artifact" as const);
const path = typeof value.path === "string" && value.path.length > 0 ? value.path : null;
- return { path };
+ const version =
+ typeof value.version === "number" && Number.isInteger(value.version) && value.version > 0
+ ? value.version
+ : null;
+ return { scope, path, version };
}
function selectionFrom(map: unknown, workspaceId: string): ArtifactSelection {
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/mcpAppViewsStore.ts b/src/browser/features/RightSidebar/ArtifactsTab/mcpAppViewsStore.ts
index 4e8c7caee01..340a96b5cdd 100644
--- a/src/browser/features/RightSidebar/ArtifactsTab/mcpAppViewsStore.ts
+++ b/src/browser/features/RightSidebar/ArtifactsTab/mcpAppViewsStore.ts
@@ -55,7 +55,11 @@ export function openMcpAppView(workspaceId: string, view: McpAppViewRef) {
const current = getMcpAppViews(workspaceId).filter((v) => v.toolCallId !== view.toolCallId);
viewsByWorkspace.set(workspaceId, [view, ...current]);
emit();
- writeArtifactSelection(workspaceId, { path: mcpAppSelectionKey(view.toolCallId) });
+ writeArtifactSelection(workspaceId, {
+ scope: "artifact",
+ path: mcpAppSelectionKey(view.toolCallId),
+ version: null,
+ });
window.dispatchEvent(
createCustomEvent(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, { workspaceId, toolCallId: view.toolCallId })
);
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/openArtifact.test.ts b/src/browser/features/RightSidebar/ArtifactsTab/openArtifact.test.ts
new file mode 100644
index 00000000000..9731b1ac7f4
--- /dev/null
+++ b/src/browser/features/RightSidebar/ArtifactsTab/openArtifact.test.ts
@@ -0,0 +1,123 @@
+import "../../../../../tests/ui/dom";
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { installDom } from "../../../../../tests/ui/dom";
+import { createTestApiClient } from "@/browser/testUtils";
+import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events";
+import { readArtifactSelection, writeArtifactSelection } from "./artifactSelection";
+import { openArtifact, pinAndOpenArtifact } from "./openArtifact";
+
+type OpenDetail = CustomEventPayloads[typeof CUSTOM_EVENTS.OPEN_ARTIFACT];
+const WS = "ws-open";
+
+/** Records each OPEN_ARTIFACT with the selection persisted at dispatch time. */
+function recordOpens() {
+ const opens: Array<{ detail: OpenDetail; persisted: unknown[] }> = [];
+ const toasts: string[] = [];
+ const onOpen = (event: Event) => {
+ opens.push({
+ detail: (event as CustomEvent).detail,
+ persisted: [
+ readArtifactSelection(WS).scope,
+ readArtifactSelection(WS).path,
+ readArtifactSelection(WS).version,
+ ],
+ });
+ };
+ const onToast = (event: Event) => {
+ toasts.push((event as CustomEvent<{ message: string }>).detail.message);
+ };
+ window.addEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, onOpen);
+ window.addEventListener(CUSTOM_EVENTS.ANALYTICS_REBUILD_TOAST, onToast);
+ return {
+ opens,
+ toasts,
+ stop: () => {
+ window.removeEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, onOpen);
+ window.removeEventListener(CUSTOM_EVENTS.ANALYTICS_REBUILD_TOAST, onToast);
+ },
+ };
+}
+
+describe("openArtifact", () => {
+ let cleanupDom: (() => void) | null = null;
+ let recorder: ReturnType | null = null;
+
+ beforeEach(() => {
+ cleanupDom = installDom();
+ window.localStorage.clear();
+ recorder = recordOpens();
+ });
+
+ afterEach(() => {
+ recorder?.stop();
+ cleanupDom?.();
+ cleanupDom = null;
+ });
+
+ test("persists the selection before asking for the tab", () => {
+ openArtifact({ workspaceId: WS, path: "charts/q3.html", versionId: 3 });
+ expect(recorder?.opens).toEqual([
+ {
+ detail: { workspaceId: WS, path: "charts/q3.html", versionId: 3 },
+ persisted: ["artifact", "charts/q3.html", 3],
+ },
+ ]);
+ });
+
+ test("a pinned file always opens live, dropping a previously selected version", () => {
+ writeArtifactSelection(WS, { version: 7 });
+ openArtifact({ workspaceId: WS, path: "src/app.ts", versionId: 2, pinned: true });
+ expect(recorder?.opens).toEqual([
+ {
+ detail: { workspaceId: WS, path: "src/app.ts", pinned: true },
+ persisted: ["pinned", "src/app.ts", null],
+ },
+ ]);
+ });
+
+ test("pinAndOpenArtifact opens the checkout-relative path the backend stored", async () => {
+ const api = createTestApiClient({
+ artifacts: {
+ pinFile: () => Promise.resolve({ success: true as const, data: { path: "docs/a.md" } }),
+ },
+ });
+ await pinAndOpenArtifact(api, WS, "/home/me/repo/docs/a.md");
+ expect(recorder?.opens.map((open) => open.detail)).toEqual([
+ { workspaceId: WS, path: "docs/a.md", pinned: true },
+ ]);
+ expect(recorder?.toasts).toEqual([]);
+ });
+
+ test("pinAndOpenArtifact reports a refused pin instead of opening", async () => {
+ const api = createTestApiClient({
+ artifacts: {
+ pinFile: () =>
+ Promise.resolve({ success: false as const, error: "Path is outside the checkout" }),
+ },
+ });
+ await pinAndOpenArtifact(api, WS, "/etc/passwd");
+ expect(recorder?.opens).toEqual([]);
+ expect(recorder?.toasts).toEqual(["Path is outside the checkout"]);
+ });
+
+ test("pinAndOpenArtifact: only the latest request in a workspace opens", async () => {
+ const resolvers = new Map void>();
+ const api = createTestApiClient({
+ artifacts: {
+ pinFile: (input: { workspaceId: string; path: string }) =>
+ new Promise<{ success: true; data: { path: string } }>((resolve) => {
+ resolvers.set(input.path, (path) => resolve({ success: true, data: { path } }));
+ }),
+ },
+ });
+ const first = pinAndOpenArtifact(api, WS, "first.md");
+ const second = pinAndOpenArtifact(api, WS, "second.md");
+ resolvers.get("second.md")?.("second.md");
+ await second;
+ resolvers.get("first.md")?.("first.md");
+ await first;
+ expect(recorder?.opens.map((open) => open.detail.path)).toEqual(["second.md"]);
+ expect(readArtifactSelection(WS).path).toBe("second.md");
+ });
+});
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/openArtifact.ts b/src/browser/features/RightSidebar/ArtifactsTab/openArtifact.ts
new file mode 100644
index 00000000000..62a2ecfae02
--- /dev/null
+++ b/src/browser/features/RightSidebar/ArtifactsTab/openArtifact.ts
@@ -0,0 +1,108 @@
+import type { APIClient } from "@/browser/contexts/API";
+import { createCustomEvent, CUSTOM_EVENTS } from "@/common/constants/events";
+import assert from "@/common/utils/assert";
+import { getErrorMessage } from "@/common/utils/errors";
+import { readArtifactSelection, writeArtifactSelection } from "./artifactSelection";
+
+export interface OpenArtifactTarget {
+ workspaceId: string;
+ /** Artifacts-relative path, or a checkout-relative path when `pinned`. */
+ path: string;
+ /** Version to show; null/absent means "Latest (live)". Ignored for pinned files. */
+ versionId?: number | null;
+ /** A pinned workspace file (Concept C) rather than an artifact. */
+ pinned?: boolean;
+}
+
+/**
+ * The one way to open the Artifacts tab on something (chat cards, attach_file, file cards,
+ * Review, palette). The selection is persisted first, so the panel (which reads the same state
+ * with a listener) shows it whether it is already mounted or mounts because of the event;
+ * RightSidebar (or the small-viewport dialog) only has to bring the tab into view.
+ */
+export function openArtifact(target: OpenArtifactTarget): void {
+ assert(target.workspaceId.length > 0, "openArtifact: workspaceId is required");
+ assert(target.path.length > 0, "openArtifact: path is required");
+ const pinned = target.pinned === true;
+ const versionId = pinned ? null : (target.versionId ?? null);
+ assert(
+ versionId == null || (Number.isInteger(versionId) && versionId > 0),
+ "openArtifact: versionId must be a positive integer"
+ );
+ writeArtifactSelection(target.workspaceId, {
+ scope: pinned ? "pinned" : "artifact",
+ path: target.path,
+ version: versionId,
+ });
+ window.dispatchEvent(
+ createCustomEvent(CUSTOM_EVENTS.OPEN_ARTIFACT, {
+ workspaceId: target.workspaceId,
+ path: target.path,
+ ...(versionId != null ? { versionId } : {}),
+ ...(pinned ? { pinned: true } : {}),
+ })
+ );
+}
+
+/**
+ * Bring the Artifacts tab into view without changing its selection (palette "Open Artifacts").
+ * The event still carries the current selection so its payload stays truthful; the handlers
+ * only use the workspace id.
+ */
+export function openArtifactsTab(workspaceId: string): void {
+ assert(workspaceId.length > 0, "openArtifactsTab: workspaceId is required");
+ const { path, scope, version: versionId } = readArtifactSelection(workspaceId);
+ window.dispatchEvent(
+ createCustomEvent(CUSTOM_EVENTS.OPEN_ARTIFACT, {
+ workspaceId,
+ path: path ?? "",
+ ...(versionId != null && scope === "artifact" ? { versionId } : {}),
+ ...(scope === "pinned" ? { pinned: true } : {}),
+ })
+ );
+}
+
+/** Latest pin-and-open request per workspace; an older response must not open over it. */
+const latestPinRequest = new Map();
+let nextPinRequestId = 0;
+
+/**
+ * "Open as artifact" for any checkout file: pin it (absolute inside the checkout or relative),
+ * then open the stored checkout-relative path. Failures surface as a chat toast, the same
+ * channel the command palette uses for feedback. `relativeTo: "tool-cwd"` resolves a relative
+ * path like file tools do (a sub-project's cwd), instead of from the checkout root.
+ */
+export async function pinAndOpenArtifact(
+ api: APIClient,
+ workspaceId: string,
+ path: string,
+ options?: { relativeTo?: "tool-cwd" | "checkout" }
+): Promise {
+ const requestId = ++nextPinRequestId;
+ latestPinRequest.set(workspaceId, requestId);
+ const isLatest = () => latestPinRequest.get(workspaceId) === requestId;
+ let error: string;
+ try {
+ const result = await api.artifacts.pinFile({
+ workspaceId,
+ path,
+ ...(options?.relativeTo != null ? { relativeTo: options.relativeTo } : {}),
+ });
+ if (!isLatest()) return;
+ if (result.success) {
+ openArtifact({ workspaceId, path: result.data.path, pinned: true });
+ return;
+ }
+ error = result.error;
+ } catch (caught: unknown) {
+ if (!isLatest()) return;
+ error = getErrorMessage(caught);
+ }
+ window.dispatchEvent(
+ createCustomEvent(CUSTOM_EVENTS.ANALYTICS_REBUILD_TOAST, {
+ type: "error",
+ title: "Could not open as artifact",
+ message: error,
+ })
+ );
+}
diff --git a/src/browser/features/RightSidebar/ArtifactsTab/useArtifactAssetReader.ts b/src/browser/features/RightSidebar/ArtifactsTab/useArtifactAssetReader.ts
index e56dbdf06ee..1a4961b3241 100644
--- a/src/browser/features/RightSidebar/ArtifactsTab/useArtifactAssetReader.ts
+++ b/src/browser/features/RightSidebar/ArtifactsTab/useArtifactAssetReader.ts
@@ -1,10 +1,14 @@
import { useAPI } from "@/browser/contexts/API";
import { ARTIFACT_ASSET_LIMITS, type ArtifactAssetReader } from "./artifactAssets";
-/** Reader for files an artifact references, through the same size-capped artifacts.read route. */
-export function useArtifactAssetReader(workspaceId: string): ArtifactAssetReader | null {
+/**
+ * Reader for files an artifact references, through the same size-capped artifacts.read route.
+ * A null workspaceId turns relative assets off (pinned checkout files: their neighbours are not
+ * in the artifacts folder and need not be pinned).
+ */
+export function useArtifactAssetReader(workspaceId: string | null): ArtifactAssetReader | null {
const { api } = useAPI();
- if (!api) return null;
+ if (!api || workspaceId == null) return null;
return async (path) => {
// The per-asset cap travels with the read, so an oversize asset comes back too_large
// without its bytes instead of being transferred and dropped here.
diff --git a/src/browser/features/RightSidebar/CodeReview/HunkViewer.tsx b/src/browser/features/RightSidebar/CodeReview/HunkViewer.tsx
index f7cf72c6008..1bb34905cdb 100644
--- a/src/browser/features/RightSidebar/CodeReview/HunkViewer.tsx
+++ b/src/browser/features/RightSidebar/CodeReview/HunkViewer.tsx
@@ -24,6 +24,7 @@ import { formatRelativeTime } from "@/browser/utils/ui/dateTime";
import { cn } from "@/common/lib/utils";
import { ContextCollapseIndicator } from "./ContextCollapseIndicator";
import { useReadMore } from "./useReadMore";
+import { OpenAsArtifactButton } from "../ArtifactsTab/OpenAsArtifactButton";
import { sliceHunkByNewLineRange } from "@/browser/utils/review/sliceHunkContent";
interface HunkViewerProps {
@@ -417,6 +418,7 @@ export const HunkViewer = React.memo(
{highlightedFilePath}
+
{!isPureRename && (
<>
{additions > 0 &&
+{additions}}
diff --git a/src/browser/features/RightSidebar/RightSidebar.tsx b/src/browser/features/RightSidebar/RightSidebar.tsx
index cb9cbe271be..f722ce2e412 100644
--- a/src/browser/features/RightSidebar/RightSidebar.tsx
+++ b/src/browser/features/RightSidebar/RightSidebar.tsx
@@ -1184,13 +1184,14 @@ const RightSidebarComponent: React.FC
= ({
const sidebarContainerRef = React.useRef(null);
- // "Open in Artifacts" on an MCP Apps tool card (the view itself is selected through the
- // Artifacts selection key); on small viewports WorkspaceMenuBar opens the dialog instead.
- // This handler then stays out, so the view never mounts twice (dialog plus a hidden sidebar
- // copy, each running the app's init and tool calls).
+ // "Open in Artifacts" on an MCP Apps tool card and openArtifact() (chat cards, file cards,
+ // Review, palette) persist their selection, then ask for the tab like OPEN_GOAL_TAB. On small
+ // viewports WorkspaceMenuBar opens the Artifacts dialog instead; this handler then stays out,
+ // so a view never mounts twice (dialog plus a hidden sidebar copy, each running the app's
+ // init and tool calls).
React.useEffect(() => {
if (!artifactsExperimentEnabled) return;
- const handleOpenMcpAppView = (event: Event) => {
+ const handleOpenArtifactsTab = (event: Event) => {
const detail = (event as CustomEvent<{ workspaceId: string }>).detail;
if (detail?.workspaceId !== workspaceId) return;
const container = sidebarContainerRef.current;
@@ -1198,8 +1199,12 @@ const RightSidebarComponent: React.FC = ({
setCollapsed(false);
setLayout((prev) => selectOrAddTab(prev, "artifacts"));
};
- window.addEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handleOpenMcpAppView);
- return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handleOpenMcpAppView);
+ window.addEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handleOpenArtifactsTab);
+ window.addEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, handleOpenArtifactsTab);
+ return () => {
+ window.removeEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handleOpenArtifactsTab);
+ window.removeEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, handleOpenArtifactsTab);
+ };
}, [artifactsExperimentEnabled, setCollapsed, setLayout, workspaceId]);
// Global shortcut: open (and un-collapse) the Artifacts tab. Works from the chat input
diff --git a/src/browser/features/Settings/Sections/KeybindsSection.tsx b/src/browser/features/Settings/Sections/KeybindsSection.tsx
index a9ff528e7d9..e1fb37699fe 100644
--- a/src/browser/features/Settings/Sections/KeybindsSection.tsx
+++ b/src/browser/features/Settings/Sections/KeybindsSection.tsx
@@ -55,6 +55,7 @@ const KEYBIND_LABELS: Record = {
NEXT_ARTIFACT: "Next artifact (Artifacts tab)",
PREV_ARTIFACT: "Previous artifact (Artifacts tab)",
RELOAD_ARTIFACT: "Reload artifact (Artifacts tab)",
+ UNPIN_ARTIFACT_FILE: "Unpin the selected pinned file (Artifacts tab)",
ZOOM_IN_ARTIFACT_IMAGE: "Zoom in on image artifact",
ZOOM_OUT_ARTIFACT_IMAGE: "Zoom out of image artifact",
FIT_ARTIFACT_IMAGE: "Fit image artifact to panel",
@@ -226,6 +227,7 @@ const KEYBIND_GROUPS: Array<{
"NEXT_ARTIFACT",
"PREV_ARTIFACT",
"RELOAD_ARTIFACT",
+ "UNPIN_ARTIFACT_FILE",
"ZOOM_IN_ARTIFACT_IMAGE",
"ZOOM_OUT_ARTIFACT_IMAGE",
"FIT_ARTIFACT_IMAGE",
diff --git a/src/browser/features/Tools/ArtifactToolCall.stories.tsx b/src/browser/features/Tools/ArtifactToolCall.stories.tsx
new file mode 100644
index 00000000000..fe3141e3dce
--- /dev/null
+++ b/src/browser/features/Tools/ArtifactToolCall.stories.tsx
@@ -0,0 +1,125 @@
+import type { ReactNode } from "react";
+import type { Meta, StoryObj } from "@storybook/react-vite";
+import { expect, within } from "@storybook/test";
+import { updatePersistedState } from "@/browser/hooks/usePersistedState";
+import { lightweightMeta } from "@/browser/stories/meta.js";
+import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments";
+import type { ArtifactKind } from "@/common/utils/artifactKind";
+import { ArtifactToolCall } from "./ArtifactToolCall";
+import { AttachFileToolCall } from "./AttachFileToolCall";
+
+const meta = {
+ ...lightweightMeta,
+ title: "App/Chat/Tools/Artifact",
+ component: ArtifactToolCall,
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+const WORKSPACE_ID = "ws-story-artifact-cards";
+
+function published(path: string, kind: ArtifactKind, version: number, title: string) {
+ return {
+ success: true,
+ id: `id-${path}`,
+ version,
+ path,
+ bytes: 2048,
+ kind,
+ title,
+ pin: null,
+ } as const;
+}
+
+// Titles equal to the file name are omitted on the card ("findings.md · v1").
+const CARDS = [
+ published("cache-explorer.html", "html", 3, "interactive chart"),
+ published("findings.md", "markdown", 1, "findings.md"),
+ published("bench-results.json", "json", 2, "p95 by strategy"),
+ published("architecture.svg", "svg", 1, "architecture.svg"),
+ published("revenue.csv", "csv", 4, "Q1 revenue"),
+ published("flows/checkout.mmd", "mermaid", 2, "checkout flow"),
+ published(
+ "reports/2026/q3/very-long-folder-name/service-latency-breakdown.md",
+ "markdown",
+ 12,
+ "latency breakdown with a long title"
+ ),
+] as const;
+
+function Section(props: { label: string; children: ReactNode }) {
+ return (
+
+
+ {props.label}
+
+ {props.children}
+
+ );
+}
+
+/** Every card state in one chat-width column: published kinds, a failed publish, attach_file. */
+function renderGallery() {
+ updatePersistedState(getExperimentKey(EXPERIMENT_IDS.ARTIFACTS), true);
+ return (
+
+
+
+ {CARDS.map((result) => (
+
+ ))}
+
+
+
+
+
+ );
+}
+
+async function waitForGallery(canvasElement: HTMLElement) {
+ const canvas = within(canvasElement);
+ await canvas.findByRole("button", { name: /Open in Artifacts/ });
+ const cards = await canvas.findAllByRole("button", { name: /in Artifacts$/ });
+ await expect(cards).toHaveLength(CARDS.length);
+}
+
+export const GalleryLaptop: Story = {
+ parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["laptop"] } } },
+ render: renderGallery,
+ play: ({ canvasElement }) => waitForGallery(canvasElement),
+};
+
+export const GalleryPhone: Story = {
+ globals: { viewport: { value: "phone390", isRotated: false } },
+ parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["phone"] } } },
+ render: renderGallery,
+ play: ({ canvasElement }) => waitForGallery(canvasElement),
+};
diff --git a/src/browser/features/Tools/ArtifactToolCall.test.tsx b/src/browser/features/Tools/ArtifactToolCall.test.tsx
new file mode 100644
index 00000000000..07c140aa06b
--- /dev/null
+++ b/src/browser/features/Tools/ArtifactToolCall.test.tsx
@@ -0,0 +1,204 @@
+import "../../../../tests/ui/dom";
+
+import { afterEach, beforeEach, describe, expect, test } from "bun:test";
+import { cleanup, fireEvent, render as rtlRender } from "@testing-library/react";
+import type { ReactElement } from "react";
+import { TooltipProvider } from "@/browser/components/Tooltip/Tooltip";
+import { installDom } from "../../../../tests/ui/dom";
+import { updatePersistedState } from "@/browser/hooks/usePersistedState";
+import { CUSTOM_EVENTS, type CustomEventPayloads } from "@/common/constants/events";
+import { EXPERIMENT_IDS, getExperimentKey } from "@/common/constants/experiments";
+import { ChatHostContextProvider } from "@/browser/contexts/ChatHostContext";
+import { CHAT_UI_FEATURE_IDS } from "@/common/constants/chatUiFeatures";
+import { ArtifactToolCall } from "./ArtifactToolCall";
+import { AttachFileToolCall } from "./AttachFileToolCall";
+import { APIProvider } from "@/browser/contexts/API";
+import { OpenAsArtifactButton } from "@/browser/features/RightSidebar/ArtifactsTab/OpenAsArtifactButton";
+import { createTestApiClient } from "@/browser/testUtils";
+
+/** StatusIndicator needs the app's tooltip provider. */
+const render = (ui: ReactElement) => rtlRender(ui, { wrapper: TooltipProvider });
+
+type OpenDetail = CustomEventPayloads[typeof CUSTOM_EVENTS.OPEN_ARTIFACT];
+const WS = "ws-card";
+
+const published = {
+ success: true,
+ id: "art-1",
+ version: 3,
+ path: "cache-explorer.html",
+ bytes: 1200,
+ kind: "html",
+ title: "interactive chart",
+ pin: null,
+} as const;
+
+describe("artifact chat cards", () => {
+ let cleanupDom: (() => void) | null = null;
+ let opens: OpenDetail[] = [];
+ const onOpen = (event: Event) => opens.push((event as CustomEvent).detail);
+
+ beforeEach(() => {
+ cleanupDom = installDom();
+ window.localStorage.clear();
+ opens = [];
+ window.addEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, onOpen);
+ });
+
+ afterEach(() => {
+ window.removeEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, onOpen);
+ cleanup();
+ cleanupDom?.();
+ cleanupDom = null;
+ });
+
+ test("clicking a published card opens the Artifacts tab at that version", () => {
+ updatePersistedState(getExperimentKey(EXPERIMENT_IDS.ARTIFACTS), true);
+ const view = render(
+
+ );
+ fireEvent.click(view.getByRole("button", { name: /Open cache-explorer.html version 3/ }));
+ expect(opens).toEqual([{ workspaceId: WS, path: "cache-explorer.html", versionId: 3 }]);
+ });
+
+ test("a failed publish renders as an error card, not a link", () => {
+ const view = render(
+
+ );
+ expect(view.getByText("File not found: missing.md")).toBeTruthy();
+ expect(view.queryByRole("button", { name: /Open / })).toBeNull();
+ });
+
+ test("focus opens the tab once when the result arrives live, never on replay", () => {
+ updatePersistedState(getExperimentKey(EXPERIMENT_IDS.ARTIFACTS), true);
+ const live = render(
+
+ );
+ expect(opens).toEqual([]);
+ const completed = (
+
+ );
+ live.rerender(completed);
+ live.rerender(completed);
+ expect(opens).toEqual([{ workspaceId: WS, path: "cache-explorer.html", versionId: 3 }]);
+ live.unmount();
+
+ // History replay: the card mounts with its result already present.
+ render(completed);
+ expect(opens).toHaveLength(1);
+ });
+
+ test("with the Artifacts experiment off, the card neither opens nor focuses the tab", () => {
+ const card = (result?: typeof published) => (
+
+ );
+ const view = render(card());
+ view.rerender(card(published));
+ // Same as a host without the panel: the card stays, clicking it dispatches nothing.
+ fireEvent.click(view.getByRole("button", { name: /cache-explorer.html version 3/ }));
+ expect(opens).toEqual([]);
+ });
+
+ test("hosts without an Artifacts surface render the card but never open it", () => {
+ const uiSupport = Object.fromEntries(
+ CHAT_UI_FEATURE_IDS.map((id) => [id, id === "artifactsPanel" ? "unsupported" : "supported"])
+ ) as Parameters[0]["value"]["uiSupport"];
+ const card = (result?: typeof published) => (
+
+
+
+ );
+ const view = render(card());
+ view.rerender(card(published));
+ fireEvent.click(view.getByRole("button", { name: /cache-explorer.html version 3/ }));
+ expect(opens).toEqual([]);
+ });
+
+ test("attach_file offers Open in Artifacts for a registered version", () => {
+ const result = {
+ type: "content",
+ value: [{ type: "text", text: "[File shown to user: report.md]" }],
+ ui_only: { artifact: { id: "art-report", version: 2, path: "report.md" } },
+ };
+ const card = (
+
+ );
+ const hidden = render(card);
+ // Gated on the artifacts experiment like every other entry point.
+ expect(hidden.queryByRole("button", { name: /Open in Artifacts/ })).toBeNull();
+ hidden.unmount();
+
+ updatePersistedState(getExperimentKey(EXPERIMENT_IDS.ARTIFACTS), true);
+ const view = render(card);
+ fireEvent.click(view.getByRole("button", { name: /Open in Artifacts/ }));
+ expect(opens).toEqual([{ workspaceId: WS, path: "report.md", versionId: 2 }]);
+ });
+
+ test("hosts without an Artifacts surface get no open buttons on attach_file or file cards", () => {
+ updatePersistedState(getExperimentKey(EXPERIMENT_IDS.ARTIFACTS), true);
+ const uiSupport = Object.fromEntries(
+ CHAT_UI_FEATURE_IDS.map((id) => [id, id === "artifactsPanel" ? "unsupported" : "supported"])
+ ) as Parameters[0]["value"]["uiSupport"];
+ const view = render(
+
+
+
+
+
+
+ );
+ expect(view.queryByRole("button", { name: /Open in Artifacts/ })).toBeNull();
+ expect(view.queryByRole("button", { name: /as artifact/ })).toBeNull();
+ });
+});
diff --git a/src/browser/features/Tools/ArtifactToolCall.tsx b/src/browser/features/Tools/ArtifactToolCall.tsx
new file mode 100644
index 00000000000..85a4fa5eeb7
--- /dev/null
+++ b/src/browser/features/Tools/ArtifactToolCall.tsx
@@ -0,0 +1,136 @@
+import React, { useEffect, useRef } from "react";
+import { useChatHostContext } from "@/browser/contexts/ChatHostContext";
+import { useExperimentValue } from "@/browser/hooks/useExperiments";
+import { EXPERIMENT_IDS } from "@/common/constants/experiments";
+import { openArtifact } from "@/browser/features/RightSidebar/ArtifactsTab/openArtifact";
+import type { ArtifactKind } from "@/common/utils/artifactKind";
+import {
+ ArtifactToolResultSchema,
+ type ArtifactToolSuccessResult,
+} from "@/common/utils/tools/toolDefinitions";
+import {
+ ErrorBox,
+ StatusIndicator,
+ ToolContainer,
+ ToolHeader,
+ ToolIcon,
+ ToolName,
+} from "./Shared/ToolPrimitives";
+import {
+ getStatusDisplay,
+ normalizeToolResultForRendering,
+ type ToolStatus,
+} from "./Shared/toolUtils";
+
+/** Short kind tags for chat cards only; the Artifacts picker never shows them. */
+const KIND_TAGS: Record = {
+ html: "HTML",
+ markdown: "MD",
+ json: "JSON",
+ svg: "SVG",
+ csv: "CSV",
+ image: "IMG",
+ text: "TXT",
+ mermaid: "MMD",
+ pdf: "PDF",
+ diff: "DIFF",
+ canvas: "CANVAS",
+};
+
+interface ArtifactToolCallProps {
+ toolName: string;
+ args?: { path?: string; focus?: boolean | null };
+ result?: unknown;
+ status?: ToolStatus;
+ workspaceId?: string;
+}
+
+function basename(path: string): string {
+ return path.slice(path.lastIndexOf("/") + 1);
+}
+
+/**
+ * Chat card for the `artifact` tool: " name · v · title", opening the Artifacts tab
+ * at that exact version. It renders from the tool result alone (no lookups), so the card
+ * survives compaction and older-history paging.
+ */
+export const ArtifactToolCall: React.FC = (props) => {
+ const parsed =
+ props.result == null
+ ? null
+ : ArtifactToolResultSchema.safeParse(normalizeToolResultForRendering(props.result));
+ const published: ArtifactToolSuccessResult | null =
+ parsed?.success === true && parsed.data.success ? parsed.data : null;
+ // Hosts without an Artifacts surface (VS Code webview), or with the experiment off, keep the
+ // card but never dispatch an open nobody listens for.
+ const artifactsEnabled = useExperimentValue(EXPERIMENT_IDS.ARTIFACTS);
+ const canOpen = useChatHostContext().uiSupport.artifactsPanel === "supported" && artifactsEnabled;
+ const workspaceId = canOpen ? props.workspaceId : undefined;
+
+ // `focus` opens the tab once, when the result arrives live. A card that mounts with its
+ // result already present (history replay, remounts) never saw the call pending and stays put.
+ const sawPendingRef = useRef(props.result == null);
+ const focusRequested = props.args?.focus === true;
+ useEffect(() => {
+ if (props.result == null) {
+ sawPendingRef.current = true;
+ return;
+ }
+ if (!sawPendingRef.current) return;
+ sawPendingRef.current = false;
+ if (published && focusRequested && workspaceId) {
+ openArtifact({ workspaceId, path: published.path, versionId: published.version });
+ }
+ }, [props.result, published, focusRequested, workspaceId]);
+
+ if (published == null) {
+ const status = props.status ?? "pending";
+ const error =
+ parsed?.success === true && !parsed.data.success
+ ? parsed.data.error
+ : parsed?.success === false
+ ? "Unexpected result from the artifact tool."
+ : null;
+ return (
+
+
+
+ artifact
+ {props.args?.path && (
+ {props.args.path}
+ )}
+ {getStatusDisplay(status)}
+
+ {error != null && {error}}
+
+ );
+ }
+
+ const showTitle = published.title.length > 0 && published.title !== basename(published.path);
+ return (
+
+
+
+ );
+};
diff --git a/src/browser/features/Tools/AttachFileToolCall.tsx b/src/browser/features/Tools/AttachFileToolCall.tsx
index 231c0240587..70532abd901 100644
--- a/src/browser/features/Tools/AttachFileToolCall.tsx
+++ b/src/browser/features/Tools/AttachFileToolCall.tsx
@@ -1,5 +1,11 @@
import type React from "react";
+import { PanelRight } from "lucide-react";
+import { useChatHostContext } from "@/browser/contexts/ChatHostContext";
+import { useExperimentValue } from "@/browser/hooks/useExperiments";
+import { openArtifact } from "@/browser/features/RightSidebar/ArtifactsTab/openArtifact";
+import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import { isToolContentResult } from "@/common/utils/tools/toolContentResult";
+import { getToolOutputUiOnly } from "@/common/utils/tools/toolOutputUiOnly";
import {
isDisplayOnlyFilePart,
type DisplayOnlyFilePart,
@@ -33,6 +39,7 @@ interface AttachFileToolCallProps {
args?: unknown;
result?: unknown;
status?: ToolStatus;
+ workspaceId?: string;
}
function extractDisplayFilesFromToolResult(result: unknown): DisplayOnlyFilePart[] {
@@ -70,6 +77,13 @@ export const AttachFileToolCall: React.FC = (props) =>
(image) => sanitizeImageData(image.mediaType, image.data) === null
);
const shouldShowDetails = expanded || hasImages || hasDisplayFiles;
+ // Hosts without an Artifacts surface (VS Code) cannot open it.
+ const artifactsExperimentOn = useExperimentValue(EXPERIMENT_IDS.ARTIFACTS);
+ const artifactsPanelSupported = useChatHostContext().uiSupport.artifactsPanel === "supported";
+ const artifactsEnabled = artifactsExperimentOn && artifactsPanelSupported;
+ // Read before any normalization: normalizeToolResultForRendering drops ui_only.
+ const artifactRef = getToolOutputUiOnly(props.result)?.artifact;
+ const workspaceId = props.workspaceId;
return (
@@ -82,6 +96,20 @@ export const AttachFileToolCall: React.FC = (props) =>
+ {artifactsEnabled && artifactRef != null && workspaceId != null && (
+
+ )}
+
{hasImages && }
{downloadOnlyMedia.length > 0 && (
diff --git a/src/browser/features/Tools/FileEditToolCall.tsx b/src/browser/features/Tools/FileEditToolCall.tsx
index b74e399ed94..e593e8762dd 100644
--- a/src/browser/features/Tools/FileEditToolCall.tsx
+++ b/src/browser/features/Tools/FileEditToolCall.tsx
@@ -30,6 +30,7 @@ import { DiffContainer, DiffRenderer, SelectableDiffRenderer } from "../Shared/D
import { KebabMenu, type KebabMenuItem } from "@/browser/components/KebabMenu/KebabMenu";
import { JsonHighlight } from "./Shared/HighlightedCode";
import type { ReviewNoteData } from "@/common/types/review";
+import { OpenAsArtifactButton } from "@/browser/features/RightSidebar/ArtifactsTab/OpenAsArtifactButton";
type FileEditOperationArgs =
| FileEditReplaceStringToolArgs
@@ -142,6 +143,7 @@ interface FileEditToolCallProps {
result?: FileEditToolResult;
status?: ToolStatus;
onReviewNote?: (data: ReviewNoteData) => void;
+ workspaceId?: string;
}
function renderDiff(
@@ -205,6 +207,7 @@ export const FileEditToolCall: React.FC
= ({
result,
status = "pending",
onReviewNote,
+ workspaceId,
}) => {
// Collapse failed edits by default since they're common and expected. This is just
// the fallback: the per-workspace sticky tools preference (set once the user
@@ -265,6 +268,13 @@ export const FileEditToolCall: React.FC = ({
{filePath}
+ {filePath && (
+
+ )}
{diffLineDelta && (
= ({
args,
result,
status = "pending",
+ workspaceId,
}) => {
const { expanded, toggleExpanded } = useToolExpansion();
@@ -79,6 +83,7 @@ export const FileReadToolCall: React.FC = ({
{filePath}
+
{result && result.success && parsedContent && (
diff --git a/src/browser/features/Tools/GenericToolCall.tsx b/src/browser/features/Tools/GenericToolCall.tsx
index 938a2f44df4..eea400a2696 100644
--- a/src/browser/features/Tools/GenericToolCall.tsx
+++ b/src/browser/features/Tools/GenericToolCall.tsx
@@ -22,6 +22,7 @@ import { useMcpIcon } from "@/browser/hooks/useMcpIcon";
import type { MCPToolCallDisplay } from "@/common/types/mcp";
import { mcpToolDisplayName } from "@/common/utils/mcp/mcpToolDisplayName";
import { AppWindow } from "lucide-react";
+import { useChatHostContext } from "@/browser/contexts/ChatHostContext";
import { useExperimentValue } from "@/browser/hooks/useExperiments";
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
import { openMcpAppView } from "@/browser/features/RightSidebar/ArtifactsTab/mcpAppViewsStore";
@@ -53,11 +54,13 @@ const OpenMcpAppViewButton: React.FC<{
status: ToolStatus;
}> = (props) => {
const enabled = useExperimentValue(EXPERIMENT_IDS.ARTIFACTS);
+ // Hosts without an Artifacts surface (VS Code) cannot show the view.
+ const canOpen = useChatHostContext().uiSupport.artifactsPanel === "supported";
// Offered once the call settled: the view's result (or tool-cancelled) is decided at open
// time, so a still-running call would wrongly show "Result no longer available".
const settled =
props.status === "completed" || props.status === "failed" || props.status === "interrupted";
- if (!enabled || !settled) return null;
+ if (!enabled || !canOpen || !settled) return null;
return (