Skip to content

Commit 35afaaa

Browse files
committed
🤖 feat: rich renderers for the Artifacts tab
- Rich renderers for the Artifacts tab: Markdown with local images, CSV tables, JSON tree, diffs, code, images, and sandboxed HTML/SVG frames with an asset bridge and CSP - Artifacts dialog for small viewports (Workspace actions menu, Ctrl+Shift+K while the sidebar is hidden) and an Artifacts settings panel under Experiments - Let data:image URLs through Markdown sanitize; keep the JSON tree root chevron inside the panel - Storybook gallery for the renderers (Pixel budget +4 snapshots, +1 file) - Codex review round 1 fixes: desktop blocks artifact subframe navigation and frames that load twice are dropped (frameNavigationGuard), Ctrl+Shift+K skips the hidden narrow sidebar, CSV column cap, image zoom keys in the image viewer, laptop1200 story globals, artifacts.read maxBytes for assets, stricter Markdown fence close, raw JSON shows the file text - Codex review round 2 fixes: HTML/SVG frames mount only in the desktop app (escaped source with a desktop-only notice elsewhere, executableFrames.ts), Escape reaches the host even from editable elements, patches over 5,000 hunk lines render as raw text, image zoom steps never cross the fit scale the wrong way --- _Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking: `high` • Cost: `$117.29`_ <!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high costs=117.29 -->
1 parent 12ca655 commit 35afaaa

56 files changed

Lines changed: 3446 additions & 158 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎docs/config/keybinds.mdx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ This separation keeps the switcher clean and fast while making all other command
7474

7575
### Artifacts tab (experimental)
7676

77-
These work when the **Artifacts** experiment is on. Shortcuts other than opening the tab only apply while focus is inside the Artifacts tab.
77+
These work when the **Artifacts** experiment is on. Shortcuts other than opening the tab only apply while focus is inside the Artifacts tab (`Shift+F` and `Esc` also work inside an HTML or SVG preview). On narrow windows, where the right sidebar is hidden, `Ctrl+Shift+K` opens the Artifacts dialog instead. The image zoom shortcuts apply while focus is on the image or its zoom controls.
7878

7979
| Action | Shortcut |
8080
| ---------------------------- | -------------- |
@@ -83,6 +83,8 @@ These work when the **Artifacts** experiment is on. Shortcuts other than opening
8383
| Exit fullscreen | `Esc` |
8484
| Next / previous artifact | `J` / `K` |
8585
| Reload the selected artifact | `R` |
86+
| Image: zoom in / zoom out | `=` / `-` |
87+
| Image: fit / actual size | `0` / `1` |
8688

8789
## Tips
8890

‎scripts/check-storybook-snapshot-budget.mjs‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,10 @@ import * as pixelUtils from "../node_modules/@coder/pixel-storybook/build/utils.
2929
// no new stories; 608 → 655 is a unit change from estimated to actual captures).
3030
// Keep this no-headroom guardrail tight: future growth should exclude, consolidate,
3131
// or intentionally rebalance snapshots rather than silently increasing Pixel load.
32-
const MAX_SNAPSHOTS = 655;
33-
const MAX_SNAPSHOT_ENABLED_FILES = 115;
32+
// +4 snapshots / +1 file: the Artifacts tab gallery (ArtifactsPanel.stories.tsx), consolidated
33+
// from 24 per-renderer variants to one phone and one laptop story in dark + light.
34+
const MAX_SNAPSHOTS = 659;
35+
const MAX_SNAPSHOT_ENABLED_FILES = 116;
3436

3537
const { values } = parseArgs({
3638
options: {

‎src/browser/components/WorkspaceActionsMenuContent/WorkspaceActionsMenuContent.tsx‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { ArchiveIcon } from "../icons/ArchiveIcon/ArchiveIcon";
33
import {
44
GitBranch,
55
HeartPulse,
6+
AppWindow,
67
History,
78
Maximize2,
89
MessagesSquare,
@@ -62,6 +63,8 @@ interface WorkspaceActionsMenuContentProps {
6263
onEnterImmersiveReview?: (() => void) | null;
6364
/** Small-viewport action: open the timeline dialog (the right sidebar hosting the tab is hidden there). */
6465
onOpenTimeline?: (() => void) | null;
66+
/** Small viewports only: the Artifacts dialog (the right sidebar is hidden there). */
67+
onOpenArtifacts?: (() => void) | null;
6568
onStopRuntime?: (() => void) | null;
6669
onForkChat?: ((anchorEl: HTMLElement) => void) | null;
6770
/** Pin/unpin toggle; pass null on sub-agent rows (only root chats are pinnable). */
@@ -172,6 +175,20 @@ export const WorkspaceActionsMenuContent: React.FC<WorkspaceActionsMenuContentPr
172175
testId="workspace-timeline-button"
173176
/>
174177
)}
178+
{props.onOpenArtifacts && (
179+
<WorkspaceActionButton
180+
label="Artifacts"
181+
shortcut={formatKeybind(KEYBINDS.OPEN_ARTIFACTS_TAB)}
182+
shortcutClassName={props.shortcutClassName}
183+
icon={<AppWindow className="h-3 w-3 shrink-0" />}
184+
onClick={(e) => {
185+
e.stopPropagation();
186+
props.onCloseMenu();
187+
props.onOpenArtifacts?.();
188+
}}
189+
testId="workspace-artifacts-button"
190+
/>
191+
)}
175192
{props.onStopRuntime && (
176193
<WorkspaceActionButton
177194
label="Stop container"

‎src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { useCallback, useEffect, useRef, useState } from "react";
22
import { Bell, BellOff, Ellipsis, Info, Menu, Pencil } from "lucide-react";
33
import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events";
4+
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
45
import { cn } from "@/common/lib/utils";
56
import { getErrorMessage } from "@/common/utils/errors";
67
import { isWorkspacePinnable, isWorkspacePinned } from "@/common/utils/pin";
@@ -36,6 +37,7 @@ import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace";
3637
import { useTutorial } from "@/browser/contexts/TutorialContext";
3738

3839
import type { TerminalSessionCreateOptions } from "@/browser/utils/terminal";
40+
import { useExperimentValue } from "@/browser/hooks/useExperiments";
3941
import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal";
4042
import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor";
4143
import { usePersistedState } from "@/browser/hooks/usePersistedState";
@@ -65,6 +67,8 @@ import {
6567
WORKSPACE_MENU_BAR_LEFT_SIDEBAR_COLLAPSED_PADDING_PX,
6668
} from "@/constants/layout";
6769
import { TimelineDialog } from "@/browser/features/RightSidebar/Timeline/TimelineDialog";
70+
import { ArtifactsDialog } from "@/browser/features/RightSidebar/ArtifactsTab/ArtifactsDialog";
71+
import { isRightSidebarResponsivelyHidden } from "@/browser/features/RightSidebar/rightSidebarVisibility";
6872
import type { AgentSkillDescriptor, AgentSkillIssue } from "@/common/types/agentSkill";
6973

7074
interface WorkspaceMenuBarProps {
@@ -105,6 +109,7 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
105109
useWorkspaceActions();
106110
const isArchiving = archivingWorkspaceIds.has(workspaceId);
107111
const { workspaceMetadata } = useWorkspaceContext();
112+
const artifactsExperimentEnabled = useExperimentValue(EXPERIMENT_IDS.ARTIFACTS);
108113
const openTerminalPopout = useOpenTerminal();
109114
const openInEditor = useOpenInEditor();
110115
const runtimeStatus = useRuntimeStatus(workspaceId);
@@ -152,6 +157,13 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
152157
setTimelineDialogWorkspaceId(null);
153158
}
154159
const timelineDialogOpen = timelineDialogWorkspaceId === workspaceId;
160+
// Same per-workspace keying as the timeline dialog: the Artifacts tab also lives in the
161+
// right sidebar, so small viewports need this dialog to reach it.
162+
const [artifactsDialogWorkspaceId, setArtifactsDialogWorkspaceId] = useState<string | null>(null);
163+
if (artifactsDialogWorkspaceId !== null && artifactsDialogWorkspaceId !== workspaceId) {
164+
setArtifactsDialogWorkspaceId(null);
165+
}
166+
const artifactsDialogOpen = artifactsDialogWorkspaceId === workspaceId;
155167
const [availableSkills, setAvailableSkills] = useState<AgentSkillDescriptor[]>([]);
156168
const [invalidSkills, setInvalidSkills] = useState<AgentSkillIssue[]>([]);
157169
const isSkillsMountedRef = useRef(true);
@@ -219,12 +231,7 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
219231
?.closest("[data-workspace-shell]")
220232
?.querySelector(".mobile-hide-right-sidebar");
221233
if (sidebar instanceof HTMLElement) {
222-
// Immersive review hides the sidebar on any viewport (marked aria-hidden);
223-
// only a responsive hide should surface the dialog entry points.
224-
if (sidebar.getAttribute("aria-hidden") === "true") {
225-
return false;
226-
}
227-
return window.getComputedStyle(sidebar).display === "none";
234+
return isRightSidebarResponsivelyHidden(sidebar);
228235
}
229236
return window.matchMedia(`(max-width: ${NARROW_VIEWPORT_MAX_WIDTH_PX}px)`).matches;
230237
}, []);
@@ -271,6 +278,27 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
271278
return () => window.removeEventListener("keydown", handler);
272279
}, [isTimelineSidebarHidden, workspaceId]);
273280

281+
// Ctrl+Shift+K normally opens the Artifacts tab (RightSidebar owns that handler); while the
282+
// sidebar is hidden it opens the dialog instead, the tab's only entry point there.
283+
useEffect(() => {
284+
if (!artifactsExperimentEnabled) {
285+
return;
286+
}
287+
const handler = (e: KeyboardEvent) => {
288+
if (
289+
!matchesKeybind(e, KEYBINDS.OPEN_ARTIFACTS_TAB) ||
290+
isDialogOpen() ||
291+
!isTimelineSidebarHidden()
292+
) {
293+
return;
294+
}
295+
e.preventDefault();
296+
setArtifactsDialogWorkspaceId(workspaceId);
297+
};
298+
window.addEventListener("keydown", handler);
299+
return () => window.removeEventListener("keydown", handler);
300+
}, [artifactsExperimentEnabled, isTimelineSidebarHidden, workspaceId]);
301+
274302
const isDevcontainerWorkspace = isDevcontainerRuntime(runtimeConfig);
275303
const isRuntimeRunning = isDevcontainerWorkspace && runtimeStatus === "running";
276304
// Mirrors TaskService.isLocalUnrelatedMessagingEndpoint: unrelated delivery requires local or
@@ -826,6 +854,11 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
826854
onOpenTimeline={
827855
timelineSidebarHidden ? () => setTimelineDialogWorkspaceId(workspaceId) : null
828856
}
857+
onOpenArtifacts={
858+
artifactsExperimentEnabled && timelineSidebarHidden
859+
? () => setArtifactsDialogWorkspaceId(workspaceId)
860+
: null
861+
}
829862
onStopRuntime={isRuntimeRunning ? () => void handleStopRuntime() : null}
830863
// Scratch chats have no repo: review events are ignored by
831864
// RightSidebar and fork is unsupported on the backend, so hide
@@ -903,6 +936,11 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
903936
open={timelineDialogOpen}
904937
onOpenChange={(open) => setTimelineDialogWorkspaceId(open ? workspaceId : null)}
905938
/>
939+
<ArtifactsDialog
940+
workspaceId={workspaceId}
941+
open={artifactsDialogOpen}
942+
onOpenChange={(open) => setArtifactsDialogWorkspaceId(open ? workspaceId : null)}
943+
/>
906944
<DebugLlmRequestModal
907945
workspaceId={workspaceId}
908946
open={debugLlmRequestOpen}

‎src/browser/features/Messages/MarkdownCore.tsx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,11 @@ const sanitizeSchema = {
7676
protocols: {
7777
...defaultSchema.protocols,
7878
href: [...(defaultSchema.protocols?.href ?? []), INTERNAL_INLINE_SKILL_SANITIZE_PROTOCOL],
79+
// SECURITY AUDIT: data: must survive sanitize for rehype-harden's allowDataImages to take
80+
// effect (it only ever keeps data:image/ URLs on images). Without this, every data image
81+
// was stripped here first, which also broke relative images in Markdown artifacts, which
82+
// are inlined as data: URLs.
83+
src: [...(defaultSchema.protocols?.src ?? []), "data"],
7984
},
8085
attributes: {
8186
...defaultSchema.attributes,

‎src/browser/features/Messages/MarkdownRenderer.raw-html.test.tsx‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,22 @@ describe("MarkdownRenderer raw HTML handling", () => {
3939
expect(view.container.querySelector("signoutbutton")).toBeNull();
4040
});
4141

42+
test("keeps data:image URLs on images but never other data: URLs", () => {
43+
const view = renderMarkdown(
44+
[
45+
"![ok](data:image/png;base64,iVBORw0KGgo=)",
46+
"![bad](data:text/html;base64,PHNjcmlwdD4=)",
47+
'<img alt="raw" src="data:text/html,<script>alert(1)</script>">',
48+
"[link](data:text/html,hi)",
49+
].join("\n\n")
50+
);
51+
const sources = Array.from(view.container.querySelectorAll("img")).map((img) =>
52+
img.getAttribute("src")
53+
);
54+
expect(sources).toEqual(["data:image/png;base64,iVBORw0KGgo="]);
55+
expect(view.container.querySelector('a[href^="data:"]')).toBeNull();
56+
});
57+
4258
function copyRenderedMarkdown(content: string) {
4359
const view = render(
4460
<div data-transcript-message>

0 commit comments

Comments
 (0)