Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
56be383
Keep runs tray polling while run is active
builderio-bot Aug 8, 2026
711a8f9
fix: format markdown file with proper emphasis and table alignment
builderio-bot Aug 8, 2026
56b6bc5
Increase import action timeout to prevent silent failures on large files
builderio-bot Aug 9, 2026
a03d5f0
Fix non-16:9 slide imports rendering with distorted positioning
builderio-bot Aug 9, 2026
c03939f
Fix imported slide text sizing and agent patch caller detection
builderio-bot Aug 9, 2026
efd5df7
Fix PDF import text losing spaces at style boundaries
builderio-bot Aug 9, 2026
ec3d6a2
Fix deck editor dimming overlay persisting on window resize
builderio-bot Aug 9, 2026
bbcbb01
Fix slide content jumping out of view during text editing
builderio-bot Aug 9, 2026
61ec9d7
Revert overflow clip override causing rendering issues
builderio-bot Aug 9, 2026
962a6f0
Fix slow rendering and glitches in large slide decks
builderio-bot Aug 9, 2026
b4658e3
Fix large deck rendering with content-visibility and aspect-ratio
builderio-bot Aug 9, 2026
b2633cc
Fix slide thumbnail flickering and dark overlay issues
builderio-bot Aug 9, 2026
3fd4a5b
Add AI text revision to block bubble menu with pen icon
builderio-bot Aug 9, 2026
344c411
Fix Delete key not removing selected images in flow layouts
builderio-bot Aug 9, 2026
1ab76a5
Fix images from CORS-blocked sites vanishing in PDF exports
builderio-bot Aug 10, 2026
89ba013
Merge remote-tracking branch 'refs/remotes/origin/main' into ai_main_…
builderio-bot Aug 10, 2026
631c29a
Discard changes to plans/slides-feedback-2026-08-07.md
builderio-bot Aug 10, 2026
abe711d
Fix AI revision flow and keyboard accessibility in slides editor
builderio-bot Aug 10, 2026
65e14e8
AI-generated changes
builderio-bot Aug 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/runs-tray-active-poll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@agent-native/core": patch
---

Keep the runs tray refreshing while a run still reads as active, so a run
abandoned mid-flight (budget exhausted, dead worker) can no longer spin
indefinitely in hosts that disable idle polling with `pollMs={0}`.
75 changes: 74 additions & 1 deletion packages/core/src/client/progress/RunsTray.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
DropdownMenu,
DropdownMenuContent,
} from "../components/ui/dropdown-menu.js";
import { RunsTrayMenuItem } from "./RunsTray.js";
import { RunsTray, RunsTrayMenuItem } from "./RunsTray.js";

vi.mock("../api-path.js", () => ({
agentNativePath: (path: string) => path,
Expand Down Expand Up @@ -75,3 +75,76 @@ describe("RunsTrayMenuItem", () => {
expect(document.body.textContent).toContain("No recent runs");
});
});

describe("RunsTray polling", () => {
let container: HTMLDivElement;
let root: Root;

const runningRun = {
id: "run-1",
owner: "user@example.com",
title: "Build deck",
percent: null,
status: "running",
startedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
completedAt: null,
};

beforeEach(() => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"ResizeObserver",
class ResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
},
);
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});

afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = "";
vi.unstubAllGlobals();
vi.useRealTimers();
});

it("keeps refreshing an active run even when idle polling is disabled", async () => {
const fetchMock = vi.fn(async () => Response.json([runningRun]));
vi.stubGlobal("fetch", fetchMock);

await act(async () => {
root.render(<RunsTray pollMs={0} />);
});

const afterMount = fetchMock.mock.calls.length;
expect(afterMount).toBeGreaterThan(0);

await act(async () => {
await vi.waitFor(
() => expect(fetchMock.mock.calls.length).toBeGreaterThan(afterMount),
{ timeout: 15_000, interval: 250 },
);
});
}, 20_000);

it("does not poll when nothing is running and idle polling is disabled", async () => {
const fetchMock = vi.fn(async () => Response.json([]));
vi.stubGlobal("fetch", fetchMock);

await act(async () => {
root.render(<RunsTray pollMs={0} />);
});

const afterMount = fetchMock.mock.calls.length;
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 200));
});
expect(fetchMock.mock.calls.length).toBe(afterMount);
});
});
15 changes: 14 additions & 1 deletion packages/core/src/client/progress/RunsTray.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ import { cn } from "../utils.js";
type AgentRunDto = AgentRun;
type RunsTrayTriggerVariant = "icon" | "pill";
const RUN_CHANGE_SETTLE_MS = 250;
/**
* Cadence used while a run still reads as active, even for hosts that opted
* out of idle polling with `pollMs={0}`. Those hosts only refresh on mount and
* on a `runs` change event, and a run abandoned mid-flight (budget exhausted,
* dead worker) emits neither — so the spinner has no path back to a terminal
* status. Polling only while something looks active keeps the idle cost at
* zero and still lets the server's stale sweep terminalize the row.
*/
const ACTIVE_RUN_POLL_MS = 5000;

interface RunsTrayProps {
/** Poll interval in ms. 0 disables. Default 3000. */
Expand Down Expand Up @@ -108,7 +117,11 @@ function useRunsTrayState({
return () => window.clearTimeout(timeout);
}, [refresh, runsVersion]);

usePollLoop(refresh, { intervalMs: pollMs, enabled: pollMs > 0 });
const hasActiveRun = runs.some((run) => run.status === "running");
usePollLoop(refresh, {
intervalMs: pollMs > 0 ? pollMs : ACTIVE_RUN_POLL_MS,
enabled: pollMs > 0 || hasActiveRun,
});

const dismissRun = useCallback(
async (runId: string) => {
Expand Down
15 changes: 15 additions & 0 deletions templates/slides/actions/patch-deck.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { buildSourceImportMetadata } from "../server/lib/source-import.js";
import {
applyOperation,
assertSourceImportOperationsPreserved,
isAgentPatchCaller,
resolveDeckColumnUpdates,
withDeckLock,
type Operation,
Expand Down Expand Up @@ -278,6 +279,20 @@ describe("source-imported deck structure", () => {
});
});

describe("isAgentPatchCaller", () => {
it("treats tool, mcp, and a2a callers as agent callers", () => {
expect(isAgentPatchCaller("tool")).toBe(true);
expect(isAgentPatchCaller("mcp")).toBe(true);
expect(isAgentPatchCaller("a2a")).toBe(true);
});

it("treats the browser editor and unset callers as non-agent", () => {
expect(isAgentPatchCaller("frontend")).toBe(false);
expect(isAgentPatchCaller("http")).toBe(false);
expect(isAgentPatchCaller(undefined)).toBe(false);
});
});

describe("patch-deck agent schema", () => {
it("advertises only bounded deck and slide patch operations", () => {
const parameters = patchDeckAction.tool.parameters as any;
Expand Down
20 changes: 18 additions & 2 deletions templates/slides/actions/patch-deck.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,18 @@ export function resolveDeckColumnUpdates(
};
}

/**
* The source-preservation guards (`assertSourceImportOperationsPreserved`,
* `assertSourceSlidePreserved`) exist for one failure mode: an agent asked to
* "make it prettier" silently dropping the original PDF/PPTX artwork or
* factual copy. A human editing their own imported deck in the browser isn't
* that failure mode, and the browser editor has no way to pass
* `preserveSource` — so these guards must only run for agent callers.
*/
export function isAgentPatchCaller(caller: string | undefined): boolean {
return caller === "tool" || caller === "mcp" || caller === "a2a";
}

// ---------------------------------------------------------------------------
// Action definition
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -402,8 +414,9 @@ export default defineAction({
),
}),
agentInputSchema: AgentPatchDeckInputSchema,
run: async ({ deckId, operations, creativeContext }) => {
run: async ({ deckId, operations, creativeContext }, ctx) => {
await assertAccess("deck", deckId, "editor");
const isAgentCaller = isAgentPatchCaller(ctx?.caller);

return withDeckLock(deckId, async () => {
const db = getDb();
Expand Down Expand Up @@ -435,9 +448,12 @@ export default defineAction({
}

const sourceImport = sourceImportForDeck(deck.sourceImport);
assertSourceImportOperationsPreserved(sourceImport, operations);
if (isAgentCaller) {
assertSourceImportOperationsPreserved(sourceImport, operations);
}
for (const op of operations) {
if (
!isAgentCaller ||
op.op !== "patch-slide" ||
(op.fields.content === undefined && op.fields.notes === undefined)
) {
Expand Down
40 changes: 40 additions & 0 deletions templates/slides/app/components/deck/SlideRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -362,4 +362,44 @@ describe("SlideInner autofit", () => {
);
});
});

it("defers measuring an off-screen slide until it scrolls into view", async () => {
let notify: ((entries: { isIntersecting: boolean }[]) => void) | undefined;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(cb: (entries: { isIntersecting: boolean }[]) => void) {
notify = cb;
}
observe() {}
disconnect() {}
},
);
// Far below the viewport, the way most thumbnails in a long deck are.
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
() => rect(0, 100_000, 740, 380),
);

const slide: Slide = {
id: "raw-offscreen",
layout: "blank",
notes: "",
content:
'<div class="fmd-slide" style="padding: 80px 110px;"><h2>Flow title</h2></div>',
};
render(<SlideInner slide={slide} />);

// The fit layer is only ever created by a measure pass, so its absence
// proves the expensive per-descendant measurement never ran.
await new Promise((resolve) => window.setTimeout(resolve, 20));
expect(document.querySelector("[data-fmd-autofit-content]")).toBeNull();

notify?.([{ isIntersecting: true }]);

await waitFor(() => {
expect(
document.querySelector("[data-fmd-autofit-content]"),
).not.toBeNull();
});
});
});
46 changes: 46 additions & 0 deletions templates/slides/app/components/deck/SlideRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -385,6 +385,29 @@ function useSlideAutofit(

let raf = 0;
let disposed = false;
// Measuring costs a full-document reflow per slide (every descendant is
// read with getBoundingClientRect, interleaved with style writes). A deck
// with dozens of slides mounts that many renderers at once, so off-screen
// thumbnails are left unmeasured until they scroll into view. Without an
// IntersectionObserver there is nothing to defer against, so measure
// eagerly as before.
const canDefer = typeof IntersectionObserver !== "undefined";
// Resolved synchronously rather than waiting for the observer's first
// callback: a slide that is already on screen must be measured on this
// pass, and nothing may depend on a callback that a given environment
// might never deliver. This reads one rect, not one per descendant.
const isNearViewport = () => {
const rect = root.getBoundingClientRect();
const margin = 200;
return (
rect.bottom >= -margin &&
rect.right >= -margin &&
rect.top <= (window.innerHeight || 0) + margin &&
rect.left <= (window.innerWidth || 0) + margin
);
};
let visible = !canDefer || isNearViewport();
let measurePending = false;

const resetTarget = (target: HTMLElement) => {
target.style.setProperty("--fmd-fit-scale", "1");
Expand Down Expand Up @@ -482,6 +505,10 @@ function useSlideAutofit(

const scheduleMeasure = () => {
if (disposed) return;
if (!visible) {
measurePending = true;
return;
}
cancelAnimationFrame(raf);
raf = requestAnimationFrame(measureNow);
};
Expand All @@ -502,11 +529,30 @@ function useSlideAutofit(
root.addEventListener("load", scheduleMeasure, true);
document.fonts?.ready.then(scheduleMeasure).catch(() => {});

// `rootMargin` measures a thumbnail just before it scrolls in, so the fit
// transform is already applied by the time it is on screen.
const visibilityObserver = canDefer
? new IntersectionObserver(
(entries) => {
const isVisible = entries.some((entry) => entry.isIntersecting);
if (isVisible === visible) return;
visible = isVisible;
if (visible && measurePending) {
measurePending = false;
scheduleMeasure();
}
},
{ rootMargin: "200px" },
)
: null;
visibilityObserver?.observe(root);

return () => {
disposed = true;
cancelAnimationFrame(raf);
resizeObserver.disconnect();
mutationObserver.disconnect();
visibilityObserver?.disconnect();
root.removeEventListener("load", scheduleMeasure, true);
};
}, [canvasWidth, canvasHeight, fitKey, ref]);
Expand Down
47 changes: 47 additions & 0 deletions templates/slides/app/components/editor/BlockBubbleMenu.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";

import { buildReviseSelectionPrompt } from "./BlockBubbleMenu";

describe("buildReviseSelectionPrompt", () => {
it("quotes the selection verbatim so the agent can find it in the slide HTML", () => {
const prompt = buildReviseSelectionPrompt({
selectedText: "Breathe cleaner air at home — naturally.",
instruction: "make it punchier",
slideId: "slide-42",
});

expect(prompt).toContain("Breathe cleaner air at home — naturally.");
expect(prompt).toContain("How to revise it: make it punchier");
expect(prompt).toContain("Slide id: `slide-42`");
});

it("keeps the edit bounded to the selection", () => {
const prompt = buildReviseSelectionPrompt({
selectedText: "Perfect LIGHT for every home",
instruction: "shorter",
slideId: "slide-1",
});

expect(prompt).toContain("replaces only the quoted text");
expect(prompt).toContain("update-slide --fullContent");
});

it("omits the slide id line when the slide is unknown", () => {
const prompt = buildReviseSelectionPrompt({
selectedText: "Some text",
instruction: "fix grammar",
});

expect(prompt).not.toContain("Slide id:");
expect(prompt).toContain("How to revise it: fix grammar");
});

it("preserves multi-line selections", () => {
const prompt = buildReviseSelectionPrompt({
selectedText: "Line one\nLine two",
instruction: "merge into one sentence",
});

expect(prompt).toContain("Line one\nLine two");
});
});
Loading
Loading