+ {(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 ? (
+
+ Save & copy message ({commentDraftCount})
+
+ ) : (
+
+ )}
+
+ )}
+
+
+
{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"}
@@ -251,38 +350,48 @@ function NavButton({
);
}
-function StatusRow({
- status,
- busy,
- onSet,
-}: {
- status: FrameStatus;
- busy: boolean;
- onSet: (next: FrameStatus) => void;
-}) {
+function ReadOnlyStatus({ status }: { status: StoryboardFrameView["status"] }) {
+ const meta = FRAME_STATUS_META[status];
return (
Status
-
- {FRAME_STATUS_ORDER.map((option) => (
- onSet(option)}
- className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
- status === option
- ? "bg-neutral-700 text-neutral-100"
- : "text-neutral-400 hover:text-neutral-200"
- }`}
- >
- {FRAME_STATUS_META[option].label}
-
- ))}
+
+ {meta.label}
+
+ Updated by your agent
+
+ );
+}
+
+// The empty state selects copy from frame status and whichever storyboard fields are available.
+// fallow-ignore-next-line complexity
+function FramePlan({ frame }: { frame: StoryboardFrameView }) {
+ const title = frame.title ?? `Frame ${frame.index}`;
+ const isOutline = frame.status === "outline";
+ return (
+
+
+
+ {isOutline ? "Planned frame" : "Preview unavailable"}
+
+
{title}
+ {frame.scene && (
+
{frame.scene}
+ )}
+ {!frame.scene && frame.narrative && (
+
+ {frame.narrative}
+
+ )}
+
+ {isOutline
+ ? "A visual preview will appear when your agent builds the sketch."
+ : frame.src
+ ? `Frame file not found: ${frame.src}`
+ : "This frame does not link to a source file."}
+
);
diff --git a/packages/studio/src/components/storyboard/StoryboardLoaded.tsx b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx
index 3cd5fc3dc..3aa880fba 100644
--- a/packages/studio/src/components/storyboard/StoryboardLoaded.tsx
+++ b/packages/studio/src/components/storyboard/StoryboardLoaded.tsx
@@ -3,10 +3,14 @@ import type { StoryboardResponse } from "../../hooks/useStoryboard";
import { Button } from "../ui/Button";
import { StoryboardDirection } from "./StoryboardDirection";
import { StoryboardGrid } from "./StoryboardGrid";
-import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
+import { StoryboardReviewGuide } from "./StoryboardReviewGuide";
+import {
+ AgentChatMessageButton,
+ APPLY_STORYBOARD_FEEDBACK_MESSAGE,
+} from "./AgentChatMessageButton";
import { useFrameComments, type CommentsSubmitState } from "./useFrameComments";
type SubView = "board" | "source";
@@ -35,6 +39,7 @@ export function StoryboardLoaded({
const [subView, setSubView] = useState
("board");
const [sourceDirty, setSourceDirty] = useState(false);
const [focusedIndex, setFocusedIndex] = useState(null);
+ const [feedbackMessageCopied, setFeedbackMessageCopied] = useState(false);
const comments = useFrameComments(data.frames);
// When the board refreshes off a project change (agent revised frames), the
// agent has likely consumed the comments file too — re-check so the pending
@@ -43,6 +48,20 @@ export function StoryboardLoaded({
useEffect(() => {
void refreshPending();
}, [data.signature, refreshPending]);
+ useEffect(() => {
+ if (comments.draftCount > 0) setFeedbackMessageCopied(false);
+ }, [comments.draftCount]);
+
+ const saveFeedbackAndCopyMessage = async () => {
+ const saved = await comments.submit();
+ if (!saved) return;
+ try {
+ await navigator.clipboard.writeText(APPLY_STORYBOARD_FEEDBACK_MESSAGE);
+ setFeedbackMessageCopied(true);
+ } catch {
+ setFeedbackMessageCopied(false);
+ }
+ };
const sourceFiles = useMemo(() => {
const files: SourceFile[] = [{ path: data.path, label: data.path }];
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
@@ -82,31 +101,51 @@ export function StoryboardLoaded({
}
onSaved={reload}
onSelectComposition={onSelectComposition}
+ scriptExists={Boolean(data.script?.exists)}
+ commentDraft={comments.drafts[focusedFrame.index] ?? ""}
+ onCommentDraftChange={(text) => comments.setDraft(focusedFrame.index, text)}
+ pendingComment={
+ comments.pending?.find((entry) => entry.frame === focusedFrame.index)?.text ?? null
+ }
+ pendingCommentCount={comments.pending?.length ?? 0}
+ commentDraftCount={comments.draftCount}
+ commentsSubmitState={comments.submitState}
+ commentsSubmitError={comments.submitError}
+ feedbackMessageCopied={feedbackMessageCopied}
+ onSaveFeedback={() => void saveFeedbackAndCopyMessage()}
posterVersion={data.signature}
/>
);
}
return (
-
-
+
+
{subView === "board" && (
void comments.submit()}
+ submitError={comments.submitError}
+ messageCopied={feedbackMessageCopied}
+ onSave={() => void saveFeedbackAndCopyMessage()}
/>
)}
{subView === "board" ? (
-
+
-
-
-
+
+
changeSubView("source")}
+ />
void;
+ submitError: string | null;
+ messageCopied: boolean;
+ onSave: () => void;
}) {
return (
-
+
{pendingCount > 0 && (
-
- {pendingCount} comment{pendingCount > 1 ? "s" : ""} pending — reply anything in your agent
- chat and it will apply them.
+ <>
+
+ {messageCopied
+ ? "Feedback saved · Message copied — paste it in agent chat."
+ : "Feedback saved · Agent not notified."}
+
+
+ >
+ )}
+ {pendingCount === 0 && draftCount === 0 && (
+ Add frame comments to request changes.
+ )}
+ {submitError && (
+
+ Couldn’t submit: {submitError}
)}
-
- {draftCount > 0 ? `Submit comments (${draftCount})` : "Submit comments"}
-
+ {draftCount > 0 && (
+
+ Save & copy message ({draftCount})
+
+ )}
);
}
+function StoryboardWarnings({
+ warnings,
+ onOpenSource,
+}: {
+ warnings: StoryboardResponse["warnings"];
+ onOpenSource: () => void;
+}) {
+ if (warnings.length === 0) return null;
+ return (
+
+
+ {warnings.length} storyboard warning{warnings.length === 1 ? "" : "s"}
+
+
+ {warnings.map((warning, index) => (
+
+ {warning.line ? `Line ${warning.line}: ` : ""}
+ {warning.message}
+
+ ))}
+
+
+ Open source to fix
+
+
+ );
+}
+
const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
{ value: "board", label: "Board" },
{ value: "source", label: "Source" },
diff --git a/packages/studio/src/components/storyboard/StoryboardReviewGuide.tsx b/packages/studio/src/components/storyboard/StoryboardReviewGuide.tsx
new file mode 100644
index 000000000..aad5b6419
--- /dev/null
+++ b/packages/studio/src/components/storyboard/StoryboardReviewGuide.tsx
@@ -0,0 +1,286 @@
+import type { StoryboardFrameView } from "../../hooks/useStoryboard";
+import {
+ AgentChatMessageButton,
+ APPLY_STORYBOARD_FEEDBACK_MESSAGE,
+} from "./AgentChatMessageButton";
+import { FRAME_STATUS_META, FRAME_STATUS_ORDER } from "./frameStatus";
+import {
+ deriveStoryboardHandoffStep,
+ deriveStoryboardReviewStage,
+ type StoryboardHandoffStep,
+ type StoryboardReviewStage,
+} from "./storyboardReviewStage";
+
+const GUIDE_COPY: Record
=
+ {
+ empty: {
+ eyebrow: "Waiting for a plan",
+ title: "The storyboard has no frames yet",
+ body: "Ask your agent to draft the story plan. Frames will appear here automatically.",
+ },
+ "plan-review": {
+ eyebrow: "Ready for review",
+ title: "Review the story plan",
+ body: "Check the sequence, scene direction, and voiceover before visual work begins. Leave frame comments, save them, then reply in agent chat to request changes or approve the plan.",
+ },
+ "sketch-in-progress": {
+ eyebrow: "Build in progress",
+ title: "Visual sketches are in progress",
+ body: "New posters appear automatically as your agent builds them. You can comment now; wait until no frames remain in Outline before approving the layouts.",
+ },
+ "sketch-review": {
+ eyebrow: "Ready for review",
+ title: "Review the visual direction",
+ body: "Check composition, hierarchy, and copy. Save frame comments, then reply in agent chat to request changes or approve the sketches for animation.",
+ },
+ "animation-in-progress": {
+ eyebrow: "Build in progress",
+ title: "Animation is in progress",
+ body: "The board refreshes as frames advance. Review completed frames now; the final review is ready when every frame is Animated.",
+ },
+ "final-review": {
+ eyebrow: "Ready for review",
+ title: "Review motion and timing",
+ body: "Every frame is animated. Open a frame in Preview to review it in the timeline, or leave frame comments for another revision.",
+ },
+ };
+
+const REVIEW_ACTION_COPY: Record<
+ StoryboardReviewStage,
+ { body: string; approvalMessage?: string }
+> = {
+ empty: { body: "Add comments as previews arrive." },
+ "plan-review": {
+ body: "Add comments where you want changes. If everything looks right, approve this pass in agent chat.",
+ approvalMessage: "Approve this storyboard plan and continue to visual sketches.",
+ },
+ "sketch-in-progress": {
+ body: "Add comments as previews arrive. You’ll be prompted to approve when this pass is ready.",
+ },
+ "sketch-review": {
+ body: "Add comments where you want changes. If everything looks right, approve this pass in agent chat.",
+ approvalMessage: "Approve these storyboard sketches and continue to animation.",
+ },
+ "animation-in-progress": {
+ body: "Add comments as previews arrive. You’ll be prompted to approve when this pass is ready.",
+ },
+ "final-review": {
+ body: "Add comments where you want changes. If everything looks right, approve this pass in agent chat.",
+ approvalMessage: "Approve this final storyboard review.",
+ },
+};
+
+type ReviewStepOffset = -1 | 0 | 1;
+const REVIEW_STEP_STATES: Record<
+ ReviewStepOffset,
+ {
+ textClass: string;
+ numberClass: string;
+ ariaCurrent: "step" | undefined;
+ marker: (number: StoryboardHandoffStep) => StoryboardHandoffStep | string;
+ }
+> = {
+ [-1]: {
+ textClass: "text-emerald-400",
+ numberClass: "border-emerald-700 bg-emerald-500/10",
+ ariaCurrent: undefined,
+ marker: () => "✓",
+ },
+ [0]: {
+ textClass: "text-sky-300",
+ numberClass: "border-sky-500 bg-sky-500/15",
+ ariaCurrent: "step",
+ marker: (number) => number,
+ },
+ [1]: {
+ textClass: "text-neutral-600",
+ numberClass: "border-neutral-700",
+ ariaCurrent: undefined,
+ marker: (number) => number,
+ },
+};
+
+const REVIEW_STEP_SEPARATOR_CLASS: Record = {
+ 1: "invisible",
+ 2: "",
+ 3: "",
+};
+
+export interface StoryboardReviewGuideProps {
+ frames: StoryboardFrameView[];
+ draftCount: number;
+ pendingCount: number;
+}
+
+/** Stage-aware instructions plus the explicit Studio → agent handoff. */
+export function StoryboardReviewGuide({
+ frames,
+ draftCount,
+ pendingCount,
+}: StoryboardReviewGuideProps) {
+ const summary = deriveStoryboardReviewStage(frames);
+ const copy = GUIDE_COPY[summary.stage];
+ const handoffStep = deriveStoryboardHandoffStep(draftCount, pendingCount);
+ const progress = progressLabel(summary);
+
+ return (
+
+
+
+
+ {copy.eyebrow}
+
+
{copy.title}
+
{copy.body}
+
+ {summary.frameCount > 0 && (
+
+
+ {progress}
+
+
+ {FRAME_STATUS_ORDER.map((status) => (
+
+ {summary.counts[status]} {FRAME_STATUS_META[status].label}
+
+ ))}
+
+
+ )}
+
+
+ {summary.frameCount > 0 && (
+
+
+
+
+ )}
+
+ );
+}
+
+function ReviewSteps({ current }: { current: StoryboardHandoffStep }) {
+ const steps = ["Review frames", "Save feedback", "Reply in agent chat"];
+ return (
+
+ {steps.map((label, index) => (
+
+ ))}
+
+ );
+}
+
+function ReviewStep({
+ label,
+ number,
+ current,
+}: {
+ label: string;
+ number: StoryboardHandoffStep;
+ current: StoryboardHandoffStep;
+}) {
+ const offset = Math.sign(number - current) as ReviewStepOffset;
+ const state = REVIEW_STEP_STATES[offset];
+
+ return (
+
+
+ →
+
+
+
+ {state.marker(number)}
+
+ {label}
+
+
+ );
+}
+
+function NextAction({
+ stage,
+ step,
+ draftCount,
+}: {
+ stage: StoryboardReviewStage;
+ step: StoryboardHandoffStep;
+ draftCount: number;
+}) {
+ if (step === 3) return ;
+ if (step === 2) return ;
+ return ;
+}
+
+function AgentHandoffAction() {
+ return (
+
+
+
Next: return to agent chat
+
+ Feedback is saved, but the agent has not been notified. Paste this message in chat: “
+ {APPLY_STORYBOARD_FEEDBACK_MESSAGE}”
+
+
+
+
+ );
+}
+
+function SaveFeedbackAction({ draftCount }: { draftCount: number }) {
+ return (
+
+
+
Next: save your feedback
+
+ {draftCount} frame{draftCount === 1 ? " has" : "s have"} feedback ready. Use Save &
+ copy message above to prepare this batch for your agent.
+
+
+
+ );
+}
+
+function ReviewFramesAction({ stage }: { stage: StoryboardReviewStage }) {
+ const copy = REVIEW_ACTION_COPY[stage];
+
+ return (
+
+
+
Next: review the frames
+
{copy.body}
+
+ {copy.approvalMessage && (
+
+ )}
+
+ );
+}
+
+// The label intentionally folds six workflow states into three user-facing progress formats.
+// fallow-ignore-next-line complexity
+function progressLabel(summary: ReturnType): string {
+ if (summary.stage === "sketch-in-progress" || summary.stage === "sketch-review") {
+ const ready = summary.frameCount - summary.counts.outline;
+ return `${ready} of ${summary.frameCount} visual sketches ready`;
+ }
+ if (summary.stage === "animation-in-progress" || summary.stage === "final-review") {
+ return `${summary.counts.animated} of ${summary.frameCount} animations ready`;
+ }
+ return `${summary.frameCount} plan frame${summary.frameCount === 1 ? "" : "s"} ready`;
+}
diff --git a/packages/studio/src/components/storyboard/storyboardReviewStage.test.ts b/packages/studio/src/components/storyboard/storyboardReviewStage.test.ts
new file mode 100644
index 000000000..6ae80079d
--- /dev/null
+++ b/packages/studio/src/components/storyboard/storyboardReviewStage.test.ts
@@ -0,0 +1,52 @@
+import { describe, expect, it } from "vitest";
+import {
+ deriveStoryboardHandoffStep,
+ deriveStoryboardReviewStage,
+ isReviewReadyStage,
+} from "./storyboardReviewStage";
+
+describe("deriveStoryboardReviewStage", () => {
+ it.each([
+ [[], "empty"],
+ [["outline", "outline"], "plan-review"],
+ [["outline", "built"], "sketch-in-progress"],
+ [["outline", "animated"], "sketch-in-progress"],
+ [["built", "built"], "sketch-review"],
+ [["built", "animated"], "animation-in-progress"],
+ [["animated", "animated"], "final-review"],
+ ] as const)("maps %j to %s", (statuses, expected) => {
+ expect(deriveStoryboardReviewStage(statuses.map((status) => ({ status }))).stage).toBe(
+ expected,
+ );
+ });
+
+ it("returns counts for the visible progress summary", () => {
+ expect(
+ deriveStoryboardReviewStage([
+ { status: "outline" },
+ { status: "built" },
+ { status: "animated" },
+ { status: "animated" },
+ ]).counts,
+ ).toEqual({ outline: 1, built: 1, animated: 2 });
+ });
+});
+
+describe("storyboard review handoff", () => {
+ it.each([
+ [0, 0, 1],
+ [2, 0, 2],
+ [0, 3, 3],
+ [1, 3, 3],
+ ] as const)("maps %i drafts and %i pending comments to step %i", (drafts, pending, step) => {
+ expect(deriveStoryboardHandoffStep(drafts, pending)).toBe(step);
+ });
+
+ it("only offers approval when a review pass is complete", () => {
+ expect(isReviewReadyStage("plan-review")).toBe(true);
+ expect(isReviewReadyStage("sketch-review")).toBe(true);
+ expect(isReviewReadyStage("final-review")).toBe(true);
+ expect(isReviewReadyStage("sketch-in-progress")).toBe(false);
+ expect(isReviewReadyStage("animation-in-progress")).toBe(false);
+ });
+});
diff --git a/packages/studio/src/components/storyboard/storyboardReviewStage.ts b/packages/studio/src/components/storyboard/storyboardReviewStage.ts
new file mode 100644
index 000000000..6fa83c5b8
--- /dev/null
+++ b/packages/studio/src/components/storyboard/storyboardReviewStage.ts
@@ -0,0 +1,49 @@
+import type { FrameStatus } from "@hyperframes/core/storyboard";
+
+export type StoryboardReviewStage =
+ | "empty"
+ | "plan-review"
+ | "sketch-in-progress"
+ | "sketch-review"
+ | "animation-in-progress"
+ | "final-review";
+
+export interface StoryboardReviewSummary {
+ stage: StoryboardReviewStage;
+ counts: Record;
+ frameCount: number;
+}
+
+export type StoryboardHandoffStep = 1 | 2 | 3;
+
+/** Current user action in the Studio → agent feedback handoff. */
+export function deriveStoryboardHandoffStep(
+ draftCount: number,
+ pendingCount: number,
+): StoryboardHandoffStep {
+ if (pendingCount > 0) return 3;
+ if (draftCount > 0) return 2;
+ return 1;
+}
+
+export function isReviewReadyStage(stage: StoryboardReviewStage): boolean {
+ return stage === "plan-review" || stage === "sketch-review" || stage === "final-review";
+}
+
+/** Derive the board-level review moment from agent-owned frame statuses. */
+export function deriveStoryboardReviewStage(
+ frames: ReadonlyArray<{ status: FrameStatus }>,
+): StoryboardReviewSummary {
+ const counts: Record = { outline: 0, built: 0, animated: 0 };
+ for (const frame of frames) counts[frame.status] += 1;
+
+ let stage: StoryboardReviewStage;
+ if (frames.length === 0) stage = "empty";
+ else if (counts.outline === frames.length) stage = "plan-review";
+ else if (counts.outline > 0) stage = "sketch-in-progress";
+ else if (counts.built === frames.length) stage = "sketch-review";
+ else if (counts.built > 0) stage = "animation-in-progress";
+ else stage = "final-review";
+
+ return { stage, counts, frameCount: frames.length };
+}
diff --git a/packages/studio/src/components/storyboard/useFrameComments.ts b/packages/studio/src/components/storyboard/useFrameComments.ts
index c90694993..443bb618d 100644
--- a/packages/studio/src/components/storyboard/useFrameComments.ts
+++ b/packages/studio/src/components/storyboard/useFrameComments.ts
@@ -17,8 +17,10 @@ export interface FrameCommentsValue {
/** How many frames currently carry a non-empty draft. */
draftCount: number;
submitState: CommentsSubmitState;
+ /** Most recent submit failure, shown next to the submit action. */
+ submitError: string | null;
/** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
- submit: () => Promise;
+ submit: () => Promise;
/**
* Comments already submitted but not yet consumed by the agent (the file
* still exists on disk). Refreshed on mount, after submit, and on window
@@ -34,6 +36,7 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
const { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
const [drafts, setDrafts] = useState>({});
const [submitState, setSubmitState] = useState("idle");
+ const [submitError, setSubmitError] = useState(null);
const [pending, setPending] = useState(null);
const refreshPending = useCallback(async () => {
@@ -61,22 +64,35 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
[drafts],
);
+ // One guarded async transaction owns loading, success, failure, and cleanup state.
+ // fallow-ignore-next-line complexity
const submit = useCallback(async () => {
- if (draftCount === 0 || submitState === "saving") return;
+ if (draftCount === 0 || submitState === "saving") return false;
setSubmitState("saving");
+ setSubmitError(null);
try {
const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
setDrafts({});
setPending(file.comments);
- } catch {
- // writeProjectFile surfaces save failures through the studio save banner;
- // just re-arm the button so the user can retry.
+ return true;
+ } catch (err: unknown) {
+ setSubmitError(err instanceof Error ? err.message : "Failed to submit comments");
+ return false;
} finally {
setSubmitState("idle");
}
}, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
- return { drafts, setDraft, draftCount, submitState, submit, pending, refreshPending };
+ return {
+ drafts,
+ setDraft,
+ draftCount,
+ submitState,
+ submitError,
+ submit,
+ pending,
+ refreshPending,
+ };
}