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
20 changes: 20 additions & 0 deletions .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,25 @@
designChangeDraftStore, the composer renders it as an inline pill
(element + compact delta, × to discard, tooltip shows the markdown),
pending pills count toward sendability so a pill-only message sends,
a Send REPLACES the pending pill for the same preview tab AND document
rather than adding a second one (buildSend builds from all of a tab's
live drafts, never from the selection, so a re-send built from the same
draft set always supersedes the earlier pill — as a superset, or after a
discard as the corrected set; both used to ride the same message as
overlapping, sometimes contradictory asks). Both halves of that key are
load-bearing: the TAB because two preview tabs hold independent draft
sets, and the DOCUMENT because supersession is a per-document guarantee —
drafts live exactly as long as their document, so the payload carries a
per-engine-instance documentId (SPA route churn moves location.href but
keeps the document, the engine, and the drafts) with pageUrl as the
reload fallback (a reload mints a fresh documentId but restores the same
page's drafts from sessionStorage). A Send after a real cross-page
navigation misses both halves and appends — its drafts were re-located
against the new document and dropped when they didn't resolve, so
replacing would silently drop the previous page's pill: a duplicate ask
is visible and recoverable, lost work is neither. Replacement reuses the previous entry's id and
position so the chip updates in place instead of remounting and
recoloring (the composer derives both its key and its fill from that id),
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
Expand Down Expand Up @@ -1653,6 +1672,7 @@
- apps/web/src/custom/designMode/designModeStore.ts
- apps/web/src/custom/designMode/designModeBridge.ts
- apps/web/src/custom/designMode/designChangeDraftStore.ts
- apps/web/src/custom/designMode/designChangeDraftStore.test.ts
- apps/web/src/custom/designMode/ForkPreviewDesignMode.tsx
- apps/web/src/custom/designMode/ForkComposerDesignChanges.tsx
- apps/web/src/custom/designMode/ForkTranscriptDesignChanges.tsx
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/__fork_guards__/forkDesignMode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,9 +38,11 @@ import {
import { FORK_MARKER_ATTRIBUTE, FORK_MARKER_VALUE } from "../custom/forkMarker";
import { cssRules } from "./cssRules";
import {
capPageUrl,
DESIGN_MODE_CONSOLE_PREFIX,
DESIGN_MODE_GLOBAL,
DESIGN_MODE_LAYERS_MAX_DEPTH,
DESIGN_MODE_PAGE_URL_CAP,
DESIGN_MODE_STYLE_KEYS,
parseDesignChangeRequestPayload,
parseDesignModeConsoleMessage,
Expand Down Expand Up @@ -672,6 +674,8 @@ describe("fork guard: design mode", () => {
markdown: "Change button padding",
elementCount: 1,
elements: [{ tag: "button", sourceLabel: "App.tsx:5", deltas: ["8px → 12px"] }],
documentId: "doc-1",
pageUrl: "http://localhost:5173/",
};
expect(parseDesignChangeRequestPayload(payload)).toEqual(payload);
expect(parseDesignChangeRequestPayload({ ...payload, elementCount: 2 })).toBeNull();
Expand All @@ -681,5 +685,30 @@ describe("fork guard: design mode", () => {
elements: [{ ...payload.elements[0], deltas: [3] }],
}),
).toBeNull();
// documentId and pageUrl are the composer's replace-vs-append key (designChangeDraftStore),
// so a payload missing either can't be honored. An engine too old to send them fails the
// parse here; designModeBridge.buildSend reports that as "stale-engine" and the panel
// surfaces it (boot()'s version check only rebuilds the engine at the NEXT injection —
// the toggle or a navigation — never on the Send path).
const { pageUrl: _omitted, ...withoutPage } = payload;
expect(parseDesignChangeRequestPayload(withoutPage)).toBeNull();
const { documentId: _omittedDoc, ...withoutDocument } = payload;
expect(parseDesignChangeRequestPayload(withoutDocument)).toBeNull();
expect(parseDesignChangeRequestPayload({ ...payload, documentId: "" })).toBeNull();
expect(parseDesignChangeRequestPayload({ ...payload, pageUrl: "x".repeat(2049) })).toBeNull();
});

it("caps long page urls without letting distinct documents collide", () => {
// The guest sends capPageUrl(location.href); the parser rejects anything longer than the
// cap, so the capped form must always fit.
const base = `data:text/html,${"a".repeat(3000)}`;
expect(capPageUrl(base).length).toBeLessThanOrEqual(DESIGN_MODE_PAGE_URL_CAP);
expect(capPageUrl("http://localhost:5173/")).toBe("http://localhost:5173/");
// Two long data: documents sharing their first 2KB must still compare UNEQUAL — a plain
// slice would collide them, and the draft store would replace one page's pill with the
// other's, silently dropping its asks (PR #63 review).
expect(capPageUrl(`${base}-variant-one`)).not.toBe(capPageUrl(`${base}-variant-two`));
// Identical hrefs still cap identically, or the reload half of the key would never match.
expect(capPageUrl(`${base}-same`)).toBe(capPageUrl(`${base}-same`));
});
});
192 changes: 192 additions & 0 deletions apps/web/src/custom/designMode/designChangeDraftStore.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { scopeThreadRef, scopedThreadKey } from "@t3tools/client-runtime/environment";
import { type EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
import { beforeEach, describe, expect, it } from "vite-plus/test";

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

/**
* The pending-attachment reducer's own rules, which nothing else can hold: the manifest
* intent is prose, and forkDesignMode.test.ts only greps for the delivery fences — a merge
* could restore plain appending with every guard still green (PR #63 review).
*
* Plain zustand, no DOM.
*/
const THREAD = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-1"));
const OTHER_THREAD = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-2"));

const payload = (
overrides: Partial<DesignChangeRequestPayload> = {},
): DesignChangeRequestPayload => ({
markdown: "# Design change request",
elementCount: 1,
elements: [{ tag: "div", sourceLabel: "App.tsx:12", deltas: ["padding-top 24px → 32px"] }],
documentId: "doc-1",
pageUrl: "http://localhost:5173/",
...overrides,
});

const pendingFor = (threadRef: ScopedThreadRef) => {
// Through the store's own key helper — a test that rebuilt the key format by hand would
// keep passing if the two ever disagreed.
const { byThreadKey } = useDesignChangeDraftStore.getState();
return byThreadKey[scopedThreadKey(threadRef)] ?? [];
};

describe("designChangeDraftStore", () => {
beforeEach(() => {
useDesignChangeDraftStore.setState({ byThreadKey: {} });
});

it("replaces the pending attachment when the same tab re-sends the same page", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "first" }));
add(THREAD, "tab-a", payload({ markdown: "second" }));

const pending = pendingFor(THREAD);
expect(pending).toHaveLength(1);
expect(pending[0]?.markdown).toBe("second");
});

it("reuses the entry id on replacement so the composer chip updates in place", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "first" }));
const firstId = pendingFor(THREAD)[0]?.id;
add(THREAD, "tab-a", payload({ markdown: "second" }));

// ForkComposerDesignChanges derives its React key AND its chip fill from this id — a
// fresh one per Send would remount and recolor the chip on every re-send.
expect(pendingFor(THREAD)[0]?.id).toBe(firstId);
});

it("keeps both pages when the preview truly navigates between sends", () => {
const { add } = useDesignChangeDraftStore.getState();
// A real navigation wipes the guest and mints a new engine: BOTH halves of the key move.
add(
THREAD,
"tab-a",
payload({ markdown: "home", documentId: "doc-1", pageUrl: "http://localhost:5173/" }),
);
add(
THREAD,
"tab-a",
payload({ markdown: "settings", documentId: "doc-2", pageUrl: "http://localhost:5173/s" }),
);

// Drafts are re-located per document, so the second request describes ONLY the new page:
// replacing on tab alone would silently drop the first page's asks.
expect(pendingFor(THREAD).map((entry) => entry.markdown)).toEqual(["home", "settings"]);
});

it("replaces when an SPA route change moves the href under the same document", () => {
const { add } = useDesignChangeDraftStore.getState();
// pushState churn: same engine, same live draft set, different location.href. The second
// Send is built from the same drafts as the first — stacking them would put two
// overlapping asks in one message, the exact defect supersession exists to prevent.
add(
THREAD,
"tab-a",
payload({ markdown: "first", documentId: "doc-1", pageUrl: "http://localhost:5173/a" }),
);
add(
THREAD,
"tab-a",
payload({ markdown: "second", documentId: "doc-1", pageUrl: "http://localhost:5173/b" }),
);

const pending = pendingFor(THREAD);
expect(pending).toHaveLength(1);
expect(pending[0]?.markdown).toBe("second");
});

it("replaces when the same page reloads between sends", () => {
const { add } = useDesignChangeDraftStore.getState();
// A reload mints a fresh engine (new documentId) but restores the same page's drafts
// from the guest's sessionStorage — the pageUrl half of the key covers it.
add(
THREAD,
"tab-a",
payload({ markdown: "first", documentId: "doc-1", pageUrl: "http://localhost:5173/" }),
);
add(
THREAD,
"tab-a",
payload({ markdown: "second", documentId: "doc-2", pageUrl: "http://localhost:5173/" }),
);

const pending = pendingFor(THREAD);
expect(pending).toHaveLength(1);
expect(pending[0]?.markdown).toBe("second");
});

it("holds a replaced chip's position in the row", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "first" }));
add(THREAD, "tab-b", payload({ markdown: "second" }));
add(THREAD, "tab-a", payload({ markdown: "first-again" }));

expect(pendingFor(THREAD).map((entry) => entry.markdown)).toEqual(["first-again", "second"]);
});

it("keeps one pill per preview tab", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "from a" }));
add(THREAD, "tab-b", payload({ markdown: "from b" }));

expect(pendingFor(THREAD)).toHaveLength(2);
});

it("re-adds after a removal instead of resurrecting the old entry", () => {
const store = useDesignChangeDraftStore.getState();
store.add(THREAD, "tab-a", payload({ markdown: "first" }));
store.remove(THREAD, pendingFor(THREAD)[0]!.id);
expect(pendingFor(THREAD)).toHaveLength(0);

store.add(THREAD, "tab-a", payload({ markdown: "second" }));
const pending = pendingFor(THREAD);
expect(pending).toHaveLength(1);
expect(pending[0]?.markdown).toBe("second");
});

it("scopes attachments to their own thread", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "mine" }));
add(OTHER_THREAD, "tab-a", payload({ markdown: "theirs" }));

expect(pendingFor(THREAD)).toHaveLength(1);
expect(pendingFor(OTHER_THREAD)).toHaveLength(1);
forkDesignChanges.clear(THREAD);
expect(pendingFor(THREAD)).toHaveLength(0);
expect(pendingFor(OTHER_THREAD)).toHaveLength(1);
});

it("round-trips every pending block through the transcript extractor", () => {
const { add } = useDesignChangeDraftStore.getState();
add(THREAD, "tab-a", payload({ markdown: "# one", pageUrl: "http://localhost:5173/" }));
add(THREAD, "tab-b", payload({ markdown: "# two", pageUrl: "http://localhost:5173/" }));

const sent = forkDesignChanges.appendToPrompt(THREAD, "make it pop");
const extracted = extractTrailingDesignChanges(sent);
expect(extracted.promptText).toBe("make it pop");
expect(extracted.blocks).toEqual(["# one", "# two"]);
});
});

describe("extractTrailingDesignChanges", () => {
it("agrees with the regex on the literal its fast path screens for", () => {
// The `includes` bail must screen for exactly what the pattern requires — an edit to
// either side (case-insensitivity, optional whitespace, an attribute) would let the fast
// path answer "no blocks" for a prompt that has one.
const prompt = "hi\n\n<design_change_request>\n# body\n</design_change_request>";
expect(extractTrailingDesignChanges(prompt).blocks).toEqual(["# body"]);
expect(extractTrailingDesignChanges("no blocks here")).toEqual({
promptText: "no blocks here",
blocks: [],
});
// Present but not trailing: the fast path lets it through, and the regex declines it.
const notTrailing =
"<design_change_request>\n# body\n</design_change_request>\n\ntrailing text";
expect(extractTrailingDesignChanges(notTrailing).blocks).toEqual([]);
});
});
55 changes: 51 additions & 4 deletions apps/web/src/custom/designMode/designChangeDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@ import type { DesignChangeRequestPayload } from "./protocol";
*/
export interface PendingDesignChange extends DesignChangeRequestPayload {
readonly id: string;
/** The preview webview whose drafts built this request. `runtimeTabId`, NOT the server tab
* id the panel also carries: server tab ids are unique only within one server process, so a
* restart could collide two tabs onto one pill. Half of the replacement key — see `add`. */
readonly runtimeTabId: string;
}

interface DesignChangeDraftStoreState {
readonly byThreadKey: Record<string, readonly PendingDesignChange[]>;
readonly add: (threadRef: ScopedThreadRef, payload: DesignChangeRequestPayload) => void;
readonly add: (
threadRef: ScopedThreadRef,
runtimeTabId: string,
payload: DesignChangeRequestPayload,
) => void;
readonly remove: (threadRef: ScopedThreadRef, id: string) => void;
readonly clear: (threadRef: ScopedThreadRef) => void;
}
Expand All @@ -32,12 +40,51 @@ let nextId = 1;

export const useDesignChangeDraftStore = create<DesignChangeDraftStoreState>()((set) => ({
byThreadKey: {},
add: (threadRef, payload) =>
/**
* Attaches one request, REPLACING the pending one for the same preview tab AND document.
*
* The guest builds every request from all of its live drafts (headlessMode's buildSend),
* never from the selection, so a second Send built from the same draft set always
* supersedes the first — either as a strict superset, or (after a Discard) as the
* corrected set. Stacking them put two overlapping, sometimes contradicting asks in one
* message.
*
* Drafts live exactly as long as their document, so "same draft set" is "same document" —
* matched by EITHER half of the key. `documentId` is the live-document half: it is minted
* per engine instance, which survives SPA pushState/hash churn just like the drafts do, so
* a re-Send after a client-side route change still supersedes even though `location.href`
* moved (PR #63 review). `pageUrl` is the reload half: a full reload mints a fresh
* `documentId` but restores the same page's drafts from the guest's sessionStorage, so a
* re-Send there supersedes by URL. A Send after a real cross-page navigation misses both
* halves and APPENDS — its drafts were re-located against the new document and dropped
* when they didn't resolve, so replacing would silently drop the previous page's asks: a
* duplicate ask is recoverable and visible, lost work is neither.
*
* The tab half is the second reason to key on more than the thread: two preview tabs hold
* independent draft sets and must each be able to contribute.
*
* Replacement REUSES the previous entry's id, so a re-send updates a chip in place rather
* than remounting and recoloring it — ForkComposerDesignChanges derives both its React key
* and its fill from the id on purpose.
*/
add: (threadRef, runtimeTabId, payload) =>
set((state) => {
const key = scopedThreadKey(threadRef);
const pending = state.byThreadKey[key] ?? [];
const entry: PendingDesignChange = { ...payload, id: `design-change-${nextId++}` };
return { byThreadKey: { ...state.byThreadKey, [key]: [...pending, entry] } };
const supersedes = (candidate: PendingDesignChange): boolean =>
candidate.runtimeTabId === runtimeTabId &&
(candidate.documentId === payload.documentId || candidate.pageUrl === payload.pageUrl);
const existing = pending.find(supersedes);
const entry: PendingDesignChange = {
...payload,
id: existing?.id ?? `design-change-${nextId++}`,
runtimeTabId,
};
// Position is held too: an in-place update must not jump the chip to the end of the row.
const next = existing
? pending.map((candidate) => (candidate.id === existing.id ? entry : candidate))
: [...pending, entry];
return { byThreadKey: { ...state.byThreadKey, [key]: next } };
}),
remove: (threadRef, id) =>
set((state) => {
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/custom/designMode/designChangeTranscript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export interface ExtractedDesignChanges {
}

export function extractTrailingDesignChanges(prompt: string): ExtractedDesignChanges {
// Runs per render of every user row in the transcript, and almost no message carries a
// block — a literal scan is far cheaper than the regex, and answers for all of them.
if (!prompt.includes("<design_change_request>")) return { promptText: prompt, blocks: [] };
const match = TRAILING_DESIGN_CHANGE_BLOCKS_PATTERN.exec(prompt);
if (!match) return { promptText: prompt, blocks: [] };
const blocks = [...match[0].matchAll(DESIGN_CHANGE_BLOCK_PATTERN)].map((block) => block[1] ?? "");
Expand Down
Loading
Loading