Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions docs/hooks/tools.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,19 @@ If a value is too large for the environment, it may be omitted (not set). Xum al

</details>

<details>
<summary>artifact (5)</summary>

| Env var | JSON path | Type | Description |
| ---------------------- | --------- | ------- | ------------------------------------------------------------------- |
| `XUM_TOOL_INPUT_FOCUS` | `focus` | boolean | Open the Artifacts tab on this version for the user. |
| `XUM_TOOL_INPUT_KIND` | `kind` | enum | Override the viewer; by default it follows the file extension. |
| `XUM_TOOL_INPUT_PATH` | `path` | string | Path relative to $XUM_SCRATCH_DIR/artifacts, or absolute inside it. |
| `XUM_TOOL_INPUT_PIN` | `pin` | enum | Request that this artifact be kept on the project or global shelf. |
| `XUM_TOOL_INPUT_TITLE` | `title` | string | Short label for this version (defaults to the file name). |

</details>

<details>
<summary>ask_user_question (8)</summary>

Expand Down
6 changes: 4 additions & 2 deletions scripts/check-storybook-snapshot-budget.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,10 @@ import * as pixelUtils from "../node_modules/@coder/pixel-storybook/build/utils.
// or intentionally rebalance snapshots rather than silently increasing Pixel load.
// +4 snapshots / +1 file: the Artifacts tab gallery (ArtifactsPanel.stories.tsx), consolidated
// from 24 per-renderer variants to one phone and one laptop story in dark + light.
const MAX_SNAPSHOTS = 659;
const MAX_SNAPSHOT_ENABLED_FILES = 116;
// +6 snapshots / +1 file: Artifacts M4 chat cards (ArtifactToolCall.stories.tsx, phone + laptop
// in dark + light) and the open version menu (ArtifactsPanel.stories.tsx, laptop dark + light).
const MAX_SNAPSHOTS = 665;
const MAX_SNAPSHOT_ENABLED_FILES = 117;

const { values } = parseArgs({
options: {
Expand Down
1 change: 1 addition & 0 deletions scripts/check-test-seam-comments.allowlist.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
{ "file": "src/node/services/agentPlugins/hookService.ts", "symbol": "readHookSourceCapped", "reason": "production caller: HookService.discoverHookPlugins in the same file" },
{ "file": "src/node/services/agentPlugins/installService.ts", "symbol": "withDiskQuotaWatchdog", "reason": "production caller: runGit in the same file" },
{ "file": "src/node/services/agentSkills/skillDynamicContext.ts", "symbol": "injectSkillDynamicContext", "reason": "injection point: the timeoutMs argument; skillDynamicContext.test.ts uses it to witness that a hung directive becomes a timeout note without failing the send; production (AgentSession) uses the default" },
{ "file": "src/node/services/agentSession.ts", "symbol": "AgentSessionOptions.logicalTurnCompletedTimeoutMs", "reason": "injection point: the logical-turn completion hook deadline (LOGICAL_TURN_COMPLETED_TIMEOUT_MS, 10 s); src/node/services/agentSession.logicalTurn.test.ts uses it to witness that a stalled artifact snapshot cannot hold the session busy; production uses the default" },
{ "file": "src/node/services/agentSession.ts", "symbol": "AgentSessionOptions.planSnapshotCaptureTimeoutMs", "reason": "injection point: the plan-review snapshot capture deadline (PLAN_REVIEW_SNAPSHOT_CAPTURE_TIMEOUT_MS, 15 s); src/node/services/agentSession.planReviewSnapshot.test.ts uses it to witness that a stalled capture is abandoned at the deadline, cannot append afterwards, and stops delaying later turns; production uses the default" },
{ "file": "src/node/services/branchSummary.ts", "symbol": "deriveSideChannelModelCandidates", "reason": "production caller: WorkspaceService.getWorkspaceNamingCandidates (workspaceService.ts)" },
{ "file": "src/node/services/historyScanner.ts", "symbol": "classifyHistoryScanRow", "reason": "plain-row gate property test (#4655): historyScanner.plainRow.test.ts compares readPlainHistoryRow with the full classifier on generated rows; production callers: findProviderHistoryStart, hasUnreadableHistoryResetEvidence and the history row readers in the same file" },
Expand Down
2 changes: 2 additions & 0 deletions src/browser/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ function AppInner() {

const [isMultiProjectWorkspaceModalOpen, setMultiProjectWorkspaceModalOpen] = useState(false);
const multiProjectWorkspacesEnabled = useExperimentValue(EXPERIMENT_IDS.MULTI_PROJECT_WORKSPACES);
const artifactsEnabled = useExperimentValue(EXPERIMENT_IDS.ARTIFACTS);
const autoModelRoutingEnabled = useExperimentValue(EXPERIMENT_IDS.AUTO_MODEL_ROUTING);

// Left sidebar is drag-resizable (mirrors RightSidebar). Width is persisted globally;
Expand Down Expand Up @@ -1013,6 +1014,7 @@ function AppInner() {
onStartWorkspaceCreation: openNewWorkspaceFromPalette,
onStartMultiProjectWorkspaceCreation: openNewMultiProjectWorkspaceFromPalette,
multiProjectWorkspacesEnabled,
artifactsEnabled,
onArchiveMergedWorkspacesInProject: archiveMergedWorkspacesInProjectFromPalette,
getBranchesForProject,
onSelectWorkspace: selectWorkspaceFromPalette,
Expand Down
11 changes: 8 additions & 3 deletions src/browser/components/WorkspaceMenuBar/WorkspaceMenuBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,9 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
return () => window.removeEventListener("keydown", handler);
}, [artifactsExperimentEnabled, isTimelineSidebarHidden, workspaceId]);

// "Open in Artifacts" on an MCP Apps tool card: while the sidebar is hidden, the dialog is
// the only place the view can show.
// "Open in Artifacts" on an MCP Apps tool card and openArtifact() (chat cards, file cards,
// Review, palette): while the sidebar is hidden, the dialog is the only place to show them
// (RightSidebar handles the same events on wider layouts).
useEffect(() => {
if (!artifactsExperimentEnabled) {
return;
Expand All @@ -319,7 +320,11 @@ export const WorkspaceMenuBar: React.FC<WorkspaceMenuBarProps> = ({
setArtifactsDialogWorkspaceId(workspaceId);
};
window.addEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handler);
return () => window.removeEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handler);
window.addEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, handler);
return () => {
window.removeEventListener(CUSTOM_EVENTS.OPEN_MCP_APP_VIEW, handler);
window.removeEventListener(CUSTOM_EVENTS.OPEN_ARTIFACT, handler);
};
}, [artifactsExperimentEnabled, isTimelineSidebarHidden, workspaceId]);

const isDevcontainerWorkspace = isDevcontainerRuntime(runtimeConfig);
Expand Down
144 changes: 144 additions & 0 deletions src/browser/features/RightSidebar/ArtifactsTab/ArtifactVersionMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import React, { useEffect, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import { TooltipIfPresent } from "@/browser/components/Tooltip/Tooltip";
import { stopKeyboardPropagation } from "@/browser/utils/events";
import { formatRelativeTime } from "@/browser/utils/ui/dateTime";
import { cn } from "@/common/lib/utils";
import type { ArtifactVersion } from "@/common/orpc/schemas/artifacts";

/** Label for a stored version: its publish title, or a plain name for turn-end snapshots. */
function versionLabel(version: ArtifactVersion): string {
if (version.label != null && version.label.length > 0) return version.label;
return version.source === "turn-end" ? "Turn snapshot" : "Untitled";
}

/**
* Toolbar version menu ("v<N>"): "Latest (live)" follows the working file, every other entry
* shows a stored version. Rendered inline (not through a Radix portal) so it opens in
* happy-dom tests and inside the panel's fullscreen overlay alike.
*/
export function ArtifactVersionMenu(props: {
/** Newest first; the caller hides the menu when there are none. */
versions: readonly ArtifactVersion[];
/** null = Latest (live). */
selectedVersion: number | null;
onSelect: (version: number | null) => void;
}) {
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null);

// Close when clicking anywhere outside the trigger and its list.
useEffect(() => {
if (!open) return;
const handleMouseDown = (event: MouseEvent) => {
if (containerRef.current?.contains(event.target as Node)) return;
setOpen(false);
};
document.addEventListener("mousedown", handleMouseDown);
return () => document.removeEventListener("mousedown", handleMouseDown);
}, [open]);

// Keyboard users land on the checked entry when the list opens.
useEffect(() => {
if (!open) return;
listRef.current?.querySelector<HTMLElement>('[aria-checked="true"]')?.focus();
}, [open]);

const choose = (version: number | null) => {
setOpen(false);
triggerRef.current?.focus();
props.onSelect(version);
};

const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Escape") {
// Closing the menu must not also exit fullscreen or interrupt the stream.
event.preventDefault();
stopKeyboardPropagation(event);
setOpen(false);
triggerRef.current?.focus();
return;
}
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault();
Comment thread
ThomasK33 marked this conversation as resolved.
const items = Array.from(
listRef.current?.querySelectorAll<HTMLElement>('[role="menuitemradio"]') ?? []
);
const index = items.findIndex((item) => item === document.activeElement);
const next = event.key === "ArrowDown" ? index + 1 : index - 1;
items[Math.min(Math.max(next, 0), items.length - 1)]?.focus();
};

const triggerText = props.selectedVersion == null ? "Live" : `v${props.selectedVersion}`;
const itemClassName =
"hover:bg-hover focus:bg-hover flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-left text-xs outline-none";

return (
<div ref={containerRef} className="relative shrink-0">
<TooltipIfPresent tooltip="Version">
<button
ref={triggerRef}
type="button"
aria-label={`Version: ${props.selectedVersion == null ? "Latest (live)" : triggerText}`}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(!open)}
className="border-border-light text-muted hover:text-foreground bg-background flex h-6 items-center gap-0.5 rounded border pr-1 pl-1.5 text-[11px]"
>
<span className="counter-nums">{triggerText}</span>
<ChevronDown className="h-3 w-3" />
</button>
</TooltipIfPresent>
{open && (
<div
ref={listRef}
role="menu"
aria-label="Artifact versions"
onKeyDown={handleListKeyDown}
className="bg-dark border-border text-foreground absolute top-full right-0 z-[1600] mt-1 max-h-72 w-64 max-w-[calc(100vw-1rem)] overflow-y-auto rounded-md border p-1 shadow-md"
>
<button
type="button"
role="menuitemradio"
aria-checked={props.selectedVersion == null}
onClick={() => choose(null)}
className={itemClassName}
>
<MenuCheck checked={props.selectedVersion == null} />
<span className="min-w-0 flex-1 truncate">Latest (live)</span>
</button>
{props.versions.map((version) => {
const checked = props.selectedVersion === version.version;
return (
<button
key={version.version}
type="button"
role="menuitemradio"
aria-checked={checked}
onClick={() => choose(version.version)}
className={itemClassName}
>
<MenuCheck checked={checked} />
<span className="counter-nums shrink-0 font-medium">v{version.version}</span>
<span className="min-w-0 flex-1 truncate">{versionLabel(version)}</span>
<span className="text-muted shrink-0 text-[10px]">
{formatRelativeTime(version.createdAtMs)}
</span>
</button>
);
})}
</div>
)}
</div>
);
}

function MenuCheck(props: { checked: boolean }) {
return (
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
<Check className={cn("h-3 w-3", !props.checked && "invisible")} />
</span>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ let capabilityRequests = 0;
function Wrapper(props: { children: ReactNode }) {
const api: TestApiOverrides<APIClient> = {
artifacts: {
// No versions or pinned files: these tests cover the live renderers.
listVersions: (input: { workspaceId: string; path: string }) =>
Promise.resolve({
success: true as const,
data: { artifactId: input.path, path: input.path, pin: null, versions: [] },
}),
listPinned: () =>
Promise.resolve({ success: true as const, data: { available: true as const, files: [] } }),
list: () =>
Promise.resolve({
success: true as const,
Expand Down
21 changes: 16 additions & 5 deletions src/browser/features/RightSidebar/ArtifactsTab/ArtifactViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,19 @@ export const AGENT_BROWSER_MISSING_WARNING =
* none, say so above the frame (null means unknown, which shows nothing).
*/
function SandboxedArtifactWithCheckNotice(
props: React.ComponentProps<typeof SandboxedArtifactFrame>
props: React.ComponentProps<typeof SandboxedArtifactFrame> & {
/** The workspace whose runtime is probed; `workspaceId` may be null (no relative assets). */
checkWorkspaceId: string;
}
) {
const agentBrowserAvailable = useAgentBrowserAvailable(props.workspaceId);
const { checkWorkspaceId, ...frameProps } = props;
const agentBrowserAvailable = useAgentBrowserAvailable(checkWorkspaceId);
// One stable tree, so the answer arriving never remounts (reloads) the frame.
return (
<div className="flex h-full min-h-0 flex-col">
{agentBrowserAvailable === false && <NoteBar>{AGENT_BROWSER_MISSING_WARNING}</NoteBar>}
<div className="min-h-0 flex-1">
<SandboxedArtifactFrame {...props} />
<SandboxedArtifactFrame {...frameProps} />
</div>
</div>
);
Expand All @@ -112,8 +116,14 @@ export function ArtifactViewer(props: {
artifactsDir?: string | null;
/** Escape / Shift+F pressed inside a sandboxed HTML/SVG frame. */
onFrameKey?: (key: ArtifactFrameKey) => void;
/**
* Resolve relative images/assets through the artifacts folder (default). Off for pinned
* checkout files, whose neighbours live in the checkout, not the artifacts folder.
*/
readRelativeAssets?: boolean;
}) {
const result = props.result;
const assetWorkspaceId = props.readRelativeAssets === false ? null : props.workspaceId;
if (result.status === "too_large") {
return (
<TooLarge
Expand All @@ -138,7 +148,7 @@ export function ArtifactViewer(props: {
<MarkdownArtifact
content={result.content}
path={result.path}
workspaceId={props.workspaceId}
workspaceId={assetWorkspaceId}
/>
);
case "json":
Expand All @@ -159,7 +169,8 @@ export function ArtifactViewer(props: {
}
return (
<SandboxedArtifactWithCheckNotice
workspaceId={props.workspaceId}
checkWorkspaceId={props.workspaceId}
workspaceId={assetWorkspaceId}
path={result.path}
kind={result.kind}
content={result.content}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ function renderPanel(
files: Record<string, ArtifactReadResult> = FILES,
options: { allowCdn?: boolean } = {}
) {
writeArtifactSelection(WORKSPACE_ID, { path: selectedPath });
writeArtifactSelection(WORKSPACE_ID, { scope: "artifact", path: selectedPath, version: null });
updatePersistedState(ARTIFACTS_ALLOW_CDN_SCRIPTS_KEY, options.allowCdn ?? true);
return (
<APIProvider
Expand Down Expand Up @@ -347,7 +347,11 @@ export const DiffLaptop: Story = {
function renderGallery() {
updatePersistedState(ARTIFACTS_ALLOW_CDN_SCRIPTS_KEY, true);
for (const renderer of RENDERERS) {
writeArtifactSelection(`${WORKSPACE_ID}-${renderer.path}`, { path: renderer.path });
writeArtifactSelection(`${WORKSPACE_ID}-${renderer.path}`, {
scope: "artifact",
path: renderer.path,
version: null,
});
}
return (
<APIProvider
Expand Down Expand Up @@ -390,6 +394,62 @@ export const GalleryLaptop: Story = {
play: ({ canvasElement }) => waitForGallery(canvasElement),
};

/** Toolbar version menu, open: "Latest (live)" plus stored versions, newest first. */
function renderVersionMenu() {
const workspaceId = `${WORKSPACE_ID}-versions`;
writeArtifactSelection(workspaceId, { scope: "artifact", path: "report.md", version: null });
const now = Date.now();
const at = (minutesAgo: number) => now - minutesAgo * 60_000;
const base = { sha256: "sha", size: MARKDOWN.length, path: "report.md" } as const;
return (
<APIProvider
client={createMockORPCClient({
artifacts: {
listing: listingFor(FILES),
files: FILES,
versions: {
"report.md": [
{
...base,
version: 3,
label: "Weekly report, final",
source: "publish",
createdAtMs: at(4),
},
{ ...base, version: 2, label: null, source: "turn-end", createdAtMs: at(95) },
{
...base,
version: 1,
label: "First draft",
source: "publish",
createdAtMs: at(60 * 26),
},
],
},
},
})}
>
<div className="bg-background flex h-screen justify-end">
<div className="bg-sidebar border-border-light h-full w-full max-w-[440px] border-l">
<ArtifactsPanel workspaceId={workspaceId} />
</div>
</div>
</APIProvider>
);
}

export const VersionMenuOpen: Story = {
parameters: { pixel: { matrix: { themes: ["dark", "light"], viewports: ["laptop"] } } },
render: renderVersionMenu,
play: async ({ canvasElement }) => {
await waitForMarkdown(canvasElement);
const canvas = within(canvasElement);
await userEvent.click(await canvas.findByRole("button", { name: "Version: Latest (live)" }));
const menu = await canvas.findByRole("menu", { name: "Artifact versions" });
await within(menu).findByText("Turn snapshot");
},
};

// ---------------------------------------------------------------------------------------------
// Escape attempts (executed in a real browser by the Storybook test runner).
//
Expand Down
Loading
Loading