From d74e17a3fd1213049c42c0f12abef32d6e5dd176 Mon Sep 17 00:00:00 2001 From: Sajal Chaplot Date: Wed, 12 Aug 2026 19:09:05 +0530 Subject: [PATCH 01/11] fix: move Slides new-slide action to the slide rail, insert-below prompt, and Cmd+C/V duplicate Moves the "New Slide" trigger from the top toolbar into the left slide rail as an outline button. Clicking it now inserts a blank slide directly below the active one and opens the describe-slide prompt beside that new thumbnail, with an explicit close button; typing a description tells the agent to fill in the existing placeholder slide instead of adding a duplicate. Adds a Cmd/Ctrl+C then Cmd/Ctrl+V shortcut that duplicates the selected slide directly below itself when no slide element is selected. --- .../app/components/editor/AddSlidePopover.tsx | 95 +++++++++++----- .../components/editor/EditorActionCluster.tsx | 102 +++--------------- .../app/components/editor/EditorSidebar.tsx | 83 +++++++++++++- .../components/editor/EditorToolbar.test.tsx | 1 - .../app/components/editor/EditorToolbar.tsx | 32 +----- templates/slides/app/context/DeckContext.tsx | 5 +- templates/slides/app/i18n/ar-SA.ts | 3 + templates/slides/app/i18n/de-DE.ts | 3 + templates/slides/app/i18n/en-US.ts | 3 + templates/slides/app/i18n/es-ES.ts | 3 + templates/slides/app/i18n/fr-FR.ts | 3 + templates/slides/app/i18n/hi-IN.ts | 3 + templates/slides/app/i18n/ja-JP.ts | 3 + templates/slides/app/i18n/ko-KR.ts | 3 + templates/slides/app/i18n/pt-BR.ts | 3 + templates/slides/app/i18n/zh-CN.ts | 3 + templates/slides/app/i18n/zh-TW.ts | 3 + templates/slides/app/pages/DeckEditor.tsx | 77 +++++++++---- 18 files changed, 256 insertions(+), 172 deletions(-) diff --git a/templates/slides/app/components/editor/AddSlidePopover.tsx b/templates/slides/app/components/editor/AddSlidePopover.tsx index cd84b72142..d87263c355 100644 --- a/templates/slides/app/components/editor/AddSlidePopover.tsx +++ b/templates/slides/app/components/editor/AddSlidePopover.tsx @@ -1,7 +1,7 @@ import { appBasePath } from "@agent-native/core/client/api-path"; import { PromptComposer } from "@agent-native/core/client/composer"; import { useT } from "@agent-native/core/client/i18n"; -import { IconCopy, IconSquarePlus } from "@tabler/icons-react"; +import { IconCopy, IconSquarePlus, IconX } from "@tabler/icons-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; @@ -66,6 +66,8 @@ export function AddSlidePopover({ agentSubmit, onDuplicateCurrent, onAddEmpty, + placement = "below", + targetSlideId, }: { open: boolean; onOpenChange: (open: boolean) => void; @@ -78,6 +80,12 @@ export function AddSlidePopover({ agentSubmit: (message: string, context: string) => void; onDuplicateCurrent?: () => void; onAddEmpty?: () => void; + /** "below" anchors under the trigger button; "right" sits beside a slide thumbnail. */ + placement?: "below" | "right"; + /** Id of a blank slide already inserted — the agent fills it in instead of + * inserting another one. Used when this popover follows a "New slide" + * click that already created the placeholder. */ + targetSlideId?: string; }) { const t = useT(); const panelRef = useRef(null); @@ -141,25 +149,39 @@ export function AddSlidePopover({ const googleDocSourceForContext = truncateSourceForContext(googleDocContext); const fileContext = describeUploadedFilesForAgent(uploaded, deckId); - const context = [ - `Add a new slide to deck "${deckTitle}" (id: ${deckId}).`, - `Insert after slide ${activeSlideIndex + 1} of ${slideCount} (active slide id: ${activeSlideId}).`, - "The visible user message above contains the user's request and/or pasted source material for the new slide(s). Treat pasted memo content as source material even if the user did not explicitly say they are pasting it.", - googleDocSourceForContext.text, - googleDocSourceForContext.truncated - ? `The pasted source was longer than ${MAX_SOURCE_CONTEXT_CHARS} characters, so only the first ${MAX_SOURCE_CONTEXT_CHARS} characters were included to keep the agent request reliable.` - : "", - fileContext, - "", - "Create the slide content and insert it at the correct position using `add-slide` with --deckId=" + - deckId + - ".", - "Every slide is rendered into a fixed native canvas (default 16:9 is 960x540 CSS pixels). Keep each slide within the density limits in AGENTS.md; split dense source material across more slides instead of packing it tightly.", - "If the user asked for multiple slides, call `add-slide` once per slide. Use positions starting at " + - (activeSlideIndex + 1) + - " so the new slides land after the active slide in order.", - "For larger requests, keep adding slides sequentially: wait for each add-slide result, then call add-slide for the next slide. Start slide 1 immediately; do not wait to design the entire sequence before adding it.", - ].join("\n"); + const context = targetSlideId + ? [ + `Fill in slide ${activeSlideIndex + 1} of ${slideCount} (id: ${targetSlideId}) in deck "${deckTitle}" (id: ${deckId}).`, + "This slide already exists as a blank placeholder that the user just inserted — update it with `update-slide`, do not call `add-slide` for it.", + "The visible user message above contains the user's request and/or pasted source material for this slide. Treat pasted memo content as source material even if the user did not explicitly say they are pasting it.", + googleDocSourceForContext.text, + googleDocSourceForContext.truncated + ? `The pasted source was longer than ${MAX_SOURCE_CONTEXT_CHARS} characters, so only the first ${MAX_SOURCE_CONTEXT_CHARS} characters were included to keep the agent request reliable.` + : "", + fileContext, + "", + "Every slide is rendered into a fixed native canvas (default 16:9 is 960x540 CSS pixels). Keep the slide within the density limits in AGENTS.md; split dense source material across more slides instead of packing it tightly.", + "If the user asked for more than one slide's worth of content, update this slide with the first one, then call `add-slide` for the rest, positioned starting right after this slide.", + ].join("\n") + : [ + `Add a new slide to deck "${deckTitle}" (id: ${deckId}).`, + `Insert after slide ${activeSlideIndex + 1} of ${slideCount} (active slide id: ${activeSlideId}).`, + "The visible user message above contains the user's request and/or pasted source material for the new slide(s). Treat pasted memo content as source material even if the user did not explicitly say they are pasting it.", + googleDocSourceForContext.text, + googleDocSourceForContext.truncated + ? `The pasted source was longer than ${MAX_SOURCE_CONTEXT_CHARS} characters, so only the first ${MAX_SOURCE_CONTEXT_CHARS} characters were included to keep the agent request reliable.` + : "", + fileContext, + "", + "Create the slide content and insert it at the correct position using `add-slide` with --deckId=" + + deckId + + ".", + "Every slide is rendered into a fixed native canvas (default 16:9 is 960x540 CSS pixels). Keep each slide within the density limits in AGENTS.md; split dense source material across more slides instead of packing it tightly.", + "If the user asked for multiple slides, call `add-slide` once per slide. Use positions starting at " + + (activeSlideIndex + 1) + + " so the new slides land after the active slide in order.", + "For larger requests, keep adding slides sequentially: wait for each add-slide result, then call add-slide for the next slide. Start slide 1 immediately; do not wait to design the entire sequence before adding it.", + ].join("\n"); agentSubmit(addSlideAgentMessage(trimmedText), context); onOpenChange(false); @@ -173,6 +195,7 @@ export function AddSlidePopover({ googleDocContext, onOpenChange, slideCount, + targetSlideId, ], ); @@ -187,23 +210,39 @@ export function AddSlidePopover({ const rect = anchorRef.current.getBoundingClientRect(); const panelWidth = Math.min(420, window.innerWidth - 24); - const left = Math.max( - 12, - Math.min(rect.left, window.innerWidth - panelWidth - 12), - ); + const left = + placement === "right" + ? Math.min(rect.right + 8, window.innerWidth - panelWidth - 12) + : Math.max(12, Math.min(rect.left, window.innerWidth - panelWidth - 12)); + const top = + placement === "right" + ? Math.max(12, Math.min(rect.top, window.innerHeight - 12)) + : rect.bottom + 8; return createPortal(
-

- {t("editorSidebar.addSlides")} -

+
+

+ {targetSlideId + ? t("editorSidebar.describeThisSlide") + : t("editorSidebar.addSlides")} +

+ +
{(onAddEmpty || (onDuplicateCurrent && slideCount > 0)) && ( <> {onAddEmpty && ( diff --git a/templates/slides/app/components/editor/EditorActionCluster.tsx b/templates/slides/app/components/editor/EditorActionCluster.tsx index cbc468fecb..28bd0b0cef 100644 --- a/templates/slides/app/components/editor/EditorActionCluster.tsx +++ b/templates/slides/app/components/editor/EditorActionCluster.tsx @@ -1,130 +1,56 @@ import { useT } from "@agent-native/core/client/i18n"; -import { IconLoader2, IconPlus, IconTextSize } from "@tabler/icons-react"; -import { useEffect, useRef, useState } from "react"; +import { IconTextSize } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { useAgentGenerating } from "@/hooks/use-agent-generating"; import { cn } from "@/lib/utils"; -import { AddSlidePopover } from "./AddSlidePopover"; - const BUTTON_CLASS = "inline-flex size-7 flex-shrink-0 items-center justify-center rounded-md transition-colors"; const IDLE_CLASS = "text-muted-foreground hover:bg-accent hover:text-foreground/70"; const ACTIVE_CLASS = "bg-accent text-foreground"; -const DIVIDER_CLASS = "mx-1 h-4 w-px shrink-0 bg-border"; /** - * Add slide, undo, redo, and add-text-box — the actions that stay put - * regardless of what is selected. Rendered at the head of the contextual - * toolbar, and as a fallback in the deck toolbar where that row is hidden. + * Add-text-box — stays put regardless of what is selected. Rendered at the + * head of the contextual toolbar, and as a fallback in the deck toolbar + * where that row is hidden. Adding a slide now lives in the slide rail + * (EditorSidebar), not here. */ export function EditorActionCluster({ - deckId, - deckTitle, - currentSlideId, - slideCount, - currentSlideIndex, - addSlideGenerating = false, - onAddSlideGeneratingChange, - onAddEmptySlide, - onDuplicateCurrentSlide, textBoxMode, onToggleTextBoxMode, className, }: { - deckId: string; - deckTitle: string; - currentSlideId?: string; - slideCount: number; - currentSlideIndex: number; - addSlideGenerating?: boolean; - onAddSlideGeneratingChange?: (generating: boolean) => void; - onAddEmptySlide?: () => void; - onDuplicateCurrentSlide?: () => void; textBoxMode?: boolean; onToggleTextBoxMode?: () => void; className?: string; }) { const t = useT(); - const { generating, submit: agentSubmit } = useAgentGenerating(); - const [addSlideOpen, setAddSlideOpen] = useState(false); - const addSlideRef = useRef(null); - useEffect(() => { - if (!generating) onAddSlideGeneratingChange?.(false); - }, [generating, onAddSlideGeneratingChange]); + if (!onToggleTextBoxMode) return null; return (
- {t("editorSidebar.addSlides")} + {t("editorToolbar.addTextBox")} (T) - { - onAddSlideGeneratingChange?.(true); - agentSubmit(message, context); - }} - onDuplicateCurrent={onDuplicateCurrentSlide} - onAddEmpty={onAddEmptySlide} - /> - - {onToggleTextBoxMode && ( - <> -
- - - - - {t("editorToolbar.addTextBox")} (T) - - - )}
); } diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 08ea6410fa..b6b1374361 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -17,19 +17,23 @@ import { import { CSS } from "@dnd-kit/utilities"; import { appStateKeyForBrowserTab } from "@shared/app-state-tabs"; import { hashSlideContent, type DeckFitState } from "@shared/slide-fit"; -import { useRef, useEffect } from "react"; +import { IconPlus } from "@tabler/icons-react"; +import { useRef, useEffect, useState } from "react"; import { useCallback } from "react"; import SlideRenderer from "@/components/deck/SlideRenderer"; import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer"; +import { AddSlidePopover } from "@/components/editor/AddSlidePopover"; import { AiEditingMarker } from "@/components/editor/AiEditingMarker"; import GeneratingSlidePreview from "@/components/editor/GeneratingSlidePreview"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import type { Slide } from "@/context/DeckContext"; +import { useAgentGenerating } from "@/hooks/use-agent-generating"; import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios"; import { TAB_ID } from "@/lib/tab-id"; @@ -39,6 +43,7 @@ interface EditorSidebarProps { slides: Slide[]; activeSlideId: string; deckId: string; + deckTitle: string; onSelectSlide: (id: string) => void; /** Viewer-role decks get thumbnails only: no add, duplicate, or delete. */ readOnly?: boolean; @@ -54,6 +59,11 @@ interface EditorSidebarProps { generatingSlide?: { index: number }; generatingSlideSelected?: boolean; onSelectGeneratingSlide?: () => void; + /** Inserts a blank slide directly below the active slide and returns its id. */ + onAddEmptySlide?: () => string | undefined; + /** True while an agent add-slide request is in flight. */ + addSlideGenerating?: boolean; + onAddSlideGeneratingChange?: (generating: boolean) => void; } const DECK_FIT_STATE_KEYS = [ @@ -307,6 +317,7 @@ export default function EditorSidebar({ slides, activeSlideId, deckId, + deckTitle, onSelectSlide, readOnly = false, slidePresence, @@ -316,7 +327,15 @@ export default function EditorSidebar({ generatingSlide, generatingSlideSelected = false, onSelectGeneratingSlide, + onAddEmptySlide, + addSlideGenerating = false, + onAddSlideGeneratingChange, }: EditorSidebarProps) { + const t = useT(); + const { submit: agentSubmit } = useAgentGenerating(); + const [describeSlideId, setDescribeSlideId] = useState(null); + const [describeAnchorEl, setDescribeAnchorEl] = + useState(null); const slideButtonRefs = useRef(new Map()); const measurementsRef = useRef( new Map< @@ -391,6 +410,11 @@ export default function EditorSidebar({ measurementsRef.current.clear(); }, [deckId, aspectRatio]); + useEffect(() => { + setDescribeSlideId(null); + setDescribeAnchorEl(null); + }, [deckId]); + useEffect(() => { return () => { if (writeTimerRef.current) clearTimeout(writeTimerRef.current); @@ -411,10 +435,27 @@ export default function EditorSidebar({ } else { slideButtonRefs.current.delete(slideId); } + // The new-slide prompt anchors to the just-created slide's thumbnail, + // which doesn't exist yet at click time — pick it up as soon as it mounts. + if (node && slideId === describeSlideId) { + setDescribeAnchorEl(node); + } }, - [], + [describeSlideId], ); + const handleNewSlideClick = useCallback(() => { + const newId = onAddEmptySlide?.(); + if (newId) { + setDescribeAnchorEl(null); + setDescribeSlideId(newId); + } + }, [onAddEmptySlide]); + + const describeSlideIndex = describeSlideId + ? slides.findIndex((s) => s.id === describeSlideId) + : -1; + // Arrow key navigation for slides useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { @@ -454,6 +495,21 @@ export default function EditorSidebar({ return (
+ {!readOnly && onAddEmptySlide && ( +
+ +
+ )}
s.id)} @@ -488,6 +544,29 @@ export default function EditorSidebar({ /> )}
+ {describeSlideId && describeSlideIndex !== -1 && describeAnchorEl && ( + { + if (!open) { + setDescribeSlideId(null); + setDescribeAnchorEl(null); + } + }} + anchorRef={{ current: describeAnchorEl }} + placement="right" + deckId={deckId} + deckTitle={deckTitle} + activeSlideId={describeSlideId} + activeSlideIndex={describeSlideIndex} + slideCount={slides.length} + targetSlideId={describeSlideId} + agentSubmit={(message, context) => { + onAddSlideGeneratingChange?.(true); + agentSubmit(message, context); + }} + /> + )}
); } diff --git a/templates/slides/app/components/editor/EditorToolbar.test.tsx b/templates/slides/app/components/editor/EditorToolbar.test.tsx index ada1029b04..44f9ffba6c 100644 --- a/templates/slides/app/components/editor/EditorToolbar.test.tsx +++ b/templates/slides/app/components/editor/EditorToolbar.test.tsx @@ -100,7 +100,6 @@ describe("", () => { deckId="deck-1" deckTitle="Test deck" onTitleChange={vi.fn()} - slideCount={0} currentSlideIndex={0} sidebarOpen={true} onToggleSidebar={vi.fn()} diff --git a/templates/slides/app/components/editor/EditorToolbar.tsx b/templates/slides/app/components/editor/EditorToolbar.tsx index d0764f292f..a3f514c912 100644 --- a/templates/slides/app/components/editor/EditorToolbar.tsx +++ b/templates/slides/app/components/editor/EditorToolbar.tsx @@ -75,7 +75,6 @@ interface EditorToolbarProps { * Defaults to true for backward compatibility. */ canEdit?: boolean; onTitleChange: (title: string) => void; - slideCount: number; currentSlideIndex: number; sidebarOpen: boolean; onToggleSidebar: () => void; @@ -128,16 +127,6 @@ interface EditorToolbarProps { onExportPptx?: () => Promise | void; /** Create the deck in the user's Google Drive as native Google Slides */ onExportGoogleSlides?: () => Promise; - /** Insert a blank slide after the current one */ - onAddEmptySlide?: () => void; - /** Duplicate the current slide */ - onDuplicateCurrentSlide?: () => void; - /** Id of the current slide, so an agent add-slide lands in the right place */ - currentSlideId?: string; - /** True while an agent add-slide request is in flight */ - addSlideGenerating?: boolean; - /** Called when an agent add-slide request is submitted */ - onAddSlideGeneratingChange?: (generating: boolean) => void; } const TOOLBAR_ICON_BUTTON_CLASS = @@ -148,7 +137,6 @@ export default function EditorToolbar({ deckId, deckTitle, onTitleChange, - slideCount, currentSlideIndex, sidebarOpen, onToggleSidebar, @@ -179,11 +167,6 @@ export default function EditorToolbar({ onExportPdf, onExportPptx, onExportGoogleSlides, - onAddEmptySlide, - onDuplicateCurrentSlide, - currentSlideId, - addSlideGenerating = false, - onAddSlideGeneratingChange, canEdit = true, }: EditorToolbarProps) { const t = useT(); @@ -557,21 +540,12 @@ export default function EditorToolbar({ {t("editorToolbar.toggleSlideList")} - {/* Add slide and the text-box tool live at the head of the contextual - * toolbar below. That row is desktop-only and needs a slide to mount on, - * so keep a fallback here for narrow screens and empty decks. */} + {/* The text-box tool lives at the head of the contextual toolbar below. + * That row is desktop-only, so keep a fallback here for narrow screens + * and empty decks. */} {canEdit && ( diff --git a/templates/slides/app/context/DeckContext.tsx b/templates/slides/app/context/DeckContext.tsx index 9452bdadc0..fab87f96cb 100644 --- a/templates/slides/app/context/DeckContext.tsx +++ b/templates/slides/app/context/DeckContext.tsx @@ -223,7 +223,7 @@ interface DeckContextType { options?: UpdateSlideOptions, ) => void; deleteSlide: (deckId: string, slideId: string) => void; - duplicateSlide: (deckId: string, slideId: string) => void; + duplicateSlide: (deckId: string, slideId: string) => string | undefined; reorderSlides: (deckId: string, oldIndex: number, newIndex: number) => void; setDeckSlides: (deckId: string, slides: Slide[]) => void; /** @@ -2203,7 +2203,7 @@ export function DeckProvider({ children }: { children: ReactNode }) { (deckId: string, slideId: string) => { const before = decksRef.current.find((d) => d.id === deckId); const original = before?.slides.find((slide) => slide.id === slideId); - if (!before || !original) return; + if (!before || !original) return undefined; markDeckDirty(deckId); const copiedSlide: Slide = { ...original, id: nanoid(8) }; @@ -2234,6 +2234,7 @@ export function DeckProvider({ children }: { children: ReactNode }) { }; enqueueDeckOp(deckId, op); recordUndo(before, op, { label: "Duplicate slide" }); + return copiedSlide.id; }, [markDeckDirty, recordUndo, setDecksLocal], ); diff --git a/templates/slides/app/i18n/ar-SA.ts b/templates/slides/app/i18n/ar-SA.ts index 949ec44b14..034cafe48a 100644 --- a/templates/slides/app/i18n/ar-SA.ts +++ b/templates/slides/app/i18n/ar-SA.ts @@ -531,6 +531,9 @@ const messages = { noAi: "sin AI", duplicateCurrentSlide: "Duplicar diapositiva actual", promptPlaceholder: "Describe las diapositivas que quieres...", + newSlide: "شريحة جديدة", + closeAddSlides: "إغلاق", + describeThisSlide: "صف هذه الشريحة", }, presentation: { loadFailed: "تعذّر تحميل هذا العرض التقديمي.", diff --git a/templates/slides/app/i18n/de-DE.ts b/templates/slides/app/i18n/de-DE.ts index 1f59b1fae4..fae3fcb6d5 100644 --- a/templates/slides/app/i18n/de-DE.ts +++ b/templates/slides/app/i18n/de-DE.ts @@ -526,6 +526,9 @@ const messages = { noAi: "sin AI", duplicateCurrentSlide: "Duplicar diapositiva actual", promptPlaceholder: "Describe las diapositivas que quieres...", + newSlide: "Neue Folie", + closeAddSlides: "Schließen", + describeThisSlide: "Beschreibe diese Folie", }, presentation: { loadFailed: "Diese Präsentation konnte nicht geladen werden.", diff --git a/templates/slides/app/i18n/en-US.ts b/templates/slides/app/i18n/en-US.ts index beed5b4774..c88567c687 100644 --- a/templates/slides/app/i18n/en-US.ts +++ b/templates/slides/app/i18n/en-US.ts @@ -519,6 +519,9 @@ const messages = { noAi: "no AI", duplicateCurrentSlide: "Duplicate current slide", promptPlaceholder: "Describe the slides you want...", + newSlide: "New slide", + closeAddSlides: "Close", + describeThisSlide: "Describe this slide", }, presentation: { loadFailed: "Could not load this presentation.", diff --git a/templates/slides/app/i18n/es-ES.ts b/templates/slides/app/i18n/es-ES.ts index 339060e8e6..760f087a8b 100644 --- a/templates/slides/app/i18n/es-ES.ts +++ b/templates/slides/app/i18n/es-ES.ts @@ -530,6 +530,9 @@ const messages = { noAi: "sin AI", duplicateCurrentSlide: "Duplicar diapositiva actual", promptPlaceholder: "Describe las diapositivas que quieres...", + newSlide: "Nueva diapositiva", + closeAddSlides: "Cerrar", + describeThisSlide: "Describe esta diapositiva", }, presentation: { loadFailed: "No se pudo cargar esta presentación.", diff --git a/templates/slides/app/i18n/fr-FR.ts b/templates/slides/app/i18n/fr-FR.ts index a385018c1f..48b744bcab 100644 --- a/templates/slides/app/i18n/fr-FR.ts +++ b/templates/slides/app/i18n/fr-FR.ts @@ -534,6 +534,9 @@ const messages = { noAi: "sin AI", duplicateCurrentSlide: "Duplicar diapositiva actual", promptPlaceholder: "Describe las diapositivas que quieres...", + newSlide: "Nouvelle diapositive", + closeAddSlides: "Fermer", + describeThisSlide: "Décrivez cette diapositive", }, presentation: { loadFailed: "Impossible de charger cette présentation.", diff --git a/templates/slides/app/i18n/hi-IN.ts b/templates/slides/app/i18n/hi-IN.ts index 81dd0a8faf..04a4043d6c 100644 --- a/templates/slides/app/i18n/hi-IN.ts +++ b/templates/slides/app/i18n/hi-IN.ts @@ -514,6 +514,9 @@ const messages = { noAi: "AI नहीं", duplicateCurrentSlide: "वर्तमान स्लाइड डुप्लिकेट करें", promptPlaceholder: "बताएं कि आपको कैसी स्लाइड चाहिए...", + newSlide: "नई स्लाइड", + closeAddSlides: "बंद करें", + describeThisSlide: "इस स्लाइड का वर्णन करें", }, presentation: { loadFailed: "यह प्रस्तुति लोड नहीं हो सकी।", diff --git a/templates/slides/app/i18n/ja-JP.ts b/templates/slides/app/i18n/ja-JP.ts index 6506d9e8e0..b776f39c44 100644 --- a/templates/slides/app/i18n/ja-JP.ts +++ b/templates/slides/app/i18n/ja-JP.ts @@ -515,6 +515,9 @@ const messages = { noAi: "无 AI", duplicateCurrentSlide: "复制当前幻灯片", promptPlaceholder: "描述你想要的幻灯片...", + newSlide: "新しいスライド", + closeAddSlides: "閉じる", + describeThisSlide: "このスライドを説明してください", }, presentation: { loadFailed: "このプレゼンテーションを読み込めませんでした。", diff --git a/templates/slides/app/i18n/ko-KR.ts b/templates/slides/app/i18n/ko-KR.ts index c9135e1c2e..fe561b99e1 100644 --- a/templates/slides/app/i18n/ko-KR.ts +++ b/templates/slides/app/i18n/ko-KR.ts @@ -512,6 +512,9 @@ const messages = { noAi: "无 AI", duplicateCurrentSlide: "复制当前幻灯片", promptPlaceholder: "描述你想要的幻灯片...", + newSlide: "새 슬라이드", + closeAddSlides: "닫기", + describeThisSlide: "이 슬라이드를 설명하세요", }, presentation: { loadFailed: "이 프레젠테이션을 불러오지 못했습니다.", diff --git a/templates/slides/app/i18n/pt-BR.ts b/templates/slides/app/i18n/pt-BR.ts index ce4ee3b29b..7683581cd6 100644 --- a/templates/slides/app/i18n/pt-BR.ts +++ b/templates/slides/app/i18n/pt-BR.ts @@ -525,6 +525,9 @@ const messages = { noAi: "sin AI", duplicateCurrentSlide: "Duplicar diapositiva actual", promptPlaceholder: "Describe las diapositivas que quieres...", + newSlide: "Novo slide", + closeAddSlides: "Fechar", + describeThisSlide: "Descreva este slide", }, presentation: { loadFailed: "Não foi possível carregar esta apresentação.", diff --git a/templates/slides/app/i18n/zh-CN.ts b/templates/slides/app/i18n/zh-CN.ts index 43aa85c002..57bc8063d2 100644 --- a/templates/slides/app/i18n/zh-CN.ts +++ b/templates/slides/app/i18n/zh-CN.ts @@ -507,6 +507,9 @@ const messages = { noAi: "无 AI", duplicateCurrentSlide: "复制当前幻灯片", promptPlaceholder: "描述你想要的幻灯片...", + newSlide: "新建幻灯片", + closeAddSlides: "关闭", + describeThisSlide: "描述这张幻灯片", }, presentation: { loadFailed: "无法加载此演示文稿。", diff --git a/templates/slides/app/i18n/zh-TW.ts b/templates/slides/app/i18n/zh-TW.ts index ce9f7683f7..c560041ba3 100644 --- a/templates/slides/app/i18n/zh-TW.ts +++ b/templates/slides/app/i18n/zh-TW.ts @@ -502,6 +502,9 @@ const messages = { noAi: "無 AI", duplicateCurrentSlide: "複製目前幻燈片", promptPlaceholder: "描述你想要的幻燈片...", + newSlide: "新增投影片", + closeAddSlides: "關閉", + describeThisSlide: "描述這張投影片", }, presentation: { loadFailed: "無法載入此簡報。", diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index 1f8131dd61..5a291406ad 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -757,6 +757,54 @@ export default function DeckEditor() { return () => document.removeEventListener("keydown", handleKeyDown); }, [deck, id, activeSlideId, deleteSlideWithUndo, pinMode, drawMode]); + // Command/Ctrl+C then Command/Ctrl+V on the slide rail duplicates the + // selected slide directly below itself. Only claims the shortcut when no + // slide element is selected — SlideEditor owns Cmd+C/V for object copy/paste + // in that case. + const copiedSlideIdRef = useRef(null); + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!deck || !id || !canEdit) return; + if (!(e.metaKey || e.ctrlKey) || e.altKey) return; + const key = e.key.toLowerCase(); + if (key !== "c" && key !== "v") return; + if (pinMode || drawMode) return; + + const isInsideSafeZone = (el: Element | null) => { + if (!el) return false; + if (el instanceof HTMLInputElement) return true; + if (el instanceof HTMLTextAreaElement) return true; + if (el instanceof HTMLElement) { + if (el.isContentEditable) return true; + if (el.closest("[contenteditable='true']")) return true; + if (el.closest("input, textarea, [role='textbox']")) return true; + if (el.closest("[data-pin-popover]")) return true; + if (el.closest(".agent-panel-root")) return true; + } + return false; + }; + if (isInsideSafeZone(e.target as Element | null)) return; + if (isInsideSafeZone(document.activeElement)) return; + if (document.querySelector("[data-pin-popover]")) return; + if (document.querySelector("[data-slide-element-selected='true']")) + return; + + if (key === "c") { + if (!activeSlideId) return; + copiedSlideIdRef.current = activeSlideId; + return; + } + + const copiedId = copiedSlideIdRef.current; + if (!copiedId || !deck.slides.some((s) => s.id === copiedId)) return; + e.preventDefault(); + const newId = duplicateSlide(id, copiedId); + if (newId) setActiveSlideId(newId); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [deck, id, canEdit, activeSlideId, duplicateSlide, pinMode, drawMode]); + // Resolve the active slide from URL/deck state. Imports replace slide IDs, so // keep this valid after deck contents change instead of only on first load. // Track the last URL ?slide param we processed so we can tell "the URL changed @@ -989,9 +1037,9 @@ export default function DeckEditor() { const handleAddEmptySlide = () => { const activeIdx = deck.slides.findIndex((s) => s.id === activeSlideId); - setActiveSlideId( - addSlide(id, "blank", activeIdx >= 0 ? activeIdx : undefined), - ); + const newId = addSlide(id, "blank", activeIdx >= 0 ? activeIdx : undefined); + setActiveSlideId(newId); + return newId; }; return ( @@ -1006,7 +1054,6 @@ export default function DeckEditor() { deckTitle={deck.title} canEdit={canEdit} onTitleChange={(title) => updateDeck(id, { title })} - slideCount={deck.slides.length} currentSlideIndex={currentIndex >= 0 ? currentIndex : 0} sidebarOpen={sidebarOpen} onToggleSidebar={() => setSidebarOpen(!sidebarOpen)} @@ -1092,13 +1139,6 @@ export default function DeckEditor() { } return exportDeckToGoogleSlides(deck.title, slides, deck.aspectRatio); }} - currentSlideId={currentSlide?.id} - addSlideGenerating={addSlideGenerating} - onAddSlideGeneratingChange={setAddSlideGenerating} - onAddEmptySlide={handleAddEmptySlide} - onDuplicateCurrentSlide={ - currentSlide ? () => duplicateSlide(id, currentSlide.id) : undefined - } /> {/* Full-width host for the slide's contextual style toolbar: it spans the @@ -1126,6 +1166,10 @@ export default function DeckEditor() { slides={deck.slides} activeSlideId={currentSlide?.id || ""} deckId={id} + deckTitle={deck.title} + onAddEmptySlide={canEdit ? handleAddEmptySlide : undefined} + addSlideGenerating={addSlideGenerating} + onAddSlideGeneratingChange={setAddSlideGenerating} onSelectSlide={(slideId) => { setGeneratingSlideSelected(false); setActiveSlideId(slideId); @@ -1204,17 +1248,6 @@ export default function DeckEditor() { contextToolbarLeading={ canEdit ? ( = 0 ? currentIndex : 0} - addSlideGenerating={addSlideGenerating} - onAddSlideGeneratingChange={setAddSlideGenerating} - onAddEmptySlide={handleAddEmptySlide} - onDuplicateCurrentSlide={() => - duplicateSlide(id, currentSlide.id) - } textBoxMode={textBoxMode} onToggleTextBoxMode={toggleTextBoxMode} /> From 930161aa671529fe0b0b9e1be51e51b9f60300c1 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 12 Aug 2026 13:51:47 +0000 Subject: [PATCH 02/11] fix(slides): fix oxfmt formatting in EditorActionCluster --- .../slides/app/components/editor/EditorActionCluster.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/templates/slides/app/components/editor/EditorActionCluster.tsx b/templates/slides/app/components/editor/EditorActionCluster.tsx index 28bd0b0cef..14d71bc380 100644 --- a/templates/slides/app/components/editor/EditorActionCluster.tsx +++ b/templates/slides/app/components/editor/EditorActionCluster.tsx @@ -44,7 +44,10 @@ export function EditorActionCluster({ aria-label={t("editorToolbar.addTextBox")} aria-pressed={textBoxMode} aria-keyshortcuts="T" - className={cn(BUTTON_CLASS, textBoxMode ? ACTIVE_CLASS : IDLE_CLASS)} + className={cn( + BUTTON_CLASS, + textBoxMode ? ACTIVE_CLASS : IDLE_CLASS, + )} > From ea189aef54db6a2ff262027c276226887da80a19 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 12 Aug 2026 14:07:49 +0000 Subject: [PATCH 03/11] fix(slides): resolve new-slide review findings - Reset addSlideGenerating when the agent run finishes so the New slide button doesn't stay disabled for the rest of the session - Clamp the right-anchored AddSlidePopover using its measured height so it doesn't render off-screen near the bottom of the slide rail - Persist a new blank slide immediately (skip the 500ms debounce) since it's immediately followed by an agent update-slide request that can otherwise race the add-slide persistence --- .../app/components/editor/AddSlidePopover.tsx | 18 ++++++++++++++++-- .../app/components/editor/EditorSidebar.tsx | 10 +++++++++- templates/slides/app/context/DeckContext.tsx | 10 ++++++++-- templates/slides/app/pages/DeckEditor.tsx | 12 +++++++++++- 4 files changed, 44 insertions(+), 6 deletions(-) diff --git a/templates/slides/app/components/editor/AddSlidePopover.tsx b/templates/slides/app/components/editor/AddSlidePopover.tsx index d87263c355..5f13e83fdd 100644 --- a/templates/slides/app/components/editor/AddSlidePopover.tsx +++ b/templates/slides/app/components/editor/AddSlidePopover.tsx @@ -2,7 +2,13 @@ import { appBasePath } from "@agent-native/core/client/api-path"; import { PromptComposer } from "@agent-native/core/client/composer"; import { useT } from "@agent-native/core/client/i18n"; import { IconCopy, IconSquarePlus, IconX } from "@tabler/icons-react"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; @@ -91,6 +97,14 @@ export function AddSlidePopover({ const panelRef = useRef(null); const [promptText, setPromptText] = useState(""); const [googleDocContext, setGoogleDocContext] = useState(""); + // Estimate before the panel has painted so the first frame doesn't hang + // off the bottom of the viewport; corrected once the real height is known. + const [panelHeight, setPanelHeight] = useState(320); + + useLayoutEffect(() => { + if (!open || !panelRef.current) return; + setPanelHeight(panelRef.current.getBoundingClientRect().height); + }); useEffect(() => { if (!open) return; @@ -216,7 +230,7 @@ export function AddSlidePopover({ : Math.max(12, Math.min(rect.left, window.innerWidth - panelWidth - 12)); const top = placement === "right" - ? Math.max(12, Math.min(rect.top, window.innerHeight - 12)) + ? Math.max(12, Math.min(rect.top, window.innerHeight - panelHeight - 12)) : rect.bottom + 8; return createPortal( diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index b6b1374361..8b743f6361 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -332,7 +332,8 @@ export default function EditorSidebar({ onAddSlideGeneratingChange, }: EditorSidebarProps) { const t = useT(); - const { submit: agentSubmit } = useAgentGenerating(); + const { generating: agentGenerating, submit: agentSubmit } = + useAgentGenerating(); const [describeSlideId, setDescribeSlideId] = useState(null); const [describeAnchorEl, setDescribeAnchorEl] = useState(null); @@ -344,6 +345,13 @@ export default function EditorSidebar({ >(), ); const writeTimerRef = useRef | null>(null); + + useEffect(() => { + if (addSlideGenerating && !agentGenerating) { + onAddSlideGeneratingChange?.(false); + } + }, [addSlideGenerating, agentGenerating, onAddSlideGeneratingChange]); + const aiEditedSlideIds = new Set( (recentEdits ?? []) .filter((edit) => edit.isAgent) diff --git a/templates/slides/app/context/DeckContext.tsx b/templates/slides/app/context/DeckContext.tsx index fab87f96cb..f8356ecdbd 100644 --- a/templates/slides/app/context/DeckContext.tsx +++ b/templates/slides/app/context/DeckContext.tsx @@ -215,6 +215,7 @@ interface DeckContextType { deckId: string, layout?: SlideLayout, afterIndex?: number, + options?: { persistence?: "debounced" | "immediate" }, ) => string; updateSlide: ( deckId: string, @@ -2076,7 +2077,12 @@ export function DeckProvider({ children }: { children: ReactNode }) { ); const addSlide = useCallback( - (deckId: string, layout: SlideLayout = "content", afterIndex?: number) => { + ( + deckId: string, + layout: SlideLayout = "content", + afterIndex?: number, + addOptions?: { persistence?: "debounced" | "immediate" }, + ) => { markDeckDirty(deckId); const newSlide: Slide = { id: nanoid(8), @@ -2114,7 +2120,7 @@ export function DeckProvider({ children }: { children: ReactNode }) { background: newSlide.background, }, }; - enqueueDeckOp(deckId, op); + enqueueDeckOp(deckId, op, addOptions); if (before) recordUndo(before, op, { label: "Add slide" }); return newSlide.id; diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index 5a291406ad..390f113ef7 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -1037,7 +1037,17 @@ export default function DeckEditor() { const handleAddEmptySlide = () => { const activeIdx = deck.slides.findIndex((s) => s.id === activeSlideId); - const newId = addSlide(id, "blank", activeIdx >= 0 ? activeIdx : undefined); + // Immediate persistence: this placeholder is immediately followed by an + // agent request to `update-slide` it, which can reach the server before + // the default 500ms debounce would have flushed the `add-slide` op. + const newId = addSlide( + id, + "blank", + activeIdx >= 0 ? activeIdx : undefined, + { + persistence: "immediate", + }, + ); setActiveSlideId(newId); return newId; }; From c67d6947db78347c1a5a0656f36443f45272661d Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 12 Aug 2026 14:22:01 +0000 Subject: [PATCH 04/11] fix(slides): await immediate placeholder persistence before agent update-slide Immediate persistence via enqueueDeckOp only kicks off drainPendingDeckOps without blocking the caller, so the earlier fix still had a (much narrower) race window between the fire-and-forget add-slide save and the agent's update-slide request. Add flushDeckSave to await the in-flight save chain (including any follow-up drain queued while a save is already running), and have EditorSidebar await it right before submitting the agent request. --- .../app/components/editor/EditorSidebar.tsx | 8 +++++++- templates/slides/app/context/DeckContext.tsx | 19 +++++++++++++++++++ templates/slides/app/pages/DeckEditor.tsx | 2 ++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 8b743f6361..51daede8f8 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -64,6 +64,10 @@ interface EditorSidebarProps { /** True while an agent add-slide request is in flight. */ addSlideGenerating?: boolean; onAddSlideGeneratingChange?: (generating: boolean) => void; + /** Resolves once a just-inserted blank slide has actually reached the + * server, so the agent's update-slide request can't race the add-slide + * persistence. */ + onAwaitAddSlidePersisted?: () => Promise; } const DECK_FIT_STATE_KEYS = [ @@ -330,6 +334,7 @@ export default function EditorSidebar({ onAddEmptySlide, addSlideGenerating = false, onAddSlideGeneratingChange, + onAwaitAddSlidePersisted, }: EditorSidebarProps) { const t = useT(); const { generating: agentGenerating, submit: agentSubmit } = @@ -569,8 +574,9 @@ export default function EditorSidebar({ activeSlideIndex={describeSlideIndex} slideCount={slides.length} targetSlideId={describeSlideId} - agentSubmit={(message, context) => { + agentSubmit={async (message, context) => { onAddSlideGeneratingChange?.(true); + await onAwaitAddSlidePersisted?.(); agentSubmit(message, context); }} /> diff --git a/templates/slides/app/context/DeckContext.tsx b/templates/slides/app/context/DeckContext.tsx index f8356ecdbd..50b09db8ea 100644 --- a/templates/slides/app/context/DeckContext.tsx +++ b/templates/slides/app/context/DeckContext.tsx @@ -217,6 +217,7 @@ interface DeckContextType { afterIndex?: number, options?: { persistence?: "debounced" | "immediate" }, ) => string; + flushDeckSave: (deckId: string) => Promise; updateSlide: ( deckId: string, slideId: string, @@ -469,6 +470,23 @@ function drainPendingDeckOps(deckId: string): Promise { return next; } +/** + * Wait for a deck's in-flight save(s) to fully settle, including any + * follow-up drain chained by immediateFlushRequests for ops queued while a + * save was already running. Used when a caller must not proceed (e.g. firing + * an agent request against a slide) until an "immediate" persistence op has + * actually reached the server. + */ +async function flushDeckSave(deckId: string): Promise { + let previous: Promise | undefined; + for (let i = 0; i < 5; i++) { + const active = inFlightSaveChains.get(deckId); + if (!active || active === previous) return; + previous = active; + await active; + } +} + function enqueueDeckOp( deckId: string, op: GranularOp, @@ -2327,6 +2345,7 @@ export function DeckProvider({ children }: { children: ReactNode }) { refreshOpenDeck: refetchOpenDeckIfChanged, getDeck, addSlide, + flushDeckSave, updateSlide, deleteSlide, duplicateSlide, diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index 390f113ef7..b86ad6af0c 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -158,6 +158,7 @@ export default function DeckEditor() { duplicateSlide, duplicateDeck, addSlide, + flushDeckSave, reorderSlides, markDeckDirty, undo, @@ -1178,6 +1179,7 @@ export default function DeckEditor() { deckId={id} deckTitle={deck.title} onAddEmptySlide={canEdit ? handleAddEmptySlide : undefined} + onAwaitAddSlidePersisted={() => flushDeckSave(id)} addSlideGenerating={addSlideGenerating} onAddSlideGeneratingChange={setAddSlideGenerating} onSelectSlide={(slideId) => { From 189e9ae2900bd88841ed7a26ef1c71813d56a301 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 12 Aug 2026 14:43:42 +0000 Subject: [PATCH 05/11] fix(slides): propagate save failures and fix generating-state race in new-slide flow - flushDeckSave previously resolved even when drainPendingDeckOps swallowed a save failure internally to drive its own retry loop; it now polls through requeued retries and throws once retries are exhausted, so the caller learns the placeholder never persisted - EditorSidebar's addSlideGenerating reset effect fired while onAwaitAddSlidePersisted() was still pending (agentGenerating was still false at that point), re-enabling New slide mid-save; gated the reset on having actually observed a run start via sawAgentGeneratingRef - Surface a toast and reset the generating flag when persistence fails so the button doesn't stay stuck disabled --- .../app/components/editor/EditorSidebar.tsx | 22 +++++++++++-- templates/slides/app/context/DeckContext.tsx | 31 ++++++++++++++----- templates/slides/app/i18n/ar-SA.ts | 1 + templates/slides/app/i18n/de-DE.ts | 2 ++ templates/slides/app/i18n/en-US.ts | 1 + templates/slides/app/i18n/es-ES.ts | 2 ++ templates/slides/app/i18n/fr-FR.ts | 2 ++ templates/slides/app/i18n/hi-IN.ts | 1 + templates/slides/app/i18n/ja-JP.ts | 2 ++ templates/slides/app/i18n/ko-KR.ts | 2 ++ templates/slides/app/i18n/pt-BR.ts | 2 ++ templates/slides/app/i18n/zh-CN.ts | 1 + templates/slides/app/i18n/zh-TW.ts | 1 + 13 files changed, 60 insertions(+), 10 deletions(-) diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 51daede8f8..69610cb5d2 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -20,6 +20,7 @@ import { hashSlideContent, type DeckFitState } from "@shared/slide-fit"; import { IconPlus } from "@tabler/icons-react"; import { useRef, useEffect, useState } from "react"; import { useCallback } from "react"; +import { toast } from "sonner"; import SlideRenderer from "@/components/deck/SlideRenderer"; import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer"; @@ -351,8 +352,18 @@ export default function EditorSidebar({ ); const writeTimerRef = useRef | null>(null); + // Only auto-clear addSlideGenerating once we've actually observed a run + // start and finish. Without sawAgentGeneratingRef, this would fire while + // onAwaitAddSlidePersisted() is still pending below (agentGenerating is + // still false at that point), re-enabling New slide mid-save. + const sawAgentGeneratingRef = useRef(false); useEffect(() => { - if (addSlideGenerating && !agentGenerating) { + if (agentGenerating) { + sawAgentGeneratingRef.current = true; + return; + } + if (addSlideGenerating && sawAgentGeneratingRef.current) { + sawAgentGeneratingRef.current = false; onAddSlideGeneratingChange?.(false); } }, [addSlideGenerating, agentGenerating, onAddSlideGeneratingChange]); @@ -576,7 +587,14 @@ export default function EditorSidebar({ targetSlideId={describeSlideId} agentSubmit={async (message, context) => { onAddSlideGeneratingChange?.(true); - await onAwaitAddSlidePersisted?.(); + try { + await onAwaitAddSlidePersisted?.(); + } catch (error) { + console.error("Failed to persist new slide:", error); + onAddSlideGeneratingChange?.(false); + toast.error(t("editorSidebar.newSlideSaveFailed")); + return; + } agentSubmit(message, context); }} /> diff --git a/templates/slides/app/context/DeckContext.tsx b/templates/slides/app/context/DeckContext.tsx index 50b09db8ea..5d4a585f1d 100644 --- a/templates/slides/app/context/DeckContext.tsx +++ b/templates/slides/app/context/DeckContext.tsx @@ -473,17 +473,32 @@ function drainPendingDeckOps(deckId: string): Promise { /** * Wait for a deck's in-flight save(s) to fully settle, including any * follow-up drain chained by immediateFlushRequests for ops queued while a - * save was already running. Used when a caller must not proceed (e.g. firing - * an agent request against a slide) until an "immediate" persistence op has - * actually reached the server. + * save was already running, and any requeued retry after a failed attempt. + * Used when a caller must not proceed (e.g. firing an agent request against + * a slide) until an "immediate" persistence op has actually reached the + * server. Throws if the save ultimately fails after retries exhaust, since + * `drainPendingDeckOps` swallows save errors internally to drive its own + * retry loop and its promise always resolves regardless of outcome. */ async function flushDeckSave(deckId: string): Promise { - let previous: Promise | undefined; - for (let i = 0; i < 5; i++) { + for (let i = 0; i < 40; i++) { const active = inFlightSaveChains.get(deckId); - if (!active || active === previous) return; - previous = active; - await active; + if (active) { + await active; + continue; + } + if (failedSaveDecks.has(deckId)) { + throw new Error( + `Failed to save deck ${deckId} after ${MAX_DECK_SAVE_RETRIES} attempts`, + ); + } + if (pendingOpsQueue.has(deckId) || pendingSaves.has(deckId)) { + // A failed op was requeued for retry, or a debounced save is armed; + // wait for it to actually run rather than declaring success early. + await new Promise((resolve) => setTimeout(resolve, 50)); + continue; + } + return; } } diff --git a/templates/slides/app/i18n/ar-SA.ts b/templates/slides/app/i18n/ar-SA.ts index 034cafe48a..90d08db1db 100644 --- a/templates/slides/app/i18n/ar-SA.ts +++ b/templates/slides/app/i18n/ar-SA.ts @@ -526,6 +526,7 @@ const messages = { generatingSlide: "Generando diapositiva", uploadFailed: "Error al subir", uploadAttachedFileFailed: "No se pudo subir el archivo adjunto.", + newSlideSaveFailed: "تعذر حفظ الشريحة الجديدة. يرجى المحاولة مرة أخرى.", addSlides: "Añadir diapositivas", addEmptySlide: "Añadir diapositiva vacía", noAi: "sin AI", diff --git a/templates/slides/app/i18n/de-DE.ts b/templates/slides/app/i18n/de-DE.ts index fae3fcb6d5..bf14dfbef7 100644 --- a/templates/slides/app/i18n/de-DE.ts +++ b/templates/slides/app/i18n/de-DE.ts @@ -521,6 +521,8 @@ const messages = { generatingSlide: "Generando diapositiva", uploadFailed: "Error al subir", uploadAttachedFileFailed: "No se pudo subir el archivo adjunto.", + newSlideSaveFailed: + "Die neue Folie konnte nicht gespeichert werden. Bitte versuche es erneut.", addSlides: "Añadir diapositivas", addEmptySlide: "Añadir diapositiva vacía", noAi: "sin AI", diff --git a/templates/slides/app/i18n/en-US.ts b/templates/slides/app/i18n/en-US.ts index c88567c687..f44776e83c 100644 --- a/templates/slides/app/i18n/en-US.ts +++ b/templates/slides/app/i18n/en-US.ts @@ -514,6 +514,7 @@ const messages = { generatingSlide: "Generating slide", uploadFailed: "Upload failed", uploadAttachedFileFailed: "Could not upload the attached file.", + newSlideSaveFailed: "Couldn't save the new slide. Please try again.", addSlides: "Add slides", addEmptySlide: "Add empty slide", noAi: "no AI", diff --git a/templates/slides/app/i18n/es-ES.ts b/templates/slides/app/i18n/es-ES.ts index 760f087a8b..8df0336103 100644 --- a/templates/slides/app/i18n/es-ES.ts +++ b/templates/slides/app/i18n/es-ES.ts @@ -525,6 +525,8 @@ const messages = { generatingSlide: "Generando diapositiva", uploadFailed: "Error al subir", uploadAttachedFileFailed: "No se pudo subir el archivo adjunto.", + newSlideSaveFailed: + "No se pudo guardar la nueva diapositiva. Inténtalo de nuevo.", addSlides: "Añadir diapositivas", addEmptySlide: "Añadir diapositiva vacía", noAi: "sin AI", diff --git a/templates/slides/app/i18n/fr-FR.ts b/templates/slides/app/i18n/fr-FR.ts index 48b744bcab..226c04cbf0 100644 --- a/templates/slides/app/i18n/fr-FR.ts +++ b/templates/slides/app/i18n/fr-FR.ts @@ -529,6 +529,8 @@ const messages = { generatingSlide: "Generando diapositiva", uploadFailed: "Error al subir", uploadAttachedFileFailed: "No se pudo subir el archivo adjunto.", + newSlideSaveFailed: + "Impossible d'enregistrer la nouvelle diapositive. Veuillez réessayer.", addSlides: "Añadir diapositivas", addEmptySlide: "Añadir diapositiva vacía", noAi: "sin AI", diff --git a/templates/slides/app/i18n/hi-IN.ts b/templates/slides/app/i18n/hi-IN.ts index 04a4043d6c..5a33b30f4c 100644 --- a/templates/slides/app/i18n/hi-IN.ts +++ b/templates/slides/app/i18n/hi-IN.ts @@ -509,6 +509,7 @@ const messages = { generatingSlide: "स्लाइड जनरेट हो रही है", uploadFailed: "अपलोड विफल", uploadAttachedFileFailed: "संलग्न फ़ाइल अपलोड नहीं हो सकी।", + newSlideSaveFailed: "नई स्लाइड सहेजी नहीं जा सकी। कृपया पुनः प्रयास करें।", addSlides: "स्लाइड जोड़ें", addEmptySlide: "खाली स्लाइड जोड़ें", noAi: "AI नहीं", diff --git a/templates/slides/app/i18n/ja-JP.ts b/templates/slides/app/i18n/ja-JP.ts index b776f39c44..4d71705cd0 100644 --- a/templates/slides/app/i18n/ja-JP.ts +++ b/templates/slides/app/i18n/ja-JP.ts @@ -510,6 +510,8 @@ const messages = { generatingSlide: "正在生成幻灯片", uploadFailed: "上传失败", uploadAttachedFileFailed: "无法上传附加文件。", + newSlideSaveFailed: + "新しいスライドを保存できませんでした。もう一度お試しください。", addSlides: "添加幻灯片", addEmptySlide: "添加空白幻灯片", noAi: "无 AI", diff --git a/templates/slides/app/i18n/ko-KR.ts b/templates/slides/app/i18n/ko-KR.ts index fe561b99e1..896f90cc1d 100644 --- a/templates/slides/app/i18n/ko-KR.ts +++ b/templates/slides/app/i18n/ko-KR.ts @@ -507,6 +507,8 @@ const messages = { generatingSlide: "正在生成幻灯片", uploadFailed: "上传失败", uploadAttachedFileFailed: "无法上传附加文件。", + newSlideSaveFailed: + "새 슬라이드를 저장하지 못했습니다. 다시 시도해 주세요.", addSlides: "添加幻灯片", addEmptySlide: "添加空白幻灯片", noAi: "无 AI", diff --git a/templates/slides/app/i18n/pt-BR.ts b/templates/slides/app/i18n/pt-BR.ts index 7683581cd6..f4963ef8db 100644 --- a/templates/slides/app/i18n/pt-BR.ts +++ b/templates/slides/app/i18n/pt-BR.ts @@ -520,6 +520,8 @@ const messages = { generatingSlide: "Generando diapositiva", uploadFailed: "Error al subir", uploadAttachedFileFailed: "No se pudo subir el archivo adjunto.", + newSlideSaveFailed: + "Não foi possível salvar o novo slide. Tente novamente.", addSlides: "Añadir diapositivas", addEmptySlide: "Añadir diapositiva vacía", noAi: "sin AI", diff --git a/templates/slides/app/i18n/zh-CN.ts b/templates/slides/app/i18n/zh-CN.ts index 57bc8063d2..4303940c88 100644 --- a/templates/slides/app/i18n/zh-CN.ts +++ b/templates/slides/app/i18n/zh-CN.ts @@ -502,6 +502,7 @@ const messages = { generatingSlide: "正在生成幻灯片", uploadFailed: "上传失败", uploadAttachedFileFailed: "无法上传附加文件。", + newSlideSaveFailed: "无法保存新幻灯片,请重试。", addSlides: "添加幻灯片", addEmptySlide: "添加空白幻灯片", noAi: "无 AI", diff --git a/templates/slides/app/i18n/zh-TW.ts b/templates/slides/app/i18n/zh-TW.ts index c560041ba3..e7dd6cdf89 100644 --- a/templates/slides/app/i18n/zh-TW.ts +++ b/templates/slides/app/i18n/zh-TW.ts @@ -497,6 +497,7 @@ const messages = { generatingSlide: "正在生成幻燈片", uploadFailed: "上傳失敗", uploadAttachedFileFailed: "無法上傳附加檔案。", + newSlideSaveFailed: "無法儲存新投影片,請重試。", addSlides: "新增幻燈片", addEmptySlide: "新增空白幻燈片", noAi: "無 AI", From 3e39c1696c150d0c06d3e23f9741f27ceee3f37c Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 12 Aug 2026 15:09:14 +0000 Subject: [PATCH 06/11] fix(slides): remount-safe generation tracking, failed-save cleanup, dialog-safe paste shortcut - Move the addSlideGenerating completion tracking from EditorSidebar (which unmounts when the rail closes on narrow viewports) up to DeckEditor, which already owns the state and never unmounts, using its existing top-level useAgentGenerating() call - Remove the orphaned blank placeholder slide when flushDeckSave ultimately fails, since AddSlidePopover closes immediately without waiting on the async submit callback and the typed prompt is lost either way - The new Cmd/Ctrl+C/V slide-duplication shortcut didn't check for an open dialog, sheet, menu, or popover before claiming the keystroke; extended its safe-zone checks to cover role=dialog/alertdialog content, Radix popper-positioned content, and AddSlidePopover --- .../app/components/editor/AddSlidePopover.tsx | 1 + .../app/components/editor/EditorSidebar.tsx | 28 +++++++------------ templates/slides/app/pages/DeckEditor.tsx | 28 +++++++++++++++++++ 3 files changed, 39 insertions(+), 18 deletions(-) diff --git a/templates/slides/app/components/editor/AddSlidePopover.tsx b/templates/slides/app/components/editor/AddSlidePopover.tsx index 5f13e83fdd..be85667efc 100644 --- a/templates/slides/app/components/editor/AddSlidePopover.tsx +++ b/templates/slides/app/components/editor/AddSlidePopover.tsx @@ -236,6 +236,7 @@ export function AddSlidePopover({ return createPortal(
Promise; + /** Removes a blank placeholder slide whose persistence ultimately failed, + * so a flaky save doesn't leave a stray empty slide in the deck. */ + onRemoveFailedSlide?: (slideId: string) => void; } const DECK_FIT_STATE_KEYS = [ @@ -336,10 +339,10 @@ export default function EditorSidebar({ addSlideGenerating = false, onAddSlideGeneratingChange, onAwaitAddSlidePersisted, + onRemoveFailedSlide, }: EditorSidebarProps) { const t = useT(); - const { generating: agentGenerating, submit: agentSubmit } = - useAgentGenerating(); + const { submit: agentSubmit } = useAgentGenerating(); const [describeSlideId, setDescribeSlideId] = useState(null); const [describeAnchorEl, setDescribeAnchorEl] = useState(null); @@ -352,22 +355,6 @@ export default function EditorSidebar({ ); const writeTimerRef = useRef | null>(null); - // Only auto-clear addSlideGenerating once we've actually observed a run - // start and finish. Without sawAgentGeneratingRef, this would fire while - // onAwaitAddSlidePersisted() is still pending below (agentGenerating is - // still false at that point), re-enabling New slide mid-save. - const sawAgentGeneratingRef = useRef(false); - useEffect(() => { - if (agentGenerating) { - sawAgentGeneratingRef.current = true; - return; - } - if (addSlideGenerating && sawAgentGeneratingRef.current) { - sawAgentGeneratingRef.current = false; - onAddSlideGeneratingChange?.(false); - } - }, [addSlideGenerating, agentGenerating, onAddSlideGeneratingChange]); - const aiEditedSlideIds = new Set( (recentEdits ?? []) .filter((edit) => edit.isAgent) @@ -592,6 +579,11 @@ export default function EditorSidebar({ } catch (error) { console.error("Failed to persist new slide:", error); onAddSlideGeneratingChange?.(false); + // The popover already closed (AddSlidePopover doesn't wait on + // this async callback), so the typed prompt is gone either + // way — remove the orphaned blank placeholder rather than + // leaving a stray empty slide the user never asked for. + onRemoveFailedSlide?.(describeSlideId); toast.error(t("editorSidebar.newSlideSaveFailed")); return; } diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index b86ad6af0c..d4f52df89b 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -37,6 +37,7 @@ import EditorToolbar from "@/components/editor/EditorToolbar"; import GeneratingSlidePreview from "@/components/editor/GeneratingSlidePreview"; import HistoryPanel from "@/components/editor/HistoryPanel"; import ImageGenPanel from "@/components/editor/ImageGenPanel"; +import { isInsidePortaledLayer } from "@/components/editor/PromptDialog"; import { QuestionFlow } from "@/components/editor/QuestionFlow"; import SlideEditor from "@/components/editor/SlideEditor"; import { TweaksPanel } from "@/components/editor/TweaksPanel"; @@ -169,6 +170,22 @@ export default function DeckEditor() { const [addSlideGenerating, setAddSlideGenerating] = useState(false); const [generatingSlideSelected, setGeneratingSlideSelected] = useState(false); const { generating } = useAgentGenerating(); + // Only auto-clear addSlideGenerating once we've actually observed a run + // start and finish. This lives here (not in EditorSidebar) because the + // sidebar rail unmounts on narrow viewports when the user closes it — a + // ref living there would forget an in-progress run and never clear the + // flag, leaving New slide disabled for the rest of the session. + const sawAddSlideAgentGeneratingRef = useRef(false); + useEffect(() => { + if (generating) { + sawAddSlideAgentGeneratingRef.current = true; + return; + } + if (addSlideGenerating && sawAddSlideAgentGeneratingRef.current) { + sawAddSlideAgentGeneratingRef.current = false; + setAddSlideGenerating(false); + } + }, [addSlideGenerating, generating]); // Generation intent can arrive after this route mounts because the user // answers pre-generation questions from the empty editor. const wasNewDeckCreation = useRef(searchParams.get("generating") === "1"); @@ -780,13 +797,23 @@ export default function DeckEditor() { if (el.closest("[contenteditable='true']")) return true; if (el.closest("input, textarea, [role='textbox']")) return true; if (el.closest("[data-pin-popover]")) return true; + if (el.closest("[data-add-slide-popover]")) return true; if (el.closest(".agent-panel-root")) return true; + if (el.closest("[role='dialog'], [role='alertdialog']")) return true; + if (isInsidePortaledLayer(el)) return true; } return false; }; if (isInsideSafeZone(e.target as Element | null)) return; if (isInsideSafeZone(document.activeElement)) return; if (document.querySelector("[data-pin-popover]")) return; + if (document.querySelector("[data-add-slide-popover]")) return; + // A dialog/sheet/menu/popover owning focus elsewhere in the DOM (not + // just under the event target) still shouldn't let this document-level + // shortcut duplicate the slide underneath it. + if (document.querySelector("[role='dialog'], [role='alertdialog']")) + return; + if (document.querySelector("[data-radix-popper-content-wrapper]")) return; if (document.querySelector("[data-slide-element-selected='true']")) return; @@ -1180,6 +1207,7 @@ export default function DeckEditor() { deckTitle={deck.title} onAddEmptySlide={canEdit ? handleAddEmptySlide : undefined} onAwaitAddSlidePersisted={() => flushDeckSave(id)} + onRemoveFailedSlide={(slideId) => deleteSlide(id, slideId)} addSlideGenerating={addSlideGenerating} onAddSlideGeneratingChange={setAddSlideGenerating} onSelectSlide={(slideId) => { From d78dd740babab40a9e088ec6677081bdf5baffc8 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 12 Aug 2026 15:29:47 +0000 Subject: [PATCH 07/11] fix(slides): scope add-slide generation tracking to its own run The remount-safe reset effect keyed off the general useAgentGenerating() 'generating' value, which reflects ANY agent chat activity in the deck editor. An unrelated concurrent run finishing could be mistaken for the add-slide run completing and clear addSlideGenerating early (or while its own run is still active). Give the add-slide flow its own useAgentGenerating() instance owned by DeckEditor, and have EditorSidebar call the submit function passed down as a prop instead of instantiating its own hook, so the run stays correctly scoped and remount-safe at the same time. --- .../app/components/editor/EditorSidebar.tsx | 9 ++++++--- templates/slides/app/pages/DeckEditor.tsx | 18 +++++++++++------- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 142a8e0d85..2fe3fde205 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -34,7 +34,6 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import type { Slide } from "@/context/DeckContext"; -import { useAgentGenerating } from "@/hooks/use-agent-generating"; import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios"; import { TAB_ID } from "@/lib/tab-id"; @@ -72,6 +71,10 @@ interface EditorSidebarProps { /** Removes a blank placeholder slide whose persistence ultimately failed, * so a flaky save doesn't leave a stray empty slide in the deck. */ onRemoveFailedSlide?: (slideId: string) => void; + /** Submits the add-slide agent request. Owned by the parent (rather than + * this component's own useAgentGenerating() call) so the run stays + * correctly scoped and trackable across a sidebar remount. */ + addSlideAgentSubmit: (message: string, context: string) => void; } const DECK_FIT_STATE_KEYS = [ @@ -340,9 +343,9 @@ export default function EditorSidebar({ onAddSlideGeneratingChange, onAwaitAddSlidePersisted, onRemoveFailedSlide, + addSlideAgentSubmit, }: EditorSidebarProps) { const t = useT(); - const { submit: agentSubmit } = useAgentGenerating(); const [describeSlideId, setDescribeSlideId] = useState(null); const [describeAnchorEl, setDescribeAnchorEl] = useState(null); @@ -587,7 +590,7 @@ export default function EditorSidebar({ toast.error(t("editorSidebar.newSlideSaveFailed")); return; } - agentSubmit(message, context); + addSlideAgentSubmit(message, context); }} /> )} diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index d4f52df89b..d30018eceb 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -170,14 +170,17 @@ export default function DeckEditor() { const [addSlideGenerating, setAddSlideGenerating] = useState(false); const [generatingSlideSelected, setGeneratingSlideSelected] = useState(false); const { generating } = useAgentGenerating(); - // Only auto-clear addSlideGenerating once we've actually observed a run - // start and finish. This lives here (not in EditorSidebar) because the - // sidebar rail unmounts on narrow viewports when the user closes it — a - // ref living there would forget an in-progress run and never clear the - // flag, leaving New slide disabled for the rest of the session. + // Dedicated instance (not the `generating` one above, which reflects ANY + // agent chat activity) so an unrelated concurrent run can't be mistaken + // for this one finishing and clear the flag early. Owning the submit call + // here — instead of in EditorSidebar, which unmounts when the rail closes + // on narrow viewports — keeps both the run-scoping and the completion + // tracking correct across a remount. + const { generating: addSlideAgentGenerating, submit: addSlideAgentSubmit } = + useAgentGenerating(); const sawAddSlideAgentGeneratingRef = useRef(false); useEffect(() => { - if (generating) { + if (addSlideAgentGenerating) { sawAddSlideAgentGeneratingRef.current = true; return; } @@ -185,7 +188,7 @@ export default function DeckEditor() { sawAddSlideAgentGeneratingRef.current = false; setAddSlideGenerating(false); } - }, [addSlideGenerating, generating]); + }, [addSlideGenerating, addSlideAgentGenerating]); // Generation intent can arrive after this route mounts because the user // answers pre-generation questions from the empty editor. const wasNewDeckCreation = useRef(searchParams.get("generating") === "1"); @@ -1208,6 +1211,7 @@ export default function DeckEditor() { onAddEmptySlide={canEdit ? handleAddEmptySlide : undefined} onAwaitAddSlidePersisted={() => flushDeckSave(id)} onRemoveFailedSlide={(slideId) => deleteSlide(id, slideId)} + addSlideAgentSubmit={addSlideAgentSubmit} addSlideGenerating={addSlideGenerating} onAddSlideGeneratingChange={setAddSlideGenerating} onSelectSlide={(slideId) => { From 061c7198de35fa50fe104002d65df8832d491ed3 Mon Sep 17 00:00:00 2001 From: "Builder.io" Date: Wed, 12 Aug 2026 17:34:44 +0000 Subject: [PATCH 08/11] fix(slides): don't delete edited placeholders, don't let tooltips block slide shortcuts - The failed-save cleanup unconditionally deleted the blank placeholder slide, but retries can take long enough for the user to have started editing it directly on the canvas in the meantime. Only delete it if it still matches the untouched blank-layout default. - data-radix-popper-content-wrapper also wraps Tooltip content, which opens on plain hover. The Cmd/Ctrl+C/V slide-duplication shortcut was treating any such wrapper as an open menu/popover, so hovering a toolbar button silently disabled the shortcut. Narrowed the check to exclude wrappers that only contain tooltip content (marked by the existing data-agent-native-tooltip attribute). --- .../app/components/editor/EditorSidebar.tsx | 16 +++++++++---- templates/slides/app/pages/DeckEditor.tsx | 23 ++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 2fe3fde205..9f6513801c 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -33,7 +33,7 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { Slide } from "@/context/DeckContext"; +import { defaultSlideContent, type Slide } from "@/context/DeckContext"; import { getAspectRatioDims, type AspectRatio } from "@/lib/aspect-ratios"; import { TAB_ID } from "@/lib/tab-id"; @@ -584,9 +584,17 @@ export default function EditorSidebar({ onAddSlideGeneratingChange?.(false); // The popover already closed (AddSlidePopover doesn't wait on // this async callback), so the typed prompt is gone either - // way — remove the orphaned blank placeholder rather than - // leaving a stray empty slide the user never asked for. - onRemoveFailedSlide?.(describeSlideId); + // way. Only remove the placeholder if it's still untouched — + // the save retries take long enough that the user could have + // started editing it directly on the canvas in the meantime, + // and deleting it would destroy that work. + const current = slides.find((s) => s.id === describeSlideId); + if ( + current?.content === defaultSlideContent.blank && + !current.notes + ) { + onRemoveFailedSlide?.(describeSlideId); + } toast.error(t("editorSidebar.newSlideSaveFailed")); return; } diff --git a/templates/slides/app/pages/DeckEditor.tsx b/templates/slides/app/pages/DeckEditor.tsx index d30018eceb..55c7d8c54a 100644 --- a/templates/slides/app/pages/DeckEditor.tsx +++ b/templates/slides/app/pages/DeckEditor.tsx @@ -37,7 +37,6 @@ import EditorToolbar from "@/components/editor/EditorToolbar"; import GeneratingSlidePreview from "@/components/editor/GeneratingSlidePreview"; import HistoryPanel from "@/components/editor/HistoryPanel"; import ImageGenPanel from "@/components/editor/ImageGenPanel"; -import { isInsidePortaledLayer } from "@/components/editor/PromptDialog"; import { QuestionFlow } from "@/components/editor/QuestionFlow"; import SlideEditor from "@/components/editor/SlideEditor"; import { TweaksPanel } from "@/components/editor/TweaksPanel"; @@ -791,6 +790,15 @@ export default function DeckEditor() { if (key !== "c" && key !== "v") return; if (pinMode || drawMode) return; + // Radix Popper positions Popover/DropdownMenu/Select/Tooltip content + // inside the same [data-radix-popper-content-wrapper]. A tooltip opens + // on plain hover, so treating every such wrapper as blocking would + // disable this shortcut just by mousing over a toolbar button; only + // wrappers that aren't tooltips (marked with data-agent-native-tooltip) + // should count as an open menu/popover/dialog owning the keystroke. + const isBlockingPopperWrapper = (el: Element) => + el.matches("[data-radix-popper-content-wrapper]") && + !el.querySelector("[data-agent-native-tooltip]"); const isInsideSafeZone = (el: Element | null) => { if (!el) return false; if (el instanceof HTMLInputElement) return true; @@ -803,7 +811,11 @@ export default function DeckEditor() { if (el.closest("[data-add-slide-popover]")) return true; if (el.closest(".agent-panel-root")) return true; if (el.closest("[role='dialog'], [role='alertdialog']")) return true; - if (isInsidePortaledLayer(el)) return true; + const popperWrapper = el.closest( + "[data-radix-popper-content-wrapper]", + ); + if (popperWrapper && isBlockingPopperWrapper(popperWrapper)) + return true; } return false; }; @@ -816,7 +828,12 @@ export default function DeckEditor() { // shortcut duplicate the slide underneath it. if (document.querySelector("[role='dialog'], [role='alertdialog']")) return; - if (document.querySelector("[data-radix-popper-content-wrapper]")) return; + if ( + Array.from( + document.querySelectorAll("[data-radix-popper-content-wrapper]"), + ).some(isBlockingPopperWrapper) + ) + return; if (document.querySelector("[data-slide-element-selected='true']")) return; From f9ebb8916be9a8e4cf431f828569826780748e64 Mon Sep 17 00:00:00 2001 From: Sajal Chaplot Date: Thu, 13 Aug 2026 17:43:09 +0530 Subject: [PATCH 09/11] fix(slides): restyle new-slide button, keep AI prompt on-screen near bottom Switches the sidebar "New slide" trigger to a compact bordered ghost button (matches the slide list's own row padding) instead of the solid filled outline variant. Fixes the "Describe this slide" popover clipping off the bottom of the viewport when the newly inserted slide lands near the end of a long deck: the new slide's thumbnail is scrolled into view before the popover anchors to it, and the popover's height-based vertical clamp now re-measures via a ResizeObserver so it keeps fitting the viewport as its content grows (Google Doc hint, file chips, textarea growth) instead of only clamping on the first paint. --- .../app/components/editor/AddSlidePopover.tsx | 13 +++++++++++++ .../slides/app/components/editor/EditorSidebar.tsx | 11 +++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/templates/slides/app/components/editor/AddSlidePopover.tsx b/templates/slides/app/components/editor/AddSlidePopover.tsx index be85667efc..b72bc2823a 100644 --- a/templates/slides/app/components/editor/AddSlidePopover.tsx +++ b/templates/slides/app/components/editor/AddSlidePopover.tsx @@ -106,6 +106,19 @@ export function AddSlidePopover({ setPanelHeight(panelRef.current.getBoundingClientRect().height); }); + // Content can grow after the first paint (Google Doc hint, file chips, + // an auto-growing textarea) without necessarily triggering a React + // re-render. Watch the panel directly so it keeps clamping to the + // viewport as it resizes, not just on the frame it first opens. + useEffect(() => { + if (!open || !panelRef.current) return; + const observer = new ResizeObserver(([entry]) => { + setPanelHeight(entry.borderBoxSize?.[0]?.blockSize ?? entry.contentRect.height); + }); + observer.observe(panelRef.current); + return () => observer.disconnect(); + }, [open]); + useEffect(() => { if (!open) return; const handleClick = (e: MouseEvent) => { diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 9f6513801c..bbe967546c 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -451,7 +451,10 @@ export default function EditorSidebar({ } // The new-slide prompt anchors to the just-created slide's thumbnail, // which doesn't exist yet at click time — pick it up as soon as it mounts. + // Center it in the scroll area first so the prompt has room on-screen + // instead of opening off the bottom edge when the new slide lands there. if (node && slideId === describeSlideId) { + node.scrollIntoView({ block: "center" }); setDescribeAnchorEl(node); } }, @@ -510,16 +513,16 @@ export default function EditorSidebar({ return (
{!readOnly && onAddEmptySlide && ( -
+
From 98a0a1f47ac67b8e848f84b37f178f4c9527237c Mon Sep 17 00:00:00 2001 From: Sajal Chaplot Date: Fri, 14 Aug 2026 00:24:52 +0530 Subject: [PATCH 10/11] fix(slides): taller top bar, New Slide back in element-controls row Increases the deck-title/Share/Present bar height and moves New Slide back into the row with the text/style controls and zoom in/out, always rendered as its own line below the title bar at every viewport size instead of merging into it on wide screens. Lifts the describe-slide popover trigger from EditorSidebar up to DeckEditor so the button works from its new toolbar location. --- .../components/editor/EditorActionCluster.tsx | 71 +++++++++++++------ .../app/components/editor/EditorSidebar.tsx | 46 ++++-------- .../app/components/editor/EditorToolbar.tsx | 25 +++++-- .../components/editor/SlideContextToolbar.tsx | 2 +- templates/slides/app/global.css | 47 ++---------- templates/slides/app/pages/DeckEditor.tsx | 27 ++++++- 6 files changed, 112 insertions(+), 106 deletions(-) diff --git a/templates/slides/app/components/editor/EditorActionCluster.tsx b/templates/slides/app/components/editor/EditorActionCluster.tsx index 14d71bc380..11e8241e17 100644 --- a/templates/slides/app/components/editor/EditorActionCluster.tsx +++ b/templates/slides/app/components/editor/EditorActionCluster.tsx @@ -1,6 +1,7 @@ import { useT } from "@agent-native/core/client/i18n"; -import { IconTextSize } from "@tabler/icons-react"; +import { IconPlus, IconTextSize } from "@tabler/icons-react"; +import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, @@ -13,47 +14,71 @@ const BUTTON_CLASS = const IDLE_CLASS = "text-muted-foreground hover:bg-accent hover:text-foreground/70"; const ACTIVE_CLASS = "bg-accent text-foreground"; +const DIVIDER_CLASS = "mx-1 h-4 w-px shrink-0 bg-border"; /** - * Add-text-box — stays put regardless of what is selected. Rendered at the - * head of the contextual toolbar, and as a fallback in the deck toolbar - * where that row is hidden. Adding a slide now lives in the slide rail - * (EditorSidebar), not here. + * Selection-independent actions pinned to the head of the contextual + * toolbar: add-slide and add-text-box. Rendered both as the `leading` slot + * of the element-controls row and as a fallback directly in the deck + * toolbar for when that row is hidden (narrow viewports) or never mounts + * (no current slide, e.g. an empty deck). */ export function EditorActionCluster({ textBoxMode, onToggleTextBoxMode, + onAddEmptySlide, + addSlideGenerating, className, }: { textBoxMode?: boolean; onToggleTextBoxMode?: () => void; + onAddEmptySlide?: () => void; + addSlideGenerating?: boolean; className?: string; }) { const t = useT(); - if (!onToggleTextBoxMode) return null; + if (!onToggleTextBoxMode && !onAddEmptySlide) return null; return (
- - - - - {t("editorToolbar.addTextBox")} (T) - + + {t("editorSidebar.newSlide")} + + {onToggleTextBoxMode &&
} + + )} + {onToggleTextBoxMode && ( + + + + + {t("editorToolbar.addTextBox")} (T) + + )}
); } diff --git a/templates/slides/app/components/editor/EditorSidebar.tsx b/templates/slides/app/components/editor/EditorSidebar.tsx index 0778f6ae0d..18a487ed83 100644 --- a/templates/slides/app/components/editor/EditorSidebar.tsx +++ b/templates/slides/app/components/editor/EditorSidebar.tsx @@ -17,7 +17,6 @@ import { import { CSS } from "@dnd-kit/utilities"; import { appStateKeyForBrowserTab } from "@shared/app-state-tabs"; import { hashSlideContent, type DeckFitState } from "@shared/slide-fit"; -import { IconPlus } from "@tabler/icons-react"; import { useRef, useEffect, useState } from "react"; import { useCallback } from "react"; import { toast } from "sonner"; @@ -27,7 +26,6 @@ import type { SlideOverflowInfo } from "@/components/deck/SlideRenderer"; import { AddSlidePopover } from "@/components/editor/AddSlidePopover"; import { AiEditingMarker } from "@/components/editor/AiEditingMarker"; import GeneratingSlidePreview from "@/components/editor/GeneratingSlidePreview"; -import { Button } from "@/components/ui/button"; import { Tooltip, TooltipContent, @@ -59,10 +57,15 @@ interface EditorSidebarProps { generatingSlide?: { index: number }; generatingSlideSelected?: boolean; onSelectGeneratingSlide?: () => void; - /** Inserts a blank slide directly below the active slide and returns its id. */ - onAddEmptySlide?: () => string | undefined; - /** True while an agent add-slide request is in flight. */ - addSlideGenerating?: boolean; + /** The slide just inserted via the toolbar's New Slide button — the rail + * anchors the "describe this slide" popover to that slide's thumbnail + * once it mounts. Owned by the parent since the button that sets it now + * lives in the toolbar, outside this component. */ + describeSlideId: string | null; + /** Clears `describeSlideId` in the parent when the popover closes. */ + onCloseDescribe: () => void; + /** Reports add-slide generation state up so the toolbar's New Slide button + * can disable itself while a request is in flight. */ onAddSlideGeneratingChange?: (generating: boolean) => void; /** Resolves once a just-inserted blank slide has actually reached the * server, so the agent's update-slide request can't race the add-slide @@ -338,15 +341,14 @@ export default function EditorSidebar({ generatingSlide, generatingSlideSelected = false, onSelectGeneratingSlide, - onAddEmptySlide, - addSlideGenerating = false, + describeSlideId, + onCloseDescribe, onAddSlideGeneratingChange, onAwaitAddSlidePersisted, onRemoveFailedSlide, addSlideAgentSubmit, }: EditorSidebarProps) { const t = useT(); - const [describeSlideId, setDescribeSlideId] = useState(null); const [describeAnchorEl, setDescribeAnchorEl] = useState(null); const slideButtonRefs = useRef(new Map()); @@ -425,7 +427,6 @@ export default function EditorSidebar({ }, [deckId, aspectRatio]); useEffect(() => { - setDescribeSlideId(null); setDescribeAnchorEl(null); }, [deckId]); @@ -461,14 +462,6 @@ export default function EditorSidebar({ [describeSlideId], ); - const handleNewSlideClick = useCallback(() => { - const newId = onAddEmptySlide?.(); - if (newId) { - setDescribeAnchorEl(null); - setDescribeSlideId(newId); - } - }, [onAddEmptySlide]); - const describeSlideIndex = describeSlideId ? slides.findIndex((s) => s.id === describeSlideId) : -1; @@ -512,21 +505,6 @@ export default function EditorSidebar({ return (
- {!readOnly && onAddEmptySlide && ( -
- -
- )}
s.id)} @@ -566,7 +544,7 @@ export default function EditorSidebar({ open onOpenChange={(open) => { if (!open) { - setDescribeSlideId(null); + onCloseDescribe(); setDescribeAnchorEl(null); } }} diff --git a/templates/slides/app/components/editor/EditorToolbar.tsx b/templates/slides/app/components/editor/EditorToolbar.tsx index bad8c1562b..e61299078c 100644 --- a/templates/slides/app/components/editor/EditorToolbar.tsx +++ b/templates/slides/app/components/editor/EditorToolbar.tsx @@ -129,6 +129,13 @@ interface EditorToolbarProps { onExportPptx?: () => Promise | void; /** Create the deck in the user's Google Drive as native Google Slides */ onExportGoogleSlides?: () => Promise; + /** Inserts a blank slide directly below the active slide. Threaded through + * to the fallback action cluster below so an empty deck (no current + * slide, so the primary element-controls toolbar never mounts) still has + * a way to add its first slide. */ + onAddEmptySlide?: () => void; + /** True while an agent add-slide request is in flight. */ + addSlideGenerating?: boolean; } const TOOLBAR_ICON_BUTTON_CLASS = @@ -169,6 +176,8 @@ export default function EditorToolbar({ onExportPdf, onExportPptx, onExportGoogleSlides, + onAddEmptySlide, + addSlideGenerating, canEdit = true, canComment = canEdit, }: EditorToolbarProps) { @@ -513,7 +522,7 @@ export default function EditorToolbar({ useEffect(() => registerEditorCommands(() => editorCommandsRef.current), []); return ( -
+
{/* Back button */} @@ -544,14 +553,18 @@ export default function EditorToolbar({ {t("editorToolbar.toggleSlideList")} - {/* The text-box tool lives at the head of the contextual toolbar below. - * That row is desktop-only, so keep a fallback here for narrow screens - * and empty decks. */} - {canEdit && ( + {/* New Slide and the text-box tool live at the head of the contextual + * toolbar below, which SlideEditor portals in at every viewport size + * (a wide inline row or a narrow standalone row) whenever there's a + * current slide. Render this fallback only when there isn't one — an + * empty deck — so those two rows never end up showing the same + * buttons twice. */} + {canEdit && !contextToolbarVisible && ( )} diff --git a/templates/slides/app/components/editor/SlideContextToolbar.tsx b/templates/slides/app/components/editor/SlideContextToolbar.tsx index b7712b4a1f..c01cfdccd2 100644 --- a/templates/slides/app/components/editor/SlideContextToolbar.tsx +++ b/templates/slides/app/components/editor/SlideContextToolbar.tsx @@ -926,7 +926,7 @@ export function SlideContextToolbar({ )} {zoomControls && ( <> -
+