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
8 changes: 7 additions & 1 deletion .fork/customizations.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1744,7 +1744,12 @@
data-t3-native-source so it is never mistaken for a project tag).
Elements whose source never resolves stay fully editable and send
with selector/text/style context; no install prompt, warning toast,
or Forge handoff exists anymore. Canvas mode (the Forge's vendored
or Forge handoff exists anymore. That downgrade is VISIBLE, not
silent: the payload's per-element sourceLabel (null = unaddressed by
send time) is counted host-side (protocol.ts
countUnresolvedDesignElements) and surfaced on the composer pill and
in the send toast, because buildSend's ~1.5s native-source grace
means WHEN Send was clicked can change how precise the ask is. Canvas mode (the Forge's vendored
CanvasMode, engine/vendor/canvas.ts) turns the page into a
pannable/zoomable artboard — space-drag/middle-drag pan,
cursor-anchored wheel and pinch zoom, the powers-of-2 ladder,
Expand Down Expand Up @@ -1840,6 +1845,7 @@
tier: 4
files:
- apps/web/src/custom/designMode/protocol.ts
- apps/web/src/custom/designMode/protocol.test.ts
# Outside engine/ on purpose: like protocol.ts it crosses the TS-island fence
# (the engine tsconfig includes it by path) so the web project can type-check
# and unit-test it, which files under engine/ cannot be.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions apps/web/src/custom/designMode/ForkComposerDesignChanges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
useDesignChangeTargetRef,
useForkPendingDesignChanges,
} from "./designChangeDraftStore";
import { countUnresolvedDesignElements } from "./protocol";

interface Props {
/** The composer's own draft target — a DraftId for unstarted threads. Resolved to the
Expand Down Expand Up @@ -65,6 +66,7 @@ function DesignChangeChip({
}) {
const source = chipSource(entry);
const summary = chipSummary(entry);
const unresolved = countUnresolvedDesignElements(entry);
return (
<Tooltip>
<TooltipTrigger
Expand All @@ -81,6 +83,15 @@ function DesignChangeChip({
<span className="shrink-0">{chipLabel(entry)}</span>
{source ? <span className="shrink-0">{source}</span> : null}
{summary ? <span className="truncate">{summary}</span> : null}
{/* min-w-0 + truncate: this note must yield BEFORE the remove button. In a
squeezed pill every unshrinkable child clips from the right, and the
right-most child is the X — an unremovable attachment is a one-way door
(PR #71 review). */}
{unresolved > 0 ? (
<span className="min-w-0 truncate text-[11px] font-medium opacity-80">
{entry.elements.length === 1 ? "no source" : `${unresolved} without source`}
</span>
) : null}
Comment on lines +90 to +94

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new note is shrink-0 and sits before the remove button inside a pill that is max-w-full overflow-hidden. Once the summary has shrunk to zero, every remaining child is unshrinkable, so the overflow is clipped from the right — and the right-most child is the X button.

Concrete: preview open (design panel is a fixed 325px, so the chat column is already squeezed), a 12-element change with all sources unresolved. The pill now carries icon + 12 elements + 12 without source + X, roughly 100px more unshrinkable width than before this PR. When that exceeds the composer width the X clips out of the pill and the attachment can no longer be removed — a one-way door on the only exit for a pending design change.

Cheapest fixes: give the note min-w-0 truncate so it yields before the button does, or move it ahead of the summary so the truncating child is the last thing that can be squeezed.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Real — an unremovable attachment is a one-way door. Fixed in ed01494: the note is min-w-0 truncate, so it yields before the X does; the comment now documents that ordering constraint.

<button
type="button"
className="flex size-5 shrink-0 items-center justify-center rounded-full transition-colors hover:bg-black/16"
Expand All @@ -96,6 +107,9 @@ function DesignChangeChip({
}
/>
<TooltipPopup className="max-w-96 whitespace-pre-wrap font-mono text-[11px]">
{unresolved > 0
? `${unresolved === entry.elements.length ? (unresolved === 1 ? "This element has" : "These elements have") : `${unresolved} of ${entry.elements.length} elements ${unresolved === 1 ? "has" : "have"}`} no source location — the request carries selector and text context instead.\n\n`
: ""}
{entry.markdown.slice(0, 600)}
{entry.markdown.length > 600 ? "…" : ""}
</TooltipPopup>
Expand Down
20 changes: 14 additions & 6 deletions apps/web/src/custom/designMode/panel/ForkDesignPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ import { designModeBridge } from "../designModeBridge";
import { selectDesignModeTab, useDesignModeStore } from "../designModeStore";
import { applyDesignUndoEntry } from "../designUndoApply";
import { designUndoHistory } from "../designUndoHistory";
import type {
DesignModeAlignAxis,
DesignModeAlignValue,
DesignModeSizeMode,
DesignModeWritableKey,
import {
countUnresolvedDesignElements,
type DesignModeAlignAxis,
type DesignModeAlignValue,
type DesignModeSizeMode,
type DesignModeWritableKey,
} from "../protocol";
import { CanvasControls } from "./CanvasControls";
import { AppearanceSection } from "./sections/AppearanceSection";
Expand Down Expand Up @@ -222,13 +223,20 @@ export function ForkDesignPanel({ runtimeTabId, threadRef, tabId }: Props) {
// navigation adds one — the drafts behind it are a different page's. See
// designChangeDraftStore's `add`.
useDesignChangeDraftStore.getState().add(threadRef, runtimeTabId, result);
// Precision is part of the receipt: buildSend's ~1.5s native-source grace can expire
// and downgrade elements to selector/text context. Say so here rather than letting
// WHEN Send was clicked silently change what the agent gets (the pill repeats it).
const unresolved = countUnresolvedDesignElements(result);

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a second look before merge: the framing here ("buildSend's grace can expire") only holds when resolution could have succeeded. boot.ts:109 reports selector-only when the page has neither forge tags nor the native resolver, and on such a page every element is sourceLabel: null on every send, forever — nothing was ever racing.

The result is a permanent, unactionable nag on a surface the fork deliberately de-nagged: .fork/customizations.yaml#fork-design-mode says "no install prompt, warning toast, or Forge handoff exists anymore" for exactly these elements. Now every send in a non-instrumented preview pops a toast about a downgrade the user cannot fix.

tab.sourceMode is already on the store here — gating the copy on sourceMode !== "selector-only" would keep the signal where it means something (a timing race under forge/native-react) and stay quiet where it doesn't. Non-blocking, but the current behavior reads as a regression against the customization's stated intent.


Generated by Claude Code

toastManager.add({
type: "success",
title:
result.elementCount === 1
? "Design change attached"
: `Design changes for ${result.elementCount} elements attached`,
description: "It rides along with your next message — add a comment or just press Enter.",
description:
unresolved > 0
? `${unresolved === result.elements.length ? (unresolved === 1 ? "The element has" : "All of them have") : `${unresolved} of them ${unresolved === 1 ? "has" : "have"}`} no source location — sent with selector and text context. Rides along with your next message.`
: "It rides along with your next message — add a comment or just press Enter.",
});
} finally {
setSending(false);
Expand Down
33 changes: 33 additions & 0 deletions apps/web/src/custom/designMode/protocol.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vite-plus/test";

import { countUnresolvedDesignElements } from "./protocol";

/**
* The unresolved-count rule the pill and send toast surface: an element with a null
* sourceLabel is one the request could not source-address by send time (the
* SEND_SOURCE_WAIT_MS grace expired, or the page has no source mapping at all).
*/

const element = (sourceLabel: string | null) => ({ sourceLabel });

describe("countUnresolvedDesignElements", () => {
it("counts only null sourceLabels", () => {
expect(
countUnresolvedDesignElements({
elements: [element("App.tsx:12"), element(null), element("Card.tsx:8"), element(null)],
}),
).toBe(2);
});

it("is zero when every element resolved", () => {
expect(countUnresolvedDesignElements({ elements: [element("App.tsx:12")] })).toBe(0);
});

it("is zero for an empty element list", () => {
expect(countUnresolvedDesignElements({ elements: [] })).toBe(0);
});

it("an empty-string label is resolved, not unresolved — only null means no source", () => {
expect(countUnresolvedDesignElements({ elements: [element("")] })).toBe(0);
});
});
9 changes: 9 additions & 0 deletions apps/web/src/custom/designMode/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,15 @@ export interface DesignChangeElementSummary {
readonly deltas: readonly string[];
}

/** Elements the request could not source-address by send time. buildSend waits at most
* ~1.5s for in-flight native resolution and then ships selector/text context for whatever
* is left (`sourceLabel` null) — WHEN the user pressed Send silently changed how precise
* the ask was. This count is what the pill and the send toast surface so the downgrade is
* visible instead. */
export const countUnresolvedDesignElements = (payload: {
readonly elements: ReadonlyArray<{ readonly sourceLabel: string | null }>;
}): number => payload.elements.filter((element) => element.sourceLabel === null).length;

/** Longest `pageUrl` accepted — the guest reads it off a page-controlled `location.href`,
* which a data: document can make arbitrarily long. Only ever compared, never rendered. */
export const DESIGN_MODE_PAGE_URL_CAP = 2048;
Expand Down
Loading