mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
feat(studio): clarify storyboard review handoff
This commit is contained in:
@@ -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 (
|
||||||
|
<Button size="sm" variant="secondary" onClick={() => void copyMessage()}>
|
||||||
|
{copyState === "copied"
|
||||||
|
? "Copied — paste in agent chat"
|
||||||
|
: copyState === "failed"
|
||||||
|
? "Copy failed"
|
||||||
|
: label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
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 type { StoryboardFrameView } from "../../hooks/useStoryboard";
|
||||||
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
import { useFileManagerContext } from "../../contexts/FileManagerContext";
|
||||||
import { useViewMode } from "../../contexts/ViewModeContext";
|
import { useViewMode } from "../../contexts/ViewModeContext";
|
||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { FramePoster, posterTime } from "./FramePoster";
|
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 {
|
export interface StoryboardFrameFocusProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -19,13 +24,25 @@ export interface StoryboardFrameFocusProps {
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
/** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
|
/** Select a composition in the timeline (sets active comp + editing file + sidebar highlight). */
|
||||||
onSelectComposition: (path: string) => void;
|
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). */
|
/** Project signature the board was loaded with (busts the poster cache). */
|
||||||
posterVersion?: string;
|
posterVersion?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Full-area focus on a single frame: large poster, editable voiceover guide,
|
* 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).
|
* 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
|
* Mounted with a `key` per frame, so `draft` initializes from the frame and a
|
||||||
@@ -41,25 +58,38 @@ export function StoryboardFrameFocus({
|
|||||||
onNavigate,
|
onNavigate,
|
||||||
onSaved,
|
onSaved,
|
||||||
onSelectComposition,
|
onSelectComposition,
|
||||||
|
scriptExists,
|
||||||
|
commentDraft,
|
||||||
|
onCommentDraftChange,
|
||||||
|
pendingComment,
|
||||||
|
pendingCommentCount,
|
||||||
|
commentDraftCount,
|
||||||
|
commentsSubmitState,
|
||||||
|
commentsSubmitError,
|
||||||
|
feedbackMessageCopied,
|
||||||
|
onSaveFeedback,
|
||||||
posterVersion,
|
posterVersion,
|
||||||
}: StoryboardFrameFocusProps) {
|
}: StoryboardFrameFocusProps) {
|
||||||
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
const { readProjectFile, writeProjectFile } = useFileManagerContext();
|
||||||
const { setViewMode } = useViewMode();
|
const { setViewMode } = useViewMode();
|
||||||
const [draft, setDraft] = useState(frame.voiceover ?? "");
|
const [draft, setDraft] = useState(frame.voiceover ?? "");
|
||||||
|
const [savedVoiceover, setSavedVoiceover] = useState(frame.voiceover ?? "");
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const applyEdit = useCallback(
|
const applyEdit = useCallback(
|
||||||
async (edit: (source: string) => string) => {
|
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);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const source = await readProjectFile(storyboardPath);
|
const source = await readProjectFile(storyboardPath);
|
||||||
await writeProjectFile(storyboardPath, edit(source));
|
await writeProjectFile(storyboardPath, edit(source));
|
||||||
onSaved();
|
onSaved();
|
||||||
|
return true;
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
setError(err instanceof Error ? err.message : "failed to save");
|
setError(err instanceof Error ? err.message : "failed to save");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -68,11 +98,12 @@ export function StoryboardFrameFocus({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const title = frame.title ?? `Frame ${frame.index}`;
|
const title = frame.title ?? `Frame ${frame.index}`;
|
||||||
const dirty = draft !== (frame.voiceover ?? "");
|
const dirty = draft !== savedVoiceover;
|
||||||
const canOpenPreview = frame.srcExists && Boolean(frame.src);
|
const canOpenPreview = frame.status !== "outline" && frame.srcExists && Boolean(frame.src);
|
||||||
|
|
||||||
const saveVoiceover = useCallback(() => {
|
const saveVoiceover = useCallback(async () => {
|
||||||
return applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
|
const saved = await applyEdit((src) => setFrameVoiceover(src, frame.index, draft));
|
||||||
|
if (saved) setSavedVoiceover(draft);
|
||||||
}, [applyEdit, frame.index, draft]);
|
}, [applyEdit, frame.index, draft]);
|
||||||
|
|
||||||
// Closing the tab with a dirty voiceover would lose it silently — same
|
// Closing the tab with a dirty voiceover would lose it silently — same
|
||||||
@@ -118,7 +149,7 @@ export function StoryboardFrameFocus({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
<div className="flex w-full max-w-[100vw] flex-1 min-h-0 min-w-0 flex-col overflow-hidden bg-neutral-950 text-neutral-200">
|
||||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -127,10 +158,10 @@ export function StoryboardFrameFocus({
|
|||||||
>
|
>
|
||||||
← Board
|
← Board
|
||||||
</button>
|
</button>
|
||||||
<span className="text-sm font-medium text-neutral-200">
|
<span className="min-w-0 flex-1 truncate text-sm font-medium text-neutral-200">
|
||||||
Frame {frame.number ?? frame.index} — {title}
|
Frame {frame.number ?? frame.index} — {title}
|
||||||
</span>
|
</span>
|
||||||
<div className="ml-auto flex items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<NavButton
|
<NavButton
|
||||||
label="‹ Prev"
|
label="‹ Prev"
|
||||||
disabled={frame.index <= 1}
|
disabled={frame.index <= 1}
|
||||||
@@ -144,8 +175,40 @@ export function StoryboardFrameFocus({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 min-h-0">
|
{(commentDraftCount > 0 || pendingCommentCount > 0) && (
|
||||||
<div className="flex w-3/5 min-w-0 items-center justify-center bg-neutral-900/40 p-8">
|
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-neutral-800 bg-neutral-900/80 px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-neutral-200">
|
||||||
|
{commentDraftCount > 0 ? "Feedback ready to save" : "Feedback saved"}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-neutral-500">
|
||||||
|
{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."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{commentDraftCount > 0 ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={onSaveFeedback}
|
||||||
|
loading={commentsSubmitState === "saving"}
|
||||||
|
>
|
||||||
|
Save & copy message ({commentDraftCount})
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<AgentChatMessageButton
|
||||||
|
message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
|
||||||
|
label={feedbackMessageCopied ? "Copy again" : "Copy agent message"}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-1 min-h-0 flex-col overflow-auto lg:flex-row lg:overflow-hidden">
|
||||||
|
<div className="flex w-full shrink-0 items-center justify-center bg-neutral-900/40 p-4 sm:p-8 lg:h-full lg:w-3/5">
|
||||||
<div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
|
<div className="aspect-video w-full max-w-[900px] overflow-hidden rounded-lg border border-neutral-800 bg-neutral-900">
|
||||||
{canOpenPreview && frame.src ? (
|
{canOpenPreview && frame.src ? (
|
||||||
<FramePoster
|
<FramePoster
|
||||||
@@ -157,19 +220,13 @@ export function StoryboardFrameFocus({
|
|||||||
posterVersion={posterVersion}
|
posterVersion={posterVersion}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-sm text-neutral-600">
|
<FramePlan frame={frame} />
|
||||||
{frame.status === "outline" ? "Not built yet" : "No preview"}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="w-2/5 min-w-0 space-y-6 overflow-auto border-l border-neutral-800 px-6 py-5">
|
<div className="w-full shrink-0 space-y-6 border-t border-neutral-800 px-4 py-5 sm:px-6 lg:h-full lg:w-2/5 lg:overflow-auto lg:border-t-0 lg:border-l">
|
||||||
<StatusRow
|
<ReadOnlyStatus status={frame.status} />
|
||||||
status={frame.status}
|
|
||||||
busy={busy}
|
|
||||||
onSet={(s) => applyEdit((src) => setFrameStatus(src, frame.index, s))}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
|
<div className="flex flex-wrap gap-x-6 gap-y-1 text-[11px] text-neutral-500">
|
||||||
{frame.duration && <span>Duration {frame.duration}</span>}
|
{frame.duration && <span>Duration {frame.duration}</span>}
|
||||||
@@ -187,31 +244,67 @@ export function StoryboardFrameFocus({
|
|||||||
onClick={saveVoiceover}
|
onClick={saveVoiceover}
|
||||||
disabled={!dirty}
|
disabled={!dirty}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
className="bg-emerald-600 text-white enabled:hover:bg-emerald-500 shadow-none"
|
|
||||||
>
|
>
|
||||||
{busy ? "Saving…" : "Save"}
|
{busy ? "Saving…" : "Save voiceover"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.target.value)}
|
onChange={(e) => setDraft(e.target.value)}
|
||||||
onBlur={() => {
|
|
||||||
// Same autosave paradigm as the status row above — mixed save
|
|
||||||
// models inside one panel taught users the panel autosaves,
|
|
||||||
// then lost their voiceover. Explicit Save stays as the
|
|
||||||
// affordance; blur is the safety net.
|
|
||||||
if (dirty && !busy) void saveVoiceover();
|
|
||||||
}}
|
|
||||||
rows={3}
|
rows={3}
|
||||||
placeholder="What the narrator says over this frame…"
|
placeholder="What the narrator says over this frame…"
|
||||||
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
|
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 outline-none focus:border-neutral-600"
|
||||||
/>
|
/>
|
||||||
<p className="mt-1 text-[11px] text-neutral-600">
|
<p className="mt-1 text-[11px] text-neutral-600">
|
||||||
A draft guide. SCRIPT.md locks the final narration that drives TTS.
|
{dirty
|
||||||
|
? "Unsaved changes"
|
||||||
|
: scriptExists
|
||||||
|
? "Saved to STORYBOARD.md as a guide. SCRIPT.md owns final narration and TTS."
|
||||||
|
: "Saved to STORYBOARD.md. This voiceover guides narration for the frame."}
|
||||||
</p>
|
</p>
|
||||||
{error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
|
{error && <p className="mt-1 text-[11px] text-red-400">{error}</p>}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="mb-1">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||||
|
Frame feedback
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={commentDraft}
|
||||||
|
onChange={(e) => onCommentDraftChange(e.target.value)}
|
||||||
|
rows={3}
|
||||||
|
placeholder="Tell your agent what to change in this frame…"
|
||||||
|
aria-label={`Comment on ${title}`}
|
||||||
|
className="w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-2 text-sm text-neutral-200 placeholder:text-neutral-600 outline-none focus:border-sky-700"
|
||||||
|
/>
|
||||||
|
{pendingCommentCount > 0 ? (
|
||||||
|
<div className="mt-2 flex flex-wrap items-center justify-between gap-2 rounded-md border border-sky-900/70 bg-sky-950/20 px-2.5 py-2">
|
||||||
|
<p className="text-[11px] text-sky-200">
|
||||||
|
Feedback saved. Next: paste the agent message in chat.
|
||||||
|
</p>
|
||||||
|
<AgentChatMessageButton message={APPLY_STORYBOARD_FEEDBACK_MESSAGE} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[11px] text-neutral-600">
|
||||||
|
{commentDraftCount > 0
|
||||||
|
? "Your change is ready. Save it using the review bar above."
|
||||||
|
: "Add a change to prepare feedback for the agent."}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{pendingComment && (
|
||||||
|
<p className="mt-1 text-[11px] text-sky-400/90">
|
||||||
|
<span className="font-medium">Pending:</span> “{pendingComment}”
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{commentsSubmitError && (
|
||||||
|
<p className="mt-1 text-[11px] text-red-400">
|
||||||
|
Couldn’t submit: {commentsSubmitError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{frame.narrative && (
|
{frame.narrative && (
|
||||||
<section>
|
<section>
|
||||||
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
<h3 className="mb-1 text-xs font-semibold uppercase tracking-wider text-neutral-400">
|
||||||
@@ -221,9 +314,15 @@ export function StoryboardFrameFocus({
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Button size="sm" variant="secondary" onClick={openInPreview} disabled={!canOpenPreview}>
|
{canOpenPreview ? (
|
||||||
Open in Preview →
|
<Button size="sm" variant="secondary" onClick={openInPreview}>
|
||||||
</Button>
|
Open in Preview →
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-neutral-800 bg-neutral-900/60 px-3 py-2 text-xs text-neutral-500">
|
||||||
|
Preview becomes available after your agent builds this frame.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -251,38 +350,48 @@ function NavButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusRow({
|
function ReadOnlyStatus({ status }: { status: StoryboardFrameView["status"] }) {
|
||||||
status,
|
const meta = FRAME_STATUS_META[status];
|
||||||
busy,
|
|
||||||
onSet,
|
|
||||||
}: {
|
|
||||||
status: FrameStatus;
|
|
||||||
busy: boolean;
|
|
||||||
onSet: (next: FrameStatus) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
<span className="text-xs font-semibold uppercase tracking-wider text-neutral-500">
|
||||||
Status
|
Status
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-0.5 rounded-md bg-neutral-900 p-0.5">
|
<span className={`rounded px-2 py-1 text-xs font-medium ${meta.chipClass}`}>
|
||||||
{FRAME_STATUS_ORDER.map((option) => (
|
{meta.label}
|
||||||
<button
|
</span>
|
||||||
key={option}
|
<span className="text-[11px] text-neutral-600">Updated by your agent</span>
|
||||||
type="button"
|
</div>
|
||||||
disabled={busy}
|
);
|
||||||
aria-pressed={status === option}
|
}
|
||||||
title={FRAME_STATUS_META[option].tooltip}
|
|
||||||
onClick={() => onSet(option)}
|
// The empty state selects copy from frame status and whichever storyboard fields are available.
|
||||||
className={`rounded px-2.5 py-1 text-xs font-medium transition-colors disabled:opacity-50 ${
|
// fallow-ignore-next-line complexity
|
||||||
status === option
|
function FramePlan({ frame }: { frame: StoryboardFrameView }) {
|
||||||
? "bg-neutral-700 text-neutral-100"
|
const title = frame.title ?? `Frame ${frame.index}`;
|
||||||
: "text-neutral-400 hover:text-neutral-200"
|
const isOutline = frame.status === "outline";
|
||||||
}`}
|
return (
|
||||||
>
|
<div className="flex h-full w-full items-center justify-center bg-[radial-gradient(circle_at_center,_rgba(38,38,38,0.8),_rgba(10,10,10,1))] p-10">
|
||||||
{FRAME_STATUS_META[option].label}
|
<div className="max-w-xl text-center">
|
||||||
</button>
|
<span className="rounded-full border border-neutral-700 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-neutral-400">
|
||||||
))}
|
{isOutline ? "Planned frame" : "Preview unavailable"}
|
||||||
|
</span>
|
||||||
|
<h2 className="mt-4 text-2xl font-semibold text-neutral-100">{title}</h2>
|
||||||
|
{frame.scene && (
|
||||||
|
<p className="mt-3 text-base leading-relaxed text-neutral-300">{frame.scene}</p>
|
||||||
|
)}
|
||||||
|
{!frame.scene && frame.narrative && (
|
||||||
|
<p className="mt-3 line-clamp-4 text-sm leading-relaxed text-neutral-400">
|
||||||
|
{frame.narrative}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-5 text-xs text-neutral-600">
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,14 @@ import type { StoryboardResponse } from "../../hooks/useStoryboard";
|
|||||||
import { Button } from "../ui/Button";
|
import { Button } from "../ui/Button";
|
||||||
import { StoryboardDirection } from "./StoryboardDirection";
|
import { StoryboardDirection } from "./StoryboardDirection";
|
||||||
import { StoryboardGrid } from "./StoryboardGrid";
|
import { StoryboardGrid } from "./StoryboardGrid";
|
||||||
import { StoryboardStatusLegend } from "./StoryboardStatusLegend";
|
|
||||||
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
import { StoryboardScriptPanel } from "./StoryboardScriptPanel";
|
||||||
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
|
import { StoryboardSourceEditor, type SourceFile } from "./StoryboardSourceEditor";
|
||||||
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
|
import { StoryboardFrameFocus } from "./StoryboardFrameFocus";
|
||||||
|
import { StoryboardReviewGuide } from "./StoryboardReviewGuide";
|
||||||
|
import {
|
||||||
|
AgentChatMessageButton,
|
||||||
|
APPLY_STORYBOARD_FEEDBACK_MESSAGE,
|
||||||
|
} from "./AgentChatMessageButton";
|
||||||
import { useFrameComments, type CommentsSubmitState } from "./useFrameComments";
|
import { useFrameComments, type CommentsSubmitState } from "./useFrameComments";
|
||||||
|
|
||||||
type SubView = "board" | "source";
|
type SubView = "board" | "source";
|
||||||
@@ -35,6 +39,7 @@ export function StoryboardLoaded({
|
|||||||
const [subView, setSubView] = useState<SubView>("board");
|
const [subView, setSubView] = useState<SubView>("board");
|
||||||
const [sourceDirty, setSourceDirty] = useState(false);
|
const [sourceDirty, setSourceDirty] = useState(false);
|
||||||
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
const [focusedIndex, setFocusedIndex] = useState<number | null>(null);
|
||||||
|
const [feedbackMessageCopied, setFeedbackMessageCopied] = useState(false);
|
||||||
const comments = useFrameComments(data.frames);
|
const comments = useFrameComments(data.frames);
|
||||||
// When the board refreshes off a project change (agent revised frames), the
|
// 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
|
// agent has likely consumed the comments file too — re-check so the pending
|
||||||
@@ -43,6 +48,20 @@ export function StoryboardLoaded({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshPending();
|
void refreshPending();
|
||||||
}, [data.signature, 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<SourceFile[]>(() => {
|
const sourceFiles = useMemo<SourceFile[]>(() => {
|
||||||
const files: SourceFile[] = [{ path: data.path, label: data.path }];
|
const files: SourceFile[] = [{ path: data.path, label: data.path }];
|
||||||
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
|
if (data.script?.exists) files.push({ path: data.script.path, label: data.script.path });
|
||||||
@@ -82,31 +101,51 @@ export function StoryboardLoaded({
|
|||||||
}
|
}
|
||||||
onSaved={reload}
|
onSaved={reload}
|
||||||
onSelectComposition={onSelectComposition}
|
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}
|
posterVersion={data.signature}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 min-h-0 flex-col bg-neutral-950 text-neutral-200">
|
<div className="flex w-full max-w-[100vw] flex-1 min-h-0 min-w-0 flex-col overflow-hidden bg-neutral-950 text-neutral-200">
|
||||||
<div className="flex items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
<div className="flex flex-wrap items-center gap-3 border-b border-neutral-800 px-4 py-2">
|
||||||
<SubViewToggle value={subView} onChange={changeSubView} />
|
<SubViewToggle value={subView} onChange={changeSubView} />
|
||||||
{subView === "board" && (
|
{subView === "board" && (
|
||||||
<CommentsSubmitBar
|
<CommentsSubmitBar
|
||||||
draftCount={comments.draftCount}
|
draftCount={comments.draftCount}
|
||||||
pendingCount={comments.pending?.length ?? 0}
|
pendingCount={comments.pending?.length ?? 0}
|
||||||
submitState={comments.submitState}
|
submitState={comments.submitState}
|
||||||
onSubmit={() => void comments.submit()}
|
submitError={comments.submitError}
|
||||||
|
messageCopied={feedbackMessageCopied}
|
||||||
|
onSave={() => void saveFeedbackAndCopyMessage()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{subView === "board" ? (
|
{subView === "board" ? (
|
||||||
<div className="flex-1 min-h-0 overflow-auto">
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
<div className="mx-auto max-w-[1400px] px-8 py-8">
|
<div className="mx-auto max-w-[1400px] px-4 py-5 sm:px-8 sm:py-8">
|
||||||
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
|
<StoryboardDirection globals={data.globals} frameCount={data.frames.length} />
|
||||||
<div className="mt-5">
|
<StoryboardReviewGuide
|
||||||
<StoryboardStatusLegend />
|
frames={data.frames}
|
||||||
</div>
|
draftCount={comments.draftCount}
|
||||||
|
pendingCount={comments.pending?.length ?? 0}
|
||||||
|
/>
|
||||||
|
<StoryboardWarnings
|
||||||
|
warnings={data.warnings}
|
||||||
|
onOpenSource={() => changeSubView("source")}
|
||||||
|
/>
|
||||||
<StoryboardGrid
|
<StoryboardGrid
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
frames={data.frames}
|
frames={data.frames}
|
||||||
@@ -135,34 +174,87 @@ function CommentsSubmitBar({
|
|||||||
draftCount,
|
draftCount,
|
||||||
pendingCount,
|
pendingCount,
|
||||||
submitState,
|
submitState,
|
||||||
onSubmit,
|
submitError,
|
||||||
|
messageCopied,
|
||||||
|
onSave,
|
||||||
}: {
|
}: {
|
||||||
draftCount: number;
|
draftCount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
submitState: CommentsSubmitState;
|
submitState: CommentsSubmitState;
|
||||||
onSubmit: () => void;
|
submitError: string | null;
|
||||||
|
messageCopied: boolean;
|
||||||
|
onSave: () => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="ml-auto flex items-center gap-3">
|
<div className="ml-auto flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
|
||||||
{pendingCount > 0 && (
|
{pendingCount > 0 && (
|
||||||
<span className="text-xs text-sky-300">
|
<>
|
||||||
{pendingCount} comment{pendingCount > 1 ? "s" : ""} pending — reply anything in your agent
|
<span className="text-xs text-sky-300">
|
||||||
chat and it will apply them.
|
{messageCopied
|
||||||
|
? "Feedback saved · Message copied — paste it in agent chat."
|
||||||
|
: "Feedback saved · Agent not notified."}
|
||||||
|
</span>
|
||||||
|
<AgentChatMessageButton
|
||||||
|
message={APPLY_STORYBOARD_FEEDBACK_MESSAGE}
|
||||||
|
label={messageCopied ? "Copy again" : "Copy agent message"}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{pendingCount === 0 && draftCount === 0 && (
|
||||||
|
<span className="text-xs text-neutral-500">Add frame comments to request changes.</span>
|
||||||
|
)}
|
||||||
|
{submitError && (
|
||||||
|
<span className="max-w-64 truncate text-xs text-red-400" title={submitError}>
|
||||||
|
Couldn’t submit: {submitError}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button
|
{draftCount > 0 && (
|
||||||
variant="primary"
|
<Button
|
||||||
size="sm"
|
variant="primary"
|
||||||
loading={submitState === "saving"}
|
size="sm"
|
||||||
disabled={draftCount === 0 || submitState === "saving"}
|
loading={submitState === "saving"}
|
||||||
onClick={onSubmit}
|
disabled={submitState === "saving"}
|
||||||
>
|
onClick={onSave}
|
||||||
{draftCount > 0 ? `Submit comments (${draftCount})` : "Submit comments"}
|
>
|
||||||
</Button>
|
Save & copy message ({draftCount})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function StoryboardWarnings({
|
||||||
|
warnings,
|
||||||
|
onOpenSource,
|
||||||
|
}: {
|
||||||
|
warnings: StoryboardResponse["warnings"];
|
||||||
|
onOpenSource: () => void;
|
||||||
|
}) {
|
||||||
|
if (warnings.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<details className="mt-3 rounded-lg border border-amber-900/60 bg-amber-950/20 px-4 py-2 text-xs text-amber-200">
|
||||||
|
<summary className="cursor-pointer font-medium">
|
||||||
|
{warnings.length} storyboard warning{warnings.length === 1 ? "" : "s"}
|
||||||
|
</summary>
|
||||||
|
<ul className="mt-2 space-y-1 text-amber-200/80">
|
||||||
|
{warnings.map((warning, index) => (
|
||||||
|
<li key={`${warning.line ?? "unknown"}-${index}`}>
|
||||||
|
{warning.line ? `Line ${warning.line}: ` : ""}
|
||||||
|
{warning.message}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onOpenSource}
|
||||||
|
className="mt-2 rounded text-amber-100 underline underline-offset-2 hover:text-white"
|
||||||
|
>
|
||||||
|
Open source to fix
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
|
const SUB_VIEWS: Array<{ value: SubView; label: string }> = [
|
||||||
{ value: "board", label: "Board" },
|
{ value: "board", label: "Board" },
|
||||||
{ value: "source", label: "Source" },
|
{ value: "source", label: "Source" },
|
||||||
|
|||||||
@@ -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<StoryboardReviewStage, { eyebrow: string; title: string; body: string }> =
|
||||||
|
{
|
||||||
|
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<StoryboardHandoffStep, string> = {
|
||||||
|
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 (
|
||||||
|
<section className="mt-5 rounded-lg border border-neutral-800 bg-neutral-900/60 px-4 py-3">
|
||||||
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-sky-400">
|
||||||
|
{copy.eyebrow}
|
||||||
|
</div>
|
||||||
|
<h2 className="mt-0.5 text-sm font-semibold text-neutral-100">{copy.title}</h2>
|
||||||
|
<p className="mt-1 text-xs leading-relaxed text-neutral-400">{copy.body}</p>
|
||||||
|
</div>
|
||||||
|
{summary.frameCount > 0 && (
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="mb-1.5 text-right text-[11px] font-medium text-neutral-300">
|
||||||
|
{progress}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap justify-end gap-1.5" aria-label="Frame status summary">
|
||||||
|
{FRAME_STATUS_ORDER.map((status) => (
|
||||||
|
<span
|
||||||
|
key={status}
|
||||||
|
className={`rounded px-2 py-1 text-[10px] font-medium ${FRAME_STATUS_META[status].chipClass}`}
|
||||||
|
>
|
||||||
|
{summary.counts[status]} {FRAME_STATUS_META[status].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{summary.frameCount > 0 && (
|
||||||
|
<div className="mt-3 border-t border-neutral-800 pt-3">
|
||||||
|
<ReviewSteps current={handoffStep} />
|
||||||
|
<NextAction stage={summary.stage} step={handoffStep} draftCount={draftCount} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewSteps({ current }: { current: StoryboardHandoffStep }) {
|
||||||
|
const steps = ["Review frames", "Save feedback", "Reply in agent chat"];
|
||||||
|
return (
|
||||||
|
<ol className="hidden items-center gap-2 sm:flex" aria-label="Storyboard review workflow">
|
||||||
|
{steps.map((label, index) => (
|
||||||
|
<ReviewStep
|
||||||
|
key={label}
|
||||||
|
label={label}
|
||||||
|
number={(index + 1) as StoryboardHandoffStep}
|
||||||
|
current={current}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`text-neutral-700 ${REVIEW_STEP_SEPARATOR_CLASS[number]}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
aria-current={state.ariaCurrent}
|
||||||
|
className={`flex items-center gap-1.5 text-[11px] font-medium ${state.textClass}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`flex h-5 w-5 items-center justify-center rounded-full border text-[10px] ${state.numberClass}`}
|
||||||
|
>
|
||||||
|
{state.marker(number)}
|
||||||
|
</span>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NextAction({
|
||||||
|
stage,
|
||||||
|
step,
|
||||||
|
draftCount,
|
||||||
|
}: {
|
||||||
|
stage: StoryboardReviewStage;
|
||||||
|
step: StoryboardHandoffStep;
|
||||||
|
draftCount: number;
|
||||||
|
}) {
|
||||||
|
if (step === 3) return <AgentHandoffAction />;
|
||||||
|
if (step === 2) return <SaveFeedbackAction draftCount={draftCount} />;
|
||||||
|
return <ReviewFramesAction stage={stage} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgentHandoffAction() {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-3 rounded-md border border-sky-900/70 bg-sky-950/20 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="text-xs font-semibold text-sky-200">Next: return to agent chat</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-neutral-400">
|
||||||
|
Feedback is saved, but the agent has not been notified. Paste this message in chat: “
|
||||||
|
{APPLY_STORYBOARD_FEEDBACK_MESSAGE}”
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<AgentChatMessageButton message={APPLY_STORYBOARD_FEEDBACK_MESSAGE} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SaveFeedbackAction({ draftCount }: { draftCount: number }) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 rounded-md border border-neutral-800 bg-neutral-950/40 px-3 py-2.5">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-neutral-200">Next: save your feedback</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-neutral-500">
|
||||||
|
{draftCount} frame{draftCount === 1 ? " has" : "s have"} feedback ready. Use Save &
|
||||||
|
copy message above to prepare this batch for your agent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReviewFramesAction({ stage }: { stage: StoryboardReviewStage }) {
|
||||||
|
const copy = REVIEW_ACTION_COPY[stage];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-col gap-3 rounded-md border border-neutral-800 bg-neutral-950/40 px-3 py-2.5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-neutral-200">Next: review the frames</div>
|
||||||
|
<p className="mt-0.5 text-[11px] text-neutral-500">{copy.body}</p>
|
||||||
|
</div>
|
||||||
|
{copy.approvalMessage && (
|
||||||
|
<AgentChatMessageButton message={copy.approvalMessage} label="Copy approval message" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The label intentionally folds six workflow states into three user-facing progress formats.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
|
function progressLabel(summary: ReturnType<typeof deriveStoryboardReviewStage>): 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`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<FrameStatus, number>;
|
||||||
|
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<FrameStatus, number> = { 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 };
|
||||||
|
}
|
||||||
@@ -17,8 +17,10 @@ export interface FrameCommentsValue {
|
|||||||
/** How many frames currently carry a non-empty draft. */
|
/** How many frames currently carry a non-empty draft. */
|
||||||
draftCount: number;
|
draftCount: number;
|
||||||
submitState: CommentsSubmitState;
|
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. */
|
/** Write the batch to `.hyperframes/frame-comments.json` and clear the drafts. */
|
||||||
submit: () => Promise<void>;
|
submit: () => Promise<boolean>;
|
||||||
/**
|
/**
|
||||||
* Comments already submitted but not yet consumed by the agent (the file
|
* Comments already submitted but not yet consumed by the agent (the file
|
||||||
* still exists on disk). Refreshed on mount, after submit, and on window
|
* 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 { writeProjectFile, readOptionalProjectFile } = useFileManagerContext();
|
||||||
const [drafts, setDrafts] = useState<Record<number, string>>({});
|
const [drafts, setDrafts] = useState<Record<number, string>>({});
|
||||||
const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
|
const [submitState, setSubmitState] = useState<CommentsSubmitState>("idle");
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||||
const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
|
const [pending, setPending] = useState<FrameCommentEntry[] | null>(null);
|
||||||
|
|
||||||
const refreshPending = useCallback(async () => {
|
const refreshPending = useCallback(async () => {
|
||||||
@@ -61,22 +64,35 @@ export function useFrameComments(frames: StoryboardFrameView[]): FrameCommentsVa
|
|||||||
[drafts],
|
[drafts],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// One guarded async transaction owns loading, success, failure, and cleanup state.
|
||||||
|
// fallow-ignore-next-line complexity
|
||||||
const submit = useCallback(async () => {
|
const submit = useCallback(async () => {
|
||||||
if (draftCount === 0 || submitState === "saving") return;
|
if (draftCount === 0 || submitState === "saving") return false;
|
||||||
setSubmitState("saving");
|
setSubmitState("saving");
|
||||||
|
setSubmitError(null);
|
||||||
try {
|
try {
|
||||||
const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
|
const previous = parseCommentsFile(await readOptionalProjectFile(FRAME_COMMENTS_PATH));
|
||||||
const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
|
const file = buildCommentsFile(frames, drafts, previous, new Date().toISOString());
|
||||||
await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
|
await writeProjectFile(FRAME_COMMENTS_PATH, `${JSON.stringify(file, null, 2)}\n`);
|
||||||
setDrafts({});
|
setDrafts({});
|
||||||
setPending(file.comments);
|
setPending(file.comments);
|
||||||
} catch {
|
return true;
|
||||||
// writeProjectFile surfaces save failures through the studio save banner;
|
} catch (err: unknown) {
|
||||||
// just re-arm the button so the user can retry.
|
setSubmitError(err instanceof Error ? err.message : "Failed to submit comments");
|
||||||
|
return false;
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitState("idle");
|
setSubmitState("idle");
|
||||||
}
|
}
|
||||||
}, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
|
}, [draftCount, submitState, frames, drafts, readOptionalProjectFile, writeProjectFile]);
|
||||||
|
|
||||||
return { drafts, setDraft, draftCount, submitState, submit, pending, refreshPending };
|
return {
|
||||||
|
drafts,
|
||||||
|
setDraft,
|
||||||
|
draftCount,
|
||||||
|
submitState,
|
||||||
|
submitError,
|
||||||
|
submit,
|
||||||
|
pending,
|
||||||
|
refreshPending,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user