diff --git a/.changeset/runs-tray-active-poll.md b/.changeset/runs-tray-active-poll.md new file mode 100644 index 0000000000..9bbdedf091 --- /dev/null +++ b/.changeset/runs-tray-active-poll.md @@ -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}`. diff --git a/packages/core/src/client/progress/RunsTray.spec.tsx b/packages/core/src/client/progress/RunsTray.spec.tsx index f1a6b14c34..bfee8ab682 100644 --- a/packages/core/src/client/progress/RunsTray.spec.tsx +++ b/packages/core/src/client/progress/RunsTray.spec.tsx @@ -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, @@ -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(); + }); + + 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(); + }); + + const afterMount = fetchMock.mock.calls.length; + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 200)); + }); + expect(fetchMock.mock.calls.length).toBe(afterMount); + }); +}); diff --git a/packages/core/src/client/progress/RunsTray.tsx b/packages/core/src/client/progress/RunsTray.tsx index d2143569d2..da83e0fb9c 100644 --- a/packages/core/src/client/progress/RunsTray.tsx +++ b/packages/core/src/client/progress/RunsTray.tsx @@ -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. */ @@ -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) => { diff --git a/packages/docs/server/plugins/agent-chat.spec.ts b/packages/docs/server/plugins/agent-chat.spec.ts index 80a6e877e6..78469b4bf7 100644 --- a/packages/docs/server/plugins/agent-chat.spec.ts +++ b/packages/docs/server/plugins/agent-chat.spec.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { DOCS_AGENT_SYSTEM_PROMPT } from "./agent-chat"; +// Codegen output that only exists after a dev/build run, so a clean checkout +// cannot resolve it. Every other app's plugin spec stubs it the same way. +vi.mock("../../.generated/actions-registry.js", () => ({ default: {} })); + describe("Docs agent system prompt", () => { it("keeps response language tied to the user's message, not the browser or page locale", () => { expect(DOCS_AGENT_SYSTEM_PROMPT).toContain( diff --git a/scripts/qa-standalone-chat-dev-smoke.ts b/scripts/qa-standalone-chat-dev-smoke.ts index 5820708840..66aaa4594b 100644 --- a/scripts/qa-standalone-chat-dev-smoke.ts +++ b/scripts/qa-standalone-chat-dev-smoke.ts @@ -657,6 +657,24 @@ async function gotoCommitted( throw lastError; } +/** + * Console/HTTP noise that is expected during dev warmup and therefore never + * fails the smoke. It is still the most common explanation for a page that + * renders blank (an outdated optimized dep 504s, so the app never mounts), so + * keep the tail around to attach to readiness timeouts. + */ +const suppressedBrowserNoise: string[] = []; + +function recordSuppressedNoise(entry: string): void { + suppressedBrowserNoise.push(entry); + if (suppressedBrowserNoise.length > 40) suppressedBrowserNoise.shift(); +} + +function suppressedNoiseBlock(): string { + if (suppressedBrowserNoise.length === 0) return ""; + return `\nSuppressed browser noise:\n${suppressedBrowserNoise.join("\n")}`; +} + function isBenignConsoleError(text: string): boolean { if (text.startsWith("Failed to load resource:")) return true; if (text.includes("favicon")) return true; @@ -800,6 +818,19 @@ async function readAuthenticatedSessionEmail( throw lastError; } +/** + * An empty preview is ambiguous: it means both "app rendered nothing" and "the + * read raced a reload". Distinguish them so timeouts point at the right cause. + */ +async function readBodyPreview(page: Page): Promise { + try { + return await page.locator("body").innerText({ timeout: 2_000 }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return ``; + } +} + async function gotoAndWaitForAgentPage( page: Page, running: RunningDev, @@ -810,6 +841,7 @@ async function gotoAndWaitForAgentPage( const deadline = Date.now() + (isCi ? 90_000 : 45_000); let lastError: unknown; let lastBody = ""; + let lastUrl = ""; while (Date.now() < deadline) { browserErrors.length = 0; @@ -830,10 +862,8 @@ async function gotoAndWaitForAgentPage( return; } catch (err) { lastError = err; - lastBody = await page - .locator("body") - .innerText({ timeout: 2_000 }) - .catch(() => ""); + lastBody = await readBodyPreview(page); + lastUrl = page.url(); if (Date.now() >= deadline) break; if (verbose || isCi) { const message = err instanceof Error ? err.message : String(err); @@ -849,7 +879,9 @@ async function gotoAndWaitForAgentPage( lastError instanceof Error ? lastError.message : String(lastError); throw new Error( `${path} did not show Agent or Settings sections tabs before timeout: ${message}\n` + - `Body preview: ${lastBody.slice(0, 400)}`, + `Last URL: ${lastUrl}\n` + + `Body preview: ${lastBody.slice(0, 400)}` + + suppressedNoiseBlock(), ); } @@ -863,6 +895,7 @@ async function gotoAndWaitForChatPage( const deadline = Date.now() + (isCi ? 90_000 : 45_000); let lastError: unknown; let lastBody = ""; + let lastUrl = ""; while (Date.now() < deadline) { browserErrors.length = 0; @@ -884,10 +917,8 @@ async function gotoAndWaitForChatPage( return; } catch (err) { lastError = err; - lastBody = await page - .locator("body") - .innerText({ timeout: 2_000 }) - .catch(() => ""); + lastBody = await readBodyPreview(page); + lastUrl = page.url(); if (Date.now() >= deadline) break; if (verbose || isCi) { const message = err instanceof Error ? err.message : String(err); @@ -903,7 +934,9 @@ async function gotoAndWaitForChatPage( lastError instanceof Error ? lastError.message : String(lastError); throw new Error( `${path} did not render the Chat surface before timeout: ${message}\n` + - `Body preview: ${lastBody.slice(0, 400)}`, + `Last URL: ${lastUrl}\n` + + `Body preview: ${lastBody.slice(0, 400)}` + + suppressedNoiseBlock(), ); } @@ -1050,7 +1083,10 @@ async function main(): Promise { page.on("console", (message) => { if (message.type() !== "error") return; const text = message.text(); - if (isBenignConsoleError(text)) return; + if (isBenignConsoleError(text)) { + recordSuppressedNoise(text); + return; + } browserErrors.push(text); }); page.on("response", (response) => { @@ -1058,7 +1094,10 @@ async function main(): Promise { if (status < 400) return; const url = response.url(); if (!url.startsWith(running.baseUrl)) return; - if (isBenignHttpError(status, url)) return; + if (isBenignHttpError(status, url)) { + recordSuppressedNoise(`${status} ${url}`); + return; + } httpErrors.push(`${status} ${url}`); }); diff --git a/templates/slides/actions/patch-deck.test.ts b/templates/slides/actions/patch-deck.test.ts index c3bec6b6ba..ce9c4ad0b6 100644 --- a/templates/slides/actions/patch-deck.test.ts +++ b/templates/slides/actions/patch-deck.test.ts @@ -4,6 +4,7 @@ import { buildSourceImportMetadata } from "../server/lib/source-import.js"; import { applyOperation, assertSourceImportOperationsPreserved, + isAgentPatchCaller, resolveDeckColumnUpdates, withDeckLock, type Operation, @@ -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; diff --git a/templates/slides/actions/patch-deck.ts b/templates/slides/actions/patch-deck.ts index f34811f607..201dffe912 100644 --- a/templates/slides/actions/patch-deck.ts +++ b/templates/slides/actions/patch-deck.ts @@ -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 // --------------------------------------------------------------------------- @@ -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(); @@ -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) ) { diff --git a/templates/slides/app/components/deck/SlideRenderer.test.tsx b/templates/slides/app/components/deck/SlideRenderer.test.tsx index b974341003..1b8ef1f60b 100644 --- a/templates/slides/app/components/deck/SlideRenderer.test.tsx +++ b/templates/slides/app/components/deck/SlideRenderer.test.tsx @@ -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: + '

Flow title

', + }; + render(); + + // 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(); + }); + }); }); diff --git a/templates/slides/app/components/deck/SlideRenderer.tsx b/templates/slides/app/components/deck/SlideRenderer.tsx index 5cd979d9eb..2d0083a86b 100644 --- a/templates/slides/app/components/deck/SlideRenderer.tsx +++ b/templates/slides/app/components/deck/SlideRenderer.tsx @@ -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"); @@ -482,6 +505,10 @@ function useSlideAutofit( const scheduleMeasure = () => { if (disposed) return; + if (!visible) { + measurePending = true; + return; + } cancelAnimationFrame(raf); raf = requestAnimationFrame(measureNow); }; @@ -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]); diff --git a/templates/slides/app/components/editor/BlockBubbleMenu.test.ts b/templates/slides/app/components/editor/BlockBubbleMenu.test.ts new file mode 100644 index 0000000000..1e6072d7be --- /dev/null +++ b/templates/slides/app/components/editor/BlockBubbleMenu.test.ts @@ -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"); + }); +}); diff --git a/templates/slides/app/components/editor/BlockBubbleMenu.tsx b/templates/slides/app/components/editor/BlockBubbleMenu.tsx index f68a77d989..6530cbee5c 100644 --- a/templates/slides/app/components/editor/BlockBubbleMenu.tsx +++ b/templates/slides/app/components/editor/BlockBubbleMenu.tsx @@ -1,3 +1,4 @@ +import { sendToAgentChatAndConfirm } from "@agent-native/core/client/agent-chat"; import { useT } from "@agent-native/core/client/i18n"; import { IconBold, @@ -6,11 +7,15 @@ import { IconStrikethrough, IconLink, IconPalette, + IconPencilStar, IconCheck, IconX, + IconArrowUp, + IconLoader2, } from "@tabler/icons-react"; import { useEffect, useState, useRef } from "react"; import { createPortal } from "react-dom"; +import { toast } from "sonner"; import { Tooltip, @@ -22,6 +27,17 @@ import { shortcutLabel } from "@/lib/utils"; interface BlockBubbleMenuProps { /** The element currently in contentEditable mode. Menu only shows while selection is inside it. */ editingEl: HTMLElement | null; + /** Slide the edited block belongs to, so an AI revision can target it. */ + slideId?: string; + /** Deck that owns the slide — pins the revision to the right deck. */ + deckId?: string; + /** + * Ends the inline edit session and persists whatever is in the DOM now. + * Required before handing work to the agent: otherwise the still-open + * contentEditable serializes its stale text on the user's next click and + * overwrites the revision the agent just wrote. + */ + onCommitInlineEdit?: () => void; } interface Position { @@ -44,32 +60,89 @@ const COLORS = [ "#EF4444", ]; +/** Shown above the input so the user can confirm what the agent will rewrite. */ +const AI_TARGET_PREVIEW_LIMIT = 160; + +const AI_SEND_BUTTON_CLASS = + // guard:allow-raw-color — same accent as the link Apply button below. + "rounded p-1.5 text-[#609FF8] hover:bg-accent disabled:pointer-events-none disabled:opacity-40"; + +export function buildReviseSelectionPrompt({ + selectedText, + instruction, + slideId, + deckId, +}: { + selectedText: string; + instruction: string; + slideId?: string; + deckId?: string; +}): string { + // The deck is named explicitly rather than left to "the current slide": the + // request is queued, and if the user opens another deck before the agent + // runs, an implicit target would pair this slide id with the wrong deck. + const target = [ + deckId ? `Deck id: \`${deckId}\`` : null, + slideId ? `Slide id: \`${slideId}\`` : null, + ].filter((line) => line !== null); + + return [ + `Revise this exact text:`, + ``, + `"""`, + selectedText, + `"""`, + ``, + `How to revise it: ${instruction}`, + ...(target.length > 0 ? [``, ...target] : []), + ``, + `Read that slide first with \`view-screen\`, then make one bounded \`update-slide --fullContent\` edit that replaces only the quoted text. Leave the surrounding HTML, inline styles, and layout untouched, and keep the replacement close to the original length so the slide still fits its canvas.`, + ] + .filter((line) => line !== null) + .join("\n"); +} + /** * Floating formatting toolbar for contentEditable text blocks. Shows on * non-empty selection inside the editing element and applies inline * formatting (bold, italic, underline, strike, link, color) directly to * the DOM. Designed to work with the in-place per-block editing in * SlideEditor — it never mutates anything outside the editing element. + * + * The "Revise with AI" action is the exception: it does not touch the DOM. + * It hands the selected text plus the user's instruction to the agent, which + * rewrites the slide through `update-slide`. */ -export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) { +export function BlockBubbleMenu({ + editingEl, + slideId, + deckId, + onCommitInlineEdit, +}: BlockBubbleMenuProps) { const t = useT(); const [pos, setPos] = useState(null); const [showColors, setShowColors] = useState(false); const [showLinkInput, setShowLinkInput] = useState(false); const [linkValue, setLinkValue] = useState(""); + const [showAiInput, setShowAiInput] = useState(false); + const [aiInstruction, setAiInstruction] = useState(""); + const [aiTargetText, setAiTargetText] = useState(""); + const [aiSending, setAiSending] = useState(false); const savedRangeRef = useRef(null); // True while a popup/input has the user's focus — keeps the menu pinned // even when the contentEditable selection collapses behind the scenes. const interactingRef = useRef(false); useEffect(() => { - interactingRef.current = showColors || showLinkInput; - }, [showColors, showLinkInput]); + interactingRef.current = showColors || showLinkInput || showAiInput; + }, [showColors, showLinkInput, showAiInput]); // Hide menu when the editing element changes useEffect(() => { setPos(null); setShowColors(false); setShowLinkInput(false); + setShowAiInput(false); + setAiInstruction(""); }, [editingEl]); // Track selection and position the menu @@ -158,6 +231,61 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) { setLinkValue(""); }; + const openAiInput = () => { + if (showAiInput) { + setShowAiInput(false); + return; + } + // Snapshot the text now: opening the input moves focus out of the + // contentEditable and the live selection collapses. + const selected = savedRangeRef.current?.toString().trim() ?? ""; + if (!selected) return; + setAiTargetText(selected); + setAiInstruction(""); + interactingRef.current = true; + setShowAiInput(true); + setShowColors(false); + setShowLinkInput(false); + }; + + const submitAiRevision = async () => { + const instruction = aiInstruction.trim(); + if (!instruction || !aiTargetText || aiSending) return; + + // Close the inline edit first. The block is still a live contentEditable + // session; leaving it open means the next click away serializes the old + // text over whatever the agent writes. + onCommitInlineEdit?.(); + + setAiSending(true); + try { + const delivery = await sendToAgentChatAndConfirm({ + message: buildReviseSelectionPrompt({ + selectedText: aiTargetText, + instruction, + slideId, + deckId, + }), + submit: true, + chatTarget: "local", + }); + + if (!delivery.delivered) { + // Keep the typed instruction so the user can retry without retyping. + toast.error(t("raw.sendToAgent"), { + description: delivery.reason ?? "The agent did not receive this.", + }); + return; + } + + toast.success(t("raw.sentToAgent"), { description: instruction }); + setShowAiInput(false); + setAiInstruction(""); + } finally { + setAiSending(false); + } + }; + return createPortal(
+ +
!v); setShowLinkInput(false); + setShowAiInput(false); }} active={showColors} /> @@ -229,9 +365,64 @@ export function BlockBubbleMenu({ editingEl }: BlockBubbleMenuProps) { if (!showLinkInput) interactingRef.current = true; setShowLinkInput((v) => !v); setShowColors(false); + setShowAiInput(false); }} active={showLinkInput} /> + {showAiInput && ( +
e.stopPropagation()} + className="absolute top-full left-1/2 -translate-x-1/2 mt-1 w-80 p-2 rounded-lg bg-popover border border-border shadow-2xl shadow-black/60" + > +

+ {aiTargetText.length > AI_TARGET_PREVIEW_LIMIT + ? `${aiTargetText.slice(0, AI_TARGET_PREVIEW_LIMIT)}…` + : aiTargetText} +

+
+