Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ Open the printed URL. You can also download the portable Node.js package from th

Most artifacts are published by an agent through the [Artifact Server Skill](./skills/artifact-server/SKILL.md) or [MCP](./docs/mcp.md). For direct access, use the `artifactserver` CLI to publish files and directories, connect to remote servers, and create new versions. [Read the CLI guide](./docs/cli.md).

HTML prototypes can open in Interact mode so people can use them immediately. Set `--review-mode interact` when creating an artifact, or share a Review link with `mode=interact`. Annotate remains the default, and reviewers can switch modes at any time. [Read about Review mode](./docs/review-mode.md).

## Security model

Artifact Server serves untrusted artifact content from isolated version hosts, separate from the trusted application origin used for authentication, review, comments, API, and MCP. Private content uses version-scoped browser sessions, and untrusted paths cannot select storage locations.
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";

const accessSettingSchema = z.enum(["account_required", "public_link"]);
const reviewModeSchema = z.enum(["annotate", "interact"]);
const membershipRoleSchema = z.enum(["administrator", "member"]);
const principalKindSchema = z.enum(["human", "service"]);
const capabilitySchema = z.enum([
Expand Down Expand Up @@ -135,6 +136,7 @@ const artifactSchema = z.object({
id: z.string(),
name: z.string(),
projectId: z.string(),
reviewMode: reviewModeSchema.default("annotate"),
tags: z.array(z.string()),
});

Expand Down Expand Up @@ -189,6 +191,7 @@ const apiErrorBodySchema = z.object({
const noContentStatuses = new Set([204, 205]);

export type AccessSetting = z.infer<typeof accessSettingSchema>;
export type ReviewMode = z.infer<typeof reviewModeSchema>;
export type DeploymentCapabilities = z.infer<typeof deploymentCapabilitiesSchema>;
export type SourceFreshness = z.infer<typeof sourceFreshnessSchema>;
export type SourceBinding = z.infer<typeof sourceBindingSchema>;
Expand Down Expand Up @@ -295,6 +298,7 @@ export interface ArtifactAction {
readonly action:
| "capture"
| "change_access"
| "change_review_mode"
| "change_tags"
| "comment_create"
| "comment_delete"
Expand Down Expand Up @@ -394,6 +398,7 @@ const actionSchema: z.ZodType<ArtifactAction> = z.object({
action: z.enum([
"capture",
"change_access",
"change_review_mode",
"change_tags",
"comment_create",
"comment_delete",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/presentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export function actionLabel(value: ArtifactAction["action"]): string {
return "Relinked source file";
case "change_access":
return "Changed access";
case "change_review_mode":
return "Changed review mode";
case "change_tags":
return "Replaced tags";
case "comment_create":
Expand Down
60 changes: 52 additions & 8 deletions apps/web/src/review/review-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
type ArtifactPage,
type ArtifactVersion,
type Project,
type ReviewMode,
type Session,
type Version,
} from "@/api/client";
Expand Down Expand Up @@ -142,6 +143,7 @@ interface ReviewDownload {

interface ReviewLocation {
readonly artifactId: string | null;
readonly mode: ReviewMode | null;
readonly path: string | null;
readonly projectId: string | null;
readonly versionId: string | null;
Expand Down Expand Up @@ -533,6 +535,8 @@ function ArtifactReview({
const [selectedPath, setSelectedPath] = useState<string | null>(
initialLocation.path,
);
const [modeOverride, setModeOverride] = useState(initialLocation.mode);
const [reviewModeRestoration, setReviewModeRestoration] = useState(0);
const [query, setQuery] = useState("");
const searchQuery = useDeferredValue(query.trim());
const [catalogCommentFilter, setCatalogCommentFilter] = useState<CatalogCommentFilter>("all");
Expand Down Expand Up @@ -565,7 +569,37 @@ function ArtifactReview({
const [focusControlsCollapsed, setFocusControlsCollapsed] = useState(false);
const [focusControlsHoverArmed, setFocusControlsHoverArmed] = useState(true);
const [focusControlsInstant, setFocusControlsInstant] = useState(false);
const [htmlAnnotateModeActive, setHtmlAnnotateModeActive] = useState(true);
const [htmlAnnotateModeActive, setHtmlAnnotateModeActive] = useState(
initialLocation.mode !== "interact",
);
// History restores the URL's mode; refreshing details preserves manual choices.
const reviewModeContext = `${projectId}:${selectedArtifactId}:${modeOverride}:${reviewModeRestoration}`;
const reviewModeInitializationRef = useRef({
context: reviewModeContext,
initialized: initialLocation.mode !== null,
});
const changeHtmlAnnotateMode = useCallback((
active: boolean | ((current: boolean) => boolean),
): void => {
reviewModeInitializationRef.current.initialized = true;
setHtmlAnnotateModeActive(active);
}, []);
useEffect(() => {
const initialization = reviewModeInitializationRef.current;
if (initialization.context !== reviewModeContext) {
initialization.context = reviewModeContext;
initialization.initialized = modeOverride !== null;
setHtmlAnnotateModeActive(modeOverride !== "interact");
}
if (
!initialization.initialized
&& details?.artifact.id === selectedArtifactId
&& details.artifact.projectId === projectId
) {
initialization.initialized = true;
setHtmlAnnotateModeActive(details.artifact.reviewMode !== "interact");
}
}, [details, modeOverride, projectId, reviewModeContext, selectedArtifactId]);
const [homeOpen, setHomeOpen] = useState(false);
const focusCommentsButtonRef = useRef<HTMLButtonElement>(null);
const focusControlsRestoreRef = useRef<HTMLButtonElement>(null);
Expand Down Expand Up @@ -888,14 +922,15 @@ function ArtifactReview({
useEffect(() => {
const href = reviewHref({
artifactId: selectedArtifactId,
mode: modeOverride,
path: selectedPath,
projectId,
versionId: selectedVersionId,
view: focusMode ? "focus" : null,
});
window.history.replaceState(null, "", href);
window.sessionStorage.setItem("artifact-review-return-url", href);
}, [focusMode, projectId, selectedArtifactId, selectedPath, selectedVersionId]);
}, [focusMode, modeOverride, projectId, selectedArtifactId, selectedPath, selectedVersionId]);

useEffect(() => {
const restoreLocation = (): void => {
Expand All @@ -906,6 +941,8 @@ function ArtifactReview({
setProjectId(restored.projectId);
}
setSelectedArtifactId(restored.artifactId);
setModeOverride(restored.mode);
setReviewModeRestoration((restoration) => restoration + 1);
setSelectedVersionId(restored.versionId);
setSelectedPath(restored.path);
setFocusMode(restored.view === "focus");
Expand Down Expand Up @@ -990,6 +1027,7 @@ function ArtifactReview({
const selectManifestPath = (path: string): void => {
window.history.pushState(null, "", reviewHref({
artifactId: selectedArtifactId,
mode: modeOverride,
path,
projectId,
versionId: selectedVersionId,
Expand Down Expand Up @@ -1148,6 +1186,7 @@ function ArtifactReview({
const enterFocusMode = useCallback((): void => {
window.history.pushState(null, "", reviewHref({
artifactId: selectedArtifactId,
mode: modeOverride,
path: selectedPath,
projectId,
versionId: selectedVersionId,
Expand All @@ -1156,10 +1195,11 @@ function ArtifactReview({
setFocusCommentsOpen(false);
setFocusControlsCollapsed(false);
setFocusMode(true);
}, [projectId, selectedArtifactId, selectedPath, selectedVersionId]);
}, [modeOverride, projectId, selectedArtifactId, selectedPath, selectedVersionId]);
const exitFocusMode = useCallback((): void => {
window.history.pushState(null, "", reviewHref({
artifactId: selectedArtifactId,
mode: modeOverride,
path: selectedPath,
projectId,
versionId: selectedVersionId,
Expand All @@ -1168,7 +1208,7 @@ function ArtifactReview({
setFocusCommentsOpen(false);
setFocusControlsCollapsed(false);
setFocusMode(false);
}, [projectId, selectedArtifactId, selectedPath, selectedVersionId]);
}, [modeOverride, projectId, selectedArtifactId, selectedPath, selectedVersionId]);
useEffect(() => {
const handleReviewShortcut = (event: KeyboardEvent): void => {
if (homeOpen || reviewShortcutBlocked(event)) return;
Expand All @@ -1180,7 +1220,7 @@ function ArtifactReview({
setFocusCommentsOpen(false);
} else if (htmlAnnotateModeActive) {
event.preventDefault();
setHtmlAnnotateModeActive(false);
changeHtmlAnnotateMode(false);
} else if (focusMode) {
event.preventDefault();
exitFocusMode();
Expand Down Expand Up @@ -1226,6 +1266,7 @@ function ArtifactReview({
return () => window.removeEventListener("keydown", handleReviewShortcut);
}, [
catalogItems,
changeHtmlAnnotateMode,
enterFocusMode,
exitFocusMode,
focusCommentsOpen,
Expand Down Expand Up @@ -1635,7 +1676,7 @@ function ArtifactReview({
aria-pressed={htmlAnnotateModeActive}
className="as-button as-focus-controls__button"
data-active={htmlAnnotateModeActive}
onClick={() => setHtmlAnnotateModeActive((active) => !active)}
onClick={() => changeHtmlAnnotateMode((active) => !active)}
title={htmlAnnotateModeActive
? "Annotate mode: click an element or select text to comment. Press Escape to interact."
: "Interact mode: links and controls work normally. Select text or turn annotation mode back on to comment."}
Expand Down Expand Up @@ -1763,7 +1804,7 @@ function ArtifactReview({
label={htmlAnnotateModeActive
? "Annotate mode: click an element or select text to comment. Press Escape to interact."
: "Interact mode: links and controls work normally. Select text or turn annotation mode back on to comment."}
onClick={() => setHtmlAnnotateModeActive((active) => !active)}
onClick={() => changeHtmlAnnotateMode((active) => !active)}
>
<HugeiconsIcon icon={Edit02Icon} strokeWidth={1.8} />
</IconButton>
Expand Down Expand Up @@ -1838,7 +1879,7 @@ function ArtifactReview({
artifactName={details?.artifact.name ?? selectedItem?.artifact.name ?? "Artifact"}
isLight={theme === "dawn"}
onOpenRawArtifact={() => void openRawArtifact()}
onAnnotateModeChange={setHtmlAnnotateModeActive}
onAnnotateModeChange={changeHtmlAnnotateMode}
onSelectAnnotation={(threadId) => {
comments.selectThread(threadId);
if (threadId !== null) {
Expand Down Expand Up @@ -2753,8 +2794,10 @@ function readInitialInspectorOpen(): boolean {

function readReviewLocation(): ReviewLocation {
const search = new URLSearchParams(window.location.search);
const mode = search.get("mode");
return {
artifactId: search.get("artifact"),
mode: mode === "annotate" || mode === "interact" ? mode : null,
path: search.get("path"),
projectId: search.get("project"),
versionId: search.get("version"),
Expand All @@ -2768,6 +2811,7 @@ function reviewHref(location: ReviewLocation): string {
search.set("project", location.projectId);
}
if (location.artifactId !== null) search.set("artifact", location.artifactId);
if (location.mode !== null) search.set("mode", location.mode);
if (location.versionId !== null) search.set("version", location.versionId);
if (location.path !== null) search.set("path", location.path);
if (location.view !== null) search.set("view", location.view);
Expand Down
Loading