diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx
index 8d0697d7..e133ed78 100644
--- a/apps/yaak-client/components/HttpResponsePane.tsx
+++ b/apps/yaak-client/components/HttpResponsePane.tsx
@@ -30,6 +30,7 @@ import { EmptyStateText } from "./EmptyStateText";
import { ErrorBoundary } from "./ErrorBoundary";
import { HttpResponseTimeline } from "./HttpResponseTimeline";
import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown";
+import { RequestVersionDropdown } from "./RequestVersionDropdown";
import { RequestBodyViewer } from "./RequestBodyViewer";
import { ResponseCookies } from "./ResponseCookies";
import { ResponseHeaders } from "./ResponseHeaders";
@@ -263,13 +264,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) {
) : (
)}
-
+
+
-
+
)}
diff --git a/apps/yaak-client/components/RequestVersionDropdown.tsx b/apps/yaak-client/components/RequestVersionDropdown.tsx
new file mode 100644
index 00000000..57e04319
--- /dev/null
+++ b/apps/yaak-client/components/RequestVersionDropdown.tsx
@@ -0,0 +1,79 @@
+import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models";
+import { Icon } from "@yaakapp-internal/ui";
+import { stringify } from "yaml";
+import { useRequestVersion } from "../hooks/useRequestVersion";
+import { showDialog } from "../lib/dialog";
+import { restoreRequestVersion } from "../lib/restoreRequestVersion";
+import { Button } from "./core/Button";
+import { DiffViewer } from "./core/Editor/DiffViewer";
+import { Dropdown } from "./core/Dropdown";
+
+interface Props {
+ response: Pick;
+}
+
+/**
+ * Offers the request a response was sent from, when that is no longer the
+ * request you have.
+ *
+ * Hidden while the two agree, which is the overwhelmingly common case and the
+ * one where there is nothing to say. Responses recorded before versioning
+ * existed have no version and stay quiet forever.
+ */
+export function RequestVersionDropdown({ response }: Props) {
+ const comparison = useRequestVersion(response.versionId, response.requestId);
+ if (comparison.data == null || !comparison.data.differs) {
+ return null;
+ }
+
+ return (
+ ,
+ onSelect: () => showRequestVersionDiff(comparison.data!),
+ },
+ {
+ label: "Restore This Version",
+ leftSlot: ,
+ onSelect: () => restoreRequestVersion(comparison.data!.version),
+ },
+ ]}
+ >
+
+
+ );
+}
+
+function showRequestVersionDiff(comparison: RequestVersionComparison) {
+ showDialog({
+ id: "request-version-diff",
+ title: "Request Changes Since This Response",
+ size: "full",
+ noPadding: true,
+ render: () => (
+
+
+
+ ),
+ });
+}
+
+/** Matches how the Git dialog renders a model for diffing. */
+function toYaml(document: unknown): string {
+ return stringify(document, { indent: 2, lineWidth: 0 });
+}
diff --git a/apps/yaak-client/hooks/useRequestVersion.ts b/apps/yaak-client/hooks/useRequestVersion.ts
new file mode 100644
index 00000000..b2d3fd23
--- /dev/null
+++ b/apps/yaak-client/hooks/useRequestVersion.ts
@@ -0,0 +1,26 @@
+import { useQuery } from "@tanstack/react-query";
+import type { RequestVersionComparison } from "@yaakapp-internal/models";
+import { useAtomValue } from "jotai";
+import { allRequestsAtom } from "./useAllRequests";
+import { rpc } from "../lib/rpc";
+
+/**
+ * The request version a response was sent from, alongside the request as it
+ * stands now.
+ *
+ * Refetches when the live request is written, which is what keeps the "has this
+ * changed?" answer honest while someone edits. The comparison itself is the
+ * backend's — the frontend never hashes anything.
+ */
+export function useRequestVersion(versionId: string | null | undefined, requestId: string | null) {
+ const requests = useAtomValue(allRequestsAtom);
+ const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt;
+
+ return useQuery({
+ placeholderData: (prev) => prev,
+ queryKey: ["request_version", versionId, liveUpdatedAt],
+ enabled: versionId != null,
+ queryFn: () =>
+ rpc("models_request_version", { versionId: versionId! }),
+ });
+}
diff --git a/apps/yaak-client/lib/editSessionTracker.test.ts b/apps/yaak-client/lib/editSessionTracker.test.ts
new file mode 100644
index 00000000..dc54c5a2
--- /dev/null
+++ b/apps/yaak-client/lib/editSessionTracker.test.ts
@@ -0,0 +1,75 @@
+import type { ModelVersionReason } from "@yaakapp-internal/models";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { EditSessionTracker } from "./editSessionTracker";
+
+type Capture = [requestId: string, reason: ModelVersionReason];
+
+function tracker(idleMs = 1000) {
+ const captured: Capture[] = [];
+ return {
+ captured,
+ tracker: new EditSessionTracker((id, reason) => captured.push([id, reason]), idleMs),
+ };
+}
+
+describe("EditSessionTracker", () => {
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
+ test("captures a request once it has been left alone", () => {
+ const { tracker: t, captured } = tracker();
+ t.noteEdit("rq_1");
+
+ vi.advanceTimersByTime(999);
+ expect(captured).toEqual([]);
+
+ vi.advanceTimersByTime(1);
+ expect(captured).toEqual([["rq_1", "idle"]]);
+ });
+
+ test("a burst of edits is one capture, not one per keystroke", () => {
+ const { tracker: t, captured } = tracker();
+ for (let i = 0; i < 10; i++) {
+ t.noteEdit("rq_1");
+ vi.advanceTimersByTime(500);
+ }
+ expect(captured).toEqual([]);
+
+ vi.advanceTimersByTime(1000);
+ expect(captured).toEqual([["rq_1", "idle"]]);
+ });
+
+ test("captures the request being left, not the one being opened", () => {
+ const { tracker: t, captured } = tracker();
+ t.noteActiveRequest("rq_1");
+ expect(captured).toEqual([]);
+
+ t.noteActiveRequest("rq_2");
+ expect(captured).toEqual([["rq_1", "switch"]]);
+ });
+
+ test("re-selecting the same request is not a boundary", () => {
+ const { tracker: t, captured } = tracker();
+ t.noteActiveRequest("rq_1");
+ t.noteActiveRequest("rq_1");
+ expect(captured).toEqual([]);
+ });
+
+ test("blur and close capture the request still on screen", () => {
+ const { tracker: t, captured } = tracker();
+ t.noteActiveRequest("rq_1");
+ t.noteBoundary();
+ t.noteBoundary();
+ expect(captured).toEqual([
+ ["rq_1", "switch"],
+ ["rq_1", "switch"],
+ ]);
+ });
+
+ test("nothing is captured before a request is open", () => {
+ const { tracker: t, captured } = tracker();
+ t.noteBoundary();
+ t.noteActiveRequest(null);
+ expect(captured).toEqual([]);
+ });
+});
diff --git a/apps/yaak-client/lib/editSessionTracker.ts b/apps/yaak-client/lib/editSessionTracker.ts
new file mode 100644
index 00000000..17e43440
--- /dev/null
+++ b/apps/yaak-client/lib/editSessionTracker.ts
@@ -0,0 +1,57 @@
+import type { ModelVersionReason } from "@yaakapp-internal/models";
+
+/**
+ * How long a request has to sit untouched before its edits become a version.
+ * Long enough that typing a URL is one version rather than forty, short enough
+ * that walking away from a half-finished edit still records it.
+ */
+export const IDLE_MS = 60_000;
+
+type Snapshot = (requestId: string, reason: ModelVersionReason) => void;
+
+/**
+ * When a request's editing session ends.
+ *
+ * The backend versions a request on every send, which covers "what produced
+ * this response". This covers the rest: an edit someone made and then walked
+ * away from, which no send would ever have captured.
+ *
+ * It deliberately knows nothing about *what* changed. Versions are
+ * content-addressed, so a boundary that turns out to have nothing behind it
+ * costs one query and creates nothing — which is what lets this stay a timer
+ * and two assignments instead of a change-tracking system.
+ */
+export class EditSessionTracker {
+ private timer: ReturnType | null = null;
+ private idleRequestId: string | null = null;
+ private activeRequestId: string | null = null;
+
+ constructor(
+ private readonly snapshot: Snapshot,
+ private readonly idleMs: number = IDLE_MS,
+ ) {}
+
+ /** A request was written. Restarts its idle countdown. */
+ noteEdit(requestId: string) {
+ if (this.timer != null) clearTimeout(this.timer);
+ this.idleRequestId = requestId;
+ this.timer = setTimeout(() => {
+ this.timer = null;
+ const requestId = this.idleRequestId;
+ if (requestId != null) this.snapshot(requestId, "idle");
+ }, this.idleMs);
+ }
+
+ /** The user moved to a different request, so the one they left is finished. */
+ noteActiveRequest(requestId: string | null) {
+ if (requestId === this.activeRequestId) return;
+ const left = this.activeRequestId;
+ this.activeRequestId = requestId;
+ if (left != null) this.snapshot(left, "switch");
+ }
+
+ /** The window lost focus or is closing. */
+ noteBoundary() {
+ if (this.activeRequestId != null) this.snapshot(this.activeRequestId, "switch");
+ }
+}
diff --git a/apps/yaak-client/lib/requestVersions.ts b/apps/yaak-client/lib/requestVersions.ts
new file mode 100644
index 00000000..690529f6
--- /dev/null
+++ b/apps/yaak-client/lib/requestVersions.ts
@@ -0,0 +1,50 @@
+import { flushAllPendingPatches } from "@yaakapp-internal/models";
+import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models";
+import { platform } from "@yaakapp-internal/platform";
+import { EditSessionTracker } from "./editSessionTracker";
+import { activeRequestIdAtom } from "../hooks/useActiveRequestId";
+import { jotaiStore } from "./jotai";
+import { rpc } from "./rpc";
+
+const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"];
+
+/**
+ * Ask the backend to capture a request's current content.
+ *
+ * Quiet by design: a version that fails to write is not worth a toast, because
+ * every caller below is reacting to the user leaving rather than asking for
+ * anything.
+ */
+export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) {
+ // Edits reach the database on a debounce, so flush before asking for a
+ // version of what is in it
+ flushAllPendingPatches();
+ rpc("models_snapshot_request", { requestId, reason }).catch((err: unknown) => {
+ console.warn("Failed to snapshot request version", err);
+ });
+}
+
+export function initRequestVersionSnapshots() {
+ const tracker = new EditSessionTracker(snapshotRequestVersion);
+
+ platform.listen("model_writes", (payloads) => {
+ for (const payload of payloads) {
+ if (payload.change.type !== "upsert") continue;
+ if (!REQUEST_MODELS.includes(payload.model.model)) continue;
+ tracker.noteEdit(payload.model.id);
+ }
+ });
+
+ tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
+ jotaiStore.sub(activeRequestIdAtom, () => {
+ tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom));
+ });
+
+ platform.window.onFocusChanged((focused) => {
+ if (!focused) tracker.noteBoundary();
+ });
+
+ // Closing is the last boundary there is. Nothing can be awaited here, but the
+ // write is already on its way and the backend outlives the window.
+ window.addEventListener("beforeunload", () => tracker.noteBoundary());
+}
diff --git a/apps/yaak-client/lib/restoreRequestVersion.ts b/apps/yaak-client/lib/restoreRequestVersion.ts
new file mode 100644
index 00000000..2daff40c
--- /dev/null
+++ b/apps/yaak-client/lib/restoreRequestVersion.ts
@@ -0,0 +1,35 @@
+import { flushAllModelWrites } from "@yaakapp-internal/models";
+import type { ModelVersion } from "@yaakapp-internal/models";
+import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey";
+import { fireAndForget } from "./fireAndForget";
+import { rpc } from "./rpc";
+import { showToast } from "./toast";
+
+/**
+ * Put an old version's content back into the live request.
+ *
+ * The backend captures whatever the request currently holds before
+ * overwriting it, so this is not a destructive action even when the last edit
+ * was never versioned — but the request is still rewritten under the user's
+ * cursor, so it is announced.
+ */
+export function restoreRequestVersion(version: ModelVersion) {
+ fireAndForget(
+ (async () => {
+ // The backend restores from the database, so anything still sitting in a
+ // debounce has to land first — otherwise it would overwrite the restore
+ await flushAllModelWrites();
+ const requestId = await rpc("models_restore_request_version", {
+ versionId: version.id,
+ });
+ // The write came from this window, so the store's echo suppression would
+ // otherwise leave open editors showing what was there before
+ wasUpdatedExternally(requestId);
+ showToast({
+ id: "request-version-restored",
+ color: "success",
+ message: "Restored the request that produced this response",
+ });
+ })(),
+ );
+}
diff --git a/apps/yaak-client/main.tsx b/apps/yaak-client/main.tsx
index 8c23f777..cfcbf55e 100644
--- a/apps/yaak-client/main.tsx
+++ b/apps/yaak-client/main.tsx
@@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client";
import { initGit } from "./init/git";
import { initSync } from "./init/sync";
import { initGlobalListeners } from "./lib/initGlobalListeners";
+import { initRequestVersionSnapshots } from "./lib/requestVersions";
import { jotaiStore } from "./lib/jotai";
import { router } from "./lib/router";
@@ -36,6 +37,7 @@ initGit();
initSync();
initModelStore(jotaiStore);
initGlobalListeners();
+initRequestVersionSnapshots();
await changeModelStoreWorkspace(null); // Load global models
console.log("Creating React root");