feat(web): show the page taking shape while a run builds it

The Design Files pane used to sit on an empty state until a run finished
and the first artifact dropped in. Everything the run was doing happened
somewhere the user could not see, and the wait carried no signal that
anything was happening at all.

It now watches the shallowest index.html the run has produced (or the
most recently written HTML file) and renders it live beside a feed of the
steps behind it, with a toggle between the step feed and the full
preview. A new cross-runtime contract, preview-build-focus, lets the
sandboxed preview frame answer "where in this page are you writing right
now" — either by a literal string the agent just wrote or by naming one
of the page's own top-level sections — so the host can walk a cursor down
the page as it is written instead of jumping to wherever the last run of
text happens to land. The frame has no allow-same-origin, so it posts to
'*' and the host identifies it by event.source.

latest-user-prompt goes away with the empty state that echoed it: the
pane now names what the run is doing, which is the more useful of the
two, and keeping both meant recomputing a prompt string on every streamed
token for a line nobody read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
wangchenglong
2026-09-04 15:43:24 +08:00
co-authored by Claude Opus 5
parent 61dba52422
commit f7c39b9d2a
51 changed files with 2451 additions and 240 deletions
+28 -2
View File
@@ -6,6 +6,10 @@ import {
PREVIEW_OBSERVABILITY_BRIDGE_MARKER,
buildPreviewObservabilityBridge,
} from '@open-design/contracts/runtime/preview-observability';
import {
PREVIEW_BUILD_FOCUS_BRIDGE_MARKER,
buildPreviewBuildFocusBridge,
} from '@open-design/contracts/runtime/preview-build-focus';
import {
defaultScenarioPluginIdForProjectMetadata,
type ChatSessionMode,
@@ -1394,6 +1398,12 @@ function wantsUrlPreviewObservabilityBridge(value: unknown): boolean {
return previewBridgeTokens(value).some((token) => token === 'observability' || token === 'errors' || token === 'diagnostics');
}
/** The build-focus bridge: lets the host park a cursor on the part of the page
* the agent is writing right now (see the contracts module for the protocol). */
function wantsUrlPreviewBuildFocusBridge(value: unknown): boolean {
return previewBridgeTokens(value).some((token) => token === 'buildfocus' || token === 'build-focus');
}
function injectBeforeBodyClose(html: string, marker: string, injection: string): string {
if (html.includes(marker)) return html;
const bodyCloseIndex = html.search(/<\/body\s*>/i);
@@ -1414,7 +1424,10 @@ function injectAfterHeadOpen(html: string, marker: string, injection: string): s
return `${injection}${html}`;
}
function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection' | 'snapshot' | 'observability'): string {
function injectUrlPreviewBridge(
html: string,
bridge: 'scroll' | 'selection' | 'snapshot' | 'observability' | 'buildfocus',
): string {
if (bridge === 'observability') {
return injectAfterHeadOpen(
html,
@@ -1422,6 +1435,15 @@ function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection' | '
buildPreviewObservabilityBridge(),
);
}
if (bridge === 'buildfocus') {
// Before </body>, like the scroll bridge: it walks the rendered DOM, so it
// must not run before the document it measures exists.
return injectBeforeBodyClose(
html,
PREVIEW_BUILD_FOCUS_BRIDGE_MARKER,
buildPreviewBuildFocusBridge(),
);
}
if (bridge === 'scroll') {
return injectBeforeBodyClose(html, 'data-od-url-scroll-bridge', URL_PREVIEW_SCROLL_BRIDGE);
}
@@ -1441,7 +1463,8 @@ function applyUrlPreviewBridgesToHtml(
wantsUrlPreviewScrollBridge(requestedBridge) ||
wantsUrlPreviewSelectionBridge(requestedBridge) ||
wantsUrlPreviewSnapshotBridge(requestedBridge) ||
wantsUrlPreviewObservabilityBridge(requestedBridge)
wantsUrlPreviewObservabilityBridge(requestedBridge) ||
wantsUrlPreviewBuildFocusBridge(requestedBridge)
) ||
!/^text\/html(?:;|$)/i.test(mime)
) {
@@ -1456,6 +1479,9 @@ function applyUrlPreviewBridgesToHtml(
if (wantsUrlPreviewObservabilityBridge(requestedBridge)) {
html = injectUrlPreviewBridge(html, 'observability');
}
if (wantsUrlPreviewBuildFocusBridge(requestedBridge)) {
html = injectUrlPreviewBridge(html, 'buildfocus');
}
if (wantsUrlPreviewScrollBridge(requestedBridge)) {
html = injectUrlPreviewBridge(html, 'scroll');
}
@@ -359,6 +359,41 @@ describe('project preview containment routes', () => {
}
});
// The build-focus bridge is what lets the Design Files pane park a cursor on
// the part of a page the agent is writing right now. It rides the same
// `odPreviewBridge` query as the other four and must stay opt-in: a preview
// that did not ask for it gets a clean document.
it('injects the build-focus bridge only when asked, and only once', async () => {
const projectId = await createProject();
await writeProjectFile(
projectId,
'index.html',
'<!doctype html><html><body><h1>Studio Nine</h1></body></html>',
);
const plain = await fetch(`${baseUrl}/api/projects/${projectId}/raw/index.html`);
expect(await plain.text()).not.toContain('data-od-preview-build-focus');
const bridged = await fetch(
`${baseUrl}/api/projects/${projectId}/raw/index.html?odPreviewBridge=buildfocus`,
);
expect(bridged.status).toBe(200);
const html = await bridged.text();
expect(html.split('data-od-preview-build-focus').length - 1).toBe(1);
expect(html).toContain('od:preview-build-focus');
// Asking for one bridge must not hand out the others — the observability
// bridge in particular reports page errors into a host-wide buffer.
expect(html).not.toContain('data-od-preview-observability');
expect(html).not.toContain('data-od-url-scroll-bridge');
// A non-HTML file has nothing to inject into.
await writeProjectFile(projectId, 'notes.txt', 'plain text');
const text = await fetch(
`${baseUrl}/api/projects/${projectId}/raw/notes.txt?odPreviewBridge=buildfocus`,
);
expect(await text.text()).not.toContain('data-od-preview-build-focus');
});
it('rejects invalid preview scopes and escaping preview-url paths', async () => {
const projectId = await createProject();
await writeProjectFile(projectId, 'index.html', '<!doctype html>');
+49 -10
View File
@@ -34,7 +34,10 @@ import {
getHtmlThumbnailSource,
loadHtmlThumbnailSource,
} from './html-thumbnail-source-cache';
import { BuildPreviewToggle } from './design-files/BuildPreviewToggle';
import { DesignFilesEmptyState } from './design-files/DesignFilesEmptyState';
import { DesignFilesBuildingState } from './design-files/DesignFilesBuildingState';
import { selectBuildPreviewHtmlEntry } from './auto-open-file';
import type { RunProgressStep } from '../runtime/run-progress';
type TranslateFn = (key: keyof Dict, vars?: Record<string, string | number>) => string;
@@ -68,8 +71,6 @@ interface Props {
// True while the chat agent is generating. Drives the empty state's status
// line, so an empty project reads as "working on it" rather than blank.
running?: boolean;
/** The chat's most recent user prompt, echoed by the empty state. */
latestUserPrompt?: string | null;
/** The running turn's tool calls, newest first. The empty state names the
* first one as the current step and stacks the rest beneath it. */
runSteps?: RunProgressStep[];
@@ -451,7 +452,6 @@ export function DesignFilesPanel({
rootDirName,
reloading,
running = false,
latestUserPrompt = null,
runSteps,
files,
folders,
@@ -479,6 +479,23 @@ export function DesignFilesPanel({
const { workspaceContext } = useProjectCollabContext();
const t = useT();
const analytics = useAnalytics();
// The page the run is currently building, if it has produced one. Only HTML
// qualifies: there is nothing to watch take shape in a markdown file or an
// image, and swapping the preview for one mid-run would be a downgrade.
const buildPreviewName = useMemo(
() => (running ? selectBuildPreviewHtmlEntry(files) : null),
[running, files],
);
const buildPreviewFile = useMemo(
() => (buildPreviewName ? files.find((file) => file.name === buildPreviewName) ?? null : null),
[buildPreviewName, files],
);
// A long run must not trap the user away from their files. The topbar's
// preview switch flips this both ways; it resets when the next run starts.
const [buildPreviewDismissed, setBuildPreviewDismissed] = useState(false);
useEffect(() => {
if (running) setBuildPreviewDismissed(false);
}, [running]);
const [draggingFiles, setDraggingFiles] = useState(false);
const [dropReadError, setDropReadError] = useState<string | null>(null);
const dragDepthRef = useRef(0);
@@ -1445,7 +1462,19 @@ export function DesignFilesPanel({
<div className="df-main">
<div className="df-topbar">
<div className="df-topbar-left">{breadcrumbs}</div>
<div className="df-topbar-right">{fileActions}</div>
<div className="df-topbar-right">
{/* Only while there is something to preview: a run in flight that
has already written a page. Outside that window the pane has
one view, and a switch with nothing on its other side would be
a control that does nothing. */}
{buildPreviewFile && running ? (
<BuildPreviewToggle
checked={!buildPreviewDismissed}
onChange={(next) => setBuildPreviewDismissed(!next)}
/>
) : null}
{fileActions}
</div>
</div>
<div
className="df-body"
@@ -1526,7 +1555,21 @@ export function DesignFilesPanel({
</div>
</div>
) : null}
{files.length === 0 && liveArtifacts.length === 0 && (folders?.length ?? 0) === 0 ? (
{buildPreviewFile && running && !buildPreviewDismissed ? (
/* The middle state: a page exists but the run is still writing it.
Watching it take shape beats a grid of file cards whose only news
is that a file appeared. Falls back to the grid the moment the run
ends, or when the topbar's preview switch is turned off. */
<div className="df-empty" data-testid="design-files-building-host">
<DesignFilesBuildingState
projectId={projectId}
file={buildPreviewFile}
filesRefreshKey={filesRefreshKey ?? 0}
steps={runSteps ?? []}
workspaceContext={workspaceContext}
/>
</div>
) : files.length === 0 && liveArtifacts.length === 0 && (folders?.length ?? 0) === 0 ? (
downloadPending ? (
// A shared project whose local mirror has not caught up yet
// reads as EXACTLY the same zero-files result as a genuinely
@@ -1545,11 +1588,7 @@ export function DesignFilesPanel({
</div>
) : (
<div className="df-empty" data-testid="design-files-empty">
<DesignFilesEmptyState
latestUserPrompt={latestUserPrompt}
running={running}
steps={runSteps}
/>
<DesignFilesEmptyState running={running} steps={runSteps} />
</div>
)
) : (
+4 -9
View File
@@ -168,7 +168,6 @@ import {
} from './sketch-model';
import { AnimatePresence } from 'motion/react';
import type { ChatMessage } from '../types';
import { latestUserPromptText } from '../runtime/latest-user-prompt';
import { runProgressSteps } from '../runtime/run-progress';
import type { CommentSendResult } from './comment-send-result';
@@ -1614,13 +1613,10 @@ export function FileWorkspace({
[files],
);
// Echoed by the Design Files empty state while a run is in flight, so the
// right pane names the request it is working on. Memoized to a plain string
// so streamed tokens do not push a new object through the panel every frame.
const latestUserPrompt = useMemo(() => latestUserPromptText(messages), [messages]);
// The other half of that empty state: what the run is doing right now, and
// the steps behind it. Recomputed per streamed event by design — a tool call
// landing IS the update the pane is there to show.
// What the Design Files empty state shows while a run is in flight: what the
// run is doing right now, and the steps behind it. Recomputed per streamed
// event by design — a tool call landing IS the update the pane is there to
// show.
const runSteps = useMemo(() => runProgressSteps(messages), [messages]);
// Known-file set for the side chat's file-link routing — same shape
@@ -4255,7 +4251,6 @@ export function FileWorkspace({
rootDirName={rootDirName}
reloading={reloading}
running={Boolean(streaming)}
latestUserPrompt={latestUserPrompt}
runSteps={runSteps}
files={visibleFiles}
folders={projectFolders}
+34
View File
@@ -138,6 +138,40 @@ function siteEntryDepth(file: CandidateFile): number | null {
return path.split('/').length;
}
/**
* The page to watch while the agent is still building: the shallowest
* `index.html`, else the most recently written HTML file. Null when the project
* has produced no page yet.
*
* Deliberately narrower than `selectAutoOpenProducedArtifact`, which also ranks
* markdown and media — a build preview has nothing to render for those, and
* falling back to one would swap a page for a picture mid-run.
*/
export function selectBuildPreviewHtmlEntry(
files: ReadonlyArray<CandidateFile>,
): string | null {
let entry: CandidateFile | null = null;
let entryDepth = Number.POSITIVE_INFINITY;
let newest: CandidateFile | null = null;
for (const file of files) {
if (file.type === 'dir') continue;
if (!isHtmlPreviewFile(file)) continue;
const depth = siteEntryDepth(file);
if (depth !== null && depth < entryDepth) {
entry = file;
entryDepth = depth;
} else if (depth !== null && depth === entryDepth && entry) {
if (mtimeOf(file) >= mtimeOf(entry)) entry = file;
}
if (!newest || mtimeOf(file) >= mtimeOf(newest)) newest = file;
}
return (entry ?? newest)?.name ?? null;
}
function mtimeOf(file: CandidateFile): number {
return typeof file.mtime === 'number' && Number.isFinite(file.mtime) ? file.mtime : 0;
}
export interface SelectAutoOpenOptions {
// Prefer the site entry (`index.html`) among the turn's produced HTML
// files. Website-clone turns reproduce a whole multi-page site in one run —
@@ -0,0 +1,72 @@
/* Sits in the topbar's action cluster, so it takes that row's height (26px)
and stays transparent until hovered — the same shape as the buttons beside
it, wearing a switch instead of an icon. */
.toggle {
display: inline-flex;
align-items: center;
gap: 8px;
height: 26px;
padding: 0 8px;
border: 0;
border-radius: var(--radius-pill, 999px);
background: transparent;
color: var(--text-muted);
font-family: inherit;
font-size: 12px;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition:
background 140ms cubic-bezier(0.23, 1, 0.32, 1),
color 140ms cubic-bezier(0.23, 1, 0.32, 1);
}
.toggle:hover {
background: var(--bg-subtle);
color: var(--text-strong);
}
.toggle:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.toggle[aria-checked='true'] {
color: var(--text-strong);
}
.track {
position: relative;
flex: none;
width: 36px;
height: 20px;
border-radius: var(--radius-pill, 999px);
background: var(--bg-muted);
transition: background 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.toggle[aria-checked='true'] .track {
background: var(--accent);
}
/* White on both themes: it reads as the knob's own material, not as a surface
that should follow the theme, and the track behind it carries the state. */
.knob {
position: absolute;
top: 3px;
left: 3px;
width: 14px;
height: 14px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1);
}
.toggle[aria-checked='true'] .knob {
transform: translateX(16px);
}
@media (prefers-reduced-motion: reduce) {
.toggle,
.track,
.knob {
transition: none;
}
}
@@ -0,0 +1,38 @@
import { useT } from '../../i18n';
import styles from './BuildPreviewToggle.module.css';
interface Props {
/** True while the pane is showing the page the run is building. */
checked: boolean;
onChange: (next: boolean) => void;
}
/**
* The pane's one build-preview control: on, the pane is the page taking shape
* under a cursor that points at the part being written; off, it is the plain
* file grid.
*
* It replaces the "show files" button that used to float on top of the
* rendered page. A button could only ever go one way — once the preview was
* dismissed the run had no way back to it — and it sat ON the artifact it was
* about. A switch says both directions in one control, and it lives in the
* topbar with the other file actions, clear of the page underneath.
*/
export function BuildPreviewToggle({ checked, onChange }: Props) {
const t = useT();
return (
<button
type="button"
role="switch"
aria-checked={checked}
className={styles.toggle}
data-testid="design-files-preview-toggle"
onClick={() => onChange(!checked)}
>
<span className={styles.track} aria-hidden>
<span className={styles.knob} />
</span>
<span>{t('designFiles.buildingPreview')}</span>
</button>
);
}
@@ -0,0 +1,131 @@
.stage {
position: absolute;
inset: 0;
overflow: hidden;
background: #fff;
}
/* A display, not an editor. `pointer-events: none` is load-bearing twice over:
an iframe otherwise swallows dragenter/dragover and the pane's drag-to-upload
target would stop firing, and a half-built page must not take clicks meant
for the panel around it. */
.frame {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
background: #fff;
pointer-events: none;
}
.overlay {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 2;
}
/* Positioned by transform from the frame's own coordinates, so it glides
between targets instead of jumping. */
.cursor {
position: absolute;
top: 0;
left: 0;
display: block;
width: 22px;
height: 22px;
transition: transform 260ms cubic-bezier(0.23, 1, 0.32, 1);
}
.cursorGlyph {
position: absolute;
inset: 0;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.28));
}
.cursorRing {
position: absolute;
inset: -6px;
border-radius: 50%;
border: 2px solid var(--accent);
opacity: 0;
animation: df-build-focus-ring 1.6s cubic-bezier(0.23, 1, 0.32, 1) infinite;
}
/* The description the cursor carries: what is being made, right here. */
.cursorCaption {
position: absolute;
top: 22px;
left: 14px;
max-width: 320px;
padding: 4px 9px;
border-radius: var(--radius-pill, 999px);
background: var(--bg-panel);
color: var(--accent);
font-size: 12px;
font-weight: 600;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.14);
}
/* Bottom-left status: the file being built, and the step log under it. Sits on
a soft scrim so it stays legible over any page. */
.dock {
position: absolute;
left: 16px;
bottom: 14px;
z-index: 3;
display: flex;
flex-direction: column;
gap: 2px;
max-width: min(60%, 420px);
padding: 10px 12px;
border-radius: var(--radius);
background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
backdrop-filter: blur(10px);
pointer-events: none;
}
.dockCaption {
margin: 0;
font-size: 13px;
font-weight: 600;
line-height: 1.35;
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dockFeed {
max-height: 74px;
text-align: left;
}
@keyframes df-build-focus-ring {
0% {
opacity: 0.55;
transform: scale(0.9);
}
70% {
opacity: 0;
transform: scale(1.5);
}
100% {
opacity: 0;
transform: scale(1.5);
}
}
@media (prefers-reduced-motion: reduce) {
.cursor {
transition: none;
}
.cursorRing {
animation: none;
}
}
@@ -0,0 +1,249 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
isPreviewBuildFocusReady,
parsePreviewBuildFocusResult,
parsePreviewBuildFocusSections,
previewBuildFocusRequest,
type PreviewSection,
} from '@open-design/contracts/runtime/preview-build-focus';
import { useT } from '../../i18n';
import { appendResourceQuery } from '../../collab/workspace-identity';
import { projectRawUrl } from '../../providers/registry';
import type { WorkspaceCollabContext } from '@open-design/contracts';
import type { ProjectFile } from '../../types';
import type { RunProgressStep } from '../../runtime/run-progress';
import { RunStepFeed } from './RunStepFeed';
import { stepLabel } from './run-step-label';
import styles from './DesignFilesBuildingState.module.css';
interface Props {
projectId: string;
/** The page being built — see `selectBuildPreviewHtmlEntry`. */
file: ProjectFile;
/** Bumped on every coalesced `file-changed` batch; part of the cache bust. */
filesRefreshKey: number;
/** The running turn's tool calls, newest first. */
steps: RunProgressStep[];
workspaceContext: WorkspaceCollabContext | null;
}
interface FocusRect {
x: number;
y: number;
width: number;
height: number;
}
/** Keeps the cursor from being drawn half outside the pane. */
const EDGE_MARGIN = 28;
/** How long the cursor rests on each part that just landed before moving to
* the next one. Long enough to read the label, short enough that a page of
* six parts is walked in about the time the next write takes. */
const SECTION_DWELL_MS = 1200;
/**
* The page the agent is building, live, with a cursor on the part it is writing
* right now.
*
* This is the middle state the pane never had: before it, an empty project
* showed a particle field; after the first file landed, the pane jumped
* straight to a grid of file cards. Neither showed the artifact taking shape.
*
* The frame is a real URL-load preview of the file on disk, reloaded on each
* settled write (the daemon's watcher already debounces the write, and the host
* coalesces the events, so this does not thrash).
*
* The way back to the file grid is the topbar's preview switch
* (`BuildPreviewToggle`), not a button on top of the page — see that component
* for why this surface no longer carries its own.
*
* The cursor walks the page's own PARTS. After every load the frame's bridge
* broadcasts the top-level sections it can see, each with a label taken from
* its heading (or id, or tag); the host keeps the keys it has already seen, so
* what comes back after a write is exactly what that write added. It then
* rests the cursor on each new part in document order, naming it — which is
* what "the agent is on this module now" looks like when a whole page lands in
* one Write, where a single anchor would have jumped straight to the footer.
*
* Between tours the cursor falls back to the anchor: the current step carries a
* literal string of what it just wrote (`RunProgressStep.anchor`), the bridge
* finds that text, scrolls it into view, and reports its box back — which is
* the right target for an edit INSIDE a part that already exists. When neither
* a section nor an anchor can be matched — a CSS-only edit, a rewritten line —
* the caption still says what is happening and the cursor stays hidden rather
* than pointing at a guess.
*/
export function DesignFilesBuildingState({
projectId,
file,
filesRefreshKey,
steps,
workspaceContext,
}: Props) {
const t = useT();
const frameRef = useRef<HTMLIFrameElement | null>(null);
const [focus, setFocus] = useState<FocusRect | null>(null);
const requestIdRef = useRef<string | null>(null);
// The parts this preview has already shown. Everything the frame reports that
// is NOT in here is what the run just wrote.
const seenSectionsRef = useRef<Set<string>>(new Set());
const [walk, setWalk] = useState<{ sections: PreviewSection[]; index: number } | null>(null);
const current = steps[0] ?? null;
const anchor = current?.anchor ?? null;
const src = useMemo(
() =>
appendResourceQuery(
projectRawUrl(projectId, file.name, workspaceContext),
// `v` is the established mtime bust; `fr` is the necessary second half —
// an agent can rewrite the same file twice inside one filesystem mtime
// tick, and the refresh key moves on every coalesced change batch.
`v=${Math.round(file.mtime)}&fr=${filesRefreshKey}&odPreviewBridge=buildfocus`,
),
[projectId, file.name, file.mtime, filesRefreshKey, workspaceContext],
);
const request = useCallback((text: string | null, section: string | null) => {
const frame = frameRef.current;
const target = frame?.contentWindow;
if (!target) return;
const requestId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
requestIdRef.current = requestId;
setFocus(null);
target.postMessage(previewBuildFocusRequest(requestId, text, section), '*');
}, []);
const requestAnchor = useCallback(() => request(anchor, null), [request, anchor]);
// A different page means a different set of parts; nothing carries over.
useEffect(() => {
seenSectionsRef.current = new Set();
setWalk(null);
}, [projectId, file.name]);
// A new step means a new place to point at; the frame keeps its document.
// A tour of the parts that just landed owns the cursor while it runs.
useEffect(() => {
if (walk) return;
requestAnchor();
}, [requestAnchor, src, walk]);
// The tour: one stop per new part, in the order the page has them.
useEffect(() => {
if (!walk) return;
const stop = walk.sections[walk.index];
if (!stop) {
setWalk(null);
return;
}
request(null, stop.key);
const timer = setTimeout(() => {
setWalk((tour) => {
if (!tour) return null;
const next = tour.index + 1;
return next < tour.sections.length ? { sections: tour.sections, index: next } : null;
});
}, SECTION_DWELL_MS);
return () => clearTimeout(timer);
}, [walk, request]);
useEffect(() => {
function onMessage(event: MessageEvent) {
// The frame is sandboxed without `allow-same-origin`, so its origin is
// the opaque "null" — identity has to come from the window itself.
if (event.source !== frameRef.current?.contentWindow) return;
if (isPreviewBuildFocusReady(event.data)) {
requestAnchor();
return;
}
const sections = parsePreviewBuildFocusSections(event.data);
if (sections) {
const seen = seenSectionsRef.current;
const fresh = sections.filter((section) => !seen.has(section.key));
for (const section of sections) seen.add(section.key);
// Nothing new: this load only changed things inside parts that were
// already there, and the step's own anchor is the better target.
if (fresh.length > 0) setWalk({ sections: fresh, index: 0 });
return;
}
const result = parsePreviewBuildFocusResult(event.data);
if (!result) return;
if (result.requestId !== requestIdRef.current) return;
setFocus(
result.found
? { x: result.x, y: result.y, width: result.width, height: result.height }
: null,
);
}
window.addEventListener('message', onMessage);
return () => window.removeEventListener('message', onMessage);
}, [requestAnchor]);
// On a tour the cursor is pointing AT a part, so it says which part. Off it,
// it says what the run is doing — the step, worded as Chat's card titles it.
const stop = walk ? walk.sections[walk.index] ?? null : null;
const caption = stop
? stop.label
: current
? stepLabel(current, t)
: t('assistant.thinking');
const cursorStyle = focus
? {
transform: `translate3d(${Math.max(EDGE_MARGIN, focus.x + focus.width / 2)}px, ${Math.max(
EDGE_MARGIN,
focus.y + focus.height / 2,
)}px, 0)`,
}
: undefined;
return (
<div className={styles.stage} data-testid="design-files-building">
<iframe
ref={frameRef}
key={`${projectId}:${file.name}`}
className={styles.frame}
src={src}
title=""
// No `allow-same-origin`: this is generated, untrusted markup and the
// host needs nothing from it but postMessage.
sandbox="allow-scripts"
tabIndex={-1}
onLoad={requestAnchor}
/>
<div className={styles.overlay} aria-hidden>
{focus ? (
<span className={styles.cursor} style={cursorStyle} data-testid="build-focus-cursor">
<span className={styles.cursorRing} />
<svg
className={styles.cursorGlyph}
width="22"
height="22"
viewBox="0 0 24 24"
fill="none"
>
<path
d="M9.4 13V8a1.8 1.8 0 0 1 3.6 0v4.6c0-.9.7-1.6 1.6-1.6.9 0 1.6.7 1.6 1.6 0-.9.7-1.6 1.6-1.6.9 0 1.6.7 1.6 1.6v3.1c0 2.4-1.9 4.3-4.3 4.3h-1.9c-1.4 0-2.7-.7-3.5-1.9l-2.4-3.6a1.6 1.6 0 0 1 2.4-2l.7.9Z"
fill="#fff"
stroke="#1a1a1a"
strokeWidth="1.1"
strokeLinejoin="round"
/>
</svg>
<span className={styles.cursorCaption}>{caption}</span>
</span>
) : null}
</div>
{/* What is being built, and the log of how it got here. The cursor's own
caption names the CURRENT step, so this row names the page instead —
repeating the step in both places would say one thing twice. */}
<div className={styles.dock}>
<p className={styles.dockCaption} role="status">
{file.name}
</p>
<RunStepFeed running steps={steps} className={styles.dockFeed} />
</div>
</div>
);
}
@@ -11,61 +11,29 @@
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 48px 24px;
max-width: min(460px, 92%);
justify-content: center;
/* The words live INSIDE the ring, never across it. This is the box
inscribed in the field's 105px ring (`ringRadius` in the component):
168 x 112 has a half-diagonal of hypot(84, 56) ~= 101px, so even a
full-width line at the very top or bottom of the stack stays a few px
clear of the orbit. `overflow: hidden` is the guarantee — a line longer
or a stack taller than this is clipped by the box, not spilled over the
circle. Ellipsis and the log's own top fade make the clip read as
deliberate; see RunStepFeed.module.css. */
width: 168px;
max-width: 92%;
max-height: 112px;
overflow: hidden;
text-align: center;
/* The field orbits the pane's center; the text sits inside that ring and
must not swallow clicks meant for the drop target underneath. */
/* The field orbits the pane's center and now paints around these words (the
line boxes are measured and handed over as holes). The block must not
swallow clicks meant for the drop target underneath — which is also why the
step feed inside scrolls itself instead of offering a scrollbar. */
pointer-events: none;
}
.prompt {
margin: 0;
font-size: 14px;
font-weight: 600;
line-height: 1.35;
letter-spacing: -0.005em;
color: var(--text-strong);
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
line-clamp: 3;
overflow: hidden;
}
.status {
margin: 0;
font-size: 13px;
line-height: 1.4;
color: var(--text-muted);
}
/* The steps behind the current one. Newest first and clipped to a few lines:
this is peripheral context under the status line, not a log to read. The
mask does the fading — the block is click-through (see `.center`), so it
cannot scroll and must never look like it has more below a hard edge. */
.trail {
margin: 4px 0 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
max-width: 100%;
/* Five lines at this size; the last two are already inside the fade. */
max-height: 82px;
overflow: hidden;
-webkit-mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
mask-image: linear-gradient(to bottom, #000 30%, transparent 100%);
}
.trailItem {
font-size: 12px;
line-height: 1.35;
color: var(--text-soft);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* The feed owns its own type and fade (RunStepFeed.module.css); here it only
needs to stay inside the centered column. */
.centerFeed {
align-self: stretch;
}
@@ -1,89 +1,61 @@
import { useT } from '../../i18n';
import type { Dict } from '../../i18n/types';
import { useRef } from 'react';
import type { RunProgressStep } from '../../runtime/run-progress';
import { SpaceBackground } from '../workspace/SpaceBackground';
import { RunStepFeed } from './RunStepFeed';
import { useTextHoleRects } from './useTextHoleRects';
import styles from './DesignFilesEmptyState.module.css';
interface Props {
/** The user's most recent chat prompt, echoed back while the agent works. */
latestUserPrompt?: string | null;
/** True while the chat agent is generating. */
running?: boolean;
/** The running turn's tool calls, newest first (see `runtime/run-progress`). */
steps?: RunProgressStep[];
}
/** The verb naming each tool family. Categories the map does not name fall
* back to "Calling <tool>", which is the only honest line for a tool this
* build has never seen. */
const VERB_KEY: Partial<Record<RunProgressStep['category'], keyof Dict>> = {
write: 'assistant.verbWriting',
edit: 'assistant.verbEditing',
read: 'assistant.verbReading',
run: 'assistant.verbRunning',
search: 'assistant.verbSearching',
fetch: 'assistant.verbFetching',
};
function stepLabel(step: RunProgressStep, t: ReturnType<typeof useT>): string {
const verbKey = VERB_KEY[step.category];
if (!verbKey) return `${t('assistant.verbCalling')} ${step.toolName}`;
return step.target ? `${t(verbKey)} ${step.target}` : t(verbKey);
}
/**
* Design Files with nothing in it yet. Instead of a card of creation CTAs
* (those all live in the tab strip's "+" launcher), the pane shows the
* orbiting particle field with the conversation's current state at its center:
* what the user asked for, and whether the agent is working on it. An empty
* project generating its first file therefore gets feedback on both sides of
* the split, not just in the chat column.
* orbiting particle field with the AGENT's state at its center: whether it is
* thinking, and which tool call it is on.
*
* While a run streams, the status line under the prompt names the CURRENT step
* ("Editing index.html") rather than a static "Thinking", and the steps behind
* it stack underneath — newest first, fading out — so the pane reads as
* progress instead of a spinner with words.
* Only the agent's own lines go inside the ring, and only while it is working.
* Two things used to sit in there that were not the work: the user's latest
* prompt (a sentence they had just typed and can still read in the chat
* column, which also pushed the state line down a row) and, at rest, the copy
* "designs will appear here". Neither is the agent doing something, so with no
* run in flight the ring is just the field turning.
*
* The field paints AROUND the words. Every particle collapses onto one 105px
* ring centered in the pane — exactly where this text sits — so the orbit used
* to run straight through the sentences. The text's own line boxes are measured
* and handed to `SpaceBackground` as holes, which fades the dots out as they
* approach instead of clipping them. The block that holds them is sized to the
* box inscribed in that ring (see the module CSS), so no line can cross it.
*
* The steps read as a log (see `RunStepFeed`): oldest at the top, the current
* step on the bottom line, the feed following the tail.
*/
export function DesignFilesEmptyState({
latestUserPrompt,
running = false,
steps = [],
}: Props) {
const t = useT();
// The trail belongs to the run: an idle pane showing the last turn's tool
// calls would be claiming work that already finished.
const [current, ...trail] = running ? steps : [];
const status = running
? current
? stepLabel(current, t)
: // Nothing has been called yet — the turn really is just thinking.
t('assistant.thinking')
: t('designFiles.empty');
export function DesignFilesEmptyState({ running = false, steps = [] }: Props) {
const centerRef = useRef<HTMLDivElement | null>(null);
// Re-measured whenever the text can have moved: a new step, the run ending,
// or the feed scrolling its own content down by one line.
const holes = useTextHoleRects(centerRef, `${running}|${steps.length}`);
return (
<>
{/* A smaller field than the component's default (per product): the ring
and the spread around it are both driven by `ringRadius`, and the
count follows its area so the field keeps its density instead of
reading as the same orbit with holes in it. */}
<SpaceBackground className={styles.field} ringRadius={105} particleCount={210} />
<div className={styles.center} data-testid="design-files-empty-chat">
{latestUserPrompt ? <p className={styles.prompt}>{latestUserPrompt}</p> : null}
<p className={styles.status} data-running={running ? 'true' : 'false'}>
{status}
</p>
{trail.length > 0 ? (
// Newest first, so the line under the status is always the step just
// before it and the list needs no scrolling of its own — which it
// could not do anyway: the center block stays click-through so the
// drop target underneath keeps working (see `.center`).
<ul className={styles.trail} data-testid="design-files-empty-trail">
{trail.map((step) => (
<li key={step.id} className={styles.trailItem}>
{stepLabel(step, t)}
</li>
))}
</ul>
) : null}
<SpaceBackground
className={styles.field}
ringRadius={105}
particleCount={210}
holes={holes}
/>
<div className={styles.center} data-testid="design-files-empty-chat" ref={centerRef}>
<RunStepFeed running={running} steps={steps} className={styles.centerFeed} />
</div>
</>
);
@@ -0,0 +1,76 @@
/* A log, not a list: the oldest line dissolves at the TOP as the feed scrolls
itself down. `overflow: hidden` (never `auto`) keeps `scrollTop` writable
from script while giving the user nothing to grab — the surface above stays
click-through so the pane's drop target keeps working. */
.feed {
/* No top margin: the feed is the only thing in the ring now (the prompt
echo above it is gone), so a margin here would just push the stack off
the circle's center. */
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 2px;
max-width: 100%;
/* About six lines at this size — and the full height of the box inscribed
in the particle ring, so a long run fills the circle without leaving it
(see DesignFilesEmptyState.module.css). */
max-height: 112px;
overflow: hidden;
}
/* The top fade is the log DISSOLVING its oldest line as it scrolls past the
box — it is a scroll edge, not a treatment of the type. A feed that fits has
nothing scrolling out of it, and masking it anyway washed the one status
line ("thinking") into a gradient. So it is painted only while the feed
actually overflows; see the layout effect in RunStepFeed. */
.feed[data-overflowing='true'] {
-webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 24px);
mask-image: linear-gradient(to bottom, transparent 0, #000 24px);
}
.item {
font-size: 12px;
line-height: 1.35;
color: var(--text-soft);
max-width: 100%;
/* The feed is a column flex box with a capped height, and a flex item whose
`overflow` is not `visible` has a `min-height: auto` of 0 — so without
this the lines SHRANK to fit instead of scrolling. At ten steps each 16px
line was squeezed to 9px and its text overlapped the line under it, which
is the "folded" stack in the middle of the ring. Lines keep their height;
the feed scrolls past them. */
flex-shrink: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* Only a newly mounted line animates: items are keyed by tool-use id and the
list is append-only, so React never remounts the ones already up. */
animation: df-feed-enter 200ms cubic-bezier(0.23, 1, 0.32, 1) both;
}
/* The bottom line is the step happening now; it wears the weight the old
standalone status line had. */
.item[data-current='true'] {
font-size: 13px;
line-height: 1.4;
color: var(--text-muted);
}
@keyframes df-feed-enter {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.item {
animation: none;
}
}
@@ -0,0 +1,91 @@
import { useLayoutEffect, useMemo, useRef } from 'react';
import { useT } from '../../i18n';
import type { RunProgressStep } from '../../runtime/run-progress';
import { stepLabel } from './run-step-label';
import styles from './RunStepFeed.module.css';
interface Props {
/** True while the chat agent is generating. */
running: boolean;
/** The running turn's tool calls, NEWEST FIRST (see `runtime/run-progress`). */
steps: readonly RunProgressStep[];
className?: string;
}
/**
* The agent's tool calls as a log: oldest at the top, the current step on the
* bottom line, the container following the tail.
*
* Reading order is the point. The old rendering put the newest line first and
* clipped the rest behind a fade, which read as a truncated list rather than as
* progress; a log grows the way the work does.
*
* It renders nothing at all when no run is in flight.
*
* It scrolls itself and is never scrollable by hand — `overflow: hidden` still
* accepts a programmatic `scrollTop`, so the whole surface can stay
* `pointer-events: none` and leave the pane's drag-to-upload target intact.
*/
export function RunStepFeed({ running, steps, className }: Props) {
const t = useT();
const listRef = useRef<HTMLUListElement | null>(null);
// `runProgressSteps` is newest-first — the honest shape for a reducer whose
// head is "what is happening now". Presentation order belongs here.
const ordered = useMemo(() => (running ? [...steps].reverse() : []), [running, steps]);
const newestId = ordered.length > 0 ? ordered[ordered.length - 1]!.id : null;
// Before paint, so a landing line never shows at the wrong offset first.
// The same pass records whether anything is actually scrolling out of the
// top, which is the only case the fade above the list is for.
useLayoutEffect(() => {
const list = listRef.current;
if (!list) return;
list.scrollTop = list.scrollHeight;
// Measured from the items' own laid-out box, not `scrollHeight`: the line
// that just landed is still mid `df-feed-enter`, translated 6px down, and
// a transform counts toward scrollable overflow — so EVERY new line read
// as an overflow, and the flag (set once, re-checked only on the next
// step) stayed on for the rest of the run. That put the 24px fade over a
// feed of one or two lines and washed the status line out. `offsetTop`
// ignores transforms, so this is the height the lines actually occupy.
const first = list.firstElementChild as HTMLElement | null;
const last = list.lastElementChild as HTMLElement | null;
const contentHeight = first && last ? last.offsetTop + last.offsetHeight - first.offsetTop : 0;
if (contentHeight > list.clientHeight + 1) {
list.setAttribute('data-overflowing', 'true');
} else {
list.removeAttribute('data-overflowing');
}
}, [newestId, running]);
// Nothing to log between runs: the surfaces this feed sits on (the ring, the
// build preview's dock) are there to report work in flight, and a resting
// line of copy in either of them reads as a caption on the animation.
if (!running) return null;
return (
<ul
className={`${styles.feed} ${className ?? ''}`}
ref={listRef}
data-testid="run-step-feed"
>
{ordered.length === 0 ? (
// The turn really is just thinking — nothing has been called yet.
<li className={styles.item} data-current="true">
{t('assistant.thinking')}
</li>
) : (
ordered.map((step) => (
<li
key={step.id}
className={styles.item}
data-current={step.id === newestId ? 'true' : undefined}
>
{stepLabel(step, t)}
</li>
))
)}
</ul>
);
}
@@ -0,0 +1,32 @@
import type { useT } from '../../i18n';
import type { Dict } from '../../i18n/types';
import type { RunProgressStep } from '../../runtime/run-progress';
/**
* The title Chat's own card for this tool family already carries (`op-title`
* in ToolCard) — the same i18n key, not a paraphrase of it. Both surfaces are
* describing one tool call, so a step must not read "运行 pnpm build" out here
* while the card beside it in Chat is headed "Bash".
*
* A family Chat has no card title for — a skill, an MCP tool, anything this
* build has never seen — is headed by its RAW tool name there (GenericCard),
* so it is headed by the raw name here too.
*/
const TITLE_KEY: Partial<Record<RunProgressStep['category'], keyof Dict>> = {
write: 'tool.write',
edit: 'tool.edit',
read: 'tool.read',
run: 'tool.bash',
search: 'tool.search',
fetch: 'tool.fetch',
};
/** One run step, titled the way Chat titles it, followed by what it acted on
* (Chat puts that in the card's `op-meta`, right of the same title). Shared by
* the empty state's feed and the building preview's caption, so the two
* surfaces can never word the same step differently. */
export function stepLabel(step: RunProgressStep, t: ReturnType<typeof useT>): string {
const titleKey = TITLE_KEY[step.category];
const title = titleKey ? t(titleKey) : step.toolName;
return step.target ? `${title} ${step.target}` : title;
}
@@ -0,0 +1,104 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { holeRectsEqual, type HoleRect } from '../workspace/space-hole';
/** Breathing room around each line box, so glyphs never touch a dot. */
const INFLATE_X = 6;
const INFLATE_Y = 3;
function rectsFromLineBoxes(element: HTMLElement): DOMRect[] {
if (typeof document.createRange !== 'function') return [];
const range = document.createRange();
range.selectNodeContents(element);
const list = typeof range.getClientRects === 'function' ? range.getClientRects() : null;
range.detach?.();
if (!list) return [];
const rects: DOMRect[] = [];
for (const rect of Array.from(list)) {
if (rect.width <= 0 || rect.height <= 0) continue;
rects.push(rect);
}
return rects;
}
/**
* The boxes a particle field must paint around to leave this element's TEXT
* legible, in host-local CSS px.
*
* Per line box, not per block: a centered block is as wide as its `max-width`
* while its lines are only as wide as their words, and the field it sits in has
* a radius of ~105px — one block-sized hole would erase the orbit entirely.
* `Range.getClientRects()` is what gives line-level geometry without laying the
* text out a second time.
*
* Re-measures on resize AND whenever `deps` changes, because the feed scrolls
* its own content: lines move while the block's size does not.
*/
export function useTextHoleRects(
ref: { current: HTMLElement | null },
deps: unknown,
): HoleRect[] {
const [rects, setRects] = useState<HoleRect[]>([]);
const rectsRef = useRef<HoleRect[]>([]);
const measure = useRef<() => void>(() => undefined);
measure.current = () => {
const element = ref.current;
const host = element?.parentElement ?? null;
if (!element || !host) return;
const origin = host.getBoundingClientRect();
const lineBoxes = rectsFromLineBoxes(element);
const source: Array<{ left: number; top: number; width: number; height: number }> =
lineBoxes.length > 0
? lineBoxes.map((rect) => ({
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
}))
: (() => {
// jsdom and any engine without range rects: one box around the
// block. It carries no padding of its own — it is sized to the box
// inscribed in the ring and holds nothing but the lines — so the
// block box IS the text area here.
const box = element.getBoundingClientRect();
if (box.width <= 0 || box.height <= 0) return [];
return [
{ left: box.left, top: box.top, width: box.width, height: box.height },
];
})();
const next: HoleRect[] = source
.filter((box) => box.width > 0 && box.height > 0)
.map((box) => ({
x: box.left - origin.left - INFLATE_X,
y: box.top - origin.top - INFLATE_Y,
width: box.width + INFLATE_X * 2,
height: box.height + INFLATE_Y * 2,
}));
// A ResizeObserver that writes state on every callback re-enters itself
// through the layout it just caused. Only commit a genuine change.
if (holeRectsEqual(rectsRef.current, next)) return;
rectsRef.current = next;
setRects(next);
};
useLayoutEffect(() => {
measure.current();
}, [deps]);
useEffect(() => {
const element = ref.current;
const host = element?.parentElement ?? null;
if (!element) return;
if (typeof ResizeObserver !== 'function') return;
const observer = new ResizeObserver(() => measure.current());
observer.observe(element);
if (host) observer.observe(host);
return () => observer.disconnect();
}, [ref]);
return rects;
}
@@ -13,6 +13,8 @@
import { useEffect, useRef } from 'react';
import { DEFAULT_HOLE_FEATHER, holeAlphaAt, type HoleRect } from './space-hole';
/** Fallback particle tint when the host exposes no `--space-particle`. */
const AUTO_LIGHT = 'rgba(0, 0, 0, 0.85)';
const AUTO_DARK = 'rgba(255, 255, 255, 0.85)';
@@ -40,6 +42,15 @@ interface SpaceBackgroundProps {
* element, falling back to a luminance-matched neutral.
*/
particleColor?: string;
/**
* Boxes the field paints around — host-local CSS px, origin top-left. Used to
* keep the orbit off the text sitting inside it: the dots fade out as they
* approach a hole instead of being clipped, so the edge reads as density
* rather than as a cut-out.
*/
holes?: readonly HoleRect[];
/** How far a hole's fade reaches, in px. */
holeFeather?: number;
className?: string;
}
@@ -100,9 +111,21 @@ export function SpaceBackground({
particleCount = 420,
ringRadius = 150,
particleColor,
holes,
holeFeather = DEFAULT_HOLE_FEATHER,
className,
}: SpaceBackgroundProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
// Read through a ref, never a dependency: the holes are re-measured on every
// text change, and a new array identity in the effect's deps would re-seed
// the whole field — the orbit would restart every time a status line lands.
const holesRef = useRef<readonly HoleRect[]>(holes ?? []);
holesRef.current = holes ?? [];
const featherRef = useRef(holeFeather);
featherRef.current = holeFeather;
// The last committed paint, so a hole measurement can refresh the settled
// reduced-motion frame (which is painted once and never again).
const repaintRef = useRef<(() => void) | null>(null);
useEffect(() => {
const canvas = canvasRef.current;
@@ -180,18 +203,49 @@ export function SpaceBackground({
return Math.max(0, particle.radius * (1 - (1 - OUTER_SCALE) * outward));
}
/* Holes arrive in host-local, Y-down CSS px; the canvas draws from the
host's CENTER with Y up. Convert once per frame rather than per particle,
and hand the particle loop coordinates in its own space. */
function holesInCanvasSpace(): HoleRect[] {
const source = holesRef.current;
if (source.length === 0) return [];
const out: HoleRect[] = [];
for (const hole of source) {
out.push({
x: hole.x - width / 2,
y: height / 2 - (hole.y + hole.height),
width: hole.width,
height: hole.height,
...(hole.radius === undefined ? {} : { radius: hole.radius }),
});
}
return out;
}
function paint(): void {
if (!ctx) return;
ctx.clearRect(-width, -height, width * 2, height * 2);
ctx.fillStyle = color;
const holeRects = holesInCanvasSpace();
const feather = featherRef.current;
for (let i = 0; i < lit; i++) {
const particle = particles[i];
if (!particle) continue;
// A density fade, not a clip: a dot that would land on the text simply
// does not get drawn, and its neighbours dim on the way in, which is
// what makes the edge soft without a blur pass.
const alpha = holeRects.length === 0
? 1
: holeAlphaAt(particle.x, particle.y, holeRects, feather);
if (alpha <= 0) continue;
ctx.globalAlpha = alpha;
ctx.beginPath();
ctx.arc(particle.x, particle.y, drawnRadius(particle), 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
}
repaintRef.current = paint;
resize();
@@ -240,6 +294,12 @@ export function SpaceBackground({
};
}, [particleCount, ringRadius, particleColor]);
// The animated field picks new holes up on its next frame; the reduced-motion
// field has already painted its only frame, so it needs telling.
useEffect(() => {
repaintRef.current?.();
}, [holes, holeFeather]);
return (
<canvas
ref={canvasRef}
@@ -0,0 +1,88 @@
// Where the particle field must not paint, and how softly it gets out of the
// way. Kept separate from the canvas component so the falloff is testable
// without a rendering context — the component only multiplies alpha by it.
/** A rectangle the field paints around, in host-local CSS px (origin top-left). */
export interface HoleRect {
x: number;
y: number;
width: number;
height: number;
/** Corner radius. Defaults to half the short side, i.e. a stadium. */
radius?: number;
}
/** Feather distance, in px, over which a hole fades back to full density. */
export const DEFAULT_HOLE_FEATHER = 18;
function clamp01(value: number): number {
if (!Number.isFinite(value)) return 1;
if (value < 0) return 0;
if (value > 1) return 1;
return value;
}
/** Signed distance from a point to a rounded rect's edge; negative inside. */
function roundedRectDistance(x: number, y: number, rect: HoleRect): number {
const halfWidth = Math.max(rect.width, 0) / 2;
const halfHeight = Math.max(rect.height, 0) / 2;
const radius = Math.max(
0,
Math.min(rect.radius ?? Math.min(halfWidth, halfHeight), halfWidth, halfHeight),
);
const centerX = rect.x + halfWidth;
const centerY = rect.y + halfHeight;
const dx = Math.max(Math.abs(x - centerX) - (halfWidth - radius), 0);
const dy = Math.max(Math.abs(y - centerY) - (halfHeight - radius), 0);
const outside = Math.hypot(dx, dy) - radius;
if (outside > 0) return outside;
// Inside the box: distance to the nearest edge, negated.
const insideX = halfWidth - Math.abs(x - centerX);
const insideY = halfHeight - Math.abs(y - centerY);
return -Math.min(insideX, insideY);
}
/**
* How visible a particle at this point may be: 0 inside a hole, 1 once it is a
* full `feather` clear of every hole, and a linear ramp between.
*
* The holes are the TEXT's own line boxes, not the block that contains them —
* the block is up to 460px wide while the ring it sits in has a 105px radius,
* so erasing the block would erase the ring. Per-line rects keep the field
* intact everywhere the words are not.
*/
export function holeAlphaAt(
x: number,
y: number,
holes: readonly HoleRect[],
feather: number = DEFAULT_HOLE_FEATHER,
): number {
if (holes.length === 0) return 1;
const span = feather > 0 ? feather : 1;
let alpha = 1;
for (const hole of holes) {
const distance = roundedRectDistance(x, y, hole);
const next = clamp01(distance / span);
if (next < alpha) alpha = next;
if (alpha === 0) return 0;
}
return alpha;
}
/** True when two rect lists describe the same holes, to the nearest px. */
export function holeRectsEqual(a: readonly HoleRect[], b: readonly HoleRect[]): boolean {
if (a.length !== b.length) return false;
for (let i = 0; i < a.length; i++) {
const left = a[i]!;
const right = b[i]!;
if (
Math.round(left.x) !== Math.round(right.x) ||
Math.round(left.y) !== Math.round(right.y) ||
Math.round(left.width) !== Math.round(right.width) ||
Math.round(left.height) !== Math.round(right.height)
) {
return false;
}
}
return true;
}
+1
View File
@@ -2820,6 +2820,7 @@ export const ar: Dict = {
'designFiles.newDocument': 'مستند جديد',
'designFiles.newDocumentTitle': 'إنشاء مستند Markdown جديد',
'designFiles.empty': 'ستظهر الإبداعات هنا',
'designFiles.buildingPreview': 'معاينة بملء الشاشة',
'designFiles.syncing': 'جارٍ مزامنة الملفات المشتركة مع الفريق…',
'designFiles.refresh': 'تحديث',
'designFiles.delete': 'حذف',
+1
View File
@@ -2820,6 +2820,7 @@ export const de: Dict = {
'designFiles.newDocument': 'Neues Dokument',
'designFiles.newDocumentTitle': 'Ein neues Markdown-Dokument erstellen',
'designFiles.empty': 'Kreationen erscheinen hier',
'designFiles.buildingPreview': 'Vollbildvorschau',
'designFiles.syncing': 'Dateien werden vom Team synchronisiert…',
'designFiles.refresh': 'Aktualisieren',
'designFiles.delete': 'Löschen',
+1
View File
@@ -2834,6 +2834,7 @@ export const en: Dict = {
'designFiles.newDocument': 'New document',
'designFiles.newDocumentTitle': 'Create a new Markdown document',
'designFiles.empty': 'Creations will appear here',
'designFiles.buildingPreview': 'Full preview',
'designFiles.syncing': 'Syncing files from the team…',
'designFiles.refresh': 'Refresh',
'designFiles.delete': 'Delete',
+1
View File
@@ -2820,6 +2820,7 @@ export const esES: Dict = {
'designFiles.newDocument': 'Nuevo documento',
'designFiles.newDocumentTitle': 'Crear un documento Markdown nuevo',
'designFiles.empty': 'Las creaciones aparecerán aquí',
'designFiles.buildingPreview': 'Vista previa completa',
'designFiles.syncing': 'Sincronizando los archivos compartidos del equipo…',
'designFiles.refresh': 'Actualizar',
'designFiles.delete': 'Eliminar',
+1
View File
@@ -2820,6 +2820,7 @@ export const fa: Dict = {
'designFiles.newDocument': 'سند جدید',
'designFiles.newDocumentTitle': 'ایجاد یک سند Markdown جدید',
'designFiles.empty': 'آفرینش‌ها اینجا نمایش داده می‌شوند',
'designFiles.buildingPreview': 'پیش‌نمایش تمام‌صفحه',
'designFiles.syncing': 'در حال همگام‌سازی فایل‌های مشترک تیم…',
'designFiles.refresh': 'بازنشانی',
'designFiles.delete': 'حذف',
+1
View File
@@ -2820,6 +2820,7 @@ export const fr: Dict = {
'designFiles.newDocument': 'Nouveau document',
'designFiles.newDocumentTitle': 'Créer un nouveau document Markdown',
'designFiles.empty': 'Les créations apparaîtront ici',
'designFiles.buildingPreview': 'Aperçu plein écran',
'designFiles.syncing': 'Synchronisation des fichiers de l\'équipe…',
'designFiles.refresh': 'Actualiser',
'designFiles.delete': 'Supprimer',
+1
View File
@@ -2820,6 +2820,7 @@ export const hu: Dict = {
'designFiles.newDocument': 'Új dokumentum',
'designFiles.newDocumentTitle': 'Új Markdown-dokumentum létrehozása',
'designFiles.empty': 'A kreációk itt jelennek meg',
'designFiles.buildingPreview': 'Teljes képernyős előnézet',
'designFiles.syncing': 'A csapat megosztott fájljainak szinkronizálása…',
'designFiles.refresh': 'Frissítés',
'designFiles.delete': 'Törlés',
+1
View File
@@ -2820,6 +2820,7 @@ export const id: Dict = {
'designFiles.newDocument': 'Dokumen baru',
'designFiles.newDocumentTitle': 'Buat dokumen Markdown baru',
'designFiles.empty': 'Belum ada file desain.',
'designFiles.buildingPreview': 'Pratinjau layar penuh',
'designFiles.syncing': 'Menyinkronkan file yang dibagikan tim…',
'designFiles.refresh': 'Refresh',
'designFiles.delete': 'Hapus',
+1
View File
@@ -2820,6 +2820,7 @@ export const it: Dict = {
'designFiles.newDocument': 'Nuovo documento',
'designFiles.newDocumentTitle': 'Crea un nuovo documento Markdown',
'designFiles.empty': 'Le creazioni appariranno qui',
'designFiles.buildingPreview': 'Anteprima intera',
'designFiles.syncing': 'Sincronizzazione dei file condivisi dal team…',
'designFiles.refresh': 'Aggiorna',
'designFiles.delete': 'Elimina',
+1
View File
@@ -2820,6 +2820,7 @@ export const ja: Dict = {
'designFiles.newDocument': '新規ドキュメント',
'designFiles.newDocumentTitle': '新しい Markdown ドキュメントを作成',
'designFiles.empty': 'ここに作品が表示されます',
'designFiles.buildingPreview': '全画面プレビュー',
'designFiles.syncing': 'チームで共有されたファイルを同期中…',
'designFiles.refresh': '更新',
'designFiles.delete': '削除',
+1
View File
@@ -2820,6 +2820,7 @@ export const ko: Dict = {
'designFiles.newDocument': '새 문서',
'designFiles.newDocumentTitle': '새 Markdown 문서 만들기',
'designFiles.empty': '여기에 작업물이 표시됩니다',
'designFiles.buildingPreview': '전체 화면 미리보기',
'designFiles.syncing': '팀에서 공유한 파일을 동기화하는 중…',
'designFiles.refresh': '새로고침',
'designFiles.delete': '삭제',
+1
View File
@@ -2820,6 +2820,7 @@ export const pl: Dict = {
'designFiles.newDocument': 'Nowy dokument',
'designFiles.newDocumentTitle': 'Utwórz nowy dokument Markdown',
'designFiles.empty': 'Twoje kreacje pojawią się tutaj',
'designFiles.buildingPreview': 'Podgląd pełnoekranowy',
'designFiles.syncing': 'Synchronizowanie plików udostępnionych przez zespół…',
'designFiles.refresh': 'Odśwież',
'designFiles.delete': 'Usuń',
+1
View File
@@ -2820,6 +2820,7 @@ export const ptBR: Dict = {
'designFiles.newDocument': 'Novo documento',
'designFiles.newDocumentTitle': 'Criar um novo documento Markdown',
'designFiles.empty': 'As criações aparecerão aqui',
'designFiles.buildingPreview': 'Visualização completa',
'designFiles.syncing': 'Sincronizando os arquivos compartilhados da equipe…',
'designFiles.refresh': 'Atualizar',
'designFiles.delete': 'Excluir',
+1
View File
@@ -2820,6 +2820,7 @@ export const ru: Dict = {
'designFiles.newDocument': 'Новый документ',
'designFiles.newDocumentTitle': 'Создать новый документ Markdown',
'designFiles.empty': 'Здесь появятся ваши работы',
'designFiles.buildingPreview': 'Полный предпросмотр',
'designFiles.syncing': 'Синхронизация файлов, которыми поделилась команда…',
'designFiles.refresh': 'Обновить',
'designFiles.delete': 'Удалить',
+1
View File
@@ -2820,6 +2820,7 @@ export const th: Dict = {
'designFiles.newDocument': 'เอกสารใหม่',
'designFiles.newDocumentTitle': 'สร้างเอกสาร Markdown ใหม่',
'designFiles.empty': 'ลากไฟล์มาใส่เลย หรือสร้างภาพสเก็ตช์',
'designFiles.buildingPreview': 'ดูตัวอย่างเต็มจอ',
'designFiles.syncing': 'กำลังซิงค์ไฟล์ที่ทีมแชร์…',
'designFiles.refresh': 'รีเฟรช',
'designFiles.delete': 'ลบ',
+1
View File
@@ -2820,6 +2820,7 @@ export const tr: Dict = {
'designFiles.newDocument': 'Yeni belge',
'designFiles.newDocumentTitle': 'Yeni bir Markdown belgesi oluştur',
'designFiles.empty': 'Eserler burada görünecek',
'designFiles.buildingPreview': 'Tam ekran önizleme',
'designFiles.syncing': 'Ekibin paylaştığı dosyalar senkronize ediliyor…',
'designFiles.refresh': 'Yenile',
'designFiles.delete': 'Sil',
+1
View File
@@ -2820,6 +2820,7 @@ export const uk: Dict = {
'designFiles.newDocument': 'Новий документ',
'designFiles.newDocumentTitle': 'Створити новий документ Markdown',
'designFiles.empty': 'Тут з\'являться ваші роботи',
'designFiles.buildingPreview': 'Повний перегляд',
'designFiles.syncing': 'Синхронізація файлів, якими поділилася команда…',
'designFiles.refresh': 'Оновити',
'designFiles.delete': 'Видалити',
+1
View File
@@ -3029,6 +3029,7 @@ export const zhCN: Dict = {
"designFiles.newDocument": "新建文档",
"designFiles.newDocumentTitle": "新建一份 Markdown 文档",
"designFiles.empty": "生成的设计会出现在这里",
"designFiles.buildingPreview": "全屏预览",
"designFiles.syncing": "正在同步团队共享的文件…",
"designFiles.refresh": "刷新",
"designFiles.delete": "删除",
+1
View File
@@ -3037,6 +3037,7 @@ export const zhTW: Dict = {
"designFiles.newDocument": "新增文件",
"designFiles.newDocumentTitle": "新增一份 Markdown 文件",
"designFiles.empty": "生成的設計會出現在這裡",
"designFiles.buildingPreview": "全螢幕預覽",
"designFiles.syncing": "正在同步團隊共享的檔案…",
"designFiles.refresh": "重新整理",
"designFiles.delete": "刪除",
+1
View File
@@ -3669,6 +3669,7 @@ export interface Dict {
'designFiles.newDocument': string;
'designFiles.newDocumentTitle': string;
'designFiles.empty': string;
'designFiles.buildingPreview': string;
'designFiles.syncing': string;
'designFiles.refresh': string;
'designFiles.delete': string;
@@ -1,29 +0,0 @@
import type { ChatMessage } from '../types';
/** Longest prompt the Design Files empty state will echo before eliding. */
const MAX_PROMPT_CHARS = 160;
/**
* The last thing the user actually asked for, as a single tidy line.
*
* The Design Files panel's empty state echoes this back while the agent works,
* so the right pane says "working on THIS" instead of sitting blank. Mirrors
* `latestTodoWriteInputFromMessages` in `./todos.ts`: a pure reducer over the
* conversation, so the panel needs no chat wiring of its own.
*
* Returns null when the conversation has no user turn yet (a freshly created
* project), which the caller renders as "no prompt line at all" rather than an
* empty paragraph.
*/
export function latestUserPromptText(messages: ChatMessage[]): string | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message || message.role !== 'user') continue;
// Pasted prompts routinely carry hard-wrapped newlines and run long; the
// empty state has one clamped block, so collapse first and elide second.
const text = (message.content ?? '').replace(/\s+/g, ' ').trim();
if (!text) continue;
return text.length > MAX_PROMPT_CHARS ? `${text.slice(0, MAX_PROMPT_CHARS).trimEnd()}…` : text;
}
return null;
}
+90 -8
View File
@@ -11,6 +11,18 @@ export interface RunProgressStep {
toolName: string;
/** What the step acted on — file basename, command, query — already short. */
target: string | null;
/**
* A literal run of visible text this step wrote into an HTML page, used to
* find the change inside a live preview. Null for every step that wrote no
* HTML text — a read, a command, a CSS edit.
*
* It comes from the tool's OWN input rather than from diffing the file: the
* `file-changed` event that reloads a preview arrives after the write
* settles, by which point this `tool_use` is already on the message, so the
* text is free. (The HTML source snapshot cache cannot help — it is
* invalidated project-wide on every file change.)
*/
anchor: string | null;
}
/** Steps kept for the trail. Older ones are off-screen behind the fade anyway. */
@@ -21,15 +33,16 @@ const MAX_TARGET_CHARS = 44;
/**
* What the agent is doing right now, newest first.
*
* The Design Files empty state echoes the user's prompt while the agent works
* (see `./latest-user-prompt`); this is the other half — the progress under it.
* The head of the list is the current step and the rest is the trail below it,
* so the pane says "editing index.html, after reading two files" instead of a
* static "thinking".
* This is the whole of what the Design Files empty state puts inside its
* particle ring: the head of the list is the current step and the rest is the
* trail behind it, so the pane says "editing index.html, after reading two
* files" instead of a static "thinking". (The ring used to carry the user's
* own prompt above this; it no longer does — that sentence belongs to the chat
* column.)
*
* A pure reducer over the conversation, same as `latestUserPromptText`: the
* panel needs no chat wiring of its own, and a streamed `tool_use` shows up as
* soon as it lands in the message's events.
* A pure reducer over the conversation: the panel needs no chat wiring of its
* own, and a streamed `tool_use` shows up as soon as it lands in the message's
* events.
*
* Only the LAST assistant turn is read. Steps from the turn before are history,
* not progress, and the panel would be claiming work it is no longer doing.
@@ -60,11 +73,80 @@ function stepsFromEvents(events: AgentEvent[]): RunProgressStep[] {
category,
toolName: event.name,
target: targetFor(category, event.input),
anchor: anchorFor(category, event.input),
});
}
return steps;
}
/** Longest tail of a Write we bother scanning. The end of the file is where a
* freshly written page's newest content is, and a whole document per streamed
* event is more work than this reducer should ever do. */
const MAX_ANCHOR_SOURCE_CHARS = 4096;
/** Anchors longer than this stop being cheaper than the document itself. */
const MAX_ANCHOR_CHARS = 96;
/** Shorter runs match too much — "Save", "OK" — and would point anywhere. */
const MIN_ANCHOR_CHARS = 8;
/**
* The text a preview can be scrolled to, taken from what this step wrote.
*
* Only write/edit steps on an HTML file qualify: everything else has nothing to
* point at in a rendered page. The LAST visible run is chosen because that is
* the deepest point the step reached — for a whole-file write it lands near the
* bottom of the page, which is what "it is building this part now" means.
*/
function anchorFor(
category: ReturnType<typeof toolCategoryForName>,
input: unknown,
): string | null {
if (category !== 'write' && category !== 'edit') return null;
if (!input || typeof input !== 'object') return null;
const fields = input as Record<string, unknown>;
const path = firstString(fields, ['file_path', 'filePath', 'path']);
if (!path || !/\.html?$/i.test(path)) return null;
const written = writtenText(fields);
if (!written) return null;
return longestVisibleRun(written.slice(-MAX_ANCHOR_SOURCE_CHARS));
}
/** The HTML this step put on disk: an edit's replacement, a multi-edit's last
* replacement, or a write's whole body. */
function writtenText(fields: Record<string, unknown>): string | null {
const single = firstString(fields, ['new_string', 'newString', 'content', 'contents']);
if (single) return single;
const edits = fields.edits;
if (Array.isArray(edits)) {
for (let i = edits.length - 1; i >= 0; i--) {
const edit = edits[i];
if (!edit || typeof edit !== 'object') continue;
const text = firstString(edit as Record<string, unknown>, ['new_string', 'newString']);
if (text) return text;
}
}
return null;
}
/** Strip the markup and return the last run of real words in what is left.
* Script and style bodies go first: their text is never on the page. */
function longestVisibleRun(html: string): string | null {
const text = html
.replace(/<script[\s\S]*?(?:<\/script>|$)/gi, ' ')
.replace(/<style[\s\S]*?(?:<\/style>|$)/gi, ' ')
.replace(/<!--[\s\S]*?(?:-->|$)/g, ' ')
.replace(/<[^>]*>/g, '\n')
.replace(/&[a-z]+;|&#\d+;/gi, ' ');
let best: string | null = null;
for (const line of text.split('\n')) {
const run = line.replace(/\s+/g, ' ').trim();
if (run.length < MIN_ANCHOR_CHARS) continue;
// Later wins: the tail of the write is the part being built now.
best = run;
}
if (!best) return null;
return best.length > MAX_ANCHOR_CHARS ? best.slice(0, MAX_ANCHOR_CHARS).trimEnd() : best;
}
function targetFor(
category: ReturnType<typeof toolCategoryForName>,
input: unknown,
@@ -162,6 +162,11 @@
height: 30px;
justify-content: center;
padding: 0;
/* A circle (per product: 这个三个点的是圆形的). The button primitive's
`--radius-sm` drew a rounded square around a box that is already 30×30, so
the pill radius is the only thing standing between it and a disc — the
focus ring follows the same curve. */
border-radius: var(--radius-pill, 999px);
}
.df-project-menu {
position: absolute;
@@ -0,0 +1,207 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { I18nProvider } from '../../src/i18n';
import { DesignFilesBuildingState } from '../../src/components/design-files/DesignFilesBuildingState';
import type { ProjectFile } from '../../src/types';
import type { RunProgressStep } from '../../src/runtime/run-progress';
function file(overrides: Partial<ProjectFile> = {}): ProjectFile {
return {
name: 'index.html',
size: 1200,
mtime: 1_756_000_000_123,
kind: 'html',
mime: 'text/html',
...overrides,
} as ProjectFile;
}
function step(overrides: Partial<RunProgressStep> = {}): RunProgressStep {
return {
id: '1',
category: 'edit',
toolName: 'Edit',
target: 'index.html',
anchor: 'Studio Nine',
...overrides,
};
}
function renderState(props: Partial<Parameters<typeof DesignFilesBuildingState>[0]> = {}) {
return render(
<I18nProvider initial="zh-CN">
<DesignFilesBuildingState
projectId="p1"
file={file()}
filesRefreshKey={7}
steps={[step()]}
workspaceContext={null}
{...props}
/>
</I18nProvider>,
);
}
/** What the frame's bridge broadcasts after a load: the page's own parts. */
function sectionsMessage(sections: Array<{ key: string; label: string }>) {
return { type: 'od:preview-build-focus-sections', version: 1, sections };
}
function rectMessage(requestId: string) {
return {
type: 'od:preview-build-focus-rect',
version: 1,
requestId,
found: true,
x: 40,
y: 120,
width: 300,
height: 90,
viewportWidth: 900,
viewportHeight: 600,
};
}
/** Every request the host posts into the frame, newest last. */
function watchFrame(): { posted: Array<Record<string, unknown>>; frame: HTMLIFrameElement } {
const frame = document.querySelector('iframe');
if (!frame) throw new Error('no preview frame');
const posted: Array<Record<string, unknown>> = [];
const target = frame.contentWindow;
if (!target) throw new Error('no frame window');
vi.spyOn(target, 'postMessage').mockImplementation(((message: unknown) => {
posted.push(message as Record<string, unknown>);
}) as typeof target.postMessage);
return { posted, frame };
}
function send(frame: HTMLIFrameElement, data: unknown) {
act(() => {
fireEvent(window, new MessageEvent('message', { data, source: frame.contentWindow }));
});
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.useRealTimers();
});
describe('DesignFilesBuildingState', () => {
it('previews the real file, busting on BOTH the mtime and the refresh key', () => {
const { container } = renderState();
const frame = container.querySelector('iframe');
const src = frame?.getAttribute('src') ?? '';
expect(src).toContain('/api/projects/p1/raw/index.html');
expect(src).toContain('v=1756000000123');
// An agent can rewrite one file twice inside a single mtime tick; without
// the refresh key the second write would show the first write's bytes.
expect(src).toContain('fr=7');
expect(src).toContain('odPreviewBridge=buildfocus');
});
it('asks for its own bridge and nothing else', () => {
const { container } = renderState();
const src = container.querySelector('iframe')?.getAttribute('src') ?? '';
// `observability` in particular would post generated-page errors into the
// host's global preview error buffer and mis-attribute them to this pane.
expect(src).not.toContain('observability');
expect(src).not.toContain('odPreviewBridge=scroll');
});
it('sandboxes the page and keeps it out of the pointer path', () => {
const { container } = renderState();
const frame = container.querySelector('iframe');
// No `allow-same-origin`: generated markup, and the host needs only
// postMessage from it.
expect(frame?.getAttribute('sandbox')).toBe('allow-scripts');
// The class carries `pointer-events: none` (see the module CSS): an iframe
// that takes pointer events swallows the pane's drag-to-upload target.
expect(frame?.getAttribute('class')).toBeTruthy();
});
it('names the page and the current step, and draws no cursor before the frame answers', () => {
renderState();
// The page being built, once — the step log carries the rest.
expect(screen.getByRole('status').textContent).toBe('index.html');
expect(screen.getByText('编辑 index.html')).toBeTruthy();
// Nothing is known about WHERE yet; a cursor at 0,0 would be a lie.
expect(screen.queryByTestId('build-focus-cursor')).toBeNull();
});
it('falls back to "thinking" when the turn has called nothing yet', () => {
renderState({ steps: [] });
expect(screen.getByText('思考中')).toBeTruthy();
});
// The way out moved to the topbar switch (DesignFilesPanel owns it), so this
// surface carries no control of its own on top of the page it is rendering.
it('puts no button on top of the page it renders', () => {
renderState();
expect(screen.queryByRole('button')).toBeNull();
});
// A whole page landing in one Write is ONE step with one anchor, which used
// to send the cursor straight to the footer. The frame reports the page's
// own parts instead, and the cursor rests on each one that just appeared.
it('walks the parts that just landed, one stop at a time', () => {
vi.useFakeTimers();
renderState();
const { posted, frame } = watchFrame();
send(frame, sectionsMessage([
{ key: '0|header|Studio Nine', label: 'Studio Nine' },
{ key: '1|section|Selected work', label: 'Selected work' },
]));
expect(posted.at(-1)?.section).toBe('0|header|Studio Nine');
act(() => {
vi.advanceTimersByTime(1200);
});
expect(posted.at(-1)?.section).toBe('1|section|Selected work');
// Tour over: back to the step's own anchor, which is the right target for
// an edit inside a part that already exists.
act(() => {
vi.advanceTimersByTime(1200);
});
expect(posted.at(-1)?.section).toBeNull();
expect(posted.at(-1)?.anchor).toBe('Studio Nine');
});
it('names the part it is pointing at, not the file', () => {
renderState();
const { posted, frame } = watchFrame();
send(frame, sectionsMessage([{ key: '1|section|Selected work', label: 'Selected work' }]));
const requestId = posted.at(-1)?.requestId;
expect(typeof requestId).toBe('string');
send(frame, rectMessage(requestId as string));
expect(screen.getByTestId('build-focus-cursor').textContent).toContain('Selected work');
expect(screen.getByTestId('build-focus-cursor').textContent).not.toContain('index.html');
});
// A load that only changed things INSIDE existing parts adds no keys, so
// there is nothing to tour and the step's anchor keeps the cursor.
it('does not re-tour parts it has already shown', () => {
vi.useFakeTimers();
renderState();
const { posted, frame } = watchFrame();
const parts = sectionsMessage([{ key: '0|header|Studio Nine', label: 'Studio Nine' }]);
send(frame, parts);
expect(posted.at(-1)?.section).toBe('0|header|Studio Nine');
act(() => {
vi.advanceTimersByTime(1200);
});
const before = posted.length;
send(frame, parts);
expect(posted.slice(before).every((message) => message.section === null)).toBe(true);
});
});
@@ -19,7 +19,7 @@ function step(
target: string | null,
toolName = 'Tool',
): RunProgressStep {
return { id, category, toolName, target };
return { id, category, toolName, target, anchor: null };
}
function renderState(props: Parameters<typeof DesignFilesEmptyState>[0]) {
@@ -37,7 +37,6 @@ afterEach(() => {
describe('DesignFilesEmptyState', () => {
it('names the current step instead of a static "thinking"', () => {
renderState({
latestUserPrompt: '做个作品集',
running: true,
steps: [step('2', 'edit', 'index.html'), step('1', 'read', 'site.css')],
});
@@ -46,7 +45,10 @@ describe('DesignFilesEmptyState', () => {
expect(screen.queryByText('思考中')).toBeNull();
});
it('stacks the earlier steps under the current one, newest first', () => {
// The steps read as a log now: oldest at the top, the step happening RIGHT
// NOW on the bottom line. `runProgressSteps` still hands them over
// newest-first — the ordering is presentation, and lives in the component.
it('reads as a log: oldest first, the current step on the last line', () => {
renderState({
running: true,
steps: [
@@ -56,33 +58,54 @@ describe('DesignFilesEmptyState', () => {
],
});
const trail = screen.getByTestId('design-files-empty-trail');
expect([...trail.children].map((li) => li.textContent)).toEqual([
'编辑 index.html',
const feed = screen.getByTestId('run-step-feed');
expect([...feed.children].map((li) => li.textContent)).toEqual([
'读取 site.css',
'编辑 index.html',
'Bash pnpm build',
]);
const current = [...feed.children].filter(
(li) => li.getAttribute('data-current') === 'true',
);
expect(current.map((li) => li.textContent)).toEqual(['Bash pnpm build']);
});
it('falls back to "thinking" while the turn has called nothing yet', () => {
renderState({ latestUserPrompt: '做个作品集', running: true, steps: [] });
renderState({ running: true, steps: [] });
expect(screen.getByText('思考中')).toBeTruthy();
expect(screen.queryByTestId('design-files-empty-trail')).toBeNull();
});
it('drops the trail when the run is over, and keeps the idle copy', () => {
// The ring reports the agent's work. The prompt the user just typed is
// already in the chat column, and echoing it here both put user input inside
// the circle and pushed the status line down a row.
it('keeps the user\'s own prompt out of the ring, status on the first line', () => {
renderState({ running: true, steps: [] });
const center = screen.getByTestId('design-files-empty-chat');
expect(center.textContent).toBe('思考中');
});
// With no run in flight the ring is just the field turning. The copy that
// used to rest in it ("designs will appear here") is not the agent doing
// something, and inside the circle it read as a caption on the animation.
it('says nothing at all once the run is over', () => {
renderState({
running: false,
steps: [step('2', 'edit', 'index.html'), step('1', 'read', 'site.css')],
});
expect(screen.queryByTestId('design-files-empty-trail')).toBeNull();
expect(screen.getByText('生成的设计会出现在这里')).toBeTruthy();
expect(screen.queryByText('生成的设计会出现在这里')).toBeNull();
expect(screen.getByTestId('design-files-empty-chat').textContent).toBe('');
});
it('names an unclassified tool by its own name', () => {
// Chat heads a tool it has no card for with the raw name (GenericCard), so
// the ring does too — the two surfaces title one call the same way.
it('names an unclassified tool by its own name, as Chat does', () => {
renderState({ running: true, steps: [step('1', 'other', null, 'mcp__figma__export')] });
expect(screen.getByText('调用 mcp__figma__export')).toBeTruthy();
expect(screen.getByText('mcp__figma__export')).toBeTruthy();
});
});
@@ -167,11 +167,11 @@ describe("DesignFilesPanel sections", () => {
expect(screen.queryByTestId("design-files-empty-new-document")).toBeNull();
});
it("echoes the chat's latest prompt in the empty state instead of starter CTAs", () => {
renderPanel([], { latestUserPrompt: "Build me a design studio portfolio" });
it("shows the agent's state in the empty state instead of starter CTAs", () => {
renderPanel([], { running: true });
const center = screen.getByTestId("design-files-empty-chat");
expect(center.textContent).toContain("Build me a design studio portfolio");
expect(center.textContent).toContain("Thinking");
// Creating things now happens through the tab strip's "+" launcher.
expect(screen.queryByTestId("design-files-empty-new-sketch")).toBeNull();
expect(screen.queryByTestId("design-files-empty-new-document")).toBeNull();
@@ -180,16 +180,16 @@ describe("DesignFilesPanel sections", () => {
expect(screen.queryByTestId("design-files-empty-create-design-system")).toBeNull();
});
it("swaps the empty state's status line while the agent is running", () => {
renderPanel([], { latestUserPrompt: "Build me a portfolio", running: true });
it("carries the agent's state while it runs, and nothing at rest", () => {
renderPanel([], { running: true });
expect(screen.getByTestId("design-files-empty-chat").textContent).toContain("Thinking");
cleanup();
renderPanel([], { latestUserPrompt: "Build me a portfolio", running: false });
expect(screen.getByTestId("design-files-empty-chat").textContent).toContain(
"Creations will appear here",
);
// At rest the ring is just the particle field: a line of copy inside the
// circle reads as a caption on the animation, not as the pane's state.
renderPanel([], { running: false });
expect(screen.getByTestId("design-files-empty-chat").textContent).toBe("");
});
it("groups files into category tabs and shows one group at a time", () => {
@@ -1032,3 +1032,82 @@ describe("DesignFilesPanel pending sync (downloadPending)", () => {
expect(document.querySelector(".skeleton-block")).toBeNull();
});
});
// The middle state: a page exists, the run is still writing it. Before this,
// the pane jumped from the particle field straight to a grid of file cards, so
// the artifact was never shown taking shape.
describe("building preview", () => {
// The suite's other cleanup hook is scoped to its own describe block.
afterEach(() => {
cleanup();
});
const page = () => file({ name: "index.html", kind: "html" });
it("watches the page while the run is still writing it", () => {
renderPanel([page()], { running: true });
expect(screen.getByTestId("design-files-building")).toBeTruthy();
// The grid is what it replaces, not something it sits on top of.
expect(screen.queryByTestId("design-file-row-index.html")).toBeNull();
});
it("hands the pane back to the file grid when the run ends", () => {
const { rerender } = renderPanel([page()], { running: true });
expect(screen.getByTestId("design-files-building")).toBeTruthy();
rerender(
<DesignFilesPanel
projectId="test-project"
files={[page()]}
liveArtifacts={[]}
running={false}
onRefreshFiles={vi.fn()}
onOpenFile={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onRenameFile={vi.fn()}
onDeleteFile={vi.fn()}
onDeleteFiles={vi.fn()}
onUploadFiles={vi.fn()}
/>,
);
expect(screen.queryByTestId("design-files-building")).toBeNull();
expect(screen.getByTestId("design-file-row-index.html")).toBeTruthy();
});
it("stays out of the way for a run that has produced no page", () => {
renderPanel([file({ name: "notes.md", kind: "text", mime: "text/markdown" })], {
running: true,
});
expect(screen.queryByTestId("design-files-building")).toBeNull();
expect(screen.getByTestId("design-file-row-notes.md")).toBeTruthy();
});
// One switch, both directions. The button it replaced only went one way:
// once the preview was dismissed, the run had no route back to it.
it("lets the user leave the preview for the files, and come back", () => {
renderPanel([page()], { running: true });
const toggle = screen.getByTestId("design-files-preview-toggle");
expect(toggle.getAttribute("aria-checked")).toBe("true");
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-checked")).toBe("false");
expect(screen.queryByTestId("design-files-building")).toBeNull();
expect(screen.getByTestId("design-file-row-index.html")).toBeTruthy();
fireEvent.click(toggle);
expect(toggle.getAttribute("aria-checked")).toBe("true");
expect(screen.getByTestId("design-files-building")).toBeTruthy();
});
// A switch with nothing on its other side would be a control that does
// nothing: outside a run that has written a page, the pane has one view.
it("shows no preview switch when no run is writing a page", () => {
renderPanel([page()], { running: false });
expect(screen.queryByTestId("design-files-preview-toggle")).toBeNull();
});
});
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { holeAlphaAt, holeRectsEqual } from '../../src/components/workspace/space-hole';
const BOX = { x: 100, y: 50, width: 80, height: 20 };
describe('holeAlphaAt', () => {
it('paints nothing inside a hole', () => {
expect(holeAlphaAt(140, 60, [BOX], 10)).toBe(0); // centre
expect(holeAlphaAt(105, 60, [BOX], 10)).toBe(0); // just inside the left end
expect(holeAlphaAt(140, 51, [BOX], 10)).toBe(0); // just under the top edge
});
// The hole is a stadium, not a rectangle: a line of text is rounded at its
// ends, and its square corners hold no glyphs worth clearing.
it('leaves the square corners of the box outside the hole', () => {
expect(holeAlphaAt(100, 50, [BOX], 10)).toBeGreaterThan(0);
});
it('paints at full density once a full feather clear of the hole', () => {
expect(holeAlphaAt(140, 90, [BOX], 10)).toBe(1);
expect(holeAlphaAt(240, 60, [BOX], 10)).toBe(1);
});
// The soft edge is the whole point: a hard cut would read as a hole punched
// in the field rather than as the field getting out of the way.
it('ramps between the two, monotonically outward', () => {
const near = holeAlphaAt(140, 74, [BOX], 20);
const mid = holeAlphaAt(140, 80, [BOX], 20);
const far = holeAlphaAt(140, 88, [BOX], 20);
expect(near).toBeGreaterThan(0);
expect(near).toBeLessThan(mid);
expect(mid).toBeLessThan(far);
expect(far).toBeLessThanOrEqual(1);
});
it('takes the strongest hole when several overlap', () => {
const second = { x: 300, y: 50, width: 40, height: 20 };
// Just outside the first box, deep inside the second.
expect(holeAlphaAt(310, 60, [BOX, second], 10)).toBe(0);
});
it('leaves the field untouched when there are no holes', () => {
expect(holeAlphaAt(0, 0, [], 10)).toBe(1);
});
});
describe('holeRectsEqual', () => {
it('ignores sub-pixel churn so a measurement loop cannot feed itself', () => {
expect(holeRectsEqual([BOX], [{ ...BOX, x: BOX.x + 0.2 }])).toBe(true);
});
it('sees a real move', () => {
expect(holeRectsEqual([BOX], [{ ...BOX, y: BOX.y + 14 }])).toBe(false);
expect(holeRectsEqual([BOX], [])).toBe(false);
});
});
@@ -1,44 +0,0 @@
import { describe, expect, it } from 'vitest';
import { latestUserPromptText } from '../../src/runtime/latest-user-prompt';
import type { ChatMessage } from '../../src/types';
function message(role: ChatMessage['role'], content: string, id = content): ChatMessage {
return { id, role, content };
}
describe('latestUserPromptText', () => {
it('returns null for an empty conversation', () => {
expect(latestUserPromptText([])).toBeNull();
});
it('returns null when the assistant has spoken but the user has not', () => {
expect(latestUserPromptText([message('assistant', 'Ready when you are.')])).toBeNull();
});
it('picks the most recent user turn, not the first', () => {
const messages = [
message('user', 'Build a portfolio'),
message('assistant', 'On it.'),
message('user', 'Actually make it a landing page'),
];
expect(latestUserPromptText(messages)).toBe('Actually make it a landing page');
});
it('collapses hard-wrapped whitespace into one line', () => {
expect(latestUserPromptText([message('user', ' Build a\n\nportfolio ')])).toBe(
'Build a portfolio',
);
});
it('skips a blank user turn and falls back to the previous one', () => {
const messages = [message('user', 'Build a portfolio'), message('user', ' ', 'blank')];
expect(latestUserPromptText(messages)).toBe('Build a portfolio');
});
it('elides a prompt that would overrun the empty state', () => {
const long = 'a'.repeat(400);
const result = latestUserPromptText([message('user', long)]);
expect(result).toHaveLength(161);
expect(result?.endsWith('…')).toBe(true);
});
});
+70 -1
View File
@@ -76,7 +76,7 @@ describe('runProgressSteps', () => {
it('keeps an unclassified tool, with its name for the caller to render', () => {
const steps = runProgressSteps([assistant([toolUse('1', 'mcp__figma__export', {})])]);
expect(steps).toEqual([
{ id: '1', category: 'other', toolName: 'mcp__figma__export', target: null },
{ id: '1', category: 'other', toolName: 'mcp__figma__export', target: null, anchor: null },
]);
});
@@ -97,3 +97,72 @@ describe('runProgressSteps', () => {
expect(steps[0]?.target).toBe('file-39.html');
});
});
// The anchor is what lets a live preview scroll to the part being written: a
// literal run of visible text taken from the tool's own input. It exists only
// where it can point at something — an HTML page the step actually wrote.
describe('runProgressSteps anchors', () => {
it('takes the replacement text of an html edit', () => {
const [step] = runProgressSteps([
assistant([
toolUse('1', 'Edit', {
file_path: '/tmp/project/index.html',
old_string: '<h1>Old</h1>',
new_string: '<h1 class="hero">Studio Nine, a design practice</h1>',
}),
]),
]);
expect(step?.anchor).toBe('Studio Nine, a design practice');
});
it('takes the LAST edit of a multi-edit, which is where the step ended up', () => {
const [step] = runProgressSteps([
assistant([
toolUse('1', 'MultiEdit', {
file_path: 'index.html',
edits: [
{ old_string: 'a', new_string: '<p>The first paragraph of copy</p>' },
{ old_string: 'b', new_string: '<p>The closing paragraph of copy</p>' },
],
}),
]),
]);
expect(step?.anchor).toBe('The closing paragraph of copy');
});
it('takes the tail of a whole-file write — the part just finished', () => {
const [step] = runProgressSteps([
assistant([
toolUse('1', 'Write', {
file_path: 'index.html',
content: '<html><body><h1>A portfolio index</h1><footer>Contact the studio</footer></body></html>',
}),
]),
]);
expect(step?.anchor).toBe('Contact the studio');
});
it('has no anchor when the step wrote no visible html text', () => {
const steps = runProgressSteps([
assistant([
toolUse('1', 'Read', { file_path: 'index.html' }),
toolUse('2', 'Edit', { file_path: 'site.css', new_string: '.hero { color: red; }' }),
toolUse('3', 'Edit', {
file_path: 'index.html',
new_string: '<style>.hero{color:red}</style>',
}),
toolUse('4', 'Bash', { command: 'pnpm build' }),
]),
]);
expect(steps.map((step) => step.anchor)).toEqual([null, null, null, null]);
});
it('ignores a run of text too short to point at anything in particular', () => {
const [step] = runProgressSteps([
assistant([
toolUse('1', 'Edit', { file_path: 'index.html', new_string: '<button>OK</button>' }),
]),
]);
expect(step?.anchor).toBeNull();
});
});
@@ -0,0 +1,141 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
const buildingCss = readFileSync(
new URL('../../src/components/design-files/DesignFilesBuildingState.module.css', import.meta.url),
'utf8',
);
const feedCss = readFileSync(
new URL('../../src/components/design-files/RunStepFeed.module.css', import.meta.url),
'utf8',
);
const toggleCss = readFileSync(
new URL('../../src/components/design-files/BuildPreviewToggle.module.css', import.meta.url),
'utf8',
);
const emptyStateCss = readFileSync(
new URL('../../src/components/design-files/DesignFilesEmptyState.module.css', import.meta.url),
'utf8',
);
function cssDeclarations(css: string, selector: string): string {
const blocks: string[] = [];
const rulePattern = /([^{}]+)\{([^}]*)\}/g;
const cssWithoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '');
let match: RegExpExecArray | null;
while ((match = rulePattern.exec(cssWithoutComments)) !== null) {
const selectors = (match[1] ?? '').split(',').map((item) => item.trim());
if (selectors.includes(selector)) blocks.push(match[2] ?? '');
}
if (blocks.length === 0) throw new Error(`Missing CSS block for ${selector}`);
return blocks.join('\n');
}
/** The INSIDE of the reduced-motion block. The at-rule's own braces have to go
* first: the flat rule matcher above cannot see through a nested block. */
function reducedMotionBlock(css: string): string {
const start = css.indexOf('@media (prefers-reduced-motion: reduce)');
if (start < 0) throw new Error('Missing a reduced-motion block');
const open = css.indexOf('{', start);
if (open < 0) throw new Error('Malformed reduced-motion block');
return css.slice(open + 1);
}
describe('design files building preview styles', () => {
// THE drop-target guard. `.df-body` owns the drag-to-upload handlers; an
// iframe that accepts pointer events swallows dragenter/dragover and the
// whole upload path silently stops working while a preview is up.
it('keeps the preview frame out of the pointer path', () => {
expect(cssDeclarations(buildingCss, '.frame')).toMatch(
/(?:^|[;\n])\s*pointer-events:\s*none\s*;/,
);
expect(cssDeclarations(buildingCss, '.overlay')).toMatch(
/(?:^|[;\n])\s*pointer-events:\s*none\s*;/,
);
expect(cssDeclarations(buildingCss, '.dock')).toMatch(
/(?:^|[;\n])\s*pointer-events:\s*none\s*;/,
);
});
// Nothing on this surface takes a click any more: the way out is the
// topbar's preview switch (BuildPreviewToggle), so the whole overlay can
// stay click-through and the pane's drop target keeps working everywhere.
it('leaves no clickable control on top of the rendered page', () => {
expect(buildingCss).not.toMatch(/pointer-events:\s*auto/);
});
it('glides the cursor on the house easing', () => {
expect(cssDeclarations(buildingCss, '.cursor')).toMatch(
/transition:\s*transform\s+\d+ms\s+cubic-bezier\(0\.23,\s*1,\s*0\.32,\s*1\)/,
);
});
it('stops the cursor motion under reduced motion', () => {
const reduced = reducedMotionBlock(buildingCss);
expect(cssDeclarations(reduced, '.cursor')).toMatch(/transition:\s*none/);
expect(cssDeclarations(reduced, '.cursorRing')).toMatch(/animation:\s*none/);
});
});
describe('run step feed styles', () => {
// A log the user cannot grab: `overflow: hidden` still takes a scripted
// scrollTop, which is what lets the whole text block stay click-through.
it('scrolls itself without offering a scrollbar', () => {
const feed = cssDeclarations(feedCss, '.feed');
expect(feed).toMatch(/(?:^|[;\n])\s*overflow:\s*hidden\s*;/);
expect(feed).not.toMatch(/overflow:\s*auto/);
expect(feed).not.toMatch(/overflow-y:\s*(auto|scroll)/);
});
// Oldest lines leave at the TOP now that the newest one is at the bottom —
// but only while something IS leaving. A feed that fits has nothing
// scrolling out, and masking it anyway washed its single status line
// ("thinking") into a gradient, so the fade hangs off the overflow flag the
// component sets rather than off the feed itself.
it('fades the top edge only while the log actually overflows', () => {
expect(cssDeclarations(feedCss, '.feed')).not.toMatch(/mask-image/);
expect(cssDeclarations(feedCss, ".feed[data-overflowing='true']")).toMatch(
/mask-image:\s*linear-gradient\(to bottom, transparent/,
);
});
// The feed is a column flex box with a capped height and its items clip
// their own text, so their `min-height: auto` is 0 — the default
// `flex-shrink: 1` squeezed ten 16px lines into 112px at 9px each and the
// text overlapped the line below it. The log has to scroll, never compress.
it('keeps every line at its own height instead of squeezing the stack', () => {
expect(cssDeclarations(feedCss, '.item')).toMatch(
/(?:^|[;\n])\s*flex-shrink:\s*0\s*;/,
);
});
it('lands a new line on the house easing, and not at all under reduced motion', () => {
expect(cssDeclarations(feedCss, '.item')).toMatch(
/animation:\s*df-feed-enter\s+\d+ms\s+cubic-bezier\(0\.23,\s*1,\s*0\.32,\s*1\)/,
);
expect(cssDeclarations(reducedMotionBlock(feedCss), '.item')).toMatch(/animation:\s*none/);
});
it("keeps the empty state text block click-through", () => {
expect(cssDeclarations(emptyStateCss, '.center')).toMatch(
/(?:^|[;\n])\s*pointer-events:\s*none\s*;/,
);
});
});
describe('build preview toggle styles', () => {
it('throws the knob on the house easing, and not at all under reduced motion', () => {
expect(cssDeclarations(toggleCss, '.knob')).toMatch(
/transition:\s*transform\s+\d+ms\s+cubic-bezier\(0\.23,\s*1,\s*0\.32,\s*1\)/,
);
expect(cssDeclarations(reducedMotionBlock(toggleCss), '.knob')).toMatch(
/transition:\s*none/,
);
});
// It is a switch in a toolbar of icon buttons; a keyboard user has to be
// able to see which one they are on.
it('keeps a visible focus state', () => {
expect(cssDeclarations(toggleCss, '.toggle:focus-visible')).toMatch(/outline:\s*2px/);
});
});
+1
View File
@@ -16,6 +16,7 @@ await build({
"./src/api/research.ts",
"./src/runtime/deck-stage-fallback.ts",
"./src/runtime/preview-observability.ts",
"./src/runtime/preview-build-focus.ts",
"./src/design-systems/components-manifest.ts",
"./src/design-systems/derived-token-outputs.ts",
"./src/design-systems/runtime-schema.ts",
+4
View File
@@ -54,6 +54,10 @@
"types": "./dist/runtime/preview-observability.d.ts",
"default": "./dist/runtime/preview-observability.mjs"
},
"./runtime/preview-build-focus": {
"types": "./dist/runtime/preview-build-focus.d.ts",
"default": "./dist/runtime/preview-build-focus.mjs"
},
"./design-systems/components-manifest": {
"types": "./dist/design-systems/components-manifest.d.ts",
"default": "./dist/design-systems/components-manifest.mjs"
@@ -0,0 +1,415 @@
/**
* Cross-runtime protocol used by a live artifact preview to tell the Open
* Design host WHERE in the rendered page the agent is currently working, so the
* host can park a cursor there.
*
* Two ways to say where. The host can send a short literal string the agent
* just wrote (see the web's `RunProgressStep.anchor`), which the frame finds
* by text; or it can name one of the page's own top-level SECTIONS, which the
* frame enumerates and broadcasts after every load. Either way the frame
* scrolls the target into view and reports its box in its own viewport
* coordinates. Sections are what let the host walk the cursor down a page as
* it is written — one stop per part that just appeared — instead of jumping
* once to wherever the last written run of text happens to be. The frame is sandboxed
* without `allow-same-origin`, so its origin is opaque: it must post to `'*'`,
* and the host must identify it by `event.source`, never by `event.origin`.
*
* Keep this module browser-API free. The browser code is serialized as a
* string so both the web and daemon runtimes inject exactly the same script.
*/
export const PREVIEW_BUILD_FOCUS_BRIDGE_MARKER = 'data-od-preview-build-focus';
export const PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION = 1;
/** Frame → host, once the bridge can accept requests. */
export const PREVIEW_BUILD_FOCUS_READY_TYPE = 'od:preview-build-focus-ready';
/** Host → frame: "find this text". */
export const PREVIEW_BUILD_FOCUS_REQUEST_TYPE = 'od:preview-build-focus';
/** Frame → host: where it landed. */
export const PREVIEW_BUILD_FOCUS_RESULT_TYPE = 'od:preview-build-focus-rect';
/** Frame → host, unprompted after each load: the page's top-level parts. */
export const PREVIEW_BUILD_FOCUS_SECTIONS_TYPE = 'od:preview-build-focus-sections';
/** Anchors longer than this are not more precise, only more brittle. */
export const PREVIEW_BUILD_FOCUS_MAX_ANCHOR_CHARS = 96;
/** Text nodes the bridge will walk before giving up on a match. */
export const PREVIEW_BUILD_FOCUS_MAX_TEXT_NODES = 4000;
/** Elements the end-of-document fallback will consider. */
export const PREVIEW_BUILD_FOCUS_MAX_FALLBACK_ELEMENTS = 2000;
/** A page with more parts than this is a list, and a cursor tour of it would
* outlast the run it is describing. */
export const PREVIEW_BUILD_FOCUS_MAX_SECTIONS = 24;
/** A section label is a caption, not a paragraph. */
export const PREVIEW_BUILD_FOCUS_MAX_LABEL_CHARS = 40;
/** Keys are built by the frame from index, tag and label; nothing needs more. */
export const PREVIEW_BUILD_FOCUS_MAX_SECTION_KEY_CHARS = 96;
/** Coordinates outside this range are a bug or a hostile page, not a layout. */
const MAX_COORDINATE = 20_000;
export interface PreviewBuildFocusRequest {
type: typeof PREVIEW_BUILD_FOCUS_REQUEST_TYPE;
version: typeof PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION;
/** Echoed back, so the host can drop results for a request it has replaced. */
requestId: string;
/** Text to locate. Null asks for the last visible box in the document. */
anchor: string | null;
/** A section key from the frame's own broadcast. Takes precedence over
* `anchor` when set — the host is pointing at a PART of the page, not at a
* run of text inside it. */
section: string | null;
}
/** One top-level part of the previewed page, as the frame sees it. */
export interface PreviewSection {
/** Opaque to the host: index, tag and label, built by the frame. */
key: string;
/** What to call this part on screen — its heading, id, or tag. */
label: string;
}
export interface PreviewBuildFocusSections {
type: typeof PREVIEW_BUILD_FOCUS_SECTIONS_TYPE;
version: typeof PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION;
sections: PreviewSection[];
}
export interface PreviewBuildFocusResult {
type: typeof PREVIEW_BUILD_FOCUS_RESULT_TYPE;
version: typeof PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION;
requestId: string;
/** False when nothing could be located — the host then hides the cursor. */
found: boolean;
/** The located box, in the FRAME's viewport CSS px. */
x: number;
y: number;
width: number;
height: number;
viewportWidth: number;
viewportHeight: number;
}
export interface PreviewBuildFocusReady {
type: typeof PREVIEW_BUILD_FOCUS_READY_TYPE;
version: typeof PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION;
}
function finiteNumber(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
if (value < -MAX_COORDINATE || value > MAX_COORDINATE) {
return value < 0 ? -MAX_COORDINATE : MAX_COORDINATE;
}
return value;
}
/** True for a well-formed ready notice from a preview bridge. */
export function isPreviewBuildFocusReady(value: unknown): boolean {
if (!value || typeof value !== 'object') return false;
const message = value as Record<string, unknown>;
return (
message.type === PREVIEW_BUILD_FOCUS_READY_TYPE &&
message.version === PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION
);
}
/**
* Validate a frame's result and rebuild it as a fresh, bounded object. The
* payload comes from generated, untrusted page code, so nothing from it is ever
* passed through by reference.
*/
export function parsePreviewBuildFocusResult(value: unknown): PreviewBuildFocusResult | null {
if (!value || typeof value !== 'object') return null;
const message = value as Record<string, unknown>;
if (message.type !== PREVIEW_BUILD_FOCUS_RESULT_TYPE) return null;
if (message.version !== PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION) return null;
if (typeof message.requestId !== 'string' || !message.requestId) return null;
if (typeof message.found !== 'boolean') return null;
const x = finiteNumber(message.x);
const y = finiteNumber(message.y);
const width = finiteNumber(message.width);
const height = finiteNumber(message.height);
const viewportWidth = finiteNumber(message.viewportWidth);
const viewportHeight = finiteNumber(message.viewportHeight);
if (
x === null ||
y === null ||
width === null ||
height === null ||
viewportWidth === null ||
viewportHeight === null
) {
return null;
}
return {
type: PREVIEW_BUILD_FOCUS_RESULT_TYPE,
version: PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION,
requestId: message.requestId.slice(0, 64),
found: message.found,
x,
y,
width: Math.max(0, width),
height: Math.max(0, height),
viewportWidth: Math.max(0, viewportWidth),
viewportHeight: Math.max(0, viewportHeight),
};
}
/**
* Validate a frame's section broadcast and rebuild it as fresh, bounded data.
* Labels are page content — generated, untrusted text — so they are capped and
* copied, never passed through.
*/
export function parsePreviewBuildFocusSections(value: unknown): PreviewSection[] | null {
if (!value || typeof value !== 'object') return null;
const message = value as Record<string, unknown>;
if (message.type !== PREVIEW_BUILD_FOCUS_SECTIONS_TYPE) return null;
if (message.version !== PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION) return null;
if (!Array.isArray(message.sections)) return null;
const sections: PreviewSection[] = [];
for (const entry of message.sections) {
if (sections.length >= PREVIEW_BUILD_FOCUS_MAX_SECTIONS) break;
if (!entry || typeof entry !== 'object') continue;
const section = entry as Record<string, unknown>;
if (typeof section.key !== 'string' || !section.key) continue;
if (typeof section.label !== 'string') continue;
sections.push({
key: section.key.slice(0, PREVIEW_BUILD_FOCUS_MAX_SECTION_KEY_CHARS),
label: section.label.slice(0, PREVIEW_BUILD_FOCUS_MAX_LABEL_CHARS),
});
}
return sections;
}
/** Build a request the host can post into a preview frame. */
export function previewBuildFocusRequest(
requestId: string,
anchor: string | null,
section: string | null = null,
): PreviewBuildFocusRequest {
const trimmed = typeof anchor === 'string' ? anchor.trim() : '';
const key = typeof section === 'string' ? section.trim() : '';
return {
type: PREVIEW_BUILD_FOCUS_REQUEST_TYPE,
version: PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION,
requestId,
anchor: trimmed ? trimmed.slice(0, PREVIEW_BUILD_FOCUS_MAX_ANCHOR_CHARS) : null,
section: key ? key.slice(0, PREVIEW_BUILD_FOCUS_MAX_SECTION_KEY_CHARS) : null,
};
}
/**
* Locates the host's anchor text, scrolls it into view, and reports its box.
*
* The anchor is model output, so it is only ever compared as TEXT — never fed
* to `querySelector`, where it would throw or match something unrelated.
*/
export function buildPreviewBuildFocusBridge(): string {
return `<script ${PREVIEW_BUILD_FOCUS_BRIDGE_MARKER}>
(function(){
if (window.__odPreviewBuildFocus) return;
window.__odPreviewBuildFocus = true;
var READY = ${JSON.stringify(PREVIEW_BUILD_FOCUS_READY_TYPE)};
var REQUEST = ${JSON.stringify(PREVIEW_BUILD_FOCUS_REQUEST_TYPE)};
var RESULT = ${JSON.stringify(PREVIEW_BUILD_FOCUS_RESULT_TYPE)};
var VERSION = ${PREVIEW_BUILD_FOCUS_PROTOCOL_VERSION};
var SECTIONS = ${JSON.stringify(PREVIEW_BUILD_FOCUS_SECTIONS_TYPE)};
var MAX_TEXT_NODES = ${PREVIEW_BUILD_FOCUS_MAX_TEXT_NODES};
var MAX_FALLBACK = ${PREVIEW_BUILD_FOCUS_MAX_FALLBACK_ELEMENTS};
var MAX_SECTIONS = ${PREVIEW_BUILD_FOCUS_MAX_SECTIONS};
var MAX_LABEL = ${PREVIEW_BUILD_FOCUS_MAX_LABEL_CHARS};
var lastRequestId = null;
var lastAnchor = null;
var lastSection = null;
var pending = false;
function reduced(){
try {
return typeof window.matchMedia === 'function' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
} catch (_) { return false; }
}
function collapse(value){
return String(value || '').replace(/\\s+/g, ' ').trim();
}
function findByText(anchor){
var needle = collapse(anchor);
if (!needle || !document.body) return null;
var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
var seen = 0;
var loose = null;
while (walker.nextNode() && seen < MAX_TEXT_NODES) {
seen++;
var node = walker.currentNode;
var raw = node.nodeValue || '';
if (!raw) continue;
var parent = node.parentElement;
if (!parent) continue;
if (raw.indexOf(anchor) !== -1) return parent;
if (!loose && collapse(raw).indexOf(needle) !== -1) loose = parent;
}
return loose;
}
function skippable(el){
var tag = String(el && el.tagName || '').toLowerCase();
return tag === 'script' || tag === 'style' || tag === 'template' ||
tag === 'link' || tag === 'meta' || tag === 'noscript' || tag === 'title';
}
// The page's top-level parts, in document order. A body that holds one
// wrapper (a <main>, a layout div) is described by that wrapper's children —
// otherwise every page would report a single section covering all of it.
function sectionRoots(){
if (!document.body) return [];
var parent = document.body;
for (var depth = 0; depth < 3; depth++) {
var kids = [];
var children = parent.children || [];
for (var i = 0; i < children.length; i++) {
if (!skippable(children[i])) kids.push(children[i]);
}
if (kids.length === 0) return [];
var only = kids.length === 1 ? kids[0] : null;
if (!only || !only.children || only.children.length < 2) return kids;
parent = only;
}
return [];
}
function labelFor(el, index){
var heading = null;
try { heading = el.querySelector('h1, h2, h3, h4'); } catch (_) {}
var text = heading ? collapse(heading.textContent) : '';
if (!text && el.id) text = collapse(el.id);
if (!text && el.getAttribute) text = collapse(el.getAttribute('aria-label'));
if (!text) text = collapse(el.tagName).toLowerCase() + ' ' + (index + 1);
return text.slice(0, MAX_LABEL);
}
// Keyed by position, tag AND label: a part whose heading was just rewritten
// is, for the host's purposes, a part that just landed.
function sectionList(){
var roots = sectionRoots();
var out = [];
for (var i = 0; i < roots.length && out.length < MAX_SECTIONS; i++) {
var el = roots[i];
if (!el || typeof el.getBoundingClientRect !== 'function') continue;
var box = el.getBoundingClientRect();
if (box.width <= 0 || box.height <= 0) continue;
var label = labelFor(el, i);
out.push({
el: el,
key: i + '|' + collapse(el.tagName).toLowerCase() + '|' + label,
label: label
});
}
return out;
}
function postSections(){
var list = sectionList();
var plain = [];
for (var i = 0; i < list.length; i++) plain.push({ key: list[i].key, label: list[i].label });
try {
window.parent.postMessage({ type: SECTIONS, version: VERSION, sections: plain }, '*');
} catch (_) {}
}
function findSection(key){
var list = sectionList();
for (var i = 0; i < list.length; i++) {
if (list[i].key === key) return list[i].el;
}
return null;
}
function lastVisibleElement(){
if (!document.body) return null;
var all = document.body.querySelectorAll('*');
var start = all.length - 1;
var limit = Math.max(0, all.length - MAX_FALLBACK);
for (var i = start; i >= limit; i--) {
var el = all[i];
if (!el || typeof el.getBoundingClientRect !== 'function') continue;
var box = el.getBoundingClientRect();
if (box.width > 0 && box.height > 0) return el;
}
return document.body;
}
function post(requestId, found, el){
var box = found && el && typeof el.getBoundingClientRect === 'function'
? el.getBoundingClientRect()
: null;
try {
window.parent.postMessage({
type: RESULT,
version: VERSION,
requestId: requestId,
found: Boolean(found && box),
x: box ? box.left : 0,
y: box ? box.top : 0,
width: box ? box.width : 0,
height: box ? box.height : 0,
viewportWidth: window.innerWidth || 0,
viewportHeight: window.innerHeight || 0
}, '*');
} catch (_) {}
}
function locate(requestId, anchor, section){
var target = section ? findSection(section) : null;
if (!target && anchor) target = findByText(anchor);
var found = Boolean(target);
if (!target) target = lastVisibleElement();
if (!target) { post(requestId, false, null); return; }
try {
target.scrollIntoView({ block: 'center', inline: 'nearest', behavior: reduced() ? 'auto' : 'smooth' });
} catch (_) {
try { target.scrollIntoView(); } catch (__) {}
}
var el = target;
// \`found\` stays false when the anchor did not match: the page was still
// scrolled to its newest content, but the host must NOT draw a cursor —
// pointing confidently at a guess is worse than pointing at nothing.
//
// Measured TWICE, and this is not belt-and-braces. A smooth scroll is still
// animating two frames later, so the first box is the element's pre-scroll
// position — off-screen for anything below the fold. The early post keeps
// the cursor responsive when the target was already in view; the settled
// one corrects it. Same requestId, so the host simply takes the latest.
requestAnimationFrame(function(){
requestAnimationFrame(function(){ post(requestId, found, el); });
});
var settled = false;
function settle(){
if (settled) return;
settled = true;
requestAnimationFrame(function(){ post(requestId, found, el); });
}
if ('onscrollend' in window) {
window.addEventListener('scrollend', settle, { once: true });
}
setTimeout(settle, 520);
}
window.addEventListener('message', function(event){
var data = event && event.data;
if (!data || typeof data !== 'object') return;
if (data.type !== REQUEST || data.version !== VERSION) return;
if (typeof data.requestId !== 'string' || !data.requestId) return;
lastRequestId = data.requestId;
lastAnchor = typeof data.anchor === 'string' ? data.anchor : null;
lastSection = typeof data.section === 'string' ? data.section : null;
locate(lastRequestId, lastAnchor, lastSection);
});
window.addEventListener('resize', function(){
if (!lastRequestId || pending) return;
pending = true;
requestAnimationFrame(function(){
pending = false;
if (lastRequestId) locate(lastRequestId, lastAnchor, lastSection);
});
});
function ready(){
try { window.parent.postMessage({ type: READY, version: VERSION }, '*'); } catch (_) {}
// One frame later: at DOMContentLoaded the parts exist but have no boxes
// yet, and a section with no box is not a section the cursor can visit.
requestAnimationFrame(postSections);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', ready);
} else {
ready();
}
window.addEventListener('load', ready);
})();
</script>`;
}
@@ -0,0 +1,171 @@
import { describe, expect, it } from 'vitest';
import {
PREVIEW_BUILD_FOCUS_BRIDGE_MARKER,
PREVIEW_BUILD_FOCUS_MAX_ANCHOR_CHARS,
PREVIEW_BUILD_FOCUS_MAX_LABEL_CHARS,
PREVIEW_BUILD_FOCUS_MAX_SECTIONS,
PREVIEW_BUILD_FOCUS_READY_TYPE,
PREVIEW_BUILD_FOCUS_REQUEST_TYPE,
PREVIEW_BUILD_FOCUS_RESULT_TYPE,
PREVIEW_BUILD_FOCUS_SECTIONS_TYPE,
buildPreviewBuildFocusBridge,
isPreviewBuildFocusReady,
parsePreviewBuildFocusResult,
parsePreviewBuildFocusSections,
previewBuildFocusRequest,
} from '../src/runtime/preview-build-focus';
function result(overrides: Record<string, unknown> = {}) {
return {
type: PREVIEW_BUILD_FOCUS_RESULT_TYPE,
version: 1,
requestId: 'req-1',
found: true,
x: 12,
y: 340,
width: 220,
height: 48,
viewportWidth: 900,
viewportHeight: 700,
...overrides,
};
}
describe('parsePreviewBuildFocusResult', () => {
it('accepts a well-formed result and rebuilds it', () => {
const payload = result();
const parsed = parsePreviewBuildFocusResult(payload);
expect(parsed).toEqual(payload);
// Never the caller's object: the payload comes from generated page code.
expect(parsed).not.toBe(payload);
});
it('rejects a foreign or mis-versioned message', () => {
expect(parsePreviewBuildFocusResult(null)).toBeNull();
expect(parsePreviewBuildFocusResult('od:preview-build-focus-rect')).toBeNull();
expect(parsePreviewBuildFocusResult(result({ type: 'od:something-else' }))).toBeNull();
expect(parsePreviewBuildFocusResult(result({ version: 2 }))).toBeNull();
});
it('rejects a result that carries no usable geometry', () => {
expect(parsePreviewBuildFocusResult(result({ x: Number.NaN }))).toBeNull();
expect(parsePreviewBuildFocusResult(result({ height: '48' }))).toBeNull();
expect(parsePreviewBuildFocusResult(result({ requestId: '' }))).toBeNull();
expect(parsePreviewBuildFocusResult(result({ found: 'yes' }))).toBeNull();
});
it('clamps a coordinate no layout could have produced', () => {
const parsed = parsePreviewBuildFocusResult(result({ y: 10_000_000 }));
expect(parsed?.y).toBe(20_000);
});
it('never reports a negative box', () => {
const parsed = parsePreviewBuildFocusResult(result({ width: -40 }));
expect(parsed?.width).toBe(0);
});
});
describe('previewBuildFocusRequest', () => {
it('caps an anchor and normalizes an empty one to null', () => {
const long = 'x'.repeat(PREVIEW_BUILD_FOCUS_MAX_ANCHOR_CHARS + 40);
expect(previewBuildFocusRequest('r', long).anchor).toHaveLength(
PREVIEW_BUILD_FOCUS_MAX_ANCHOR_CHARS,
);
expect(previewBuildFocusRequest('r', ' ').anchor).toBeNull();
expect(previewBuildFocusRequest('r', null).anchor).toBeNull();
expect(previewBuildFocusRequest('r', ' hello ').anchor).toBe('hello');
});
});
describe('isPreviewBuildFocusReady', () => {
it('recognizes only its own ready notice', () => {
expect(isPreviewBuildFocusReady({ type: PREVIEW_BUILD_FOCUS_READY_TYPE, version: 1 })).toBe(true);
expect(isPreviewBuildFocusReady({ type: PREVIEW_BUILD_FOCUS_READY_TYPE, version: 9 })).toBe(false);
expect(isPreviewBuildFocusReady({ type: 'od:other', version: 1 })).toBe(false);
expect(isPreviewBuildFocusReady(undefined)).toBe(false);
});
});
describe('buildPreviewBuildFocusBridge', () => {
it('carries the marker and both message types', () => {
const script = buildPreviewBuildFocusBridge();
expect(script).toContain(PREVIEW_BUILD_FOCUS_BRIDGE_MARKER);
expect(script).toContain(PREVIEW_BUILD_FOCUS_REQUEST_TYPE);
expect(script).toContain(PREVIEW_BUILD_FOCUS_RESULT_TYPE);
expect(script).toContain(PREVIEW_BUILD_FOCUS_READY_TYPE);
});
it('guards against running twice in one document', () => {
expect(buildPreviewBuildFocusBridge()).toContain('__odPreviewBuildFocus');
});
// The anchor is model output. Handing it to querySelector would throw on a
// stray bracket and, worse, could match something the agent never wrote.
it('never turns the anchor into a selector', () => {
const script = buildPreviewBuildFocusBridge();
expect(script).not.toContain('querySelector(anchor');
expect(script).toContain('createTreeWalker');
});
});
describe('parsePreviewBuildFocusSections', () => {
function sections(list: unknown) {
return { type: PREVIEW_BUILD_FOCUS_SECTIONS_TYPE, version: 1, sections: list };
}
it('rebuilds a well-formed broadcast', () => {
const parsed = parsePreviewBuildFocusSections(
sections([{ key: '0|header|Studio', label: 'Studio' }]),
);
expect(parsed).toEqual([{ key: '0|header|Studio', label: 'Studio' }]);
});
it('rejects a foreign or mis-versioned broadcast', () => {
expect(parsePreviewBuildFocusSections(null)).toBeNull();
expect(parsePreviewBuildFocusSections({ ...sections([]), version: 2 })).toBeNull();
expect(parsePreviewBuildFocusSections({ ...sections([]), type: 'od:other' })).toBeNull();
expect(parsePreviewBuildFocusSections(sections('not-a-list'))).toBeNull();
});
// Labels are page content: generated, untrusted text that a hostile or
// merely verbose page can make as long as it likes.
it('caps the label, the count, and drops malformed entries', () => {
const long = 'x'.repeat(200);
const many = Array.from({ length: PREVIEW_BUILD_FOCUS_MAX_SECTIONS + 5 }, (_, i) => ({
key: `${i}|section|part`,
label: long,
}));
const parsed = parsePreviewBuildFocusSections(sections(many));
expect(parsed).toHaveLength(PREVIEW_BUILD_FOCUS_MAX_SECTIONS);
expect(parsed?.[0]?.label).toHaveLength(PREVIEW_BUILD_FOCUS_MAX_LABEL_CHARS);
expect(
parsePreviewBuildFocusSections(
sections([{ key: '', label: 'a' }, { key: '1|section|b' }, null, { key: '2|x|c', label: 'c' }]),
),
).toEqual([{ key: '2|x|c', label: 'c' }]);
});
});
describe('previewBuildFocusRequest with a section', () => {
it('carries the section key alongside the anchor, both normalized', () => {
expect(previewBuildFocusRequest('req-2', ' hello ', ' 0|header|Studio ')).toMatchObject({
anchor: 'hello',
section: '0|header|Studio',
});
expect(previewBuildFocusRequest('req-3', null).section).toBeNull();
expect(previewBuildFocusRequest('req-4', null, ' ').section).toBeNull();
});
});
describe('the bridge script', () => {
it('broadcasts the page parts and can locate one by key', () => {
const bridge = buildPreviewBuildFocusBridge();
expect(bridge).toContain(PREVIEW_BUILD_FOCUS_SECTIONS_TYPE);
expect(bridge).toContain('function sectionRoots()');
expect(bridge).toContain('function findSection(');
// A section key is host-supplied data too; it is compared, never queried.
expect(bridge).not.toContain('querySelector(key');
});
});