Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 115 additions & 0 deletions app/web/components/CartoonNextAction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { CartoonNextActionView } from "./CartoonNextAction";

beforeEach(() => {
Object.assign(navigator, { clipboard: { writeText: vi.fn().mockResolvedValue(undefined) } });
});

describe("CartoonNextActionView", () => {
it("renders the compact Story Info CTA when cover metadata is the current gate", () => {
const onOpenStoryInfo = vi.fn();
render(
<CartoonNextActionView
progress={{
name: "god-cell",
contentType: "cartoon",
metadata: {
title: "God Cell",
language: "English",
genre: "Science Fiction",
isNsfw: false,
contentType: "cartoon",
},
setup: { hasStructure: true, hasGenesis: true },
cover: "missing",
episodes: [],
summary: {
episodes: 0,
published: 0,
readyToPublish: 0,
placeholders: 0,
blocked: 0,
},
nextAction: "Add a cover image before publishing.",
nextPrompt: null,
coach: null,
}}
onCoachAction={vi.fn()}
onOpenStoryInfo={onOpenStoryInfo}
/>,
);

const cta = screen.getByTestId("story-info-cta");
expect(cta).toHaveTextContent("Next: Add a cover image before publishing.");
fireEvent.click(screen.getByRole("button", { name: "Next Action" }));
expect(onOpenStoryInfo).toHaveBeenCalledTimes(1);
});

it("renders the compact workflow CTA and routes UI actions without the old card shell", () => {
const onCoachAction = vi.fn();
render(
<CartoonNextActionView
progress={{
name: "god-cell",
contentType: "cartoon",
metadata: {
title: "God Cell",
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: "blocked",
summary: "Needs lettering",
published: false,
checklist: [],
cuts: {
total: 2,
needClean: 0,
withClean: 2,
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: "Review cuts and start lettering",
actionKind: "ui",
prompt: null,
uiAction: "open-lettering",
episodeFile: "genesis.md",
},
}}
onCoachAction={onCoachAction}
/>,
);

expect(screen.getByTestId("cartoon-next-action")).toHaveTextContent(
"Next: Review cuts and start lettering",
);
expect(screen.queryByTestId("workflow-coach")).not.toBeInTheDocument();
fireEvent.click(screen.getByTestId("workflow-coach-do"));
expect(onCoachAction).toHaveBeenCalledWith("open-lettering", "genesis.md");
});
});
190 changes: 154 additions & 36 deletions app/web/components/CartoonNextAction.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useEffect, useState } from "react";
import { useEffect, useState, type ReactNode } from "react";
import type { StoryProgress } from "@app-lib/story-progress";
import type { CoachUiAction } from "@app-lib/cartoon-coach";
import { WorkflowCoachView } from "./WorkflowCoach";
import type { CartoonCoach, CoachUiAction } from "@app-lib/cartoon-coach";

export function storyInfoNextStep(progress: StoryProgress): string {
if (progress.cover !== "present") {
Expand Down Expand Up @@ -34,39 +33,161 @@ export function cartoonWorkflowActiveKey(progress: StoryProgress): string | null
return coach?.episodeFile ?? null;
}

export function StoryInfoNextActionCard({
progress,
onOpenStoryInfo,
function CompactNextActionShell({
badge,
tone = "accent",
summary,
children,
note,
testId,
}: {
progress: StoryProgress;
onOpenStoryInfo?: () => void;
badge: string;
tone?: "accent" | "complete";
summary: ReactNode;
children?: ReactNode;
note?: ReactNode;
testId: string;
}) {
const shellTone =
tone === "complete"
? "border-green-700/20 bg-green-950/5"
: "border-accent/30 bg-background/95";
const badgeTone =
tone === "complete"
? "bg-green-700/10 text-green-700"
: "bg-accent/10 text-accent";
return (
<div className="m-3 rounded-lg border border-accent/40 bg-accent/10 px-4 py-3 shadow-sm" data-testid="story-info-cta">
<div className="flex items-center gap-3">
<div
className={`border px-3 py-3 sm:px-4 ${shellTone}`}
data-testid={testId}
data-state={tone === "complete" ? "complete" : "active"}
>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0 flex-1">
<span className="inline-flex rounded-full bg-background px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] text-accent">
Story info
<span
className={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.14em] ${badgeTone}`}
>
{badge}
</span>
<p className="mt-1 text-sm text-foreground" data-testid="story-info-next-action">
<span className="font-semibold">Next: </span>
<span>{storyInfoNextStep(progress)}</span>
</p>
<p className="mt-1 text-sm text-foreground">{summary}</p>
{note ? <p className="mt-1 text-[11px] font-medium text-accent">{note}</p> : null}
</div>
<button
type="button"
onClick={onOpenStoryInfo}
disabled={!onOpenStoryInfo}
className="flex-shrink-0 rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50"
data-testid="story-info-next-action-btn"
>
Next Action
</button>
{children ? (
<div className="flex w-full justify-end sm:w-auto sm:flex-shrink-0">
{children}
</div>
) : null}
</div>
</div>
);
}

function NextActionButton({
onClick,
disabled,
testId,
}: {
onClick: () => void;
disabled?: boolean;
testId: string;
}) {
return (
<button
type="button"
onClick={onClick}
disabled={disabled}
className="w-full rounded bg-accent px-4 py-2.5 text-sm font-bold text-white shadow-sm transition-colors hover:bg-accent-dim disabled:cursor-not-allowed disabled:opacity-50 sm:w-auto"
data-testid={testId}
>
Next Action
</button>
);
}

function WorkflowCoachCompact({
coach,
onAction,
}: {
coach: CartoonCoach | null | undefined;
onAction: (action: CoachUiAction, episodeFile: string | null) => void;
}) {
const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;

if (coach === undefined) return null;
if (!coach) {
return (
<CompactNextActionShell
badge="Complete"
tone="complete"
summary="No next action available."
note="This workflow has no queued next step right now."
testId="cartoon-next-action"
/>
);
}

const button =
coach.actionKind === "agent" && coach.prompt ? (
<NextActionButton
testId="workflow-coach-copy"
onClick={() => {
if (!coach.prompt) return;
const prompt = coach.prompt;
navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
}}
/>
) : coach.actionKind === "ui" && coach.uiAction ? (
<NextActionButton
testId="workflow-coach-do"
onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
/>
) : null;

return (
<CompactNextActionShell
badge={coach.stageLabel}
summary={(
<span data-testid="workflow-coach-action">
<span className="font-semibold">Next: </span>
<span>{coach.action}</span>
</span>
)}
note={copied ? "Prompt copied." : undefined}
testId="cartoon-next-action"
>
{button}
</CompactNextActionShell>
);
}

export function StoryInfoNextActionCard({
progress,
onOpenStoryInfo,
}: {
progress: StoryProgress;
onOpenStoryInfo?: () => void;
}) {
return (
<CompactNextActionShell
badge="Story info"
summary={(
<span data-testid="story-info-next-action">
<span className="font-semibold">Next: </span>
<span>{storyInfoNextStep(progress)}</span>
</span>
)}
testId="story-info-cta"
>
<NextActionButton
testId="story-info-next-action-btn"
onClick={() => onOpenStoryInfo?.()}
disabled={!onOpenStoryInfo}
/>
</CompactNextActionShell>
);
}

export function CartoonNextActionView({
progress,
onCoachAction,
Expand All @@ -80,31 +201,27 @@ export function CartoonNextActionView({
if (activeKey === "story-info") {
return <StoryInfoNextActionCard progress={progress} onOpenStoryInfo={onOpenStoryInfo} />;
}
return (
<WorkflowCoachView
coach={progress.coach ?? null}
showEmptyState
onAction={onCoachAction}
/>
);
return <WorkflowCoachCompact coach={progress.coach ?? null} onAction={onCoachAction} />;
}

export function CartoonNextAction({
storyName,
authFetch,
fileName,
refreshKey = 0,
onCoachAction,
onOpenStoryInfo,
}: {
storyName: string;
authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
fileName?: string | null;
refreshKey?: number;
onCoachAction: (action: CoachUiAction, episodeFile: string | null) => void;
onOpenStoryInfo?: () => void;
}) {
const [progress, setProgress] = useState<StoryProgress | null | undefined>(undefined);

const targetKey = JSON.stringify([storyName, refreshKey]);
const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
const [loadedKey, setLoadedKey] = useState<string | null>(null);
if (loadedKey !== targetKey) {
setProgress(undefined);
Expand All @@ -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);
Expand All @@ -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 <WorkflowCoachView coach={null} showEmptyState onAction={onCoachAction} />;
return <WorkflowCoachCompact coach={null} onAction={onCoachAction} />;
}
return (
<CartoonNextActionView
Expand Down
Loading
Loading