Skip to content
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -721,6 +721,8 @@ jobs:
handoff_dir="$(python3 .github/scripts/handoff.py dir comment "$handoff_id" --root "$handoff_root")"
mkdir -p "$handoff_dir"
marker="<!-- merge-queue-needs-validation -->"
# Markdown code spans are intentionally literal in these single-quoted strings.
# shellcheck disable=SC2016
{
printf '%s\n' "$marker"
printf 'Ejected from the merge queue: this PR still carries the `needs-validation` label.\n\n'
Expand Down
10 changes: 6 additions & 4 deletions .github/workflows/cut-patch-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -100,10 +100,12 @@ jobs:
# (e.g. version=0.15.1 gates open-design-v0.15.0, not the latest 0.14.0).
vmajor=${V%%.*}; vrest=${V#*.}; vminor=${vrest%%.*}
MINOR_BASE="${vmajor}.${vminor}.0"
echo "version=$V" >> "$GITHUB_OUTPUT"
echo "branch=release/v$V" >> "$GITHUB_OUTPUT"
echo "minor_base=$MINOR_BASE" >> "$GITHUB_OUTPUT"
echo "minor_tag=open-design-v$MINOR_BASE" >> "$GITHUB_OUTPUT"
{
echo "version=$V"
echo "branch=release/v$V"
echo "minor_base=$MINOR_BASE"
echo "minor_tag=open-design-v$MINOR_BASE"
} >> "$GITHUB_OUTPUT"
echo "Cutting patch v$V (branch release/v$V); gating on stable v$MINOR_BASE"

# Guard: the minor this patch sits on (the Tuesday cut) must already be a
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release-branch-direct-pr-guard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ jobs:
exit 0
fi
echo "Blocking direct release PR #$PR into $BASE by $AUTHOR"
# Markdown code spans are intentionally literal in these single-quoted strings.
# shellcheck disable=SC2016
body="$(printf '%s\n' \
'🚫 Direct PRs into release branches are not accepted.' \
'' \
Expand Down
78 changes: 78 additions & 0 deletions apps/daemon/src/routes/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -712,13 +712,91 @@ const URL_PREVIEW_SELECTION_BRIDGE = `<script data-od-url-selection-bridge>
postStroke('od:pod-stroke');
});
}
// The host switches a plain URL preview to a bridge-enabled srcDoc when
// Manual Edit opens. Capture only mutable UI state so the second document
// can show the same app page without copying or evaluating artifact code.
function runtimeStateAttributeAllowed(name){
return name === 'class' ||
name === 'style' ||
name === 'hidden' ||
name === 'open' ||
name.indexOf('aria-') === 0 ||
(name.indexOf('data-') === 0 && name.indexOf('data-od-') !== 0);
}
function runtimeStateAttributes(el){
var attrs = Object.create(null);
if (!el || !el.attributes) return attrs;
for (var i = 0; i < el.attributes.length; i++) {
var attr = el.attributes[i];
if (!attr || !runtimeStateAttributeAllowed(attr.name)) continue;
attrs[attr.name] = String(attr.value || '');
}
return attrs;
}
function runtimeStatePath(el){
var path = [];
var node = el;
while (node && node !== document.body) {
var parent = node.parentElement;
if (!parent) return null;
var index = Array.prototype.indexOf.call(parent.children, node);
if (index < 0) return null;
path.unshift(index);
node = parent;
}
return node === document.body ? path : null;
}
function captureRuntimeState(){
var entries = [];
var nodes = document.body ? document.body.querySelectorAll('*') : [];
var count = Math.min(nodes.length, 3500);
for (var i = 0; i < count; i++) {
var el = nodes[i];
var path = runtimeStatePath(el);
if (!path) continue;
var entry = {
path: path,
tag: String(el.tagName || '').toLowerCase(),
attrs: runtimeStateAttributes(el)
};
if (el.id) entry.id = String(el.id);
var odId = el.getAttribute && el.getAttribute('data-od-id');
if (odId) entry.odId = String(odId);
var tag = entry.tag;
if (tag === 'input' || tag === 'textarea' || tag === 'select') {
entry.value = String(el.value == null ? '' : el.value);
}
if (tag === 'input' && (el.type === 'checkbox' || el.type === 'radio')) {
entry.checked = !!el.checked;
}
if (tag === 'select') entry.selectedIndex = Number(el.selectedIndex);
if (el.scrollLeft) entry.scrollLeft = Number(el.scrollLeft);
if (el.scrollTop) entry.scrollTop = Number(el.scrollTop);
entries.push(entry);
}
return {
version: 1,
hash: String(window.location.hash || ''),
htmlAttrs: runtimeStateAttributes(document.documentElement),
bodyAttrs: runtimeStateAttributes(document.body),
entries: entries
};
}
window.addEventListener('message', function(ev){
var data = ev && ev.data;
if (!data || !data.type) return;
if (data.type === 'od:url-selection-bridge-probe') {
window.parent.postMessage({ type: 'od:url-selection-bridge-ready' }, '*');
return;
}
if (data.type === 'od:preview-runtime-state-capture' && data.id) {
window.parent.postMessage({
type: 'od:preview-runtime-state-captured',
id: String(data.id),
state: captureRuntimeState()
}, '*');
return;
}
if (data.type === 'od:comment-mode') {
commentEnabled = !!data.enabled;
mode = data.mode === 'pod' ? 'pod' : 'picker';
Expand Down
1 change: 1 addition & 0 deletions apps/daemon/tests/project-file-range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ describe('GET /api/projects/:id/raw/* range request route', () => {
const html = await bridged.text();
expect(html).toContain('data-od-url-selection-bridge');
expect(html).toContain("type: 'od:comment-target'");
expect(html).toContain("type: 'od:preview-runtime-state-captured'");
expect(html).not.toContain('data-od-url-scroll-bridge');
});

Expand Down
22 changes: 10 additions & 12 deletions apps/daemon/tests/run-create-workspace-gate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,8 +216,9 @@ describe('POST /api/runs — workspace mutation gate', () => {
body: JSON.stringify({ projectId: TEAM_PROJECT, agentId: 'claude', message: 'hi' }),
});
expect(resp.status).toBe(401);
const payload = await resp.json();
expect(payload.error.code).toBe('WORKSPACE_CONTEXT_REQUIRED');
await expect(resp.json()).resolves.toMatchObject({
error: { code: 'WORKSPACE_CONTEXT_REQUIRED' },
});
});

it('allows run creation with a properly-authenticated team member', async () => {
Expand All @@ -228,8 +229,7 @@ describe('POST /api/runs — workspace mutation gate', () => {
body: JSON.stringify({ projectId: TEAM_PROJECT, agentId: 'claude', message: 'hi' }),
});
expect(resp.status).toBe(202);
const payload = await resp.json();
expect(typeof payload.runId).toBe('string');
await expect(resp.json()).resolves.toMatchObject({ runId: expect.any(String) });
});

it('still allows a headerless run creation against a never-claimed (legacy) project', async () => {
Expand All @@ -240,8 +240,7 @@ describe('POST /api/runs — workspace mutation gate', () => {
body: JSON.stringify({ projectId: UNBOUND_PROJECT, agentId: 'claude', message: 'hi' }),
});
expect(resp.status).toBe(202);
const payload = await resp.json();
expect(typeof payload.runId).toBe('string');
await expect(resp.json()).resolves.toMatchObject({ runId: expect.any(String) });
});

it('still allows a headerless run creation with no projectId at all (scratch / non-project usage)', async () => {
Expand All @@ -252,8 +251,7 @@ describe('POST /api/runs — workspace mutation gate', () => {
body: JSON.stringify({ agentId: 'claude', message: 'hi' }),
});
expect(resp.status).toBe(202);
const payload = await resp.json();
expect(typeof payload.runId).toBe('string');
await expect(resp.json()).resolves.toMatchObject({ runId: expect.any(String) });
});
});

Expand Down Expand Up @@ -297,8 +295,9 @@ describe('POST /api/runs — cross-checks stale client headers against the daemo
body: JSON.stringify({ projectId: TEAM_PROJECT, agentId: 'claude', message: 'hi' }),
});
expect(resp.status).toBe(403);
const payload = await resp.json();
expect(payload.error.code).toBe('WORKSPACE_PROJECT_PERMISSION_DENIED');
await expect(resp.json()).resolves.toMatchObject({
error: { code: 'WORKSPACE_PROJECT_PERMISSION_DENIED' },
});
});

it('still allows the same headers when the daemon cache has no opinion yet (never polled this workspace)', async () => {
Expand All @@ -312,7 +311,6 @@ describe('POST /api/runs — cross-checks stale client headers against the daemo
body: JSON.stringify({ projectId: TEAM_PROJECT, agentId: 'claude', message: 'hi' }),
});
expect(resp.status).toBe(202);
const payload = await resp.json();
expect(typeof payload.runId).toBe('string');
await expect(resp.json()).resolves.toMatchObject({ runId: expect.any(String) });
});
});
154 changes: 143 additions & 11 deletions apps/web/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,12 +347,78 @@ const POWERED_PREVIEW_SANDBOX =
const POWERED_PREVIEW_ALLOW =
'accelerometer; autoplay; camera; cross-origin-isolated; fullscreen; gamepad; gyroscope; microphone; xr-spatial-tracking';
const PREVIEW_BRIDGE_QUERY = 'odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot';
// Generic runtime UI state carried across the URL-load -> srcDoc transport
// switch. This preserves the current page of multi-page prototypes while
// leaving artifact scripts and business state inside their sandboxed frames.
const PREVIEW_RUNTIME_STATE_MAX_ELEMENTS = 3500;
type PreviewRuntimeStateEntry = {
path: number[];
tag: string;
id?: string;
odId?: string;
attrs: Record<string, string>;
value?: string;
checked?: boolean;
selectedIndex?: number;
scrollLeft?: number;
scrollTop?: number;
};
type PreviewRuntimeState = {
version: 1;
hash: string;
htmlAttrs: Record<string, string>;
bodyAttrs: Record<string, string>;
entries: PreviewRuntimeStateEntry[];
};
const HTML_PASSIVE_PREVIEW_FULL_TEXT_LIMIT = 2 * 1024 * 1024;
const HTML_ROUTING_TEXT_PREVIEW_LIMIT = 96 * 1024;
const HTML_PREVIEW_ASSET_PREFLIGHT_LIMIT = 32;
type HtmlSourceLoadMode = 'full' | 'routing-preview';
type PreviewAssetWarning = { filePath: string };

function isPreviewRuntimeAttributeMap(value: unknown): value is Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const entries = Object.entries(value);
return entries.length <= 64 && entries.every(([name, attrValue]) => (
name.length <= 128 &&
typeof attrValue === 'string' &&
attrValue.length <= 20_000
));
}

function isPreviewRuntimeState(value: unknown): value is PreviewRuntimeState {
if (!value || typeof value !== 'object') return false;
const state = value as Partial<PreviewRuntimeState>;
if (
state.version !== 1 ||
typeof state.hash !== 'string' ||
state.hash.length > 4096 ||
!isPreviewRuntimeAttributeMap(state.htmlAttrs) ||
!isPreviewRuntimeAttributeMap(state.bodyAttrs) ||
!Array.isArray(state.entries) ||
state.entries.length > PREVIEW_RUNTIME_STATE_MAX_ELEMENTS
) {
return false;
}
return state.entries.every((entry) => (
!!entry &&
typeof entry === 'object' &&
typeof entry.tag === 'string' &&
entry.tag.length <= 32 &&
Array.isArray(entry.path) &&
entry.path.length <= 64 &&
entry.path.every((index) => Number.isInteger(index) && index >= 0 && index <= 100_000) &&
(entry.id === undefined || (typeof entry.id === 'string' && entry.id.length <= 4096)) &&
(entry.odId === undefined || (typeof entry.odId === 'string' && entry.odId.length <= 4096)) &&
isPreviewRuntimeAttributeMap(entry.attrs) &&
(entry.value === undefined || (typeof entry.value === 'string' && entry.value.length <= 100_000)) &&
(entry.checked === undefined || typeof entry.checked === 'boolean') &&
(entry.selectedIndex === undefined || Number.isInteger(entry.selectedIndex)) &&
(entry.scrollLeft === undefined || Number.isFinite(entry.scrollLeft)) &&
(entry.scrollTop === undefined || Number.isFinite(entry.scrollTop))
));
}

function previewTextNeedsFullSourceForSafeInline(source: string | null): boolean {
if (!source) return false;
return (
Expand Down Expand Up @@ -7276,6 +7342,11 @@ function HtmlViewer({
const iframeRef = useRef<HTMLIFrameElement | null>(null);
const urlPreviewIframeRef = useRef<HTMLIFrameElement | null>(null);
const srcDocPreviewIframeRef = useRef<HTMLIFrameElement | null>(null);
const previewRuntimeStateRef = useRef<PreviewRuntimeState | null>(null);
const previewRuntimeStateRequestSequenceRef = useRef(0);
const manualEditActivationPendingRef = useRef(false);
const previewFileIdentityRef = useRef(`${projectId}\u0000${file.name}`);
previewFileIdentityRef.current = `${projectId}\u0000${file.name}`;
const activatedSrcDocTransportHtmlRef = useRef<string | null>(null);
// Tracks the iframe DOM node whose dedupe ref was last reset by the
// srcDoc onLoad handler. We reset the dedupe exactly once per freshly
Expand All @@ -7295,6 +7366,36 @@ function HtmlViewer({
source === srcDocPreviewIframeRef.current?.contentWindow
);
}, []);
const capturePreviewRuntimeState = useCallback((target: HTMLIFrameElement | null) => {
const source = target?.contentWindow;
if (!source) return Promise.resolve<PreviewRuntimeState | null>(null);
previewRuntimeStateRequestSequenceRef.current += 1;
const id = `runtime-state-${Date.now()}-${previewRuntimeStateRequestSequenceRef.current}`;
return new Promise<PreviewRuntimeState | null>((resolve) => {
let settled = false;
const finish = (state: PreviewRuntimeState | null) => {
if (settled) return;
settled = true;
window.clearTimeout(timeout);
window.removeEventListener('message', onMessage);
resolve(state);
};
const onMessage = (event: MessageEvent) => {
if (event.source !== source) return;
const data = event.data as { type?: unknown; id?: unknown; state?: unknown } | null;
if (
data?.type !== 'od:preview-runtime-state-captured' ||
data.id !== id
) {
return;
}
finish(isPreviewRuntimeState(data.state) ? data.state : null);
};
const timeout = window.setTimeout(() => finish(null), 500);
window.addEventListener('message', onMessage);
source.postMessage({ type: 'od:preview-runtime-state-capture', id }, '*');
});
}, []);
const setCommentComposerHostRef = useCallback((node: HTMLDivElement | null) => {
setCommentComposerHost((current) => (current === node ? current : node));
}, []);
Expand Down Expand Up @@ -7360,6 +7461,7 @@ function HtmlViewer({
useEffect(() => {
setManualEditSrcDocActive(false);
setManualEditFrozenSource(null);
previewRuntimeStateRef.current = null;
// Restore this file's last measured content width instead of forcing
// `null` — this effect also fires on every HtmlViewer remount (tab-away
// and back), not only on a genuine file change, and clearing here would
Expand Down Expand Up @@ -8928,6 +9030,18 @@ function HtmlViewer({
function syncBridgeModes(target: HTMLIFrameElement | null = iframeRef.current) {
const win = target?.contentWindow;
if (!win) return;
const runtimeState = previewRuntimeStateRef.current;
Comment thread
Siri-Ray marked this conversation as resolved.
Outdated
if (
runtimeState &&
target === srcDocPreviewIframeRef.current &&
!useUrlLoadPreview
) {
// This snapshot only bridges the first URL -> srcDoc handoff. Consume it
// before posting so later srcDoc reloads cannot overwrite newer source
// attributes or runtime navigation with stale transition state.
previewRuntimeStateRef.current = null;
win.postMessage({ type: 'od:preview-runtime-state-restore', state: runtimeState }, '*');
Comment thread
Siri-Ray marked this conversation as resolved.
Outdated
}
win.postMessage({
type: 'od:comment-mode',
enabled: boardMode,
Expand Down Expand Up @@ -11131,17 +11245,35 @@ function HtmlViewer({
fireArtifactToolbarClick('edit');
capturePreviewScrollPosition();
if (!manualEditMode) {
setCommentPanelOpen(false);
setCommentCreateMode(false);
setBoardMode(false);
clearBoardComposer();
setInspectMode(false);
setDrawOverlayOpen(false);
setMode('preview');
setManualEditViewportWidth(previewBodyRef.current?.clientWidth ?? null);
setManualEditSrcDocActive(true);
setManualEditMode(true);
closeArtifactToolMenus();
if (manualEditActivationPendingRef.current) return;
const enterManualEditMode = () => {
setCommentPanelOpen(false);
setCommentCreateMode(false);
setBoardMode(false);
clearBoardComposer();
setInspectMode(false);
setDrawOverlayOpen(false);
setMode('preview');
setManualEditViewportWidth(previewBodyRef.current?.clientWidth ?? null);
setManualEditSrcDocActive(true);
setManualEditMode(true);
closeArtifactToolMenus();
};
if (!useUrlLoadPreview) {
enterManualEditMode();
return;
}
const activationFileIdentity = previewFileIdentityRef.current;
manualEditActivationPendingRef.current = true;
void capturePreviewRuntimeState(urlPreviewIframeRef.current)
Comment thread
Siri-Ray marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Blocking: preserve the existing Manual Edit activation contract while waiting for the runtime-state capture. This changed line defers enterManualEditMode() until the capture response or the 500 ms timeout, so any edit-selection message emitted during that interval is handled while manualEditMode is still false and is discarded. On this exact head, corepack pnpm --filter @open-design/web exec vitest run -c vitest.config.ts tests/components/FileViewer.manual-edit-history.test.tsx fails five of six existing scenarios at panelState.props remaining null; the two newly changed web test files pass, so this is specific to the uncovered activation path. This matters because the PR currently breaks the required Web workspace test lane and can lose a user’s first interaction during the pending handoff. Please either introduce an explicit pending/active transition that queues or rejects follow-up actions without dropping them, or update the activation/fixture contract so capture-capable frames acknowledge the request and callers wait for the visible active state before sending selections. Then restore all six history scenarios and run the full Web workspace test lane.

🔁 Powered by Looper · runner=reviewer · agent=codex · An autonomous AI dev team for your GitHub repos.

.then((state) => {
if (previewFileIdentityRef.current !== activationFileIdentity) return;
if (state) previewRuntimeStateRef.current = state;
enterManualEditMode();
})
.finally(() => {
manualEditActivationPendingRef.current = false;
});
return;
}
closeArtifactToolMenus();
Expand Down
Loading
Loading