+
{artifactViewState.canPreview && (
)}
+ {(isSaving || isDirty || activeDraft.conflict) && (
+
+ {isSaving
+ ? t.artifactEditing.saving
+ : activeDraft.conflict
+ ? t.artifactEditing.conflictShort
+ : t.artifactEditing.unsaved}
+
+ )}
- {!isWriteFile && filepath.endsWith(".skill") && isAdmin && (
-
-
-
+ {canEdit && !isEditing && (
+ {
+ setViewMode("code");
+ setEditingPath(filepath);
+ }}
+ />
)}
- {!isWriteFile && (
+ {canEdit && isEditing && (
+ <>
+ void handleSave()}
+ />
+ setEditingPath(null)}
+ />
+ {
+ if (confirmDiscard()) {
+ discardDraft();
+ }
+ }}
+ />
+ >
+ )}
+ {!isEditing &&
+ !isWriteFile &&
+ filepath.endsWith(".skill") &&
+ isAdmin && (
+
+
+
+ )}
+ {!isEditing && !isWriteFile && (
)}
- {isCodeFile && (
+ {!isEditing && isCodeFile && (
{
void (async () => {
const didCopy = await writeTextToClipboard(
- visibleContent ?? "",
+ editorContent ?? "",
);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
@@ -319,7 +559,7 @@ export function ArtifactFileDetail({
tooltip={t.clipboard.copyToClipboard}
/>
)}
- {!isWriteFile && (
+ {!isEditing && !isWriteFile && (
setOpen(false)}
+ onClick={() => {
+ if (
+ !hasUnsavedDrafts ||
+ window.confirm(t.artifactEditing.discardChanges)
+ ) {
+ setDrafts({});
+ setEditingPath(null);
+ setOpen(false);
+ }
+ }}
tooltip={t.common.close}
/>
@@ -353,7 +602,7 @@ export function ArtifactFileDetail({
viewMode === "preview" &&
(language === "markdown" || language === "html") && (
{
+ setDrafts((current) => ({
+ ...current,
+ [filepath]: {
+ ...(current[filepath] ?? activeDraft),
+ draftContent: nextContent,
+ },
+ }));
+ }}
+ onSave={() => void handleSave()}
/>
)}
{!isCodeFile && canPreviewInBrowser && (
diff --git a/frontend/src/components/workspace/artifacts/context.tsx b/frontend/src/components/workspace/artifacts/context.tsx
index 33a236930..694c789b6 100644
--- a/frontend/src/components/workspace/artifacts/context.tsx
+++ b/frontend/src/components/workspace/artifacts/context.tsx
@@ -1,15 +1,18 @@
import { usePathname } from "next/navigation";
import {
createContext,
+ type Dispatch,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
+ type SetStateAction,
} from "react";
import { useSidebar } from "@/components/ui/sidebar";
+import type { ArtifactDraftState } from "@/core/artifacts/editing";
import { env } from "@/env";
export interface ArtifactsContextType {
@@ -24,6 +27,11 @@ export interface ArtifactsContextType {
open: boolean;
autoOpen: boolean;
setOpen: (open: boolean) => void;
+
+ drafts: Record;
+ setDrafts: Dispatch>>;
+ editingPath: string | null;
+ setEditingPath: Dispatch>;
}
const ArtifactsContext = createContext(
@@ -82,6 +90,8 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
);
const [autoOpen, setAutoOpen] = useState(true);
+ const [drafts, setDrafts] = useState>({});
+ const [editingPath, setEditingPath] = useState(null);
const { setOpen: setSidebarOpen } = useSidebar();
const pathname = usePathname();
const hydratedPathRef = useRef(null);
@@ -97,9 +107,25 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
setOpen(persisted?.open ?? env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true");
setAutoOpen(true);
setAutoSelect(!persisted?.selectedArtifact);
+ setDrafts({});
+ setEditingPath(null);
hydratedPathRef.current = pathname;
}, [pathname]);
+ useEffect(() => {
+ const hasUnsavedDrafts = Object.values(drafts).some(
+ (draft) => draft.draftContent !== draft.baselineContent,
+ );
+ if (!hasUnsavedDrafts) {
+ return;
+ }
+ const handleBeforeUnload = (event: BeforeUnloadEvent) => {
+ event.preventDefault();
+ };
+ window.addEventListener("beforeunload", handleBeforeUnload);
+ return () => window.removeEventListener("beforeunload", handleBeforeUnload);
+ }, [drafts]);
+
useEffect(() => {
if (!pathname || hydratedPathRef.current !== pathname) {
return;
@@ -151,6 +177,11 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
selectedArtifact,
select,
deselect,
+
+ drafts,
+ setDrafts,
+ editingPath,
+ setEditingPath,
};
return (
diff --git a/frontend/src/components/workspace/code-editor.tsx b/frontend/src/components/workspace/code-editor.tsx
index 84c558c46..1d4fa5841 100644
--- a/frontend/src/components/workspace/code-editor.tsx
+++ b/frontend/src/components/workspace/code-editor.tsx
@@ -41,6 +41,8 @@ export function CodeEditor({
readonly,
disabled,
autoFocus,
+ onChange,
+ onSave,
settings,
}: {
className?: string;
@@ -49,6 +51,8 @@ export function CodeEditor({
readonly?: boolean;
disabled?: boolean;
autoFocus?: boolean;
+ onChange?: (value: string) => void;
+ onSave?: () => void;
settings?: unknown;
}) {
const {
@@ -76,6 +80,15 @@ export function CodeEditor({
"flex cursor-text flex-col overflow-hidden rounded-md",
className,
)}
+ onKeyDown={(event) => {
+ if (
+ (event.metaKey || event.ctrlKey) &&
+ event.key.toLowerCase() === "s"
+ ) {
+ event.preventDefault();
+ onSave?.();
+ }
+ }}
>
{isLoading ? (
) : (
)}
diff --git a/frontend/src/core/artifacts/api.ts b/frontend/src/core/artifacts/api.ts
new file mode 100644
index 000000000..fc67abd35
--- /dev/null
+++ b/frontend/src/core/artifacts/api.ts
@@ -0,0 +1,54 @@
+import { fetch } from "@/core/api/fetcher";
+
+import { urlOfArtifact } from "./utils";
+
+export interface ArtifactUpdateResponse {
+ path: string;
+ sha256: string;
+ size: number;
+}
+
+export class ArtifactRequestError extends Error {
+ readonly status: number;
+
+ constructor(status: number, message: string) {
+ super(message);
+ this.name = "ArtifactRequestError";
+ this.status = status;
+ }
+}
+
+async function readErrorDetail(response: Response): Promise {
+ const data = (await response.json().catch(() => ({}))) as {
+ detail?: string;
+ };
+ return data.detail ?? `HTTP ${response.status}: ${response.statusText}`;
+}
+
+export async function updateArtifactContent({
+ threadId,
+ filepath,
+ content,
+ expectedSha256,
+}: {
+ threadId: string;
+ filepath: string;
+ content: string;
+ expectedSha256: string;
+}): Promise {
+ const response = await fetch(urlOfArtifact({ filepath, threadId }), {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ content,
+ expected_sha256: expectedSha256,
+ }),
+ });
+ if (!response.ok) {
+ throw new ArtifactRequestError(
+ response.status,
+ await readErrorDetail(response),
+ );
+ }
+ return response.json() as Promise;
+}
diff --git a/frontend/src/core/artifacts/editing.ts b/frontend/src/core/artifacts/editing.ts
new file mode 100644
index 000000000..d13ae5182
--- /dev/null
+++ b/frontend/src/core/artifacts/editing.ts
@@ -0,0 +1,67 @@
+export interface ArtifactDraftState {
+ filepath: string;
+ baselineContent: string;
+ baselineSha256: string | null;
+ draftContent: string;
+ conflict: boolean;
+}
+
+export function createArtifactDraft(filepath: string): ArtifactDraftState {
+ return {
+ filepath,
+ baselineContent: "",
+ baselineSha256: null,
+ draftContent: "",
+ conflict: false,
+ };
+}
+
+export function reconcileArtifactDraft(
+ current: ArtifactDraftState,
+ loaded: { content: string; sha256: string },
+): ArtifactDraftState {
+ if (loaded.sha256 === current.baselineSha256) {
+ return current;
+ }
+ if (current.draftContent !== current.baselineContent) {
+ return { ...current, conflict: true };
+ }
+ return {
+ ...current,
+ baselineContent: loaded.content,
+ baselineSha256: loaded.sha256,
+ draftContent: loaded.content,
+ conflict: false,
+ };
+}
+
+export function canEditOpenedArtifact({
+ filepath,
+ isCodeFile,
+ isWriteFile,
+ isSkillFile,
+ isMock,
+ hasRevision,
+ isStaticWebsite,
+}: {
+ filepath: string;
+ isCodeFile: boolean;
+ isWriteFile: boolean;
+ isSkillFile: boolean;
+ isMock: boolean;
+ hasRevision: boolean;
+ isStaticWebsite: boolean;
+}): boolean {
+ const isOutputArtifact = filepath
+ .replace(/^\/+/, "")
+ .startsWith("mnt/user-data/outputs/");
+ return (
+ isCodeFile &&
+ !isWriteFile &&
+ !isSkillFile &&
+ !isMock &&
+ hasRevision &&
+ !isStaticWebsite &&
+ isOutputArtifact
+ );
+}
diff --git a/frontend/src/core/artifacts/hooks.ts b/frontend/src/core/artifacts/hooks.ts
index e66c3bee1..807794a3a 100644
--- a/frontend/src/core/artifacts/hooks.ts
+++ b/frontend/src/core/artifacts/hooks.ts
@@ -49,6 +49,7 @@ export function useArtifactContent({
return {
content: isWriteFile ? content : data?.content,
url: isWriteFile ? undefined : data?.url,
+ sha256: isWriteFile ? undefined : data?.sha256,
isLoading,
error,
};
diff --git a/frontend/src/core/artifacts/index.ts b/frontend/src/core/artifacts/index.ts
index ee5286f0f..d64465559 100644
--- a/frontend/src/core/artifacts/index.ts
+++ b/frontend/src/core/artifacts/index.ts
@@ -1 +1,3 @@
+export * from "./api";
+export * from "./editing";
export * from "./loader";
diff --git a/frontend/src/core/artifacts/loader.ts b/frontend/src/core/artifacts/loader.ts
index d935e1237..0b8d9b2fd 100644
--- a/frontend/src/core/artifacts/loader.ts
+++ b/frontend/src/core/artifacts/loader.ts
@@ -5,6 +5,16 @@ import type { AgentThreadState } from "../threads";
import { buildWriteFileDraftContent } from "./preview";
import { urlOfArtifact } from "./utils";
+async function sha256OfText(content: string): Promise {
+ const digest = await globalThis.crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(content),
+ );
+ return Array.from(new Uint8Array(digest), (byte) =>
+ byte.toString(16).padStart(2, "0"),
+ ).join("");
+}
+
export async function loadArtifactContent({
filepath,
threadId,
@@ -20,8 +30,14 @@ export async function loadArtifactContent({
}
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
const response = await fetch(url, { cache: "no-store" });
+ if (!response.ok) {
+ throw new Error(`Failed to load artifact: HTTP ${response.status}`);
+ }
const text = await response.text();
- return { content: text, url };
+ const etag = response.headers.get("etag");
+ const sha256 =
+ etag?.match(/^"([0-9a-f]{64})"$/)?.[1] ?? (await sha256OfText(text));
+ return { content: text, url, sha256 };
}
export function loadArtifactContentFromToolCall({
diff --git a/frontend/src/core/i18n/locales/en-US.ts b/frontend/src/core/i18n/locales/en-US.ts
index 499e1e12b..b600f9da2 100644
--- a/frontend/src/core/i18n/locales/en-US.ts
+++ b/frontend/src/core/i18n/locales/en-US.ts
@@ -101,6 +101,20 @@ export const enUS: Translations = {
linkCopied: "Link copied to clipboard",
},
+ artifactEditing: {
+ unsaved: "Unsaved",
+ saving: "Saving...",
+ saved: "Artifact saved",
+ exit: "Exit editing",
+ discard: "Discard changes",
+ discardChanges: "Discard the unsaved changes to this artifact?",
+ conflict:
+ "This artifact changed after you started editing. Discard your draft and reload before saving.",
+ conflictShort: "Changed remotely",
+ runInProgress: "Wait for the current agent run to finish before saving.",
+ saveFailed: "Failed to save artifact",
+ },
+
// Citations
citations: {
sourcesSummary: (count) =>
diff --git a/frontend/src/core/i18n/locales/types.ts b/frontend/src/core/i18n/locales/types.ts
index 5a7db16d8..eed963320 100644
--- a/frontend/src/core/i18n/locales/types.ts
+++ b/frontend/src/core/i18n/locales/types.ts
@@ -84,6 +84,19 @@ export interface Translations {
linkCopied: string;
};
+ artifactEditing: {
+ unsaved: string;
+ saving: string;
+ saved: string;
+ exit: string;
+ discard: string;
+ discardChanges: string;
+ conflict: string;
+ conflictShort: string;
+ runInProgress: string;
+ saveFailed: string;
+ };
+
// Citations
citations: {
sourcesSummary: (count: number) => string;
diff --git a/frontend/src/core/i18n/locales/zh-CN.ts b/frontend/src/core/i18n/locales/zh-CN.ts
index 780665817..65c4f3943 100644
--- a/frontend/src/core/i18n/locales/zh-CN.ts
+++ b/frontend/src/core/i18n/locales/zh-CN.ts
@@ -100,6 +100,19 @@ export const zhCN: Translations = {
linkCopied: "链接已复制到剪贴板",
},
+ artifactEditing: {
+ unsaved: "未保存",
+ saving: "正在保存...",
+ saved: "文件已保存",
+ exit: "退出编辑",
+ discard: "放弃修改",
+ discardChanges: "要放弃对此文件的未保存修改吗?",
+ conflict: "开始编辑后文件已发生变化。请放弃草稿并重新加载后再保存。",
+ conflictShort: "远端已更新",
+ runInProgress: "请等待当前 Agent 运行结束后再保存。",
+ saveFailed: "保存文件失败",
+ },
+
// Citations
citations: {
sourcesSummary: (count) => `使用了 ${count} 个来源`,
diff --git a/frontend/tests/unit/core/artifacts/api.test.ts b/frontend/tests/unit/core/artifacts/api.test.ts
new file mode 100644
index 000000000..0a054fe26
--- /dev/null
+++ b/frontend/tests/unit/core/artifacts/api.test.ts
@@ -0,0 +1,60 @@
+import { afterEach, describe, expect, it, rs } from "@rstest/core";
+
+import { updateArtifactContent } from "@/core/artifacts/api";
+
+afterEach(() => {
+ rs.restoreAllMocks();
+});
+
+describe("updateArtifactContent", () => {
+ it("sends the draft and expected revision to the opened artifact URL", async () => {
+ const fetchMock = rs.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ path: "/mnt/user-data/outputs/report.md",
+ sha256: "b".repeat(64),
+ size: 7,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+
+ await updateArtifactContent({
+ threadId: "thread-1",
+ filepath: "/mnt/user-data/outputs/report.md",
+ content: "updated",
+ expectedSha256: "a".repeat(64),
+ });
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0]!;
+ expect(typeof url).toBe("string");
+ expect(url as string).toContain(
+ "/api/threads/thread-1/artifacts/mnt/user-data/outputs/report.md",
+ );
+ expect(init?.method).toBe("PUT");
+ expect(typeof init?.body).toBe("string");
+ expect(JSON.parse(init?.body as string)).toEqual({
+ content: "updated",
+ expected_sha256: "a".repeat(64),
+ });
+ });
+
+ it("preserves the response status for conflict handling", async () => {
+ rs.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response(JSON.stringify({ detail: "Artifact changed" }), {
+ status: 412,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+
+ await expect(
+ updateArtifactContent({
+ threadId: "thread-1",
+ filepath: "/mnt/user-data/outputs/report.md",
+ content: "updated",
+ expectedSha256: "a".repeat(64),
+ }),
+ ).rejects.toMatchObject({ status: 412 });
+ });
+});
diff --git a/frontend/tests/unit/core/artifacts/editing.test.ts b/frontend/tests/unit/core/artifacts/editing.test.ts
new file mode 100644
index 000000000..cdc41a497
--- /dev/null
+++ b/frontend/tests/unit/core/artifacts/editing.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "@rstest/core";
+
+import {
+ canEditOpenedArtifact,
+ createArtifactDraft,
+ reconcileArtifactDraft,
+} from "@/core/artifacts/editing";
+
+describe("artifact draft reconciliation", () => {
+ it("adopts refreshed content while the draft is clean", () => {
+ const draft = createArtifactDraft("/mnt/user-data/outputs/report.md");
+ const loaded = reconcileArtifactDraft(draft, {
+ content: "first",
+ sha256: "a".repeat(64),
+ });
+ const refreshed = reconcileArtifactDraft(loaded, {
+ content: "second",
+ sha256: "b".repeat(64),
+ });
+
+ expect(refreshed.baselineContent).toBe("second");
+ expect(refreshed.draftContent).toBe("second");
+ expect(refreshed.conflict).toBe(false);
+ });
+
+ it("preserves a dirty draft and marks a conflict after a remote change", () => {
+ const loaded = reconcileArtifactDraft(
+ createArtifactDraft("/mnt/user-data/outputs/report.md"),
+ { content: "first", sha256: "a".repeat(64) },
+ );
+ const edited = { ...loaded, draftContent: "my changes" };
+ const refreshed = reconcileArtifactDraft(edited, {
+ content: "agent changes",
+ sha256: "b".repeat(64),
+ });
+
+ expect(refreshed.draftContent).toBe("my changes");
+ expect(refreshed.baselineContent).toBe("first");
+ expect(refreshed.conflict).toBe(true);
+ });
+});
+
+describe("opened artifact edit eligibility", () => {
+ const editable = {
+ filepath: "/mnt/user-data/outputs/report.md",
+ isCodeFile: true,
+ isWriteFile: false,
+ isSkillFile: false,
+ isMock: false,
+ hasRevision: true,
+ isStaticWebsite: false,
+ };
+
+ it("allows an opened formal output rendered by the code editor", () => {
+ expect(canEditOpenedArtifact(editable)).toBe(true);
+ });
+
+ it("does not expose editing for paths outside formal output artifacts", () => {
+ expect(
+ canEditOpenedArtifact({
+ ...editable,
+ filepath: "/mnt/user-data/workspace/report.md",
+ }),
+ ).toBe(false);
+ });
+
+ it("does not expose editing for temporary or non-editor previews", () => {
+ expect(canEditOpenedArtifact({ ...editable, isWriteFile: true })).toBe(
+ false,
+ );
+ expect(canEditOpenedArtifact({ ...editable, isCodeFile: false })).toBe(
+ false,
+ );
+ });
+});
diff --git a/frontend/tests/unit/core/artifacts/loader.test.ts b/frontend/tests/unit/core/artifacts/loader.test.ts
new file mode 100644
index 000000000..1b4717ebb
--- /dev/null
+++ b/frontend/tests/unit/core/artifacts/loader.test.ts
@@ -0,0 +1,43 @@
+import { afterEach, describe, expect, it, rs } from "@rstest/core";
+
+import { loadArtifactContent } from "@/core/artifacts/loader";
+
+afterEach(() => {
+ rs.restoreAllMocks();
+});
+
+describe("loadArtifactContent", () => {
+ it("uses the server content revision when available", async () => {
+ rs.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("content", {
+ status: 200,
+ headers: { ETag: `"${"a".repeat(64)}"` },
+ }),
+ );
+
+ const loaded = await loadArtifactContent({
+ filepath: "/mnt/user-data/outputs/report.md",
+ threadId: "thread-1",
+ });
+
+ expect(loaded.sha256).toBe("a".repeat(64));
+ });
+
+ it("computes a revision for active text responses without a SHA-256 ETag", async () => {
+ rs.spyOn(globalThis, "fetch").mockResolvedValue(
+ new Response("content", {
+ status: 200,
+ headers: { ETag: '"starlette-file-etag"' },
+ }),
+ );
+
+ const loaded = await loadArtifactContent({
+ filepath: "/mnt/user-data/outputs/page.html",
+ threadId: "thread-1",
+ });
+
+ expect(loaded.sha256).toBe(
+ "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
+ );
+ });
+});