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
32 changes: 29 additions & 3 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1738,7 +1738,10 @@
through the window.__T3_DESIGN_MODE__ command handle
(designModeBridge.ts, fire-and-forget except buildSend; the scrub-driven
writes applyDraft and setInset coalesce host-side to one IPC crossing per
animation frame, and every other command flushes them first so order holds).
animation frame, every other command flushes them first so order holds, and
hoverElement dedupes against the last id sent — mouseover bubbles, so the
layers rail fires it several times per row crossed and hover carries no new
information on a repeat).
Panel section order and field chrome follow the fork's own Figma spec (file
fZRyRTZJtLKwuq8rj2SLg4, page V2, node 193:9686): a Position section (the six
align verbs over an X/Y pair, absolute-position toggle in the header), a
Expand Down Expand Up @@ -1831,12 +1834,28 @@
and ChatView's fenced send path appends the full deterministic
token-aware change-request markdown to the outgoing message inside
<design_change_request> blocks — cleared only when the turn start
succeeds (T3 threads ARE the delivery surface — the Forge's own chat
succeeds, and then only the attachments that actually rode the message:
the ids are captured before the start is awaited, so a Send made from
the panel during that round trip is not dropped unsent (T3 threads ARE
the delivery surface — the Forge's own chat
feed, embedded sessions, MCP queue, watcher, verifier and every
/__the-forge/* endpoint are deliberately not ported, and the engine
must stay free of network calls). The host re-injects on dom-ready
while the mode is on, so navigations and dev-server reloads keep the
engine and its drafts. Element→source mapping works WITHOUT any
engine and its drafts — and it reconciles on every bridge attach too
(probe the guest's protocol version, re-inject when it is absent or
skewed, otherwise just re-activate), because this component unmounts
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
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,
making Discard restore the draft over itself and the send builder
measure before === after. Closing a preview tab is the one moment the
per-tab state (store entry, undo history, bridge hover memo) is dropped
— a mere unmount must survive. Element→source mapping works WITHOUT any
project setup: project-authored forge-mode JSX tags (data-dc-source)
remain the most precise source and always win per element, but on
untagged React pages the desktop preload's react-grab-backed resolver
Expand Down Expand Up @@ -1995,6 +2014,8 @@
- apps/web/src/custom/designMode/panel/LayerTypeIcon.tsx
- apps/web/src/custom/designMode/designChangeTranscript.ts
- apps/web/src/custom/designMode/layersDrag.ts
- apps/web/src/custom/designMode/layersDrag.test.ts
- apps/web/src/custom/designMode/designModeTabLifetime.ts
- apps/web/src/custom/designMode/panel/ForkDesignPanel.tsx
- apps/web/src/custom/designMode/panel/CanvasControls.tsx
- apps/web/src/custom/designMode/panel/canvasResolutions.ts
Expand Down Expand Up @@ -2056,6 +2077,11 @@
# under ElectronBrowserHost, outside the preview panel's subtree, so no
# descendant selector rooted at the panel can reach it.
- apps/web/src/browser/HostedBrowserWebview.tsx
# Fenced per-tab teardown in the lease's close path. This is the only place
# that knows a preview tab is CLOSED rather than merely unmounted, so it is
# where the tab's design-mode store entry, undo history and bridge memo go —
# everything else in the feature sees an unmount it must survive.
- apps/web/src/browser/desktopTabLifetime.ts
# Fenced mock adjustment: trailingActions is a fragment (toggle + menu)
# under the fork, so the mock finds the menu's props on a fragment child.
- apps/web/src/components/preview/PreviewView.test.tsx
Expand Down
141 changes: 138 additions & 3 deletions apps/web/src/__fork_guards__/forkDesignMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,40 @@ describe("fork guard: design mode", () => {
expect(previewView).not.toContain("ForkLayersTree");
});

it("reconciles the guest on every bridge attach, and forgets a closed tab", () => {
// Injection used to happen on the toggle and on `dom-ready` only. This component unmounts
// whenever the right panel shows a terminal or a diff, or the user switches threads — and
// a page reload in that window (a non-HMR-able agent edit: the feature's own loop) wiped
// the guest with nobody listening, leaving Design mode "on" over a page with no engine.
const toggle = read("src/custom/designMode/ForkPreviewDesignMode.tsx");
expect(toggle).toContain("reconcileEngine");
expect(toggle).toContain("engineIsCurrent");
expect(toggle).toContain("if (enabledRef.current) void reconcileEngine(runtimeTabId)");

// A re-injection invalidates every host memo keyed on guest ids, so injection owns those
// clears rather than each call site remembering them.
expect(toggle).toContain("designUndoHistory.clear(tabId)");
expect(toggle).toContain("designModeBridge.forgetHover(tabId)");
// ...and the reconcile re-checks the toggle after its probe's round trip, or a toggle-off
// inside that window would be undone by the injection that follows.
expect(toggle).toContain("if (!enabledRef.current) return;");

// The counterpart: the one place that knows a preview tab is CLOSED rather than merely
// unmounted. Without it `designModeStore.remove` had no call site at all. The lease makes
// ONE call — what gets released is the feature's own business, so the next per-tab memo
// does not grow another line in an upstream file.
const tabLifetime = read("src/browser/desktopTabLifetime.ts");
expect(tabLifetime).toContain("fork:begin fork-design-mode");
expect(tabLifetime).toContain("disposeDesignModeTab(tabId)");
for (const internal of ["useDesignModeStore", "designUndoHistory", "designModeBridge"]) {
expect(tabLifetime).not.toContain(internal);
}
const disposal = read("src/custom/designMode/designModeTabLifetime.ts");
expect(disposal).toContain("useDesignModeStore.getState().remove(runtimeTabId)");
expect(disposal).toContain("designUndoHistory.clear(runtimeTabId)");
expect(disposal).toContain("designModeBridge.forgetTab(runtimeTabId)");
});

it("commits the screen's real width and derives a height that fills the pane", () => {
// The whole point: the guest's CSS viewport width IS the screen's, so a page that hides
// content below a breakpoint sees the screen and not however wide the pane happens to
Expand Down Expand Up @@ -260,13 +294,18 @@ describe("fork guard: design mode", () => {
'import { forkDesignChanges } from "~/custom/designMode/designChangeDraftStore"',
);
expect(chatView).toContain("forkDesignChanges.count({ environmentId, threadId:");
expect(chatView).toContain("forkDesignChanges.appendToPrompt(");
// ONE read: the outgoing text and the entries that went into it come back together, so
// "what rode the message" is not an invariant ChatView holds by hand across the await.
expect(chatView).toContain(
"messageTextForSendWithDesignChanges || IMAGE_ONLY_BOOTSTRAP_PROMPT",
"forkDesignChanges.takeForSend(forkDesignChangeRef, messageTextForSend)",
);
expect(chatView).toContain("forkDesignSend.text || IMAGE_ONLY_BOOTSTRAP_PROMPT");
// 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)",
"if (turnStartSucceeded) forkDesignChanges.clear(forkDesignChangeRef, forkDesignSend.sent)",
);
expect(chatView).not.toContain("pendingIds");
});

it("renders sent design changes as transcript chips, not raw markdown", () => {
Expand Down Expand Up @@ -417,6 +456,102 @@ describe("fork guard: design mode", () => {
expect(budget).toEqual({ left: 7, truncated: true });
});

it("restores a persisted draft's ORIGINAL rather than re-deriving it", async () => {
// The engine's own restore contract, and the one place it can be wrong invisibly.
//
// Toggling Design mode off destroys the engine but deliberately leaves the draft previews
// painted as inline styles (they come back from sessionStorage). So when the next
// injection re-applies them into the SAME document, DraftStore's default prior oracle —
// which for a css draft reads the element's live inline style — answers with the previous
// engine's own preview. Every restored draft would then record its drafted value as the
// page's original: Discard restores the draft over itself, and the send builder measures
// before === after and drops the change, so the panel counts N changes while Send says
// there is nothing to send. The persisted third tuple slot is what closes it.
const result = await build({
stdin: {
contents: [
'export { DraftStore } from "./src/custom/designMode/engine/vendor/drafts";',
'export { loadLifecycle } from "./src/custom/designMode/engine/vendor/lifecycle-store";',
].join("\n"),
resolveDir: webRoot,
sourcefile: "design-mode-drafts-guard.ts",
loader: "ts",
},
bundle: true,
format: "esm",
platform: "node",
target: "es2022",
write: false,
logLevel: "silent",
});
const code = result.outputFiles[0]?.text ?? "";
const moduleUrl = `data:text/javascript;base64,${NodeBuffer.Buffer.from(code).toString("base64")}`;
const engine = (await import(moduleUrl)) as {
DraftStore: new () => {
apply: (el: unknown, prop: string, value: string, knownOriginal?: string) => void;
discard: (el: unknown, props?: string[]) => void;
entries: () => Map<unknown, Map<string, { original: string; value: string }>>;
};
loadLifecycle: (storage: unknown) => { drafts: unknown[] } | null;
};

// Just enough element for the css half of the store: it only ever reads and writes
// inline style declarations.
const element = () => {
const inline = new Map<string, string>();
return {
inline,
style: {
setProperty: (key: string, value: string) => inline.set(key, value),
removeProperty: (key: string) => inline.delete(key),
getPropertyValue: (key: string) => inline.get(key) ?? "",
getPropertyPriority: () => "",
},
};
};

// A restore into a document still showing the previous engine's preview.
const restored = element();
restored.style.setProperty("padding-top", "32px");
const store = new engine.DraftStore();
store.apply(restored, "padding-top", "32px", "");
expect(store.entries().get(restored)?.get("padding-top")?.original).toBe("");
store.discard(restored, ["padding-top"]);
expect(restored.inline.has("padding-top")).toBe(false);

// The default oracle on the same shape — the behaviour the persisted original exists to
// avoid, pinned here so nobody "simplifies" the parameter away.
const rederived = element();
rederived.style.setProperty("padding-top", "32px");
const naive = new engine.DraftStore();
naive.apply(rederived, "padding-top", "32px");
naive.discard(rederived, ["padding-top"]);
expect(rederived.inline.get("padding-top")).toBe("32px");

// A first-time draft is unaffected: no inline style, so the original is empty either way.
const fresh = element();
const first = new engine.DraftStore();
first.apply(fresh, "padding-top", "32px");
expect(first.entries().get(fresh)?.get("padding-top")?.original).toBe("");

// The wire shape: triples load, pre-upgrade 2-tuples still load (a session in flight must
// not be thrown away), and a non-string original is rejected — it would be handed
// straight to setProperty on discard.
const stored = (drafts: unknown) => ({
getItem: () => JSON.stringify({ v: 1, designModeOn: true, selection: [], drafts, sent: [] }),
});
const entry = (props: unknown) => ({ dcSource: "App.tsx:1:1", index: 0, props });
expect(
engine.loadLifecycle(stored([entry([["padding-top", "32px", "24px"]])]))?.drafts,
).toEqual([entry([["padding-top", "32px", "24px"]])]);
expect(engine.loadLifecycle(stored([entry([["padding-top", "32px"]])]))?.drafts).toHaveLength(
1,
);
expect(engine.loadLifecycle(stored([entry([["padding-top", "32px", 24]])]))?.drafts).toEqual(
[],
);
});

it("keeps the native source bridge contract aligned across preload and engine", () => {
// The desktop preload installs the resolver global; the engine consumes it by the
// same name. A drifted literal on either side silently degrades every untagged React
Expand Down
11 changes: 11 additions & 0 deletions apps/web/src/browser/desktopTabLifetime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { previewBridge } from "~/components/preview/previewBridge";

/* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode */
import { disposeDesignModeTab } from "~/custom/designMode/designModeTabLifetime";
/* fork:end fork-design-mode */

import { stopBrowserRecording } from "./browserRecording";

interface DesktopTabLease {
Expand Down Expand Up @@ -59,6 +63,13 @@ export function acquireDesktopTab(tabId: string): AcquiredDesktopTab {
const latest = leases.get(tabId);
if (!latest || latest.references > 0) return;
leases.delete(tabId);
/* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode
The tab's webview is about to be destroyed, taking the guest engine, its drafts and
its id registry with it. This is the only place that knows a preview tab is CLOSED
rather than merely unmounted, which is why the call hangs here — WHAT gets released
is the feature's own business (designModeTabLifetime.ts). */
disposeDesignModeTab(tabId);
/* fork:end fork-design-mode */
void enqueueDesktopTabOperation(tabId, async () => {
await stopBrowserRecording(tabId).catch(() => null);
await previewBridge?.closeTab(tabId);
Expand Down
14 changes: 7 additions & 7 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4782,10 +4782,7 @@ function ChatViewContent(props: ChatViewProps) {
Design-change attachments append their full change-request markdown here (the
composer only ever showed the pill); cleared below once the turn start succeeds. */
const forkDesignChangeRef = { environmentId, threadId: threadIdForSend };
const messageTextForSendWithDesignChanges = forkDesignChanges.appendToPrompt(
forkDesignChangeRef,
messageTextForSend,
);
const forkDesignSend = forkDesignChanges.takeForSend(forkDesignChangeRef, messageTextForSend);
/* fork:end fork-design-mode */
const messageIdForSend = newMessageId();
const messageCreatedAt = new Date().toISOString();
Expand All @@ -4795,7 +4792,7 @@ function ChatViewContent(props: ChatViewProps) {
models: ctxSelectedProviderModels,
effort: ctxSelectedPromptEffort,
/* fork:begin fork-design-mode — see .fork/customizations.yaml#fork-design-mode */
text: messageTextForSendWithDesignChanges || IMAGE_ONLY_BOOTSTRAP_PROMPT,
text: forkDesignSend.text || IMAGE_ONLY_BOOTSTRAP_PROMPT,
/* fork:end fork-design-mode */
});
const turnAttachmentsPromise = Promise.all(
Expand Down Expand Up @@ -5033,8 +5030,11 @@ 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. */
if (turnStartSucceeded) forkDesignChanges.clear(forkDesignChangeRef);
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);
/* fork:end fork-design-mode */
};

Expand Down
Loading
Loading