From 6951fbb51400682725562883441ae430888f0941 Mon Sep 17 00:00:00 2001 From: Project7 Date: Sun, 31 May 2026 00:14:29 +0000 Subject: [PATCH 1/5] [#254] Wire provider selection into New Story flow Add a Provider choice (Claude default / Codex) to the New Story modal, persisted to .story.json as agentProvider via the existing metadata endpoint. Defaults to Claude so fiction behaviour is unchanged. Helper text clarifies that Codex can generate clean cartoon images directly in the terminal, while Claude only prepares prompts for you to generate and upload images externally. Bumps version to 1.0.39. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/web/components/StoriesPage.test.tsx | 127 ++++++++++++++++++ app/web/components/StoriesPage.tsx | 32 ++++- .../{index-J_XBhN4y.js => index-sJfBa0bG.js} | 76 +++++------ app/web/dist/index.html | 2 +- package-lock.json | 4 +- package.json | 2 +- 6 files changed, 197 insertions(+), 46 deletions(-) create mode 100644 app/web/components/StoriesPage.test.tsx rename app/web/dist/assets/{index-J_XBhN4y.js => index-sJfBa0bG.js} (76%) diff --git a/app/web/components/StoriesPage.test.tsx b/app/web/components/StoriesPage.test.tsx new file mode 100644 index 0000000..b2e58b4 --- /dev/null +++ b/app/web/components/StoriesPage.test.tsx @@ -0,0 +1,127 @@ +import { describe, it, expect, vi, afterEach, beforeAll } from "vitest"; +import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react"; +import { StoriesPage } from "./StoriesPage"; + +// Capture props passed to the mocked child panels so tests can drive the +// new-story flow (open modal, expose renameRef) without real terminals. +const childProps = vi.hoisted(() => ({ + onNewStory: null as null | (() => void), + renameRef: null as null | { current: ((o: string, n: string) => Promise) | null }, +})); + +vi.mock("./StoryBrowser", () => ({ + StoryBrowser: (props: { onNewStory: () => void }) => { + childProps.onNewStory = props.onNewStory; + return ; + }, +})); + +vi.mock("./TerminalPanel", () => ({ + TerminalPanel: (props: { renameRef: { current: ((o: string, n: string) => Promise) | null } }) => { + childProps.renameRef = props.renameRef; + // Provide a rename implementation so the polling effect proceeds. + props.renameRef.current = () => Promise.resolve(true); + return
; + }, +})); + +vi.mock("./PreviewPanel", () => ({ + PreviewPanel: () =>
, +})); + +beforeAll(() => { + global.ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + } as unknown as typeof ResizeObserver; +}); + +afterEach(() => { + cleanup(); + vi.useRealTimers(); + childProps.onNewStory = null; + childProps.renameRef = null; +}); + +interface FetchCall { url: string; body: unknown } + +// authFetch that records every call. /api/stories starts empty, then returns a +// single new story ("my-tale") so the polling effect fires the metadata POST. +function makeAuthFetch() { + const calls: FetchCall[] = []; + let storiesAppeared = false; + const fn = vi.fn().mockImplementation((url: string, opts?: RequestInit) => { + let body: unknown; + try { body = opts?.body ? JSON.parse(opts.body as string) : undefined; } catch { /* ignore */ } + calls.push({ url, body }); + if (url === "/api/wallet") { + return Promise.resolve({ ok: true, json: () => Promise.resolve({ address: "0xabc" }) }); + } + if (url === "/api/stories" && !opts) { + const stories = storiesAppeared + ? [{ name: "my-tale", hasStructure: false }] + : []; + return Promise.resolve({ ok: true, json: () => Promise.resolve({ stories }) }); + } + // metadata POST or anything else + return Promise.resolve({ ok: true, json: () => Promise.resolve({ ok: true }) }); + }); + return { fn, calls, appear: () => { storiesAppeared = true; } }; +} + +function metadataBodyFor(calls: FetchCall[]): Record | undefined { + const call = calls.find((c) => c.url.includes("/metadata")); + return call?.body as Record | undefined; +} + +describe("StoriesPage new-story provider selection", () => { + async function createStory(opts: { provider?: "claude" | "codex"; contentTypeLabel: string }) { + const { fn, calls, appear } = makeAuthFetch(); + render(); + + // Open the new-story modal. + fireEvent.click(screen.getByTestId("mock-new-story")); + + // Provider control defaults to Claude. + const select = screen.getByTestId("agent-provider-select") as HTMLSelectElement; + expect(select.value).toBe("claude"); + if (opts.provider) { + fireEvent.change(select, { target: { value: opts.provider } }); + } + + // Pick a content type → registers the pending session in the maps. + fireEvent.click(screen.getByText(opts.contentTypeLabel)); + + // Now make a story "appear" and let the 3s poll run. + appear(); + await waitFor( + () => { expect(metadataBodyFor(calls)).toBeDefined(); }, + { timeout: 5000 }, + ); + return metadataBodyFor(calls)!; + } + + it("persists agentProvider 'codex' when Codex is selected (cartoon)", async () => { + const body = await createStory({ provider: "codex", contentTypeLabel: "Cartoon" }); + expect(body).toMatchObject({ contentType: "cartoon", agentProvider: "codex" }); + }, 10000); + + it("defaults agentProvider to 'claude' when the provider control is untouched", async () => { + const body = await createStory({ contentTypeLabel: "Fiction" }); + expect(body).toMatchObject({ contentType: "fiction", agentProvider: "claude" }); + }, 10000); + + it("toggles provider helper text when switching to Codex", () => { + render(); + fireEvent.click(screen.getByTestId("mock-new-story")); + + const helper = screen.getByTestId("agent-provider-helper"); + expect(helper.textContent).toContain("Claude prepares image prompts"); + + fireEvent.change(screen.getByTestId("agent-provider-select"), { target: { value: "codex" } }); + expect(screen.getByTestId("agent-provider-helper").textContent).toContain( + "Codex can generate clean cartoon images", + ); + }); +}); diff --git a/app/web/components/StoriesPage.tsx b/app/web/components/StoriesPage.tsx index 6bbc48a..2b42262 100644 --- a/app/web/components/StoriesPage.tsx +++ b/app/web/components/StoriesPage.tsx @@ -46,6 +46,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const [showNewStoryModal, setShowNewStoryModal] = useState(false); const [newStoryLanguage, setNewStoryLanguage] = useState("English"); const [newStoryAgentMode, setNewStoryAgentMode] = useState<"normal" | "bypass">("normal"); + const [newStoryAgentProvider, setNewStoryAgentProvider] = useState<"claude" | "codex">("claude"); const [bypassStories, setBypassStories] = useState>({}); // Track confirmed stories (those with structure.md) for Archive gating const [confirmedStories, setConfirmedStories] = useState>(new Set()); @@ -54,6 +55,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const contentTypeMap = useRef>(new Map()); const languageMap = useRef>(new Map()); const agentModeMap = useRef>(new Map()); + const agentProviderMap = useRef>(new Map()); const knownStoriesRef = useRef>(new Set()); const renameRef = useRef<((oldName: string, newName: string) => Promise) | null>(null); const containerRef = useRef(null); @@ -87,15 +89,17 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const handleNewStory = useCallback(() => { setNewStoryAgentMode("normal"); + setNewStoryAgentProvider("claude"); setShowNewStoryModal(true); }, []); - const handleCreateStory = useCallback((contentType: "fiction" | "cartoon", language: string, agentMode: "normal" | "bypass") => { + const handleCreateStory = useCallback((contentType: "fiction" | "cartoon", language: string, agentMode: "normal" | "bypass", agentProvider: "claude" | "codex") => { setShowNewStoryModal(false); const id = `_new_${Date.now()}`; contentTypeMap.current.set(id, contentType); languageMap.current.set(id, language); agentModeMap.current.set(id, agentMode); + agentProviderMap.current.set(id, agentProvider); if (agentMode === "bypass") { setBypassStories((prev) => ({ ...prev, [id]: true })); } @@ -131,9 +135,11 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { const ct = contentTypeMap.current.get(oldName) || "fiction"; const lang = languageMap.current.get(oldName) || "English"; const mode = agentModeMap.current.get(oldName) || "normal"; + const provider = agentProviderMap.current.get(oldName) || "claude"; contentTypeMap.current.delete(oldName); languageMap.current.delete(oldName); agentModeMap.current.delete(oldName); + agentProviderMap.current.delete(oldName); if (mode === "bypass") { setBypassStories((prev) => { const next = { ...prev, [name]: true }; @@ -144,7 +150,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { authFetch(`/api/stories/${name}/metadata`, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ contentType: ct, language: lang, agentMode: mode }), + body: JSON.stringify({ contentType: ct, language: lang, agentMode: mode, agentProvider: provider }), }).catch(() => {}); } setSelectedStory(name); @@ -331,6 +337,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { contentTypeMap.current.delete(name); languageMap.current.delete(name); agentModeMap.current.delete(name); + agentProviderMap.current.delete(name); setBypassStories((prev) => { if (!(name in prev)) return prev; const next = { ...prev }; @@ -457,17 +464,34 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {

)} +

Choose a content type