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
26 changes: 25 additions & 1 deletion .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1848,7 +1848,29 @@
whenever the right panel shows a terminal or a diff and a reload in
that window would otherwise leave the mode "on" over a page with no
engine, every command swallowed and Send answering "no changes" over
intact drafts. Restored drafts carry their ORIGINAL on the wire rather
intact drafts. A sent request leaves its drafts painted over whatever
the agent then changed — inline styles win, so the page stops being
evidence of anything until they come off — and the panel says so: once
the turn that carried them ends, the footer offers Discard all edits or
Keep previews. It states only what is true; it must NEVER claim the
edit was applied or verified, because the Forge's verifier is not
vendored here and nothing in this fork can check — and the discard
names its full blast radius (every edit on the tab, including ones made
after the send), because the guest cannot drop only the sent subset: a
hot reload re-renders the page and re-mints every element id, so sent
identity recorded at send time is stale exactly when the prompt
appears. The offer is gated on the thread's own projected turn, never
on wall clock or observed status flips: the record carries the sent
message's client-minted createdAt, adoption stamps the turn's
requestedAt from that same time, and the prompt waits for a projected
turn that covers the send AND satisfies isLatestTurnSettled — the app's
shared "is the turn actually over", which stays false through the
minutes-long adoption window where the session is null by design. So it
can never invite dropping previews out from under a running or
still-booting agent. It hides on its own while no drafts are left, but
the record survives that state (an undo/redo peek is not an answer);
only the footer's buttons, the panel's Discard, or tab teardown forget
it. Restored drafts carry their ORIGINAL on the wire rather
than re-deriving it: the toggle destroys the engine but leaves the
previews painted, so the store's default prior oracle (the element's
live inline style) would capture each draft's own value as the page's,
Expand Down Expand Up @@ -2013,6 +2035,8 @@
- apps/web/src/custom/designMode/layersTreeModel.test.ts
- apps/web/src/custom/designMode/panel/LayerTypeIcon.tsx
- apps/web/src/custom/designMode/designChangeTranscript.ts
- apps/web/src/custom/designMode/designSentPreviews.ts
- apps/web/src/custom/designMode/designSentPreviews.test.ts
- apps/web/src/custom/designMode/layersDrag.ts
- apps/web/src/custom/designMode/layersDrag.test.ts
- apps/web/src/custom/designMode/designModeTabLifetime.ts
Expand Down
31 changes: 30 additions & 1 deletion apps/web/src/__fork_guards__/forkDesignMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,40 @@ describe("fork guard: design mode", () => {
// Cleared by ENTRY, not by id — a re-send during the awaited turn start replaces the pill
// in place under the same id, so only identity distinguishes it from what was sent.
expect(chatView).toContain(
"if (turnStartSucceeded) forkDesignChanges.clear(forkDesignChangeRef, forkDesignSend.sent)",
"forkDesignChanges.markSent(forkDesignChangeRef, forkDesignSend.sent, messageCreatedAt)",
);
expect(chatView).not.toContain("pendingIds");
});

it("offers to resolve previews that were sent, without claiming they landed", () => {
// Sent drafts stay painted over whatever the agent then changed, and inline styles win —
// so the page stops being evidence until they come off. The panel says so once the turn
// ends. What it must NOT do is assert the edit was applied: the Forge's verifier is not
// vendored here (engine/vendor/README.md), so nothing in this fork can check.
//
// The turn-over invariant itself ("never offer while the turn is open") is NOT pinned
// here as an implementation string — it lives in shouldOfferPreviewResolution, a pure
// exported predicate with its own behavioral tests (designSentPreviews.test.ts). This
// guard holds the wiring: the panel's offer routes through that predicate, so the
// invariant cannot be bypassed by a hand-rolled condition in the component.
const panel = read("src/custom/designMode/panel/ForkDesignPanel.tsx");
expect(panel).toContain("shouldOfferPreviewResolution(");
expect(panel).toContain("data-fork-design-resolve-previews");
for (const claim of ["applied", "verified", "landed successfully"]) {
expect(panel.slice(panel.indexOf("data-fork-design-resolve-previews"))).not.toContain(claim);
}

// The record is minted by the send path, from the entries it is about to clear.
const store = read("src/custom/designMode/designChangeDraftStore.ts");
expect(store).toContain("sent: readonly PendingDesignChange[]");
expect(store).toContain("useDesignSentPreviews.getState().markSent(");

// And it dies with its tab, like every other per-tab design-mode state.
expect(read("src/custom/designMode/designModeTabLifetime.ts")).toContain(
"useDesignSentPreviews.getState().forget(runtimeTabId)",
);
});

it("renders sent design changes as transcript chips, not raw markdown", () => {
const timeline = read("src/components/chat/MessagesTimeline.tsx");
expect(timeline).toContain(
Expand Down
13 changes: 8 additions & 5 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5030,11 +5030,14 @@ function ChatViewContent(props: ChatViewProps) {
}
/* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode
The design-change attachments rode the sent message; a failed send keeps the
pills so nothing is lost. Clears exactly the entries takeForSend returned, not the
thread: the turn start is awaited above, and a Send from the design panel during
that window replaces a pill IN PLACE under the same id — so only entry identity
tells the two apart. */
if (turnStartSucceeded) forkDesignChanges.clear(forkDesignChangeRef, forkDesignSend.sent);
pills so nothing is lost. markSent notes which preview tabs contributed (so the
panel can offer to resolve their still-painted previews once the turn ends) and
then clears exactly the entries takeForSend returned, not the thread: the turn
start is awaited above, and a Send from the design panel during that window
replaces a pill IN PLACE under the same id — so only entry identity tells the
two apart. */
if (turnStartSucceeded)
forkDesignChanges.markSent(forkDesignChangeRef, forkDesignSend.sent, messageCreatedAt);
/* fork:end fork-design-mode */
};

Expand Down
43 changes: 39 additions & 4 deletions apps/web/src/custom/designMode/designChangeDraftStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test";

import { extractTrailingDesignChanges } from "./designChangeTranscript";
import { forkDesignChanges, useDesignChangeDraftStore } from "./designChangeDraftStore";
import { useDesignSentPreviews } from "./designSentPreviews";
import type { DesignChangeRequestPayload } from "./protocol";

/**
Expand All @@ -16,6 +17,9 @@ import type { DesignChangeRequestPayload } from "./protocol";
const THREAD = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1"));
const OTHER_THREAD = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-2"));

/** The sent message's client-minted createdAt, as ChatView passes it. */
const SENT_AT = "2026-08-08T12:00:10.000Z";

const payload = (
overrides: Partial<DesignChangeRequestPayload> = {},
): DesignChangeRequestPayload => ({
Expand All @@ -37,6 +41,7 @@ const pendingFor = (threadRef: ScopedThreadRef) => {
describe("designChangeDraftStore", () => {
beforeEach(() => {
useDesignChangeDraftStore.setState({ byThreadKey: {} });
useDesignSentPreviews.setState({ byTabId: {} });
});

it("replaces the pending attachment when the same tab re-sends the same page", () => {
Expand Down Expand Up @@ -156,7 +161,7 @@ describe("designChangeDraftStore", () => {

expect(pendingFor(THREAD)).toHaveLength(1);
expect(pendingFor(OTHER_THREAD)).toHaveLength(1);
forkDesignChanges.clear(THREAD);
useDesignChangeDraftStore.getState().clear(THREAD);
expect(pendingFor(THREAD)).toHaveLength(0);
expect(pendingFor(OTHER_THREAD)).toHaveLength(1);
});
Expand Down Expand Up @@ -185,7 +190,7 @@ describe("designChangeDraftStore", () => {
const taken = forkDesignChanges.takeForSend(THREAD, "");

add(THREAD, "tab-b", payload({ markdown: "arrived mid-flight" }));
forkDesignChanges.clear(THREAD, taken.sent);
useDesignChangeDraftStore.getState().clear(THREAD, taken.sent);

const pending = pendingFor(THREAD);
expect(pending).toHaveLength(1);
Expand All @@ -204,17 +209,47 @@ describe("designChangeDraftStore", () => {

add(THREAD, "tab-a", payload({ markdown: "re-sent mid-flight" }));
expect(pendingFor(THREAD)[0]?.id).toBe(sentId); // same id, different payload
forkDesignChanges.clear(THREAD, taken.sent);
useDesignChangeDraftStore.getState().clear(THREAD, taken.sent);

const pending = pendingFor(THREAD);
expect(pending).toHaveLength(1);
expect(pending[0]?.markdown).toBe("re-sent mid-flight");
});

it("markSent notes the contributing tabs before the pills it read them from are cleared", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "from a" }));
add(THREAD, "tab-b", payload({ markdown: "from b" }));
const taken = forkDesignChanges.takeForSend(THREAD, "");

forkDesignChanges.markSent(THREAD, taken.sent, SENT_AT);

// Both tabs recorded — the panel can now offer to resolve either one's previews.
expect(useDesignSentPreviews.getState().byTabId).toEqual({
"tab-a": { threadKey: scopedThreadKey(THREAD), sentAt: SENT_AT },
"tab-b": { threadKey: scopedThreadKey(THREAD), sentAt: SENT_AT },
});
expect(pendingFor(THREAD)).toHaveLength(0);
});

it("markSent leaves a tab whose pill did not ride this message alone", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "rode along" }));
const taken = forkDesignChanges.takeForSend(THREAD, "");
add(THREAD, "tab-b", payload({ markdown: "arrived mid-flight" }));

forkDesignChanges.markSent(THREAD, taken.sent, SENT_AT);

expect(Object.keys(useDesignSentPreviews.getState().byTabId)).toEqual(["tab-a"]);
expect(pendingFor(THREAD)).toHaveLength(1);
});

it("drops the thread's whole entry once a targeted clear empties it", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload());
forkDesignChanges.clear(THREAD, forkDesignChanges.takeForSend(THREAD, "").sent);
useDesignChangeDraftStore
.getState()
.clear(THREAD, forkDesignChanges.takeForSend(THREAD, "").sent);
expect(scopedThreadKey(THREAD) in useDesignChangeDraftStore.getState().byThreadKey).toBe(false);
});

Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/custom/designMode/designChangeDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { create } from "zustand";

import { type DraftId, useComposerDraftStore } from "~/composerDraftStore";

import { useDesignSentPreviews } from "./designSentPreviews";
import type { DesignChangeRequestPayload } from "./protocol";

/**
Expand Down Expand Up @@ -208,7 +209,26 @@ export const forkDesignChanges = {
.join("\n\n");
return { text: text.trim().length > 0 ? `${text}\n\n${blocks}` : blocks, sent };
},
clear(threadRef: ScopedThreadRef, sent?: readonly PendingDesignChange[]): void {
/**
* The send succeeded: remember which preview tabs contributed drafts to it, then drop the
* pills. Ordering is the whole reason this is one call — the tabs are only readable from the
* entries the clear is about to remove, and a caller that got that backwards would lose the
* resolution prompt with nothing to show for it. Deliberately the facade's ONLY
* send-completion verb: a bare clear-on-success export sat here briefly, and any send path
* that picked it would compile, pass, and silently never mint a sent-preview record.
*
* `sentAt` is the sent message's own client-minted `createdAt` — the turn adopted for it
* gets its `requestedAt` stamped from that exact time, which is how the resolution prompt
* later knows the thread's projected turn covers this send (designSentPreviews.ts).
*
* The drafts themselves stay applied in the guest. They are the user's, and the tool never
* commits them; what changes is that the panel now has grounds to ask about them.
*/
markSent(threadRef: ScopedThreadRef, sent: readonly PendingDesignChange[], sentAt: string): void {
const threadKey = scopedThreadKey(threadRef);
for (const runtimeTabId of new Set(sent.map((entry) => entry.runtimeTabId))) {
useDesignSentPreviews.getState().markSent(runtimeTabId, threadKey, sentAt);
}
useDesignChangeDraftStore.getState().clear(threadRef, sent);
},
};
2 changes: 2 additions & 0 deletions apps/web/src/custom/designMode/designModeTabLifetime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { designModeBridge } from "./designModeBridge";
import { useDesignModeStore } from "./designModeStore";
import { useDesignSentPreviews } from "./designSentPreviews";
import { designUndoHistory } from "./designUndoHistory";

/**
Expand All @@ -17,6 +18,7 @@ import { designUndoHistory } from "./designUndoHistory";
*/
export function disposeDesignModeTab(runtimeTabId: string): void {
useDesignModeStore.getState().remove(runtimeTabId);
useDesignSentPreviews.getState().forget(runtimeTabId);
designUndoHistory.clear(runtimeTabId);
designModeBridge.forgetTab(runtimeTabId);
}
Loading
Loading