From 89e36da13dcd5cda4455fa48e9cbb4d9b7f7dbda Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Mon, 6 Jul 2026 16:56:06 -0700 Subject: [PATCH] =?UTF-8?q?fix(studio):=20shell=20UX=20=E2=80=94=20data-lo?= =?UTF-8?q?ss=20guards,=20error=20surfacing,=20dialog=20contracts,=20toast?= =?UTF-8?q?s=20(#1964)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/studio/src/App.tsx | 27 +- .../studio/src/components/AskAgentModal.tsx | 29 +- packages/studio/src/components/LintModal.tsx | 50 ++- .../studio/src/components/MediaPreview.tsx | 37 ++- .../src/components/SaveQueuePausedBanner.tsx | 13 +- .../src/components/StudioErrorBoundary.tsx | 50 ++- .../src/components/StudioFeedbackBar.tsx | 13 +- .../components/StudioGlobalDragOverlay.tsx | 2 +- .../studio/src/components/StudioHeader.tsx | 296 +++++++++++------- .../src/components/StudioLeftSidebar.tsx | 27 +- .../studio/src/components/StudioOverlays.tsx | 42 ++- .../src/components/StudioRightPanel.tsx | 50 ++- .../studio/src/components/StudioSplash.tsx | 9 +- .../studio/src/components/StudioToast.tsx | 16 +- .../src/components/TimelineToolbar.test.tsx | 12 +- .../studio/src/components/TimelineToolbar.tsx | 92 ++++-- .../src/contexts/PanelLayoutContext.tsx | 3 + .../src/hooks/useCompositionContentLoader.ts | 40 +++ packages/studio/src/hooks/useEditorSave.ts | 14 + packages/studio/src/hooks/useFileManager.ts | 64 ++-- packages/studio/src/hooks/useFrameCapture.ts | 13 +- packages/studio/src/hooks/usePanelLayout.ts | 1 + packages/studio/src/hooks/useToast.ts | 81 ++++- 23 files changed, 700 insertions(+), 281 deletions(-) create mode 100644 packages/studio/src/hooks/useCompositionContentLoader.ts diff --git a/packages/studio/src/App.tsx b/packages/studio/src/App.tsx index d49543c25..625bda1a9 100644 --- a/packages/studio/src/App.tsx +++ b/packages/studio/src/App.tsx @@ -27,6 +27,7 @@ import { useFrameCapture } from "./hooks/useFrameCapture"; import { useLintModal } from "./hooks/useLintModal"; import { useCompositionDimensions } from "./hooks/useCompositionDimensions"; import { useToast } from "./hooks/useToast"; +import { useCompositionContentLoader } from "./hooks/useCompositionContentLoader"; import { useStudioUrlState } from "./hooks/useStudioUrlState"; import { buildStudioContextValue, @@ -132,7 +133,7 @@ export function StudioApp() { return !v; }); }, []); - const { appToast, showToast, dismissToast } = useToast(); + const { toasts, showToast, dismissToast } = useToast(); const panelLayout = usePanelLayout({ rightCollapsed: initialUrlStateRef.current.rightCollapsed, rightPanelTab: initialUrlStateRef.current.rightPanelTab, @@ -389,17 +390,13 @@ export function StudioApp() { }, [appHotkeys, resetConsoleErrors, refreshPreviewDocumentVersion], ); - const handleSelectComposition = useCallback( - (comp: string) => { - setActiveCompPath(comp.endsWith(".html") ? comp : null); - fileManager.setEditingFile({ path: comp, content: null }); - fetch(`/api/projects/${projectId}/files/${comp}`) - .then((r) => r.json()) - .then((data) => fileManager.setEditingFile({ path: comp, content: data.content })) - .catch(() => {}); - }, - [projectId, fileManager], - ); + const { setEditingFile } = fileManager; + const handleSelectComposition = useCompositionContentLoader({ + projectId, + setEditingFile, + setActiveCompPath, + showToast, + }); const { designPanelActive, inspectorPanelActive, @@ -484,6 +481,7 @@ export function StudioApp() { captureFrameFilename={frameCapture.captureFrameFilename} handleCaptureFrameClick={frameCapture.handleCaptureFrameClick} refreshCaptureFrameTime={frameCapture.refreshCaptureFrameTime} + capturing={frameCapture.capturing} inspectorButtonActive={inspectorButtonActive} inspectorPanelActive={inspectorPanelActive} onExport={() => { @@ -496,7 +494,7 @@ export function StudioApp() { {previewPersistence.domEditSaveQueuePaused && ( )} {viewModeValue.viewMode === "storyboard" && ( @@ -578,6 +576,7 @@ export function StudioApp() { diff --git a/packages/studio/src/components/AskAgentModal.tsx b/packages/studio/src/components/AskAgentModal.tsx index 2f7096560..9df38006c 100644 --- a/packages/studio/src/components/AskAgentModal.tsx +++ b/packages/studio/src/components/AskAgentModal.tsx @@ -1,6 +1,7 @@ import { useState, useRef, type CSSProperties } from "react"; import { useMountEffect } from "../hooks/useMountEffect"; import { type AgentModalAnchorPoint, clampNumber } from "../utils/studioHelpers"; +import { useDialogBehavior } from "./ui/useDialogBehavior"; function getAgentModalPositionStyle( anchorPoint: AgentModalAnchorPoint | null, @@ -39,7 +40,16 @@ export function AskAgentModal({ }) { const [value, setValue] = useState(""); const inputRef = useRef(null); + const containerRef = useRef(null); const modalPositionStyle = getAgentModalPositionStyle(anchorPoint); + // A dirty draft vetoes Escape/backdrop closes — a stray click must not + // discard typed instructions. The X button and Copy still close directly. + const { requestClose } = useDialogBehavior({ + open: true, + onClose, + containerRef, + canClose: () => !value.trim(), + }); useMountEffect(() => { requestAnimationFrame(() => inputRef.current?.focus()); @@ -54,13 +64,18 @@ export function AskAgentModal({
@@ -81,13 +105,19 @@ export function LintModal({
)} diff --git a/packages/studio/src/components/MediaPreview.tsx b/packages/studio/src/components/MediaPreview.tsx index 1eb392e4f..9547a7f61 100644 --- a/packages/studio/src/components/MediaPreview.tsx +++ b/packages/studio/src/components/MediaPreview.tsx @@ -1,8 +1,41 @@ +import { useState } from "react"; import { IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../utils/mediaTypes"; +function MediaErrorPanel({ name, filePath }: { name: string; filePath: string }) { + return ( +
+ + {name} + {filePath} + + Couldn't load this file — it may be missing or corrupt + +
+ ); +} + export function MediaPreview({ projectId, filePath }: { projectId: string; filePath: string }) { const serveUrl = `/api/projects/${projectId}/preview/${filePath}`; const name = filePath.split("/").pop() ?? filePath; + // Keyed by path so switching to another file clears a previous failure. + const [failedPath, setFailedPath] = useState(null); + const failed = failedPath === filePath; + const setFailed = () => setFailedPath(filePath); + + if (failed) return ; if (IMAGE_EXT.test(filePath)) { return ( @@ -10,6 +43,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP {name} {filePath} @@ -23,6 +57,7 @@ export function MediaPreview({ projectId, filePath }: { projectId: string; fileP
); diff --git a/packages/studio/src/components/SaveQueuePausedBanner.tsx b/packages/studio/src/components/SaveQueuePausedBanner.tsx index 37bb6ed90..ca68fbf65 100644 --- a/packages/studio/src/components/SaveQueuePausedBanner.tsx +++ b/packages/studio/src/components/SaveQueuePausedBanner.tsx @@ -1,22 +1,23 @@ interface SaveQueuePausedBannerProps { message: string; - onDismiss: () => void; + /** Resets the save-queue circuit breaker so persistence resumes. */ + onRetry: () => void; } /** Alert shown when the DOM-edit save queue circuit breaker pauses persistence. */ -export function SaveQueuePausedBanner({ message, onDismiss }: SaveQueuePausedBannerProps) { +export function SaveQueuePausedBanner({ message, onRetry }: SaveQueuePausedBannerProps) { return (
{message}
); diff --git a/packages/studio/src/components/StudioErrorBoundary.tsx b/packages/studio/src/components/StudioErrorBoundary.tsx index 429974bc9..15228b5cc 100644 --- a/packages/studio/src/components/StudioErrorBoundary.tsx +++ b/packages/studio/src/components/StudioErrorBoundary.tsx @@ -30,39 +30,27 @@ export class StudioErrorBoundary extends Component { if (!this.state.error) return this.props.children; return ( -
-
Something went wrong
-
+
+
Something went wrong
+
{this.state.error.message}
- +
+ + {/* If the error recurs immediately, "Try again" loops — a full reload + is the recovery path that always works. */} + +
); } diff --git a/packages/studio/src/components/StudioFeedbackBar.tsx b/packages/studio/src/components/StudioFeedbackBar.tsx index 11100cc67..90428aa82 100644 --- a/packages/studio/src/components/StudioFeedbackBar.tsx +++ b/packages/studio/src/components/StudioFeedbackBar.tsx @@ -69,6 +69,7 @@ function markPrompted(): void { // fallow-ignore-next-line complexity export const StudioFeedbackBar = memo(function StudioFeedbackBar() { const [visible, setVisible] = useState(false); + const [entered, setEntered] = useState(false); const [rating, setRating] = useState(null); const [comment, setComment] = useState(""); const [submitted, setSubmitted] = useState(false); @@ -88,6 +89,14 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() { return () => clearTimeout(showTimer); }, []); + // Animate height in on entrance — appearing 3s after load, an instant 32px + // bar shoves the whole preview stack up mid-task. + useEffect(() => { + if (!visible) return; + const raf = requestAnimationFrame(() => setEntered(true)); + return () => cancelAnimationFrame(raf); + }, [visible]); + // Auto-dismiss timer — reset when user interacts (sets rating) useEffect(() => { if (!visible || rating !== null || submitted) return; @@ -141,8 +150,8 @@ export const StudioFeedbackBar = memo(function StudioFeedbackBar() { return (
{submitted ? ( diff --git a/packages/studio/src/components/StudioGlobalDragOverlay.tsx b/packages/studio/src/components/StudioGlobalDragOverlay.tsx index d7ffcae87..ecd65cb61 100644 --- a/packages/studio/src/components/StudioGlobalDragOverlay.tsx +++ b/packages/studio/src/components/StudioGlobalDragOverlay.tsx @@ -1,6 +1,6 @@ export function StudioGlobalDragOverlay() { return ( -
+
) => void; refreshCaptureFrameTime: () => void; + capturing?: boolean; inspectorButtonActive: boolean; inspectorPanelActive: boolean; onExport?: () => void; @@ -149,26 +151,45 @@ const VIEW_MODE_OPTIONS: Array<{ mode: StudioViewMode; label: string }> = [ /** Segmented control switching the main stage between storyboard and preview. */ function ViewModeToggle() { const { viewMode, setViewMode } = useViewMode(); + const tabRefs = useRef>([]); + + const selectMode = (mode: StudioViewMode) => { + if (mode === viewMode) return; + trackStudioEvent("view_mode_toggle", { mode }); + setViewMode(mode); + }; + + // Complete APG tabs pattern: roving tabIndex + arrow-key navigation. + const handleKeyDown = (e: React.KeyboardEvent, index: number) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + e.preventDefault(); + const dir = e.key === "ArrowLeft" ? -1 : 1; + const next = (index + dir + VIEW_MODE_OPTIONS.length) % VIEW_MODE_OPTIONS.length; + tabRefs.current[next]?.focus(); + selectMode(VIEW_MODE_OPTIONS[next].mode); + }; + return (
- {VIEW_MODE_OPTIONS.map(({ mode, label }) => { + {VIEW_MODE_OPTIONS.map(({ mode, label }, index) => { const active = viewMode === mode; return ( - + + - - - { - trackStudioEvent("toolbar_action", { action: "capture_frame" }); - handleCaptureFrameClick(e); - }} - onFocus={refreshCaptureFrameTime} - onPointerDown={refreshCaptureFrameTime} - className="h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium text-neutral-400 transition-colors hover:text-neutral-200 hover:bg-neutral-800" - title="Capture current frame" - aria-label="Capture current frame" - > - - Capture - - - + + + { + if (capturing) { + e.preventDefault(); + return; + } + trackStudioEvent("toolbar_action", { action: "capture_frame" }); + handleCaptureFrameClick(e); + }} + onFocus={refreshCaptureFrameTime} + onPointerDown={refreshCaptureFrameTime} + aria-disabled={capturing || undefined} + className={`h-7 flex items-center gap-1.5 px-2.5 rounded-md text-[11px] font-medium transition-colors ${ + capturing + ? "text-neutral-600 cursor-default" + : "text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 active:scale-[0.98]" + }`} + aria-label={capturing ? "Capturing frame" : "Capture current frame"} + > + {capturing ? ( + + ) : ( + + )} + {capturing ? "Capturing…" : "Capture"} + + + - Export - + + + + +
); diff --git a/packages/studio/src/components/StudioLeftSidebar.tsx b/packages/studio/src/components/StudioLeftSidebar.tsx index bb39133a3..c4643c530 100644 --- a/packages/studio/src/components/StudioLeftSidebar.tsx +++ b/packages/studio/src/components/StudioLeftSidebar.tsx @@ -34,6 +34,7 @@ export function StudioLeftSidebar({ const { leftCollapsed, leftWidth, + setLeftWidth, toggleLeftSidebar, handlePanelResizeStart, handlePanelResizeMove, @@ -114,14 +115,22 @@ export function StudioLeftSidebar({ onRenameFile={handleRenameFile} onDuplicateFile={handleDuplicateFile} onMoveFile={handleMoveFile} - onImportFiles={handleImportFiles} + onImportFiles={async (files, dir) => { + await handleImportFiles(files, dir); + }} codeChildren={ editingFile ? ( isMediaFile(editingFile.path) ? ( + ) : editingFile.content == null ? ( + // Never mount the editor on unloaded content: a keystroke would + // autosave an empty document over the real file. +
+ Loading {editingFile.path}… +
) : (
handlePanelResizeStart("left", e)} onPointerMove={handlePanelResizeMove} onPointerUp={handlePanelResizeEnd} + onPointerCancel={handlePanelResizeEnd} + onKeyDown={(e) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + e.preventDefault(); + const delta = e.key === "ArrowLeft" ? -16 : 16; + const maxLeft = Math.floor(window.innerWidth * 0.5); + setLeftWidth(Math.max(160, Math.min(maxLeft, leftWidth + delta))); + }} >
diff --git a/packages/studio/src/components/StudioOverlays.tsx b/packages/studio/src/components/StudioOverlays.tsx index 7e160234e..411f124d5 100644 --- a/packages/studio/src/components/StudioOverlays.tsx +++ b/packages/studio/src/components/StudioOverlays.tsx @@ -11,6 +11,7 @@ type LintFindings = ComponentProps["findings"]; export interface StudioOverlaysProps { projectId: string; + projectDir?: string | null; lintModal: LintFindings | null; closeLintModal: () => void; consoleErrors: LintFindings | null; @@ -18,8 +19,8 @@ export interface StudioOverlaysProps { domEditSession: ReturnType; activeCompPath: string | null; dragOverlayActive: boolean; - appToast: ReturnType["appToast"]; - dismissToast: () => void; + toasts: ReturnType["toasts"]; + dismissToast: (id: number) => void; } /** @@ -30,6 +31,7 @@ export interface StudioOverlaysProps { // fallow-ignore-next-line complexity export function StudioOverlays({ projectId, + projectDir, lintModal, closeLintModal, consoleErrors, @@ -37,16 +39,30 @@ export function StudioOverlays({ domEditSession, activeCompPath, dragOverlayActive, - appToast, + toasts, dismissToast, }: StudioOverlaysProps) { return ( <> {lintModal !== null && ( - + )} - {consoleErrors !== null && consoleErrors.length > 0 && ( - + {/* One modal at a time — console errors wait behind an open lint modal + instead of stacking two full-screen overlays. */} + {lintModal === null && consoleErrors !== null && consoleErrors.length > 0 && ( + )} {domEditSession.agentModalOpen && domEditSession.domEditSelection && ( )} {dragOverlayActive && } - {appToast && ( - + {toasts.length > 0 && ( +
+ {toasts.map((toast) => ( + dismissToast(toast.id)} + /> + ))} +
)} ); diff --git a/packages/studio/src/components/StudioRightPanel.tsx b/packages/studio/src/components/StudioRightPanel.tsx index 47ce362be..2b5f2a7e9 100644 --- a/packages/studio/src/components/StudioRightPanel.tsx +++ b/packages/studio/src/components/StudioRightPanel.tsx @@ -77,6 +77,7 @@ export function StudioRightPanel({ }: StudioRightPanelProps) { const { rightWidth, + setRightWidth, rightPanelTab, setRightPanelTab, rightInspectorPanes, @@ -400,6 +401,11 @@ export function StudioRightPanel({ jobs={renderJobs} projectId={projectId} onDelete={renderQueue.deleteRender} + onCancel={renderQueue.cancelRender} + loadError={renderQueue.loadError} + onRetryLoad={renderQueue.reloadRenders} + actionError={renderQueue.actionError} + onDismissActionError={renderQueue.dismissActionError} onClearCompleted={renderQueue.clearCompleted} onStartRender={async (format, quality, resolution, fps) => { await waitForPendingDomEditSaves(); @@ -421,11 +427,23 @@ export function StudioRightPanel({ return ( <>
handlePanelResizeStart("right", e)} onPointerMove={handlePanelResizeMove} onPointerUp={handlePanelResizeEnd} + onPointerCancel={handlePanelResizeEnd} + onKeyDown={(e) => { + if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return; + e.preventDefault(); + // Panel is right-anchored: ArrowLeft grows it, ArrowRight shrinks it. + const delta = e.key === "ArrowLeft" ? 16 : -16; + setRightWidth(Math.max(160, Math.min(600, rightWidth + delta))); + }} >
@@ -444,7 +462,8 @@ export function StudioRightPanel({ +
) : ( renderQueuePanel )} diff --git a/packages/studio/src/components/StudioSplash.tsx b/packages/studio/src/components/StudioSplash.tsx index 366542c29..7d00db850 100644 --- a/packages/studio/src/components/StudioSplash.tsx +++ b/packages/studio/src/components/StudioSplash.tsx @@ -2,15 +2,18 @@ export function StudioSplash({ waiting }: { waiting?: boolean }) { return (
{waiting ? ( -
-
+
+

Waiting for preview server… run{" "} npm run dev

) : ( -
+
+
+

Connecting to project…

+
)}
); diff --git a/packages/studio/src/components/StudioToast.tsx b/packages/studio/src/components/StudioToast.tsx index ec4f08639..1de709415 100644 --- a/packages/studio/src/components/StudioToast.tsx +++ b/packages/studio/src/components/StudioToast.tsx @@ -1,18 +1,17 @@ interface StudioToastProps { message: string; tone?: "error" | "info"; + /** Plays the exit animation when true (owner removes the node after ~160ms). */ + leaving?: boolean; onDismiss?: () => void; } -// fallow-ignore-next-line complexity -export function StudioToast({ message, tone, onDismiss }: StudioToastProps) { +export function StudioToast({ message, tone, leaving, onDismiss }: StudioToastProps) { const isError = tone === "error"; return (
{ - e.stopPropagation(); - onDismiss(); - }} + onClick={onDismiss} className="flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md text-neutral-500 transition-colors hover:bg-white/10 hover:text-neutral-300" aria-label="Dismiss" > diff --git a/packages/studio/src/components/TimelineToolbar.test.tsx b/packages/studio/src/components/TimelineToolbar.test.tsx index 4b772ed05..5ee23d5af 100644 --- a/packages/studio/src/components/TimelineToolbar.test.tsx +++ b/packages/studio/src/components/TimelineToolbar.test.tsx @@ -30,21 +30,27 @@ function renderToolbar() { describe("TimelineToolbar — auto-keyframe toggle (#1808)", () => { it("renders enabled (pressed) by default with no selection", () => { const { host, root } = renderToolbar(); - const btn = host.querySelector('button[aria-pressed="true"]'); + const btn = host.querySelector( + 'button[aria-label="Auto-record manual edits as keyframes"]', + ); expect(btn).not.toBeNull(); + expect(btn?.getAttribute("aria-pressed")).toBe("true"); act(() => root.unmount()); }); it("flips autoKeyframeEnabled in the store when clicked", () => { const { host, root } = renderToolbar(); - const btn = host.querySelector('button[aria-pressed="true"]')!; + const btn = host.querySelector( + 'button[aria-label="Auto-record manual edits as keyframes"]', + ); + if (!btn) throw new Error("auto-keyframe toggle not rendered"); act(() => { btn.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(usePlayerStore.getState().autoKeyframeEnabled).toBe(false); - expect(host.querySelector('button[aria-pressed="false"]')).not.toBeNull(); + expect(btn.getAttribute("aria-pressed")).toBe("false"); act(() => root.unmount()); }); }); diff --git a/packages/studio/src/components/TimelineToolbar.tsx b/packages/studio/src/components/TimelineToolbar.tsx index e13707ebb..eff6db206 100644 --- a/packages/studio/src/components/TimelineToolbar.tsx +++ b/packages/studio/src/components/TimelineToolbar.tsx @@ -112,7 +112,9 @@ export function TimelineToolbar({ - + - - ))()} + + + ); + })()}
diff --git a/packages/studio/src/contexts/PanelLayoutContext.tsx b/packages/studio/src/contexts/PanelLayoutContext.tsx index b6d1605f2..3600e59bd 100644 --- a/packages/studio/src/contexts/PanelLayoutContext.tsx +++ b/packages/studio/src/contexts/PanelLayoutContext.tsx @@ -16,6 +16,7 @@ export function PanelLayoutProvider({ leftWidth, setLeftWidth, rightWidth, + setRightWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, @@ -39,6 +40,7 @@ export function PanelLayoutProvider({ leftWidth, setLeftWidth, rightWidth, + setRightWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, @@ -56,6 +58,7 @@ export function PanelLayoutProvider({ leftWidth, setLeftWidth, rightWidth, + setRightWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, diff --git a/packages/studio/src/hooks/useCompositionContentLoader.ts b/packages/studio/src/hooks/useCompositionContentLoader.ts new file mode 100644 index 000000000..7d2549b50 --- /dev/null +++ b/packages/studio/src/hooks/useCompositionContentLoader.ts @@ -0,0 +1,40 @@ +import { useCallback } from "react"; + +/** + * Loads a composition file's content for the source editor when a composition + * is selected. Content stays null until the fetch resolves — the source editor + * must not mount on a null-content file, or its autosave would overwrite the + * real file with an empty document. Load failures surface as an error toast + * instead of silently rendering an empty (and autosave-armed) editor. + */ +export function useCompositionContentLoader({ + projectId, + setEditingFile, + setActiveCompPath, + showToast, +}: { + projectId: string | null; + setEditingFile: (file: { path: string; content: string | null }) => void; + setActiveCompPath: (path: string | null) => void; + showToast: (message: string, tone?: "error" | "info") => void; +}) { + return useCallback( + (comp: string) => { + setActiveCompPath(comp.endsWith(".html") ? comp : null); + setEditingFile({ path: comp, content: null }); + fetch(`/api/projects/${projectId}/files/${comp}`) + .then(async (r) => { + if (!r.ok) throw new Error(`Failed to load ${comp} (${r.status})`); + return r.json(); + }) + .then((data: { content?: string }) => { + if (typeof data.content !== "string") throw new Error(`No content returned for ${comp}`); + setEditingFile({ path: comp, content: data.content }); + }) + .catch((err) => { + showToast(err instanceof Error ? err.message : `Failed to load ${comp}`, "error"); + }); + }, + [projectId, setEditingFile, setActiveCompPath, showToast], + ); +} diff --git a/packages/studio/src/hooks/useEditorSave.ts b/packages/studio/src/hooks/useEditorSave.ts index a84f69394..9c3ae7763 100644 --- a/packages/studio/src/hooks/useEditorSave.ts +++ b/packages/studio/src/hooks/useEditorSave.ts @@ -18,6 +18,7 @@ interface UseEditorSaveOptions { recordEdit: (input: RecordEditInput) => Promise; domEditSaveTimestampRef: React.MutableRefObject; setRefreshKey: React.Dispatch>; + showToast: (message: string, tone?: "error" | "info") => void; } export function useEditorSave({ @@ -28,9 +29,13 @@ export function useEditorSave({ recordEdit, domEditSaveTimestampRef, setRefreshKey, + showToast, }: UseEditorSaveOptions) { const saveRafRef = useRef(null); const refreshRafRef = useRef(null); + // One error toast per burst of failures — every keystroke retries the save, + // and error toasts persist until dismissed, so don't stack duplicates. + const lastFailureToastAtRef = useRef(0); const handleContentChange = useCallback( (content: string) => { @@ -61,6 +66,14 @@ export function useEditorSave({ source: "code_editor", error_message: error instanceof Error ? error.message : "unknown", }); + const now = Date.now(); + if (now - lastFailureToastAtRef.current > 5000) { + lastFailureToastAtRef.current = now; + showToast( + `Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`, + "error", + ); + } }); }); }, @@ -71,6 +84,7 @@ export function useEditorSave({ readProjectFile, recordEdit, setRefreshKey, + showToast, writeProjectFile, ], ); diff --git a/packages/studio/src/hooks/useFileManager.ts b/packages/studio/src/hooks/useFileManager.ts index 753dce7d8..9fbc8cc20 100644 --- a/packages/studio/src/hooks/useFileManager.ts +++ b/packages/studio/src/hooks/useFileManager.ts @@ -126,6 +126,7 @@ export function useFileManager({ recordEdit, domEditSaveTimestampRef, setRefreshKey, + showToast, }); // ── File select ── @@ -133,26 +134,34 @@ export function useFileManager({ const revealRequestIdRef = useRef(0); const revealAbortRef = useRef(null); - const handleFileSelect = useCallback((path: string) => { - const pid = projectIdRef.current; - if (!pid) return; - revealAbortRef.current?.abort(); - revealAbortRef.current = null; - revealRequestIdRef.current++; - // Skip fetching binary content for media files — just set the path for preview - if (isMediaFile(path)) { - setEditingFile({ path, content: null }); - return; - } - fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`) - .then((r) => r.json()) - .then((data: { content?: string }) => { - if (data.content != null) { - setEditingFile({ path, content: data.content }); - } - }) - .catch(() => {}); - }, []); + const handleFileSelect = useCallback( + (path: string) => { + const pid = projectIdRef.current; + if (!pid) return; + revealAbortRef.current?.abort(); + revealAbortRef.current = null; + revealRequestIdRef.current++; + // Skip fetching binary content for media files — just set the path for preview + if (isMediaFile(path)) { + setEditingFile({ path, content: null }); + return; + } + fetch(`/api/projects/${pid}/files/${encodeURIComponent(path)}`) + .then((r) => { + if (!r.ok) throw new Error(`Failed to load ${path} (${r.status})`); + return r.json(); + }) + .then((data: { content?: string }) => { + if (data.content != null) { + setEditingFile({ path, content: data.content }); + } + }) + .catch((err: unknown) => { + showToast(err instanceof Error ? err.message : `Failed to load ${path}`, "error"); + }); + }, + [showToast], + ); // ── Click-to-source ── @@ -253,9 +262,10 @@ export function useFileManager({ } else { const err = await res.json().catch(() => ({ error: "unknown" })); console.error(`Create file failed: ${err.error}`); + showToast(`Couldn't create ${path}: ${err.error}`, "error"); } }, - [refreshFileTree, handleFileSelect], + [refreshFileTree, handleFileSelect, showToast], ); const handleCreateFolder = useCallback( @@ -275,9 +285,10 @@ export function useFileManager({ } else { const err = await res.json().catch(() => ({ error: "unknown" })); console.error(`Create folder failed: ${err.error}`); + showToast(`Couldn't create folder ${path}: ${err.error}`, "error"); } }, - [refreshFileTree], + [refreshFileTree, showToast], ); const handleDeleteFile = useCallback( @@ -293,9 +304,10 @@ export function useFileManager({ } else { const err = await res.json().catch(() => ({ error: "unknown" })); console.error(`Delete failed: ${err.error}`); + showToast(`Couldn't delete ${path}: ${err.error}`, "error"); } }, - [refreshFileTree], + [refreshFileTree, showToast], ); const handleRenameFile = useCallback( @@ -316,9 +328,10 @@ export function useFileManager({ } else { const err = await res.json().catch(() => ({ error: "unknown" })); console.error(`Rename failed: ${err.error}`); + showToast(`Couldn't rename ${oldPath}: ${err.error}`, "error"); } }, - [refreshFileTree, handleFileSelect, setRefreshKey], + [refreshFileTree, handleFileSelect, setRefreshKey, showToast], ); const handleDuplicateFile = useCallback( @@ -337,9 +350,10 @@ export function useFileManager({ } else { const err = await res.json().catch(() => ({ error: "unknown" })); console.error(`Duplicate failed: ${err.error}`); + showToast(`Couldn't duplicate ${path}: ${err.error}`, "error"); } }, - [refreshFileTree, handleFileSelect], + [refreshFileTree, handleFileSelect, showToast], ); const handleMoveFile = handleRenameFile; diff --git a/packages/studio/src/hooks/useFrameCapture.ts b/packages/studio/src/hooks/useFrameCapture.ts index c7033bda3..962298e1b 100644 --- a/packages/studio/src/hooks/useFrameCapture.ts +++ b/packages/studio/src/hooks/useFrameCapture.ts @@ -1,4 +1,4 @@ -import { useState, useCallback, type MouseEvent } from "react"; +import { useState, useCallback, useRef, type MouseEvent } from "react"; import { useMountEffect } from "./useMountEffect"; import { liveTime, usePlayerStore } from "../player"; import { buildFrameCaptureFilename, buildFrameCaptureUrl } from "../utils/frameCapture"; @@ -17,6 +17,8 @@ export function useFrameCapture({ waitForPendingDomEditSaves, }: UseFrameCaptureParams) { const [captureFrameTime, setCaptureFrameTime] = useState(0); + const [capturing, setCapturing] = useState(false); + const capturingRef = useRef(false); useMountEffect(() => { setCaptureFrameTime(usePlayerStore.getState().currentTime); @@ -31,6 +33,11 @@ export function useFrameCapture({ async (event: MouseEvent) => { if (!projectId) return; event.preventDefault(); + // A capture can take up to ~35s (save drain + server render) — ignore + // re-entrant clicks instead of firing parallel captures. + if (capturingRef.current) return; + capturingRef.current = true; + setCapturing(true); try { const time = usePlayerStore.getState().currentTime; setCaptureFrameTime(time); @@ -79,6 +86,9 @@ export function useFrameCapture({ } } catch (err) { showToast(err instanceof Error ? err.message : "Capture failed", "error"); + } finally { + capturingRef.current = false; + setCapturing(false); } }, [activeCompPath, projectId, showToast, waitForPendingDomEditSaves], @@ -98,5 +108,6 @@ export function useFrameCapture({ captureFrameFilename, handleCaptureFrameClick, refreshCaptureFrameTime, + capturing, }; } diff --git a/packages/studio/src/hooks/usePanelLayout.ts b/packages/studio/src/hooks/usePanelLayout.ts index 505c15e49..384e983f6 100644 --- a/packages/studio/src/hooks/usePanelLayout.ts +++ b/packages/studio/src/hooks/usePanelLayout.ts @@ -100,6 +100,7 @@ export function usePanelLayout(initialState?: InitialPanelLayoutState) { leftWidth, setLeftWidth, rightWidth, + setRightWidth, leftCollapsed, setLeftCollapsed, rightCollapsed, diff --git a/packages/studio/src/hooks/useToast.ts b/packages/studio/src/hooks/useToast.ts index a27147a9b..ea7f5add1 100644 --- a/packages/studio/src/hooks/useToast.ts +++ b/packages/studio/src/hooks/useToast.ts @@ -2,24 +2,77 @@ import { useState, useCallback, useRef } from "react"; import { useMountEffect } from "./useMountEffect"; import type { AppToast } from "../utils/studioHelpers"; -export function useToast() { - const [appToast, setAppToast] = useState(null); - const timerRef = useRef | null>(null); +interface ToastItem extends AppToast { + id: number; + /** True while the exit animation plays, just before removal. */ + leaving?: boolean; +} - const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => { - if (timerRef.current) clearTimeout(timerRef.current); - setAppToast({ message, tone }); - timerRef.current = setTimeout(() => setAppToast(null), 4000); +const AUTO_DISMISS_MS = 4000; +const EXIT_MS = 160; +const MAX_TOASTS = 3; + +let nextToastId = 1; + +/** + * Stacked toasts (max 3). Info toasts auto-dismiss after 4s; error toasts + * persist until explicitly dismissed so failures can't silently vanish. + */ +export function useToast() { + const [toasts, setToasts] = useState([]); + const timersRef = useRef>>(new Map()); + + const clearTimer = useCallback((id: number) => { + const timer = timersRef.current.get(id); + if (timer) { + clearTimeout(timer); + timersRef.current.delete(id); + } }, []); + const removeToast = useCallback( + (id: number) => { + clearTimer(id); + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, + [clearTimer], + ); + + const dismissToast = useCallback( + (id: number) => { + clearTimer(id); + // Mark leaving so the exit animation plays, then remove. + setToasts((prev) => prev.map((t) => (t.id === id ? { ...t, leaving: true } : t))); + const timer = setTimeout(() => removeToast(id), EXIT_MS); + timersRef.current.set(id, timer); + }, + [clearTimer, removeToast], + ); + + const showToast = useCallback( + (message: string, tone: AppToast["tone"] = "error") => { + const id = nextToastId++; + setToasts((prev) => { + const next = [...prev, { id, message, tone }]; + // Cap the stack; drop the oldest (and its pending timer). + while (next.length > MAX_TOASTS) { + const dropped = next.shift(); + if (dropped) clearTimer(dropped.id); + } + return next; + }); + if (tone !== "error") { + const timer = setTimeout(() => dismissToast(id), AUTO_DISMISS_MS); + timersRef.current.set(id, timer); + } + }, + [clearTimer, dismissToast], + ); + useMountEffect(() => () => { - if (timerRef.current) clearTimeout(timerRef.current); + for (const timer of timersRef.current.values()) clearTimeout(timer); + timersRef.current.clear(); }); - const dismissToast = useCallback(() => { - if (timerRef.current) clearTimeout(timerRef.current); - setAppToast(null); - }, []); - - return { appToast, showToast, dismissToast }; + return { toasts, showToast, dismissToast }; }