+
+
-
- Story info
+
+ {badge}
-
- Next:
- {storyInfoNextStep(progress)}
-
+ {summary}
+ {note ? {note}
: null}
-
+ {children ? (
+
+ {children}
+
+ ) : null}
);
}
+function NextActionButton({
+ onClick,
+ disabled,
+ testId,
+}: {
+ onClick: () => void;
+ disabled?: boolean;
+ testId: string;
+}) {
+ return (
+
+ );
+}
+
+function WorkflowCoachCompact({
+ coach,
+ onAction,
+}: {
+ coach: CartoonCoach | null | undefined;
+ onAction: (action: CoachUiAction, episodeFile: string | null) => void;
+}) {
+ const [copiedPrompt, setCopiedPrompt] = useState
(null);
+ const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
+
+ if (coach === undefined) return null;
+ if (!coach) {
+ return (
+
+ );
+ }
+
+ const button =
+ coach.actionKind === "agent" && coach.prompt ? (
+ {
+ if (!coach.prompt) return;
+ const prompt = coach.prompt;
+ navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
+ }}
+ />
+ ) : coach.actionKind === "ui" && coach.uiAction ? (
+ onAction(coach.uiAction!, coach.episodeFile)}
+ />
+ ) : null;
+
+ return (
+
+ Next:
+ {coach.action}
+
+ )}
+ note={copied ? "Prompt copied." : undefined}
+ testId="cartoon-next-action"
+ >
+ {button}
+
+ );
+}
+
+export function StoryInfoNextActionCard({
+ progress,
+ onOpenStoryInfo,
+}: {
+ progress: StoryProgress;
+ onOpenStoryInfo?: () => void;
+}) {
+ return (
+
+ Next:
+ {storyInfoNextStep(progress)}
+
+ )}
+ testId="story-info-cta"
+ >
+ onOpenStoryInfo?.()}
+ disabled={!onOpenStoryInfo}
+ />
+
+ );
+}
+
export function CartoonNextActionView({
progress,
onCoachAction,
@@ -80,31 +201,27 @@ export function CartoonNextActionView({
if (activeKey === "story-info") {
return ;
}
- return (
-
- );
+ return ;
}
export function CartoonNextAction({
storyName,
authFetch,
+ fileName,
refreshKey = 0,
onCoachAction,
onOpenStoryInfo,
}: {
storyName: string;
authFetch: (url: string, opts?: RequestInit) => Promise;
+ fileName?: string | null;
refreshKey?: number;
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
onOpenStoryInfo?: () => void;
}) {
const [progress, setProgress] = useState(undefined);
- const targetKey = JSON.stringify([storyName, refreshKey]);
+ const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
const [loadedKey, setLoadedKey] = useState(null);
if (loadedKey !== targetKey) {
setProgress(undefined);
@@ -113,7 +230,8 @@ export function CartoonNextAction({
useEffect(() => {
let cancelled = false;
- authFetch(`/api/stories/${storyName}/progress`)
+ const focus = fileName ? `?focus=${encodeURIComponent(fileName)}` : "";
+ authFetch(`/api/stories/${storyName}/progress${focus}`)
.then((res) => (res.ok ? res.json() : null))
.then((data: StoryProgress | null) => {
if (!cancelled) setProgress(isValidProgress(data) ? data : null);
@@ -122,11 +240,11 @@ export function CartoonNextAction({
if (!cancelled) setProgress(null);
});
return () => { cancelled = true; };
- }, [storyName, authFetch, refreshKey]);
+ }, [storyName, fileName, authFetch, refreshKey]);
if (progress === undefined) return null;
if (!progress) {
- return ;
+ return ;
}
return (
{
- it("a Genesis coach UI action lands on the actionable cut workspace, not the markdown editor", async () => {
- render();
-
- // The coach loads for Genesis with an in-app (lettering) action.
- const doBtn = await screen.findByTestId("workflow-coach-do");
- expect(screen.getByTestId("workflow-coach-action")).toHaveTextContent(/Review cuts and start lettering/i);
- expect(doBtn).toHaveTextContent("Next Action");
- // Before acting, the cut workspace isn't mounted.
- expect(screen.queryByTestId("cut-list-panel")).not.toBeInTheDocument();
-
- fireEvent.click(doBtn);
+function makeGenerateMarkdownAuthFetch() {
+ const calls: string[] = [];
+ const fn = vi.fn((url: string) => {
+ calls.push(url);
+ if (url.includes("/asset/")) {
+ return Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob(["x"], { type: "image/webp" })) });
+ }
+ let data: unknown = {};
+ if (url.includes("/progress")) data = { contentType: "cartoon", coach: genesisCoach };
+ else if (url.includes("/cuts/genesis/generate-markdown")) data = { ok: true };
+ else if (url.includes("/cuts/genesis/detect-clean-images")) data = { detected: [], stale: [] };
+ else if (url.includes("/cuts/genesis/asset-diagnostics")) data = { diagnostics: [], summary: {} };
+ else if (url.includes("/cuts/genesis")) data = { version: 1, plotFile: "genesis", cuts: [genesisCut] };
+ else if (url.includes("/cover-asset")) data = { found: false };
+ else if (url.endsWith("/genesis.md")) data = { file: "genesis.md", status: "pending", content: "# Opening\n\nThe story begins." };
+ else if (url.endsWith("/structure.md")) data = { content: "# Bible" };
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data) });
+ });
+ return { fn, calls };
+}
- // After: the Genesis cut workspace is mounted and actionable — NOT the
- // plain markdown textarea (the bug @re1 caught).
- expect(await screen.findByTestId("cut-list-panel")).toBeInTheDocument();
+describe("PreviewPanel — cartoon file chrome", () => {
+ it("does not render the old top workflow coach in cartoon file views (#498)", async () => {
+ render();
+ expect(await screen.findByText("The story begins.")).toBeInTheDocument();
+ expect(screen.queryByTestId("workflow-coach")).not.toBeInTheDocument();
});
it("Genesis Edit tab keeps the opening-text editor and reaches the cut workspace via the sub-toggle", async () => {
render();
- await screen.findByTestId("workflow-coach"); // wait for first render to settle
+ await screen.findByText("The story begins.");
fireEvent.click(screen.getByRole("button", { name: /^Edit/ }));
// Default Genesis Edit sub-view is the opening-text editor (prose preserved).
@@ -94,7 +104,7 @@ describe("PreviewPanel — Genesis workflow-coach cut actions (#429)", () => {
/>,
);
- await screen.findByTestId("workflow-coach");
+ await screen.findByText("The story begins.");
for (let i = 0; i < 2; i++) {
fireEvent.click(screen.getByRole("button", { name: /^Edit/ }));
@@ -117,4 +127,39 @@ describe("PreviewPanel — Genesis workflow-coach cut actions (#429)", () => {
expect(await screen.findByText("The story begins.")).toBeInTheDocument();
}
});
+
+ it("applies an open-lettering workflow request by landing in Genesis cuts edit", async () => {
+ render(
+ ,
+ );
+
+ expect(await screen.findByTestId("cut-list-panel")).toBeInTheDocument();
+ expect(screen.getByTestId("genesis-edit-mode-cuts")).toBeInTheDocument();
+ });
+
+ it("applies a generate-markdown workflow request through the generation endpoint", async () => {
+ const { fn, calls } = makeGenerateMarkdownAuthFetch();
+ render(
+ ,
+ );
+
+ await screen.findByText("The story begins.");
+ expect(
+ calls.some((url) => url.includes("/cuts/genesis/generate-markdown")),
+ ).toBe(true);
+ });
});
diff --git a/app/web/components/PreviewPanel.tsx b/app/web/components/PreviewPanel.tsx
index 793d2c7..7ae1030 100644
--- a/app/web/components/PreviewPanel.tsx
+++ b/app/web/components/PreviewPanel.tsx
@@ -4,11 +4,10 @@ import remarkBreaks from "remark-breaks";
import remarkGfm from "remark-gfm";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import { GENRES, LANGUAGES, canonicalizeGenre } from "../../../lib/genres";
+import type { CoachUiAction } from "@app-lib/cartoon-coach";
import { CartoonPreview } from "./CartoonPreview";
import { CartoonPublishPreview } from "./CartoonPublishPreview";
import { CutListPanel } from "./CutListPanel";
-import { WorkflowCoach } from "./WorkflowCoach";
-import type { CoachUiAction } from "@app-lib/cartoon-coach";
import {
classifyCartoonReadiness,
cartoonGenesisReadiness,
@@ -120,6 +119,11 @@ interface PreviewPanelProps {
onFocusedLetteringModeChange?: (active: boolean) => void;
/** Restore/fold the wider app work area while staying in the editor. */
onFocusedLetteringWorkspaceVisibleChange?: (visible: boolean) => void;
+ workflowActionRequest?: {
+ action: CoachUiAction;
+ seq: number;
+ } | null;
+ onWorkflowActionHandled?: (seq: number) => void;
}
interface FileData {
@@ -136,6 +140,13 @@ interface FileData {
type Tab = "preview" | "edit";
+function workflowActionNeedsCuts(action: CoachUiAction | null | undefined): boolean {
+ return action === "open-cuts"
+ || action === "open-lettering"
+ || action === "upload"
+ || action === "refresh-assets";
+}
+
export function PreviewPanel({
storyName,
fileName,
@@ -155,6 +166,8 @@ export function PreviewPanel({
focusedLetteringWorkspaceVisible = false,
onFocusedLetteringModeChange,
onFocusedLetteringWorkspaceVisibleChange,
+ workflowActionRequest = null,
+ onWorkflowActionHandled,
}: PreviewPanelProps) {
const [fileData, setFileData] = useState(null);
const [loading, setLoading] = useState(false);
@@ -269,6 +282,11 @@ export function PreviewPanel({
const illustrationInputRef = useRef(null);
const prevFileRef = useRef(null);
+ const appliedWorkflowSeqRef = useRef(0);
+ const pendingWorkflowCutsRef = useRef(false);
+ pendingWorkflowCutsRef.current = workflowActionNeedsCuts(
+ workflowActionRequest?.action,
+ );
const loadFile = useCallback(async () => {
if (!storyName || !fileName) {
@@ -533,43 +551,31 @@ export function PreviewPanel({
}
}, [storyName, fileName, authFetch, loadFile]);
- // Route a workflow-coach UI action to the right control (#429). When the
- // action concerns a different episode than the open file (e.g. the coach on
- // structure.md points at the active episode), open that file first — the coach
- // there offers the same action in place. Otherwise reveal the control: the cut
- // workspace for letter/export/upload/refresh, the Preview tab for publish (the
- // writer still confirms the irreversible publish), or run Prepare directly.
- const handleCoachAction = useCallback(
- (action: CoachUiAction, episodeFile: string | null) => {
- if (action === "view-progress") {
+ useEffect(() => {
+ if (!workflowActionRequest) return;
+ if (workflowActionRequest.seq === appliedWorkflowSeqRef.current) return;
+ appliedWorkflowSeqRef.current = workflowActionRequest.seq;
+
+ switch (workflowActionRequest.action) {
+ case "view-progress":
onViewProgress?.();
- return;
- }
- if (episodeFile && episodeFile !== fileName) {
- onOpenFile?.(episodeFile);
- return;
- }
- switch (action) {
- case "open-cuts":
- case "open-lettering":
- case "upload":
- case "refresh-assets":
- setActiveTab("edit");
- // For Genesis the Edit tab defaults to the opening-text editor; switch to
- // the cut workspace so the lettering/upload/refresh action is actionable.
- // No-op for plots (the cut workspace is the only Edit view).
- setGenesisEditMode("cuts");
- break;
- case "generate-markdown":
- handleGenerateMarkdown();
- break;
- case "publish":
- setActiveTab("preview");
- break;
- }
- },
- [fileName, onViewProgress, onOpenFile, handleGenerateMarkdown],
- );
+ break;
+ case "open-cuts":
+ case "open-lettering":
+ case "upload":
+ case "refresh-assets":
+ setActiveTab("edit");
+ setGenesisEditMode("cuts");
+ break;
+ case "generate-markdown":
+ handleGenerateMarkdown();
+ break;
+ case "publish":
+ setActiveTab("preview");
+ break;
+ }
+ onWorkflowActionHandled?.(workflowActionRequest.seq);
+ }, [workflowActionRequest, onViewProgress, handleGenerateMarkdown, onWorkflowActionHandled]);
// Handle cover image selection
const handleCoverSelect = useCallback(
@@ -811,7 +817,7 @@ export function PreviewPanel({
setDetectedCoverWarning(null);
setCoverStatus("unknown");
coverUserTouchedRef.current = false;
- setGenesisEditMode("text");
+ setGenesisEditMode(pendingWorkflowCutsRef.current ? "cuts" : "text");
}, [storyName, fileName]);
// Auto-detect an agent-created cover (assets/cover.webp|jpg) for an UNPUBLISHED
@@ -1331,25 +1337,6 @@ export function PreviewPanel({
)}
- {/* Persistent cartoon workflow coach (#429): one clear next action across
- every cartoon file view, derived from real story/episode state. Sits
- above the content so it stays visible on both the Preview and Edit
- tabs. Fiction renders nothing (the coach is null), so fiction views are
- unchanged. */}
- {!hideFocusedEditorChrome &&
- contentType === "cartoon" &&
- storyName &&
- fileName && (
-
- )}
-
{/* Content area */}
{activeTab === "preview" ? (
isCartoonPlot ? (
diff --git a/app/web/components/StoriesPage.test.tsx b/app/web/components/StoriesPage.test.tsx
index fe5b23e..c85b644 100644
--- a/app/web/components/StoriesPage.test.tsx
+++ b/app/web/components/StoriesPage.test.tsx
@@ -30,6 +30,9 @@ const childProps = vi.hoisted(() => ({
}>,
previewStory: undefined as string | null | undefined,
previewFile: undefined as string | null | undefined,
+ workflowActionRequest: null as
+ | null
+ | { action: string; seq: number },
}));
vi.mock("./StoryBrowser", () => ({
@@ -84,14 +87,18 @@ vi.mock("./PreviewPanel", () => ({
fileName?: string | null;
onFocusedLetteringModeChange?: (active: boolean) => void;
onFocusedLetteringWorkspaceVisibleChange?: (visible: boolean) => void;
+ workflowActionRequest?: { action: string; seq: number } | null;
+ onWorkflowActionHandled?: (seq: number) => void;
}) => {
childProps.previewStory = props.storyName;
childProps.previewFile = props.fileName;
+ childProps.workflowActionRequest = props.workflowActionRequest ?? null;
return (
);
},
@@ -145,6 +161,7 @@ afterEach(() => {
childProps.renameCalls = [];
childProps.previewStory = undefined;
childProps.previewFile = undefined;
+ childProps.workflowActionRequest = null;
});
interface FetchCall {
@@ -780,7 +797,7 @@ function makeTwoCartoonAuthFetch() {
ok: true,
json: () => Promise.resolve({ stories }),
});
- if (url.endsWith("/progress"))
+ if (url.includes("/progress"))
return Promise.resolve({
ok: true,
json: () => Promise.resolve(progress),
@@ -794,6 +811,110 @@ function makeTwoCartoonAuthFetch() {
return { fn };
}
+function makeWorkflowActionAuthFetch(coachAction: "open-lettering" | "generate-markdown") {
+ const stories = [
+ {
+ name: "cartoon-a",
+ title: "Story A",
+ hasStructure: true,
+ hasGenesis: true,
+ contentType: "cartoon",
+ agentProvider: "codex",
+ },
+ ];
+ const progress = {
+ name: "cartoon-a",
+ contentType: "cartoon",
+ metadata: {
+ title: "Story A",
+ language: "English",
+ genre: "Science Fiction",
+ isNsfw: false,
+ contentType: "cartoon",
+ },
+ setup: { hasStructure: true, hasGenesis: true },
+ cover: "present",
+ episodes: [
+ {
+ file: "genesis.md",
+ label: "Episode 1 / Genesis",
+ kind: "genesis",
+ title: "Opening",
+ state: "in-progress",
+ summary: "Needs action",
+ published: false,
+ checklist: [],
+ cuts: {
+ total: 1,
+ needClean: 0,
+ withClean: 1,
+ withText: 0,
+ exported: 0,
+ uploaded: 0,
+ },
+ },
+ ],
+ summary: {
+ episodes: 1,
+ published: 0,
+ readyToPublish: 0,
+ placeholders: 0,
+ blocked: 1,
+ },
+ nextAction: "Review cuts and start lettering.",
+ nextPrompt: null,
+ coach: {
+ stageLabel: "Clean images ready",
+ action:
+ coachAction === "generate-markdown"
+ ? "Prepare the episode for publish"
+ : "Review cuts and start lettering",
+ actionKind: "ui",
+ prompt: null,
+ uiAction: coachAction,
+ episodeFile: "genesis.md",
+ },
+ };
+ const fn = vi.fn().mockImplementation((url: string, opts?: RequestInit) => {
+ if (url === "/api/wallet")
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ address: "0xabc" }),
+ });
+ if (url === "/api/agent/readiness") {
+ return Promise.resolve({
+ ok: true,
+ json: () =>
+ Promise.resolve({
+ claude: { installed: true },
+ codex: {
+ installed: true,
+ version: "codex-cli 0.135.0",
+ imageGeneration: "enabled",
+ auth: "ok",
+ },
+ checkedAt: 1748000000000,
+ }),
+ });
+ }
+ if (url === "/api/stories" && !opts)
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ stories }),
+ });
+ if (url.includes("/progress"))
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve(progress),
+ });
+ return Promise.resolve({
+ ok: true,
+ json: () => Promise.resolve({ contentType: "cartoon", files: [] }),
+ });
+ });
+ return { fn };
+}
+
describe("StoriesPage cartoon workflow nav routing (#439)", () => {
it("routes file-backed nav tabs to the CURRENT story after a left-tree switch to another story (#445 RE1)", async () => {
const { fn } = makeTwoCartoonAuthFetch();
@@ -847,15 +968,16 @@ describe("StoriesPage cartoon workflow nav routing (#439)", () => {
expect(screen.queryByTestId("mock-preview")).not.toBeInTheDocument();
}, 10000);
- it("shows the Story Info next-action CTA on story-level right-pane pages when cover is missing (#487 RE1)", async () => {
+ it("shows the persistent Story Info next-action CTA at the bottom of cartoon right-pane pages when cover is missing (#487 RE1)", async () => {
const { fn } = makeTwoCartoonAuthFetch();
render(
);
await waitFor(() => expect(childProps.onSelectStory).not.toBeNull());
childProps.onSelectStory!("cartoon-a");
fireEvent.click(await screen.findByTestId("nav-tab-publish"));
+ expect(screen.queryByTestId("workflow-context-next-action")).not.toBeInTheDocument();
const publishCta = await screen.findByTestId(
- "workflow-context-next-action",
+ "workflow-persistent-next-action",
);
expect(
within(publishCta).getByTestId("story-info-next-action"),
@@ -877,7 +999,7 @@ describe("StoriesPage cartoon workflow nav routing (#439)", () => {
),
);
- const storyInfoCta = screen.getByTestId("workflow-context-next-action");
+ const storyInfoCta = screen.getByTestId("workflow-persistent-next-action");
expect(
within(storyInfoCta).getByTestId("story-info-next-action"),
).toHaveTextContent(/Next: Add a cover image before publishing/i);
@@ -885,4 +1007,68 @@ describe("StoriesPage cartoon workflow nav routing (#439)", () => {
within(storyInfoCta).queryByText("No next action available"),
).not.toBeInTheDocument();
}, 10000);
+
+ it("keeps the persistent CTA on file-backed cartoon routes without restoring the old top strip", async () => {
+ const { fn } = makeTwoCartoonAuthFetch();
+ render(
);
+ await waitFor(() => expect(childProps.onSelectFile).not.toBeNull());
+
+ childProps.onSelectFile!("cartoon-a", "genesis.md");
+ await waitFor(() => expect(childProps.previewFile).toBe("genesis.md"));
+
+ expect(screen.queryByTestId("workflow-context-next-action")).not.toBeInTheDocument();
+ const cta = await screen.findByTestId("workflow-persistent-next-action");
+ expect(within(cta).getByTestId("story-info-next-action")).toHaveTextContent(
+ /Next: Add a cover image before publishing/i,
+ );
+ expect(within(cta).getByRole("button", { name: "Next Action" })).toBeInTheDocument();
+ }, 10000);
+
+ it("passes open-lettering through to PreviewPanel as a one-shot workflow action request", async () => {
+ const { fn } = makeWorkflowActionAuthFetch("open-lettering");
+ render(
);
+ await waitFor(() => expect(childProps.onSelectFile).not.toBeNull());
+
+ childProps.onSelectFile!("cartoon-a", "genesis.md");
+ await waitFor(() => expect(childProps.previewFile).toBe("genesis.md"));
+
+ const cta = await screen.findByTestId("workflow-persistent-next-action");
+ fireEvent.click(within(cta).getByRole("button", { name: "Next Action" }));
+
+ await waitFor(() =>
+ expect(childProps.workflowActionRequest).toMatchObject({
+ action: "open-lettering",
+ }),
+ );
+ const firstSeq = childProps.workflowActionRequest?.seq;
+
+ fireEvent.click(screen.getByTestId("mock-handle-workflow-action"));
+ await waitFor(() => expect(childProps.workflowActionRequest).toBeNull());
+
+ fireEvent.click(within(cta).getByRole("button", { name: "Next Action" }));
+ await waitFor(() =>
+ expect(childProps.workflowActionRequest).toMatchObject({
+ action: "open-lettering",
+ }),
+ );
+ expect(childProps.workflowActionRequest?.seq).toBeGreaterThan(firstSeq ?? 0);
+ }, 10000);
+
+ it("passes generate-markdown through to PreviewPanel instead of dropping it at file selection", async () => {
+ const { fn } = makeWorkflowActionAuthFetch("generate-markdown");
+ render(
);
+ await waitFor(() => expect(childProps.onSelectFile).not.toBeNull());
+
+ childProps.onSelectFile!("cartoon-a", "genesis.md");
+ await waitFor(() => expect(childProps.previewFile).toBe("genesis.md"));
+
+ const cta = await screen.findByTestId("workflow-persistent-next-action");
+ fireEvent.click(within(cta).getByRole("button", { name: "Next Action" }));
+
+ await waitFor(() =>
+ expect(childProps.workflowActionRequest).toMatchObject({
+ action: "generate-markdown",
+ }),
+ );
+ }, 10000);
});
diff --git a/app/web/components/StoriesPage.tsx b/app/web/components/StoriesPage.tsx
index 6986c44..6615d2e 100644
--- a/app/web/components/StoriesPage.tsx
+++ b/app/web/components/StoriesPage.tsx
@@ -143,11 +143,16 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
focusedLetteringWorkspaceVisible,
setFocusedLetteringWorkspaceVisible,
] = useState(true);
+ const [workflowActionRequest, setWorkflowActionRequest] = useState<{
+ action: CoachUiAction;
+ seq: number;
+ } | null>(null);
const contentTypeMap = useRef