diff --git a/app/routes/terminal-sessions.test.ts b/app/routes/terminal-sessions.test.ts index 056e3e1..e8ba7ca 100644 --- a/app/routes/terminal-sessions.test.ts +++ b/app/routes/terminal-sessions.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { resumeIdFrom, isSessionRecord, type StoredValue } from "./terminal"; +import { resumeIdFrom, isSessionRecord, carrySessionAcrossRename, type StoredValue } from "./terminal"; /** * Session-store back-compat: terminal-sessions.json may hold legacy bare-string @@ -68,3 +68,51 @@ describe("session store map mutation (no wholesale migration)", () => { expect(resumeIdFrom(roundTripped["c"])).toBeNull(); }); }); + +describe("carrySessionAcrossRename (rename preserves stored shape)", () => { + it("preserves a Codex provider-aware record (does not flatten to the fallback UUID)", () => { + // Regression for PR #260: a fresh cartoon _new_* Codex session stores + // {provider:"codex", sessionId:null}; renaming must keep that record so a + // later resume builds `codex resume --last`, not `codex resume `. + const map: Record = { + "_new_123": { provider: "codex", sessionId: null, lastStartedAt: 111 }, + }; + carrySessionAcrossRename(map, "_new_123", "my-toon", "fallback-pty-uuid"); + expect(map["_new_123"]).toBeUndefined(); + expect(map["my-toon"]).toEqual({ provider: "codex", sessionId: null, lastStartedAt: 111 }); + expect(map["my-toon"]).not.toBe("fallback-pty-uuid"); + expect(resumeIdFrom(map["my-toon"])).toBe(null); + }); + + it("preserves a Codex record with a real session id", () => { + const map: Record = { + "_new_9": { provider: "codex", sessionId: "cdx-real", lastStartedAt: 5 }, + }; + carrySessionAcrossRename(map, "_new_9", "toon2", "fallback"); + expect(map["toon2"]).toEqual({ provider: "codex", sessionId: "cdx-real", lastStartedAt: 5 }); + expect(resumeIdFrom(map["toon2"])).toBe("cdx-real"); + }); + + it("preserves a legacy Claude bare-string entry", () => { + const map: Record = { "_new_7": "claude-uuid" }; + carrySessionAcrossRename(map, "_new_7", "novel", "fallback"); + expect(map["_new_7"]).toBeUndefined(); + expect(map["novel"]).toBe("claude-uuid"); + }); + + it("falls back to the live PTY session id only when no stored entry exists", () => { + const map: Record = {}; + carrySessionAcrossRename(map, "_new_x", "story", "live-uuid"); + expect(map["story"]).toBe("live-uuid"); + }); + + it("leaves unrelated entries untouched", () => { + const map: Record = { + "_new_1": { provider: "codex", sessionId: null }, + "other": "keep-me", + }; + carrySessionAcrossRename(map, "_new_1", "renamed", "fb"); + expect(map["other"]).toBe("keep-me"); + expect(map["renamed"]).toEqual({ provider: "codex", sessionId: null }); + }); +}); diff --git a/app/routes/terminal.test.ts b/app/routes/terminal.test.ts index da69de2..fe874cd 100644 --- a/app/routes/terminal.test.ts +++ b/app/routes/terminal.test.ts @@ -7,6 +7,7 @@ import { buildClaudeCommand, isTerminalSocketOpen, resolveBypass, + resolveProvider, resolveAgentCommandForSession, shellQuote, } from "./terminal"; @@ -71,6 +72,68 @@ describe("resolveBypass", () => { }); }); +describe("resolveProvider", () => { + it("new story honors explicit optProvider=codex", () => { + expect(resolveProvider({ isNewStory: true, optProvider: "codex" })).toBe("codex"); + }); + + it("new story defaults to claude without explicit flag", () => { + expect(resolveProvider({ isNewStory: true })).toBe("claude"); + }); + + it("new story falls back to session provider when no optProvider", () => { + expect(resolveProvider({ isNewStory: true, sessionProvider: "codex" })).toBe("codex"); + }); + + it("existing story IGNORES client optProvider (security)", () => { + // Malicious WS sends provider=codex, but stored metadata is claude. + expect(resolveProvider({ isNewStory: false, optProvider: "codex", storedProvider: "claude" })).toBe("claude"); + }); + + it("existing story derives provider from stored .story.json", () => { + expect(resolveProvider({ isNewStory: false, storedProvider: "codex" })).toBe("codex"); + }); + + it("existing story prefers in-memory session provider over stored", () => { + expect(resolveProvider({ isNewStory: false, optProvider: "claude", sessionProvider: "codex", storedProvider: "claude" })).toBe("codex"); + }); + + // End-to-end regression for PR #260: a brand-new cartoon (_new_) session must + // invoke codex, not claude. Compose the two pure functions exactly as spawnPty + // does for a fresh _new_ spawn: resolve provider from the client flag, then + // render the concrete CLI command for that provider. + it("end-to-end: cartoon _new_ spawn (provider=codex) yields a codex command", () => { + const provider = resolveProvider({ isNewStory: true, optProvider: "codex" }); + expect(provider).toBe("codex"); + const cmd = resolveAgentCommandForSession({ + provider, + mode: "normal", + resumeRequested: false, + stored: undefined, + newSessionId: "fresh-uuid", + storyDir: "/stories/_new_123", + }); + expect(cmd.command).toBe("codex"); + expect(cmd.command).not.toBe("claude"); + }); + + // Byte-identical guarantee: a fiction _new_ spawn with NO provider flag still + // resolves to claude and renders the unchanged claude fresh-session command. + it("end-to-end: fiction _new_ spawn (no flag) yields the unchanged claude command", () => { + const provider = resolveProvider({ isNewStory: true }); + expect(provider).toBe("claude"); + const cmd = resolveAgentCommandForSession({ + provider, + mode: "normal", + resumeRequested: false, + stored: undefined, + newSessionId: "fresh-uuid", + storyDir: "/stories/_new_123", + }); + expect(cmd).toEqual({ command: "claude", args: ["--session-id", "fresh-uuid"] }); + }); +}); + describe("shellQuote (command-injection safety)", () => { it("wraps plain values in single quotes", () => { expect(shellQuote("abc-123")).toBe("'abc-123'"); diff --git a/app/routes/terminal.ts b/app/routes/terminal.ts index a4794ae..b0c5558 100644 --- a/app/routes/terminal.ts +++ b/app/routes/terminal.ts @@ -169,6 +169,11 @@ export function shellQuote(s: string): string { // reconnects before a story directory / .story.json exists). const agentModeBySession = new Map(); +// In-memory agent provider per active session name (covers _new_ sessions and +// reconnects before a story directory / .story.json exists). Mirrors +// agentModeBySession exactly. +const agentProviderBySession = new Map(); + /** * Resolve effective permissions-bypass for a spawn. * @@ -192,7 +197,47 @@ export function resolveBypass(args: { return args.storedMode === "bypass"; } -function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boolean; bypass?: boolean }) { +/** + * Resolve the effective agent provider for a spawn. + * + * Mirrors resolveBypass's trust model: the client-supplied provider flag is only + * trusted for a brand-new (_new_) story's first spawn. For existing stories the + * provider derives strictly from server-side state (already-spawned session + * provider, then stored .story.json), so a direct WS URL cannot force a provider + * on a story whose metadata says otherwise. + */ +export function resolveProvider(args: { + isNewStory: boolean; + optProvider?: "claude" | "codex"; + sessionProvider?: "claude" | "codex"; + storedProvider?: "claude" | "codex"; +}): "claude" | "codex" { + if (args.isNewStory) return args.optProvider ?? args.sessionProvider ?? "claude"; + if (args.sessionProvider !== undefined) return args.sessionProvider; + return args.storedProvider ?? "claude"; +} + +/** + * Move a persisted session entry from one key to another, PRESERVING its stored + * shape. A `_new_*` Codex session is stored as a provider-aware record + * (`{provider:"codex", sessionId, lastStartedAt}`); a Claude session as a bare + * string. Renaming must keep that shape so Codex resume metadata survives. Only + * when there is no stored entry do we fall back to the live PTY session id (a + * bare string, matching legacy Claude behavior). Mutates and returns `map`. + */ +export function carrySessionAcrossRename( + map: Record, + oldName: string, + newName: string, + fallbackSessionId: string, +): Record { + const existing = map[oldName]; + delete map[oldName]; + map[newName] = existing ?? fallbackSessionId; + return map; +} + +function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boolean; bypass?: boolean; provider?: "claude" | "codex" }) { // New story sessions spawn in the stories root so Claude can create any folder const isNewStory = storyName.startsWith("_new_"); const storyDir = isNewStory ? STORIES_DIR : path.join(STORIES_DIR, storyName); @@ -212,10 +257,16 @@ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boole }); agentModeBySession.set(storyName, bypass ? "bypass" : "normal"); - // Resolve provider from stored .story.json. Absent ⇒ Claude (no migration). - const provider: AgentProvider = isNewStory - ? "claude" - : readStoryMeta(storyDir).agentProvider ?? "claude"; + // Resolve effective provider (see resolveProvider for the trust model). For a + // brand-new _new_ session the client flag is trusted; existing stories ignore + // it and read from session state then stored .story.json (no migration). + const provider: AgentProvider = resolveProvider({ + isNewStory, + optProvider: opts?.provider, + sessionProvider: agentProviderBySession.get(storyName), + storedProvider: isNewStory ? undefined : readStoryMeta(storyDir).agentProvider, + }); + agentProviderBySession.set(storyName, provider); // Determine resume id (accepts both legacy-string and record shapes). const sessionMap = loadSessionMap(); @@ -307,9 +358,10 @@ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boole /** POST /api/terminal/spawn — spawn Claude CLI for a story */ terminal.post("/spawn", async (c) => { - const body = await c.req.json<{ storyName?: string; resume?: boolean }>().catch(() => ({})); + const body = await c.req.json<{ storyName?: string; resume?: boolean; provider?: "claude" | "codex" }>().catch(() => ({})); const storyName = safeName(body.storyName || "default"); if (!storyName) return c.json({ error: "Invalid story name" }, 400); + const optProvider = body.provider === "claude" || body.provider === "codex" ? body.provider : undefined; const existing = ptySessions.get(storyName); if (existing?.term && existing.state === "running") { @@ -323,7 +375,7 @@ terminal.post("/spawn", async (c) => { } try { - const session = spawnPty(storyName, { resume: body.resume }); + const session = spawnPty(storyName, { resume: body.resume, provider: optProvider }); return c.json({ ok: true, pid: session.term.pid, storyName, sessionId: session.sessionId }); } catch (err: unknown) { const message = err instanceof Error ? err.message : "Failed to spawn PTY"; @@ -412,10 +464,21 @@ terminal.post("/rename", async (c) => { agentModeBySession.delete(oldName); } - // Update persisted session map: remove old key, store under new key + // Carry the in-memory agent provider across the rename too (mirrors mode). + const oldProvider = agentProviderBySession.get(oldName); + if (oldProvider) { + agentProviderBySession.set(newName, oldProvider); + agentProviderBySession.delete(oldName); + } + + // Update persisted session map: carry the stored value across the rename so a + // provider-aware Codex record (`{provider,sessionId,...}`) or a legacy Claude + // string is PRESERVED, not flattened to the live PTY's fallback UUID. Writing + // session.sessionId here corrupted Codex metadata (the fresh-spawn fallback + // UUID is not a real Codex session id, so later resume built `codex resume + // ` instead of `codex resume --last`). const sessionMap = loadSessionMap(); - delete sessionMap[oldName]; - sessionMap[newName] = session.sessionId; + carrySessionAcrossRename(sessionMap, oldName, newName, session.sessionId); saveSessionMap(sessionMap); return c.json({ ok: true, sessionId: session.sessionId }); @@ -449,7 +512,7 @@ terminal.get("/status", (c) => { * Attach a raw WebSocket to a story's PTY session. * Called from server.ts WebSocket upgrade handler. */ -export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boolean, bypass?: boolean) { +export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boolean, bypass?: boolean, provider?: "claude" | "codex") { const name = storyName && safeName(storyName) ? storyName : "default"; let session = ptySessions.get(name); @@ -463,7 +526,7 @@ export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boo } try { - session = spawnPty(name, { resume, bypass }); + session = spawnPty(name, { resume, bypass, provider }); } catch (err) { console.error("PTY spawn failed:", err); ws.close(1011, "pty-spawn-failed"); diff --git a/app/server.ts b/app/server.ts index fd48e2d..387f0bb 100644 --- a/app/server.ts +++ b/app/server.ts @@ -160,8 +160,15 @@ async function start() { const story = url.searchParams.get("story") || undefined; const resume = url.searchParams.get("resume") === "true"; const bypass = url.searchParams.get("bypass") === "true"; + const provider = url.searchParams.get("provider"); wss.handleUpgrade(req, socket, head, (ws) => { - attachTerminalWs(ws as unknown as WebSocket, story, resume, bypass); + attachTerminalWs( + ws as unknown as WebSocket, + story, + resume, + bypass, + provider === "claude" || provider === "codex" ? provider : undefined, + ); }); }).catch(() => socket.destroy()); } diff --git a/app/web/components/StoriesPage.test.tsx b/app/web/components/StoriesPage.test.tsx new file mode 100644 index 0000000..e41343b --- /dev/null +++ b/app/web/components/StoriesPage.test.tsx @@ -0,0 +1,183 @@ +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 }, + agentProviders: null as null | Record, +})); + +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 }; + agentProviders?: Record; + }) => { + childProps.renameRef = props.renameRef; + childProps.agentProviders = props.agentProviders ?? null; + // 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; + childProps.agentProviders = 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("forces agentProvider 'codex' for cartoon even when the dropdown shows Claude", async () => { + // No provider override → dropdown stays on its Claude default. + const body = await createStory({ contentTypeLabel: "Cartoon" }); + expect(body).toMatchObject({ contentType: "cartoon", agentProvider: "codex" }); + }, 10000); + + it("explains why cartoon requires Codex while the modal is open", () => { + render(); + fireEvent.click(screen.getByTestId("mock-new-story")); + expect( + screen.getByText( + "Cartoon mode requires Codex because the clean-image step needs image generation support.", + ), + ).toBeInTheDocument(); + }); + + it("defaults agentProvider to 'claude' when the provider control is untouched (fiction)", async () => { + const body = await createStory({ contentTypeLabel: "Fiction" }); + expect(body).toMatchObject({ contentType: "fiction", agentProvider: "claude" }); + }, 10000); + + it("lets fiction opt into Codex via the dropdown", async () => { + const body = await createStory({ provider: "codex", contentTypeLabel: "Fiction" }); + expect(body).toMatchObject({ contentType: "fiction", agentProvider: "codex" }); + }, 10000); + + // Regression for PR #260: the provider must be threaded into the TerminalPanel + // `agentProviders` state map for the brand-new (_new_) session, so the FIRST + // WS spawn appends provider=codex. Cartoon is always codex. + it("threads provider=codex into agentProviders for a new cartoon _new_ session", async () => { + const { fn } = makeAuthFetch(); + render(); + fireEvent.click(screen.getByTestId("mock-new-story")); + fireEvent.click(screen.getByText("Cartoon")); + await waitFor(() => { + const providers = childProps.agentProviders ?? {}; + const entries = Object.entries(providers); + expect(entries.length).toBeGreaterThan(0); + // The pending session key is a _new_ id, mapped to codex. + expect(entries.some(([k, v]) => k.startsWith("_new_") && v === "codex")).toBe(true); + expect(Object.values(providers)).not.toContain("claude"); + }); + }); + + it("threads provider=claude into agentProviders for a default fiction _new_ session", async () => { + const { fn } = makeAuthFetch(); + render(); + fireEvent.click(screen.getByTestId("mock-new-story")); + fireEvent.click(screen.getByText("Fiction")); + await waitFor(() => { + const providers = childProps.agentProviders ?? {}; + expect(Object.entries(providers).some(([k, v]) => k.startsWith("_new_") && v === "claude")).toBe(true); + }); + }); + + 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..6443483 100644 --- a/app/web/components/StoriesPage.tsx +++ b/app/web/components/StoriesPage.tsx @@ -46,7 +46,9 @@ 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>({}); + const [agentProviders, setAgentProviders] = useState>({}); // Track confirmed stories (those with structure.md) for Archive gating const [confirmedStories, setConfirmedStories] = useState>(new Set()); const [storyContentTypes, setStoryContentTypes] = useState>({}); @@ -54,6 +56,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 +90,20 @@ 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()}`; + // Cartoon always uses Codex: the clean-image step needs image generation. + const provider = contentType === "cartoon" ? "codex" : agentProvider; contentTypeMap.current.set(id, contentType); languageMap.current.set(id, language); agentModeMap.current.set(id, agentMode); + agentProviderMap.current.set(id, provider); + setAgentProviders((prev) => ({ ...prev, [id]: provider })); if (agentMode === "bypass") { setBypassStories((prev) => ({ ...prev, [id]: true })); } @@ -131,9 +139,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 }; @@ -141,10 +151,15 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { return next; }); } + setAgentProviders((prev) => { + const next = { ...prev, [name]: provider }; + delete next[oldName]; + return next; + }); 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,12 +346,19 @@ 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 }; delete next[name]; return next; }); + setAgentProviders((prev) => { + if (!(name in prev)) return prev; + const next = { ...prev }; + delete next[name]; + return next; + }); } }, []); @@ -391,7 +413,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) { {/* Terminal — sized by ratio of available space */}
- +
{/* Drag Handle */} @@ -457,21 +479,41 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {

)} +

Choose a content type