From 73dd38f93ef94a17fd9eb254198e66492d0aa84f Mon Sep 17 00:00:00 2001 From: James Date: Thu, 16 Jul 2026 12:52:33 -0400 Subject: [PATCH] feat(studio): clarify storyboard review handoff --- .../storyboard/AgentChatMessageButton.tsx | 33 ++ .../storyboard/StoryboardFrameFocus.tsx | 233 ++++++++++---- .../storyboard/StoryboardLoaded.tsx | 138 +++++++-- .../storyboard/StoryboardReviewGuide.tsx | 286 ++++++++++++++++++ .../storyboard/storyboardReviewStage.test.ts | 52 ++++ .../storyboard/storyboardReviewStage.ts | 49 +++ .../components/storyboard/useFrameComments.ts | 28 +- 7 files changed, 728 insertions(+), 91 deletions(-) create mode 100644 packages/studio/src/components/storyboard/AgentChatMessageButton.tsx create mode 100644 packages/studio/src/components/storyboard/StoryboardReviewGuide.tsx create mode 100644 packages/studio/src/components/storyboard/storyboardReviewStage.test.ts create mode 100644 packages/studio/src/components/storyboard/storyboardReviewStage.ts diff --git a/packages/studio/src/components/storyboard/AgentChatMessageButton.tsx b/packages/studio/src/components/storyboard/AgentChatMessageButton.tsx new file mode 100644 index 000000000..9aa56a95c --- /dev/null +++ b/packages/studio/src/components/storyboard/AgentChatMessageButton.tsx @@ -0,0 +1,33 @@ +import { useState } from "react"; +import { Button } from "../ui/Button"; + +export const APPLY_STORYBOARD_FEEDBACK_MESSAGE = "Apply my saved storyboard feedback."; + +export function AgentChatMessageButton({ + message, + label = "Copy agent message", +}: { + message: string; + label?: string; +}) { + const [copyState, setCopyState] = useState<"idle" | "copied" | "failed">("idle"); + + const copyMessage = async () => { + try { + await navigator.clipboard.writeText(message); + setCopyState("copied"); + } catch { + setCopyState("failed"); + } + }; + + return ( + + ); +} diff --git a/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx b/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx index dea27f11f..33f077ff5 100644 --- a/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx +++ b/packages/studio/src/components/storyboard/StoryboardFrameFocus.tsx @@ -1,11 +1,16 @@ import { useCallback, useEffect, useState } from "react"; -import { setFrameStatus, setFrameVoiceover, type FrameStatus } from "@hyperframes/core/storyboard"; +import { setFrameVoiceover } from "@hyperframes/core/storyboard"; import type { StoryboardFrameView } from "../../hooks/useStoryboard"; import { useFileManagerContext } from "../../contexts/FileManagerContext"; import { useViewMode } from "../../contexts/ViewModeContext"; import { Button } from "../ui/Button"; import { FramePoster, posterTime } from "./FramePoster"; -import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus"; +import { + AgentChatMessageButton, + APPLY_STORYBOARD_FEEDBACK_MESSAGE, +} from "./AgentChatMessageButton"; +import { FRAME_STATUS_META } from "./frameStatus"; +import type { CommentsSubmitState } from "./useFrameComments"; export interface StoryboardFrameFocusProps { projectId: string; @@ -19,13 +24,25 @@ export interface StoryboardFrameFocusProps { onSaved: () => void; /** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */ onSelectComposition: (path: string) => void; + /** Whether SCRIPT.md exists and owns final narration/TTS. */ + scriptExists: boolean; + /** Shared board draft for this frame, preserved when entering/leaving focus. */ + commentDraft: string; + onCommentDraftChange: (text: string) => void; + pendingComment: string | null; + pendingCommentCount: number; + commentDraftCount: number; + commentsSubmitState: CommentsSubmitState; + commentsSubmitError: string | null; + feedbackMessageCopied: boolean; + onSaveFeedback: () => void; /** Project signature the board was loaded with (busts the poster cache). */ posterVersion?: string; } /** * Full-area focus on a single frame: large poster, editable voiceover guide, - * status advancement, full narrative, and a jump into the live preview. Edits + * review feedback, full narrative, and a jump into the live preview. Edits * are written back to STORYBOARD.md in place (markdown stays canonical). * * Mounted with a `key` per frame, so `draft` initializes from the frame and a @@ -41,25 +58,38 @@ export function StoryboardFrameFocus({ onNavigate, onSaved, onSelectComposition, + scriptExists, + commentDraft, + onCommentDraftChange, + pendingComment, + pendingCommentCount, + commentDraftCount, + commentsSubmitState, + commentsSubmitError, + feedbackMessageCopied, + onSaveFeedback, posterVersion, }: StoryboardFrameFocusProps) { const { readProjectFile, writeProjectFile } = useFileManagerContext(); const { setViewMode } = useViewMode(); const [draft, setDraft] = useState(frame.voiceover ?? ""); + const [savedVoiceover, setSavedVoiceover] = useState(frame.voiceover ?? ""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const applyEdit = useCallback( async (edit: (source: string) => string) => { - if (busy) return; // one read-modify-write at a time; avoids a lost update + if (busy) return false; // one read-modify-write at a time; avoids a lost update setBusy(true); setError(null); try { const source = await readProjectFile(storyboardPath); await writeProjectFile(storyboardPath, edit(source)); onSaved(); + return true; } catch (err: unknown) { setError(err instanceof Error ? err.message : "failed to save"); + return false; } finally { setBusy(false); } @@ -68,11 +98,12 @@ export function StoryboardFrameFocus({ ); const title = frame.title ?? `Frame ${frame.index}`; - const dirty = draft !== (frame.voiceover ?? ""); - const canOpenPreview = frame.srcExists && Boolean(frame.src); + const dirty = draft !== savedVoiceover; + const canOpenPreview = frame.status !== "outline" && frame.srcExists && Boolean(frame.src); - const saveVoiceover = useCallback(() => { - return applyEdit((src) => setFrameVoiceover(src, frame.index, draft)); + const saveVoiceover = useCallback(async () => { + const saved = await applyEdit((src) => setFrameVoiceover(src, frame.index, draft)); + if (saved) setSavedVoiceover(draft); }, [applyEdit, frame.index, draft]); // Closing the tab with a dirty voiceover would lose it silently — same @@ -118,7 +149,7 @@ export function StoryboardFrameFocus({ }; return ( -
+
- + Frame {frame.number ?? frame.index} — {title} -
+
-
-
+ {(commentDraftCount > 0 || pendingCommentCount > 0) && ( +
+
+
+ {commentDraftCount > 0 ? "Feedback ready to save" : "Feedback saved"} +
+
+ {commentDraftCount > 0 + ? "Save this batch and copy the message for your agent." + : feedbackMessageCopied + ? "Message copied — paste it in agent chat to continue." + : "The agent has not been notified yet."} +
+
+ {commentDraftCount > 0 ? ( + + ) : ( + + )} +
+ )} + +
+
{canOpenPreview && frame.src ? ( ) : ( -
- {frame.status === "outline" ? "Not built yet" : "No preview"} -
+ )}
-
- applyEdit((src) => setFrameStatus(src, frame.index, s))} - /> +
+
{frame.duration && Duration {frame.duration}} @@ -187,31 +244,67 @@ export function StoryboardFrameFocus({ onClick={saveVoiceover} disabled={!dirty} loading={busy} - className="bg-emerald-600 text-white enabled:hover:bg-emerald-500 shadow-none" > - {busy ? "Saving…" : "Save"} + {busy ? "Saving…" : "Save voiceover"}