diff --git a/apps/web/src/features/panes/FilesPane.tsx b/apps/web/src/features/panes/FilesPane.tsx
index 6bed1f63..19589c3a 100644
--- a/apps/web/src/features/panes/FilesPane.tsx
+++ b/apps/web/src/features/panes/FilesPane.tsx
@@ -1,13 +1,13 @@
// Files pane: lazy workspace tree with search over loaded folders and a
// preview surface that stays honest about what it can show — code, images,
// binaries, read failures, and offline hosts each get their own state.
-import { Badge, cn, Skeleton } from "@t4-code/ui";
+import { Badge, Button, cn, Skeleton } from "@t4-code/ui";
import { ChevronRight, FileText, Folder, ImageIcon, WifiOff } from "lucide-react";
import { useEffect, useMemo } from "react";
import { FamilyEmpty } from "./FamilyEmpty.tsx";
import { PaneHeading } from "./PaneHeading.tsx";
-import { useInspector, type InspectorStoreApi } from "./inspector-store.ts";
+import { useInspector, type FileDraft, type InspectorStoreApi } from "./inspector-store.ts";
import type { FilePreview, FileTreeNode } from "./model.ts";
function formatBytes(bytes: number): string {
@@ -203,11 +203,62 @@ function PreviewBody({ preview }: { readonly preview: FilePreview }) {
}
}
+function EditorBody({
+ api,
+ draft,
+ saveEnabled,
+}: {
+ readonly api: InspectorStoreApi;
+ readonly draft: FileDraft;
+ readonly saveEnabled: boolean;
+}) {
+ const saving = draft.status === "saving";
+ return (
+
+ );
+}
+
export function FilesPane({ api }: { readonly api: InspectorStoreApi }) {
const query = useInspector(api, (state) => state.files.query);
const selectedPath = useInspector(api, (state) => state.files.selectedPath);
const preview = useInspector(api, (state) => state.files.preview);
const offline = useInspector(api, (state) => state.files.offline);
+ const draftsByPath = useInspector(api, (state) => state.files.draftsByPath);
+ const fileWrite = useInspector(api, (state) => state.actions.fileWrite);
+ const draft = selectedPath === null ? undefined : draftsByPath[selectedPath];
+ const editablePreview =
+ preview !== null &&
+ preview !== "loading" &&
+ preview.kind === "code" &&
+ !preview.truncated;
const rootKnown = useInspector(api, (state) => state.files.childrenByPath[""] !== undefined);
// Root loads lazily on first open, like every other directory.
@@ -257,9 +308,56 @@ export function FilesPane({ api }: { readonly api: InspectorStoreApi }) {
{selectedPath}
-
- Read-only
-
+ {draft === undefined ? (
+ <>
+
+ Read-only
+
+ {preview !== null && preview !== "loading" && preview.kind === "code" && (
+
+ )}
+ >
+ ) : (
+ <>
+
+ {draft.status === "saving"
+ ? "Saving"
+ : draft.status === "conflict"
+ ? "Conflict"
+ : draft.status === "error"
+ ? "Error"
+ : "Editing"}
+
+
+
+ >
+ )}
{preview === "loading" || preview === null ? (
@@ -267,7 +365,11 @@ export function FilesPane({ api }: { readonly api: InspectorStoreApi }) {
) : (
-
+ draft === undefined ? (
+
+ ) : (
+
+ )
)}
)}
diff --git a/apps/web/src/features/panes/fixtures.ts b/apps/web/src/features/panes/fixtures.ts
index 5a2deb40..26f33f44 100644
--- a/apps/web/src/features/panes/fixtures.ts
+++ b/apps/web/src/features/panes/fixtures.ts
@@ -14,6 +14,7 @@ import {
type InspectorStoreApi,
resolveDir,
resolvePreview,
+ resolveFileWriteOutcome,
resolveReviewOutcome,
} from "./inspector-store.ts";
import { classifySessionEvent } from "./activity-log.ts";
@@ -743,6 +744,8 @@ function seedForSession(sessionId: string): Partial {
expanded: {},
selectedPath: null,
preview: null,
+ previewRevision: null,
+ draftsByPath: {},
query: "",
offline: true,
},
@@ -790,6 +793,7 @@ function agentsForSession(sessionId: string): readonly AgentNode[] {
}
function fixtureController(api: InspectorStoreApi, clock: () => number): InspectorController {
+ const editedFiles = new Map();
return {
kind: "fixture",
performControl(scope) {
@@ -857,16 +861,25 @@ function fixtureController(api: InspectorStoreApi, clock: () => number): Inspect
resolvePreview(api, { kind: "offline", path });
return;
}
+ const edited = editedFiles.get(path);
resolvePreview(
api,
- FILE_PREVIEWS[path] ?? {
- kind: "diagnostic",
- path,
- message: "The host has no readable content at this path.",
- },
+ edited === undefined
+ ? FILE_PREVIEWS[path] ?? {
+ kind: "diagnostic",
+ path,
+ message: "The host has no readable content at this path.",
+ }
+ : { kind: "code", path, text: edited, truncated: false },
);
});
},
+ writeFile(path, content) {
+ queueMicrotask(() => {
+ editedFiles.set(path, content);
+ resolveFileWriteOutcome(api, path, "saved");
+ });
+ },
};
}
diff --git a/apps/web/src/features/panes/inspector-store.ts b/apps/web/src/features/panes/inspector-store.ts
index 2706fe9f..66d19563 100644
--- a/apps/web/src/features/panes/inspector-store.ts
+++ b/apps/web/src/features/panes/inspector-store.ts
@@ -31,6 +31,17 @@ import {
export type FileChildren = readonly FileTreeNode[] | "loading" | "error";
+export type FileDraftStatus = "clean" | "dirty" | "saving" | "conflict" | "error";
+
+export interface FileDraft {
+ readonly path: string;
+ readonly originalText: string;
+ readonly baseRevision: string | null;
+ readonly text: string;
+ readonly status: FileDraftStatus;
+ readonly message: string | null;
+}
+
export interface ReviewViewState {
readonly files: readonly ReviewFile[];
readonly comments: readonly ReviewComment[];
@@ -47,7 +58,9 @@ export interface FilesViewState {
readonly expanded: Readonly>;
readonly selectedPath: string | null;
readonly preview: FilePreview | "loading" | null;
+ readonly previewRevision: string | null;
readonly query: string;
+ readonly draftsByPath: Readonly>;
/** Host unreachable: the tree stays, previews degrade to offline. */
readonly offline: boolean;
}
@@ -100,6 +113,10 @@ export interface InspectorActions {
setFilesQuery(query: string): void;
setFileExpanded(path: string, expanded: boolean): void;
selectFile(path: string | null): void;
+ startFileEdit(path: string): void;
+ updateFileDraft(path: string, text: string): void;
+ saveFile(path: string): void;
+ discardFileDraft(path: string): void;
}
export type InspectorStore = InspectorState & InspectorActions;
@@ -120,6 +137,8 @@ export interface InspectorController {
loadDir(path: string): void;
/** File preview fetch; resolves through `resolvePreview`. */
loadPreview(path: string): void;
+ /** Full-file write, gated by the authority revision that produced the draft. */
+ writeFile?(path: string, content: string, baseRevision: string | null): void;
}
const INITIAL_REVIEW: ReviewViewState = {
@@ -137,6 +156,8 @@ const INITIAL_FILES: FilesViewState = {
expanded: {},
selectedPath: null,
preview: null,
+ previewRevision: null,
+ draftsByPath: {},
query: "",
offline: false,
};
@@ -269,14 +290,88 @@ export function createInspectorStore(options: CreateInspectorStoreOptions): Insp
}
},
selectFile: (path) => {
- set((state) => ({ files: { ...state.files, selectedPath: path } }));
- if (path === null) {
- set((state) => ({ files: { ...state.files, preview: null } }));
+ set((state) => ({
+ files: {
+ ...state.files,
+ selectedPath: path,
+ preview: path === null ? null : "loading",
+ previewRevision: null,
+ },
+ }));
+ if (path !== null) controller?.loadPreview(path);
+ },
+ startFileEdit: (path) =>
+ set((state) => {
+ if (state.files.draftsByPath[path] !== undefined) return state;
+ const preview = state.files.preview;
+ if (
+ state.files.selectedPath !== path ||
+ preview === null ||
+ preview === "loading" ||
+ preview.kind !== "code" ||
+ preview.path !== path ||
+ preview.truncated
+ )
+ return state;
+ const draft: FileDraft = {
+ path,
+ originalText: preview.text,
+ baseRevision: state.files.previewRevision,
+ text: preview.text,
+ status: "clean",
+ message: null,
+ };
+ return {
+ files: {
+ ...state.files,
+ draftsByPath: { ...state.files.draftsByPath, [path]: draft },
+ },
+ };
+ }),
+ updateFileDraft: (path, text) =>
+ set((state) => {
+ const draft = state.files.draftsByPath[path];
+ if (draft === undefined || draft.status === "saving") return state;
+ return {
+ files: {
+ ...state.files,
+ draftsByPath: {
+ ...state.files.draftsByPath,
+ [path]: {
+ ...draft,
+ text,
+ status: text === draft.originalText ? "clean" : "dirty",
+ message: null,
+ },
+ },
+ },
+ };
+ }),
+ saveFile: (path) => {
+ const draft = get().files.draftsByPath[path];
+ if (draft === undefined || draft.status !== "dirty") return;
+ set((state) => ({
+ files: {
+ ...state.files,
+ draftsByPath: {
+ ...state.files.draftsByPath,
+ [path]: { ...draft, status: "saving", message: null },
+ },
+ },
+ }));
+ if (controller?.writeFile === undefined) {
+ resolveFileWriteOutcome(store, path, "error");
return;
}
- set((state) => ({ files: { ...state.files, preview: "loading" } }));
- controller?.loadPreview(path);
+ controller.writeFile(path, draft.text, draft.baseRevision);
},
+ discardFileDraft: (path) =>
+ set((state) => {
+ if (state.files.draftsByPath[path] === undefined) return state;
+ const draftsByPath = { ...state.files.draftsByPath };
+ delete draftsByPath[path];
+ return { files: { ...state.files, draftsByPath } };
+ }),
}));
controller = options.controller(store);
@@ -297,12 +392,120 @@ export function resolveDir(
}));
}
-export function resolvePreview(api: InspectorStoreApi, preview: FilePreview): void {
- api.setState((state) =>
- state.files.selectedPath === preview.path
- ? { files: { ...state.files, preview } }
- : state,
- );
+export function resolvePreview(
+ api: InspectorStoreApi,
+ preview: FilePreview,
+ baseRevision: string | null = null,
+): void {
+ api.setState((state) => {
+ if (state.files.selectedPath !== preview.path) return state;
+ const draft = state.files.draftsByPath[preview.path];
+ const files = { ...state.files, preview, previewRevision: baseRevision };
+ if (draft === undefined) return { files };
+ if (preview.kind !== "code") {
+ const shouldConflict = draft.status === "dirty" || draft.status === "saving";
+ return {
+ files: {
+ ...files,
+ ...(shouldConflict
+ ? {
+ draftsByPath: {
+ ...state.files.draftsByPath,
+ [preview.path]: {
+ ...draft,
+ status: "conflict",
+ message:
+ "The host could not confirm this file's current text. Your draft is safe; discard it only when you are ready to reload.",
+ },
+ },
+ }
+ : {}),
+ },
+ };
+ }
+ if (preview.text === draft.originalText || (draft.status === "saving" && preview.text === draft.text)) {
+ if (draft.status !== "clean") return { files };
+ return {
+ files: {
+ ...files,
+ draftsByPath: {
+ ...state.files.draftsByPath,
+ [preview.path]: { ...draft, baseRevision },
+ },
+ },
+ };
+ }
+ if (draft.status === "clean") {
+ return {
+ files: {
+ ...files,
+ draftsByPath: {
+ ...state.files.draftsByPath,
+ [preview.path]: {
+ ...draft,
+ baseRevision,
+ originalText: preview.text,
+ text: preview.text,
+ },
+ },
+ },
+ };
+ }
+ return {
+ files: {
+ ...files,
+ draftsByPath: {
+ ...state.files.draftsByPath,
+ [preview.path]: {
+ ...draft,
+ status: "conflict",
+ message:
+ "The file changed on the host while you were editing. Your draft is safe and will not overwrite it.",
+ },
+ },
+ },
+ };
+ });
+}
+
+export function resolveFileWriteOutcome(
+ api: InspectorStoreApi,
+ path: string,
+ outcome: "saved" | "conflict" | "error",
+): void {
+ api.setState((state) => {
+ const draft = state.files.draftsByPath[path];
+ if (draft === undefined) return state;
+ if (outcome === "saved") {
+ const draftsByPath = { ...state.files.draftsByPath };
+ delete draftsByPath[path];
+ return {
+ files: {
+ ...state.files,
+ draftsByPath,
+ ...(state.files.selectedPath === path
+ ? { preview: { kind: "code" as const, path, text: draft.text, truncated: false } }
+ : {}),
+ },
+ };
+ }
+ return {
+ files: {
+ ...state.files,
+ draftsByPath: {
+ ...state.files.draftsByPath,
+ [path]: {
+ ...draft,
+ status: outcome,
+ message:
+ outcome === "conflict"
+ ? "The host could not confirm this draft's base revision. Your draft is safe; discard it only when you are ready to reload."
+ : "The host did not confirm this save. Your draft is safe and was not resent.",
+ },
+ },
+ },
+ };
+ });
}
/** Review outcome applied by a controller once the runtime confirms it. */
diff --git a/apps/web/src/features/panes/live-inspector.ts b/apps/web/src/features/panes/live-inspector.ts
index 79863192..3b1f07ec 100644
--- a/apps/web/src/features/panes/live-inspector.ts
+++ b/apps/web/src/features/panes/live-inspector.ts
@@ -28,6 +28,7 @@ import {
installInspectorStoreFactory,
resolveDir,
resolvePreview,
+ resolveFileWriteOutcome,
resolveReviewOutcome,
type InspectorController,
type InspectorStoreApi,
@@ -143,6 +144,11 @@ export function deriveActionAvailability(
apply.enabled && !revisionKnown
? { enabled: false, reason: "Waiting for this session's latest state." }
: apply;
+ const write = commandAvailability(snapshot, targetId, hostId, "files.write");
+ const fileWrite =
+ write.enabled && !revisionKnown
+ ? { enabled: false, reason: "Waiting for this session's latest state." }
+ : write;
const writeGate =
sessionWriteLink(snapshot, targetId, hostId, sessionId) === "live" ? null : SYNCING_WRITE;
return {
@@ -151,6 +157,7 @@ export function deriveActionAvailability(
agentWake: NO_WAKE,
reviewApply: writeGate !== null && reviewApply.enabled ? writeGate : reviewApply,
reviewDiscard: NO_DISCARD,
+ fileWrite: writeGate !== null && fileWrite.enabled ? writeGate : fileWrite,
};
}
@@ -161,6 +168,7 @@ function sameAvailability(a: InspectorActionAvailability, b: InspectorActionAvai
[a.agentWake, b.agentWake],
[a.reviewApply, b.reviewApply],
[a.reviewDiscard, b.reviewDiscard],
+ [a.fileWrite, b.fileWrite],
];
return pairs.every(
([left, right]) => left.enabled === right.enabled && left.reason === right.reason,
@@ -224,7 +232,7 @@ export function createLiveInspectorStore(
/** Directory paths resolved from pushed file frames; refreshed on sync. */
const frameDirs = new Set();
const pendingDirs = new Map();
- const pendingPreviews = new Map();
+ const pendingPreviews = new Map();
const pendingReviewApplies = new Map();
let reviewFiles: readonly ReviewFile[] = [];
let reviewIdByPath: ReadonlyMap = new Map();
@@ -343,9 +351,10 @@ export function createLiveInspectorStore(
},
loadPreview(path) {
const snapshot = runtime.getSnapshot();
- const frame = warmSession(snapshot)?.files.get(path);
+ const warm = warmSession(snapshot);
+ const frame = warm?.files.get(path);
if (frame !== undefined && frame.content !== undefined) {
- resolvePreview(api, previewFromFileFrame(frame));
+ resolvePreview(api, previewFromFileFrame(frame), warm?.revision ?? null);
return;
}
if (snapshot.connections.get(address.targetId) !== "connected") {
@@ -359,10 +368,15 @@ export function createLiveInspectorStore(
"files.read",
);
if (readable.enabled) {
+ const readRevision = expectedRevision();
void sendCommand("files.read", { path }, false)
.then((result) => {
- if (result.accepted) pendingPreviews.set(result.requestId, path);
- else {
+ if (result.accepted) {
+ pendingPreviews.set(result.requestId, {
+ path,
+ baseRevision: readRevision === undefined ? null : String(readRevision),
+ });
+ } else {
resolvePreview(api, {
kind: "diagnostic",
path,
@@ -384,7 +398,47 @@ export function createLiveInspectorStore(
frame !== undefined
? previewFromFileFrame(frame)
: { kind: "diagnostic", path, message: "This host cannot read files from here." },
+ warm?.revision ?? null,
+ );
+ },
+ writeFile(path, content, baseRevision) {
+ const snapshot = runtime.getSnapshot();
+ const writable = commandAvailability(
+ snapshot,
+ address.targetId,
+ address.hostId,
+ "files.write",
);
+ const revisionValue = baseRevision === null ? undefined : brandRevision(baseRevision);
+ if (
+ !isSafeRelativePath(path) ||
+ !writable.enabled ||
+ !writableNow(snapshot) ||
+ revisionValue === undefined
+ ) {
+ resolveFileWriteOutcome(api, path, "conflict");
+ return;
+ }
+ void runtime
+ .command(address.targetId, {
+ hostId: wireHostId,
+ sessionId: wireSessionId,
+ command: "files.write",
+ args: { path, content },
+ expectedRevision: revisionValue,
+ })
+ .then((result) =>
+ resolveFileWriteOutcome(
+ api,
+ path,
+ result.accepted
+ ? "saved"
+ : result.error?.code === "stale_revision"
+ ? "conflict"
+ : "error",
+ ),
+ )
+ .catch(() => resolveFileWriteOutcome(api, path, "error"));
},
});
@@ -412,6 +466,7 @@ export function createLiveInspectorStore(
...nextAvailability,
agentCancel: nextAvailability.agentCancel.enabled ? gate : nextAvailability.agentCancel,
reviewApply: nextAvailability.reviewApply.enabled ? gate : nextAvailability.reviewApply,
+ fileWrite: nextAvailability.fileWrite.enabled ? gate : nextAvailability.fileWrite,
};
}
if (availability === null || !sameAvailability(availability, nextAvailability)) {
@@ -462,7 +517,7 @@ export function createLiveInspectorStore(
resolveDir(store, path, "error");
}
}
- for (const [requestId, path] of pendingPreviews) {
+ for (const [requestId, pending] of pendingPreviews) {
const result = warm.results.get(requestId);
if (result === undefined) continue;
pendingPreviews.delete(requestId);
@@ -473,8 +528,9 @@ export function createLiveInspectorStore(
resolvePreview(
store,
typeof content === "string"
- ? { kind: "code", path, text: content, truncated: content.length >= 8192 }
- : { kind: "diagnostic", path, message: "The host could not read this file." },
+ ? { kind: "code", path: pending.path, text: content, truncated: content.length >= 8192 }
+ : { kind: "diagnostic", path: pending.path, message: "The host could not read this file." },
+ pending.baseRevision,
);
}
for (const [requestId, path] of pendingReviewApplies) {
@@ -506,7 +562,7 @@ export function createLiveInspectorStore(
if (selectedPath !== null && preview === "loading") {
const frame = warm.files.get(selectedPath);
if (frame !== undefined && frame.content !== undefined) {
- resolvePreview(store, previewFromFileFrame(frame));
+ resolvePreview(store, previewFromFileFrame(frame), warm.revision ?? null);
}
}
};
diff --git a/apps/web/src/features/panes/model.ts b/apps/web/src/features/panes/model.ts
index 67dc5a85..a5425a97 100644
--- a/apps/web/src/features/panes/model.ts
+++ b/apps/web/src/features/panes/model.ts
@@ -187,6 +187,7 @@ export interface InspectorActionAvailability {
readonly agentWake: PaneActionAvailability;
readonly reviewApply: PaneActionAvailability;
readonly reviewDiscard: PaneActionAvailability;
+ readonly fileWrite: PaneActionAvailability;
}
const AVAILABLE: PaneActionAvailability = Object.freeze({ enabled: true, reason: null });
@@ -198,4 +199,5 @@ export const ALL_ACTIONS_AVAILABLE: InspectorActionAvailability = Object.freeze(
agentWake: AVAILABLE,
reviewApply: AVAILABLE,
reviewDiscard: AVAILABLE,
+ fileWrite: AVAILABLE,
});
diff --git a/apps/web/test/panes-files.test.ts b/apps/web/test/panes-files.test.ts
index c7bd78fb..1e9a5ae3 100644
--- a/apps/web/test/panes-files.test.ts
+++ b/apps/web/test/panes-files.test.ts
@@ -6,6 +6,7 @@ import {
createInspectorStore,
resolveDir,
resolvePreview,
+ resolveFileWriteOutcome,
type InspectorStoreApi,
} from "../src/features/panes/inspector-store.ts";
import type { FileTreeNode } from "../src/features/panes/model.ts";
@@ -15,9 +16,15 @@ const ROOT: FileTreeNode[] = [
{ path: "README.md", name: "README.md", kind: "file" },
];
-function storeWithDirs(): { api: InspectorStoreApi; dirCalls: string[]; previewCalls: string[] } {
+function storeWithDirs(): {
+ api: InspectorStoreApi;
+ dirCalls: string[];
+ previewCalls: string[];
+ writeCalls: Array<{ path: string; content: string; baseRevision: string | null }>;
+} {
const dirCalls: string[] = [];
const previewCalls: string[] = [];
+ const writeCalls: Array<{ path: string; content: string; baseRevision: string | null }> = [];
const api = createInspectorStore({
sampleMode: true,
controller: () => ({
@@ -26,9 +33,10 @@ function storeWithDirs(): { api: InspectorStoreApi; dirCalls: string[]; previewC
performReview: () => {},
loadDir: (path) => dirCalls.push(path),
loadPreview: (path) => previewCalls.push(path),
+ writeFile: (path, content, baseRevision) => writeCalls.push({ path, content, baseRevision }),
}),
});
- return { api, dirCalls, previewCalls };
+ return { api, dirCalls, previewCalls, writeCalls };
}
describe("lazy file tree", () => {
@@ -76,3 +84,126 @@ describe("lazy file tree", () => {
expect(api.getState().files.preview).toBeNull();
});
});
+
+describe("file drafts", () => {
+ it("edits locally, saves through the controller, and promotes confirmed text", () => {
+ const { api, writeCalls } = storeWithDirs();
+ api.getState().selectFile("src/app.ts");
+ resolvePreview(
+ api,
+ {
+ kind: "code",
+ path: "src/app.ts",
+ text: "const value = 1;\n",
+ truncated: false,
+ },
+ "rev-1",
+ );
+ api.getState().startFileEdit("src/app.ts");
+ expect(api.getState().files.draftsByPath["src/app.ts"]).toMatchObject({
+ status: "clean",
+ originalText: "const value = 1;\n",
+ baseRevision: "rev-1",
+ text: "const value = 1;\n",
+ });
+ api.getState().updateFileDraft("src/app.ts", "const value = 2;\n");
+ api.getState().saveFile("src/app.ts");
+
+ expect(writeCalls).toEqual([
+ { path: "src/app.ts", content: "const value = 2;\n", baseRevision: "rev-1" },
+ ]);
+ expect(api.getState().files.draftsByPath["src/app.ts"]?.status).toBe("saving");
+
+ resolveFileWriteOutcome(api, "src/app.ts", "saved");
+ expect(api.getState().files.draftsByPath["src/app.ts"]).toBeUndefined();
+ expect(api.getState().files.preview).toEqual({
+ kind: "code",
+ path: "src/app.ts",
+ text: "const value = 2;\n",
+ truncated: false,
+ });
+ });
+
+ it("keeps a dirty draft and marks a later host version as a conflict", () => {
+ const { api } = storeWithDirs();
+ api.getState().selectFile("src/app.ts");
+ resolvePreview(api, {
+ kind: "code",
+ path: "src/app.ts",
+ text: "before\n",
+ truncated: false,
+ });
+ api.getState().startFileEdit("src/app.ts");
+ api.getState().updateFileDraft("src/app.ts", "mine\n");
+
+ resolvePreview(api, {
+ kind: "code",
+ path: "src/app.ts",
+ text: "theirs\n",
+ truncated: false,
+ });
+
+ expect(api.getState().files.draftsByPath["src/app.ts"]).toMatchObject({
+ originalText: "before\n",
+ text: "mine\n",
+ status: "conflict",
+ });
+ expect(api.getState().files.preview).toMatchObject({ text: "theirs\n" });
+ });
+
+ it("blocks a dirty draft when a reload cannot confirm editable text", () => {
+ const { api } = storeWithDirs();
+ api.getState().selectFile("src/app.ts");
+ resolvePreview(api, {
+ kind: "code",
+ path: "src/app.ts",
+ text: "before\n",
+ truncated: false,
+ });
+ api.getState().startFileEdit("src/app.ts");
+ api.getState().updateFileDraft("src/app.ts", "mine\n");
+
+ resolvePreview(api, {
+ kind: "diagnostic",
+ path: "src/app.ts",
+ message: "The host could not read this file.",
+ });
+
+ expect(api.getState().files.draftsByPath["src/app.ts"]).toMatchObject({
+ text: "mine\n",
+ status: "conflict",
+ });
+ expect(api.getState().files.preview).toMatchObject({ kind: "diagnostic" });
+ });
+
+ it("never starts an editable draft from a truncated preview", () => {
+ const { api } = storeWithDirs();
+ api.getState().selectFile("large.ts");
+ resolvePreview(api, {
+ kind: "code",
+ path: "large.ts",
+ text: "partial",
+ truncated: true,
+ });
+ api.getState().startFileEdit("large.ts");
+ expect(api.getState().files.draftsByPath["large.ts"]).toBeUndefined();
+ });
+ it("uses generic safety copy when the host cannot confirm the draft's base revision", () => {
+ const { api } = storeWithDirs();
+ api.getState().selectFile("src/app.ts");
+ resolvePreview(
+ api,
+ { kind: "code", path: "src/app.ts", text: "before\n", truncated: false },
+ "rev-1",
+ );
+ api.getState().startFileEdit("src/app.ts");
+ api.getState().updateFileDraft("src/app.ts", "mine\n");
+
+ resolveFileWriteOutcome(api, "src/app.ts", "conflict");
+
+ expect(api.getState().files.draftsByPath["src/app.ts"]?.message).toBe(
+ "The host could not confirm this draft's base revision. Your draft is safe; discard it only when you are ready to reload.",
+ );
+ });
+
+});
diff --git a/apps/web/test/panes-live.test.ts b/apps/web/test/panes-live.test.ts
index c4a62270..6dbb5762 100644
--- a/apps/web/test/panes-live.test.ts
+++ b/apps/web/test/panes-live.test.ts
@@ -163,6 +163,7 @@ function responseFrame(
requestId: string,
ok: boolean,
result?: Record,
+ errorCode = "denied",
): ResultFrame {
return {
v: PROTOCOL_VERSION,
@@ -173,7 +174,7 @@ function responseFrame(
sessionId: brandSessionId(SESSION),
ok,
...(result === undefined ? {} : { result }),
- ...(ok ? {} : { error: { code: "denied", message: "The host said no." } }),
+ ...(ok ? {} : { error: { code: errorCode, message: "The host said no." } }),
};
}
@@ -258,6 +259,7 @@ class FakeRuntime implements LiveInspectorRuntime {
commands: CommandIntent[] = [];
targets: string[] = [];
failCommands = false;
+ commandResult: Pick = { accepted: true };
private snapshotValue: DesktopRuntimeSnapshot;
private readonly listeners = new Set<(snapshot: DesktopRuntimeSnapshot) => void>();
private readonly settled: Promise[] = [];
@@ -304,7 +306,7 @@ class FakeRuntime implements LiveInspectorRuntime {
targetId,
requestId: `req-${this.requestCounter}`,
commandId: `cmd-${this.requestCounter}`,
- accepted: true,
+ ...this.commandResult,
});
this.settled.push(result);
return result;
@@ -346,6 +348,7 @@ const AGENT_CATALOG: CatalogFrame = {
items: [
{ id: brandCatalogId("agent.cancel"), kind: "command", name: "agent.cancel" },
{ id: brandCatalogId("review.apply"), kind: "command", name: "review.apply" },
+ { id: brandCatalogId("files.write"), kind: "command", name: "files.write" },
],
};
@@ -743,6 +746,7 @@ describe("live pane actions", () => {
};
expect(store.getState().actions.agentCancel).toEqual(expected);
expect(store.getState().actions.reviewApply).toEqual(expected);
+ expect(store.getState().actions.fileWrite).toEqual(expected);
});
it("steer, wake, and discard have no wire command: disabled, honest, silent", async () => {
@@ -793,6 +797,79 @@ describe("live pane actions", () => {
expect(store.getState().review.files[0]?.applyState).toBe("applied");
});
+ it("file save sends revision-gated text and settles from its final command result", async () => {
+ const fake = new FakeRuntime({ catalog: AGENT_CATALOG });
+ const base = project([
+ snapshotFrame("rev-3"),
+ fileFrame("src/app.ts", "const value = 1;\n"),
+ ]);
+ fake.setProjection(base);
+ const store = createLiveInspectorStore(fake, VIEW_ID);
+ expect(store.getState().actions.fileWrite.enabled).toBe(true);
+
+ store.getState().selectFile("src/app.ts");
+ store.getState().startFileEdit("src/app.ts");
+ store.getState().updateFileDraft("src/app.ts", "const value = 2;\n");
+ store.getState().saveFile("src/app.ts");
+ await fake.settle();
+
+ expect(fake.commands).toEqual([
+ {
+ hostId: HOST,
+ sessionId: SESSION,
+ command: "files.write",
+ args: { path: "src/app.ts", content: "const value = 2;\n" },
+ expectedRevision: "rev-3",
+ },
+ ]);
+ expect(store.getState().files.draftsByPath["src/app.ts"]).toBeUndefined();
+ expect(store.getState().files.preview).toMatchObject({ text: "const value = 2;\n" });
+ });
+
+ it("pins a file save to the revision that produced its draft", async () => {
+ const fake = new FakeRuntime({ catalog: AGENT_CATALOG });
+ const base = project([
+ snapshotFrame("rev-3"),
+ fileFrame("src/app.ts", "const value = 1;\n"),
+ ]);
+ fake.setProjection(base);
+ const store = createLiveInspectorStore(fake, VIEW_ID);
+
+ store.getState().selectFile("src/app.ts");
+ store.getState().startFileEdit("src/app.ts");
+ store.getState().updateFileDraft("src/app.ts", "const value = 2;\n");
+ fake.setProjection(project([snapshotFrame("rev-4")], base));
+ store.getState().saveFile("src/app.ts");
+ await fake.settle();
+
+ expect(fake.commands[0]?.expectedRevision).toBe("rev-3");
+ });
+
+ it("a stale file save preserves the draft as a conflict", async () => {
+ const fake = new FakeRuntime({ catalog: AGENT_CATALOG });
+ const base = project([
+ snapshotFrame("rev-3"),
+ fileFrame("src/app.ts", "before\n"),
+ ]);
+ fake.setProjection(base);
+ const store = createLiveInspectorStore(fake, VIEW_ID);
+ fake.commandResult = {
+ accepted: false,
+ error: { code: "stale_revision", message: "revision changed" },
+ };
+ store.getState().selectFile("src/app.ts");
+ store.getState().startFileEdit("src/app.ts");
+ store.getState().updateFileDraft("src/app.ts", "mine\n");
+ store.getState().saveFile("src/app.ts");
+ await fake.settle();
+
+ expect(store.getState().files.draftsByPath["src/app.ts"]).toMatchObject({
+ text: "mine\n",
+ status: "conflict",
+ });
+ expect(fake.commands).toHaveLength(1);
+ });
+
it("a denied review apply keeps the row pending", async () => {
const fake = new FakeRuntime({ catalog: AGENT_CATALOG });
const base = project([