plotlink-ows 1.0.32 → 1.2.94

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +10 -3
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +209 -28
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +1017 -144
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-BFw-v-OZ.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -3,6 +3,17 @@ import { Terminal } from "@xterm/xterm";
3
3
  import { FitAddon } from "@xterm/addon-fit";
4
4
  import { SerializeAddon } from "@xterm/addon-serialize";
5
5
  import "@xterm/xterm/css/xterm.css";
6
+ import { isCodexAuthUnclear, CODEX_AUTH_UNCLEAR_MESSAGE, type AgentReadiness } from "@app-lib/agent-readiness";
7
+ import { FRESH_SPAWN_SIGNAL } from "@app-lib/terminal-protocol";
8
+ import { redactTerminalSecrets } from "@app-lib/terminal-redact";
9
+
10
+ /** Story metadata persisted with a `_new_*` → real-folder rename (#295). */
11
+ export interface RenameMeta {
12
+ contentType?: "fiction" | "cartoon";
13
+ language?: string;
14
+ agentMode?: "normal" | "bypass";
15
+ agentProvider?: "claude" | "codex";
16
+ }
6
17
 
7
18
  interface TerminalPanelProps {
8
19
  token: string;
@@ -12,7 +23,49 @@ interface TerminalPanelProps {
12
23
  onDestroySession?: (storyName: string) => void;
13
24
  onArchiveStory?: (storyName: string) => void;
14
25
  confirmedStories?: Set<string>;
15
- renameRef?: React.RefObject<((oldName: string, newName: string) => Promise<boolean>) | null>;
26
+ // The optional `meta` is persisted to the confirmed story's .story.json
27
+ // atomically with the rename so a fresh story's provider/contentType survive (#295).
28
+ renameRef?: React.RefObject<((oldName: string, newName: string, meta?: RenameMeta) => Promise<boolean>) | null>;
29
+ bypassStories?: Record<string, boolean>;
30
+ agentProviders?: Record<string, "claude" | "codex">;
31
+ /** Local agent (Codex) readiness. null/undefined = not yet loaded (fail-open). */
32
+ readiness?: AgentReadiness | null;
33
+ /** Content type of the currently-selected story (undefined = unknown). */
34
+ contentType?: "fiction" | "cartoon";
35
+ /**
36
+ * True only for the selected real (non-`_new_*`) cartoon story whose
37
+ * `.story.json` has no `agentProvider` recorded (legacy). When true, show the
38
+ * explicit provider-repair CTA instead of auto-spawning, so the writer sets
39
+ * the provider to Codex before launching. Never true for fiction or a cartoon
40
+ * that already has a provider.
41
+ */
42
+ needsProviderRepair?: boolean;
43
+ /** Set this story's provider to Codex (scoped, non-destructive repair). */
44
+ onRepairProvider?: () => void | Promise<void>;
45
+ }
46
+
47
+ const CODEX_ENABLE_CMD = "codex features enable image_generation";
48
+
49
+ /**
50
+ * Pure predicate: should the cartoon agent LAUNCH be blocked?
51
+ *
52
+ * Blocked ONLY when ALL of:
53
+ * - the selected story is a cartoon, AND
54
+ * - readiness has loaded (non-null), AND
55
+ * - Codex is NOT ready (not installed OR image generation not enabled).
56
+ *
57
+ * Fail-open: readiness null/undefined => NOT blocked (a probe failure must never
58
+ * brick terminals). Fiction / undefined contentType => NEVER blocked.
59
+ */
60
+ export function isCartoonLaunchBlocked(
61
+ contentType: "fiction" | "cartoon" | undefined,
62
+ readiness: AgentReadiness | null | undefined,
63
+ ): boolean {
64
+ if (contentType !== "cartoon") return false;
65
+ if (!readiness) return false; // fail-open until readiness resolves
66
+ const codexReady =
67
+ readiness.codex.installed && readiness.codex.imageGeneration === "enabled";
68
+ return !codexReady;
16
69
  }
17
70
 
18
71
  interface TerminalSession {
@@ -106,13 +159,21 @@ async function deleteScrollback(storyName: string): Promise<void> {
106
159
  // Sessions live outside React state to avoid ref-in-effect lint issues
107
160
  const sessions = new Map<string, TerminalSession>();
108
161
 
109
- export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories, renameRef }: TerminalPanelProps) {
162
+ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories, renameRef, bypassStories, agentProviders, readiness, contentType, needsProviderRepair, onRepairProvider }: TerminalPanelProps) {
110
163
  const wrapperRef = useRef<HTMLDivElement>(null);
111
164
  const authFetchRef = useRef(authFetch);
112
165
  const [sessionList, setSessionList] = useState<string[]>([]);
113
166
  const [disconnected, setDisconnected] = useState<Set<string>>(new Set());
114
167
  const [confirmingDiscard, setConfirmingDiscard] = useState<string | null>(null);
115
168
  const [confirmingArchive, setConfirmingArchive] = useState<string | null>(null);
169
+ const [copiedEnableCmd, setCopiedEnableCmd] = useState(false);
170
+ const [repairing, setRepairing] = useState(false);
171
+
172
+ // Gate the cartoon agent launch for the currently-selected story.
173
+ const cartoonLaunchBlocked = isCartoonLaunchBlocked(contentType, readiness);
174
+ // Legacy cartoon (no provider recorded) ⇒ require explicit provider repair
175
+ // before auto-spawning a terminal. Scoped to the selected story only.
176
+ const showProviderRepair = !!needsProviderRepair;
116
177
 
117
178
  const connectWsRef = useRef<(name: string, session: TerminalSession, resume: boolean) => void>(() => {});
118
179
 
@@ -142,10 +203,19 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
142
203
  }
143
204
  }, [safeFit]);
144
205
 
206
+ const bypassRef = useRef<Record<string, boolean>>({});
207
+ useEffect(() => { bypassRef.current = bypassStories || {}; }, [bypassStories]);
208
+
209
+ const providerRef = useRef<Record<string, "claude" | "codex">>({});
210
+ useEffect(() => { providerRef.current = agentProviders || {}; }, [agentProviders]);
211
+
145
212
  const connectWs = useCallback((name: string, session: TerminalSession, resume: boolean) => {
146
213
  const wsProto = window.location.protocol === "https:" ? "wss:" : "ws:";
214
+ const bypass = bypassRef.current[name] ? "&bypass=true" : "";
215
+ const provider = providerRef.current[name];
216
+ const providerParam = provider ? `&provider=${encodeURIComponent(provider)}` : "";
147
217
  const ws = new WebSocket(
148
- `${wsProto}//${window.location.host}/ws/terminal?story=${encodeURIComponent(name)}&token=${token}&resume=${resume}`
218
+ `${wsProto}//${window.location.host}/ws/terminal?story=${encodeURIComponent(name)}&token=${token}&resume=${resume}${bypass}${providerParam}`
149
219
  );
150
220
 
151
221
  ws.onopen = () => {
@@ -155,8 +225,22 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
155
225
  ws.send(JSON.stringify({ type: "resize", cols: session.term.cols, rows: session.term.rows }));
156
226
  };
157
227
 
228
+ // The very first frame may be a control signal (#453). A fresh server spawn
229
+ // (the agent process reprints its banner/history) sends FRESH_SPAWN_SIGNAL —
230
+ // drop the restored scrollback so the banner isn't duplicated. Any other
231
+ // first frame (a live-PTY reconnect) is normal PTY output and is kept.
232
+ let firstFrame = true;
158
233
  ws.onmessage = (e) => {
159
- session.term.write(e.data);
234
+ if (firstFrame) {
235
+ firstFrame = false;
236
+ if (typeof e.data === "string" && e.data === FRESH_SPAWN_SIGNAL) {
237
+ session.term.reset();
238
+ deleteScrollback(name).catch(() => {});
239
+ return;
240
+ }
241
+ }
242
+ // Mask obvious auth secrets before they reach the terminal / scrollback (#454).
243
+ session.term.write(typeof e.data === "string" ? redactTerminalSecrets(e.data) : e.data);
160
244
  };
161
245
 
162
246
  ws.onclose = (event) => {
@@ -242,11 +326,16 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
242
326
  sessions.set(name, session);
243
327
  setSessionList((prev) => [...prev, name]);
244
328
 
245
- // Restore scrollback from IndexedDB
329
+ // Restore scrollback from IndexedDB, masking any auth secrets first so an OLD
330
+ // transcript can't re-display previously-persisted tokens/passphrases (#454).
331
+ // If redaction changed it, re-save the masked copy so the raw value is gone
332
+ // from storage and never re-persisted later.
246
333
  try {
247
334
  const saved = await loadScrollback(name);
248
335
  if (saved) {
249
- term.write(saved);
336
+ const redacted = redactTerminalSecrets(saved);
337
+ term.write(redacted);
338
+ if (redacted !== saved) saveScrollback(name, redacted).catch(() => {});
250
339
  }
251
340
  } catch { /* ignore */ }
252
341
 
@@ -330,15 +419,16 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
330
419
 
331
420
  /** Rename a session key (e.g. _new_123 → paper-chair) without killing the PTY.
332
421
  * Returns true on success, false on failure. */
333
- const renameSession = useCallback(async (oldName: string, newName: string): Promise<boolean> => {
422
+ const renameSession = useCallback(async (oldName: string, newName: string, meta?: RenameMeta): Promise<boolean> => {
334
423
  const session = sessions.get(oldName);
335
424
  if (!session || sessions.has(newName)) return false;
336
425
 
337
- // Rename on the server first
426
+ // Rename on the server first. Forward the confirmed story's metadata so the
427
+ // server persists contentType/provider atomically with the rename (#295).
338
428
  const res = await authFetchRef.current("/api/terminal/rename", {
339
429
  method: "POST",
340
430
  headers: { "Content-Type": "application/json" },
341
- body: JSON.stringify({ oldName, newName }),
431
+ body: JSON.stringify({ oldName, newName, ...(meta ?? {}) }),
342
432
  });
343
433
  if (!res.ok) return false;
344
434
 
@@ -363,6 +453,24 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
363
453
  return next;
364
454
  });
365
455
 
456
+ // #377: the renamed session's live PTY keeps the cwd it was SPAWNED in, so
457
+ // after the story folder moves (e.g. _new_* → final, or a partial slug →
458
+ // full title) the agent's trust prompt / working directory still points at
459
+ // the old (now-renamed) folder — confusing, and the old folder may no longer
460
+ // exist. If this session is live, move the terminal into the FINAL folder:
461
+ // kill the stale-cwd PTY and reconnect WITH RESUME, so the new spawn runs in
462
+ // stories/<newName> (correct cwd/trust prompt) while the conversation is
463
+ // preserved (claude --resume / codex resume). The server reads the provider
464
+ // from the .story.json it just persisted, so the respawn stays provider-aware.
465
+ // If resume can't recover, the existing code-4000 path reconnects fresh in
466
+ // the same (correct) folder. A never-connected session needs no respawn — its
467
+ // first connect already uses the final name.
468
+ if (session.connected || session.ws) {
469
+ await authFetchRef.current(`/api/terminal/${encodeURIComponent(newName)}`, { method: "DELETE" }).catch(() => {});
470
+ if (session.ws) { session.ws.close(); session.ws = null; }
471
+ connectWsRef.current(newName, session, true);
472
+ }
473
+
366
474
  return true;
367
475
  }, []);
368
476
 
@@ -375,6 +483,20 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
375
483
  // Auto-spawn + show/hide when story changes
376
484
  useEffect(() => {
377
485
  if (!storyName) return;
486
+ // Cartoon readiness gate: never spawn/connect a terminal for a cartoon
487
+ // story whose Codex/image_generation is known-not-ready. Show guidance
488
+ // instead (rendered below). Fail-open when readiness is null/undefined.
489
+ if (cartoonLaunchBlocked) {
490
+ showSession(null);
491
+ return;
492
+ }
493
+ // Legacy cartoon with no recorded provider: do NOT auto-spawn. Show the
494
+ // explicit repair CTA so the writer sets the provider to Codex first. After
495
+ // repair, `needsProviderRepair` flips false and normal gating/launch applies.
496
+ if (showProviderRepair) {
497
+ showSession(null);
498
+ return;
499
+ }
378
500
  if (!sessions.has(storyName)) {
379
501
  // Check if a previous session exists — if so, show overlay instead of auto-connecting
380
502
  authFetchRef.current(`/api/terminal/session/${encodeURIComponent(storyName)}`)
@@ -395,7 +517,7 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
395
517
  } else {
396
518
  showSession(storyName);
397
519
  }
398
- }, [storyName, createSession, showSession]);
520
+ }, [storyName, createSession, showSession, cartoonLaunchBlocked, showProviderRepair]);
399
521
 
400
522
  // Periodic scrollback save (every 30s for active session)
401
523
  useEffect(() => {
@@ -496,7 +618,7 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
496
618
  <div ref={wrapperRef} className="h-full" />
497
619
 
498
620
  {/* Empty state overlay */}
499
- {isEmpty && (
621
+ {isEmpty && !cartoonLaunchBlocked && !showProviderRepair && (
500
622
  <div className="absolute inset-0 flex items-center justify-center text-muted">
501
623
  <div className="text-center">
502
624
  <p className="text-lg font-serif">Select a story on the left menu</p>
@@ -505,6 +627,106 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
505
627
  </div>
506
628
  )}
507
629
 
630
+ {/* Cartoon launch gated: Codex / image generation not ready */}
631
+ {cartoonLaunchBlocked && (
632
+ <div
633
+ data-testid="cartoon-launch-blocked"
634
+ className="absolute inset-0 flex items-center justify-center"
635
+ style={{ background: "rgba(240, 235, 225, 0.9)" }}
636
+ >
637
+ <div className="space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-md">
638
+ <p className="text-sm font-serif text-foreground font-medium">
639
+ Cartoon agent can&apos;t launch yet
640
+ </p>
641
+ <p className="text-xs text-muted">
642
+ This is a cartoon story. The writing agent needs Codex with image
643
+ generation enabled before it can start, because the clean-image
644
+ step relies on image generation support.
645
+ </p>
646
+ {readiness && !readiness.codex.installed ? (
647
+ <p className="text-xs text-amber-700">
648
+ Codex was not detected. Install the Codex CLI and sign in
649
+ (e.g. <span className="font-mono">npm i -g @openai/codex</span> then{" "}
650
+ <span className="font-mono">codex login</span>), then reopen this story.
651
+ </p>
652
+ ) : isCodexAuthUnclear(readiness) ? (
653
+ <p className="text-xs text-amber-700" data-testid="codex-auth-unknown-launch">
654
+ {CODEX_AUTH_UNCLEAR_MESSAGE} Then reopen this story.
655
+ </p>
656
+ ) : (
657
+ <div className="space-y-1">
658
+ <p className="text-xs text-amber-700">
659
+ Codex is installed but image generation isn&apos;t enabled. Enable
660
+ it, then reopen this story:
661
+ </p>
662
+ <div className="flex items-center gap-1">
663
+ <code className="flex-1 truncate rounded border border-border bg-surface px-1.5 py-1 text-left text-[10px] font-mono text-foreground">
664
+ {CODEX_ENABLE_CMD}
665
+ </code>
666
+ <button
667
+ type="button"
668
+ data-testid="copy-codex-enable-launch"
669
+ onClick={async () => {
670
+ try {
671
+ await navigator.clipboard.writeText(CODEX_ENABLE_CMD);
672
+ setCopiedEnableCmd(true);
673
+ setTimeout(() => setCopiedEnableCmd(false), 2000);
674
+ } catch { /* clipboard unavailable */ }
675
+ }}
676
+ className="rounded border border-border px-2 py-1 text-[10px] text-muted hover:border-accent hover:text-accent transition-colors"
677
+ >
678
+ {copiedEnableCmd ? "Copied!" : "Copy"}
679
+ </button>
680
+ </div>
681
+ </div>
682
+ )}
683
+ </div>
684
+ </div>
685
+ )}
686
+
687
+ {/* Legacy cartoon: no provider recorded — explicit, scoped repair CTA.
688
+ Separate from readiness gating; about a MISSING provider on this one
689
+ story. Setting it to Codex never touches other stories or fiction. */}
690
+ {showProviderRepair && !cartoonLaunchBlocked && (
691
+ <div
692
+ data-testid="legacy-cartoon-provider-repair"
693
+ className="absolute inset-0 flex items-center justify-center"
694
+ style={{ background: "rgba(240, 235, 225, 0.9)" }}
695
+ >
696
+ <div className="space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-md">
697
+ <p className="text-sm font-serif text-foreground font-medium">
698
+ Set this cartoon story&apos;s provider
699
+ </p>
700
+ <p className="text-xs text-muted">
701
+ This cartoon story was created before provider tracking, so it has
702
+ no provider recorded and would launch with Claude — which can&apos;t
703
+ generate the clean images cartoons need. Set this story&apos;s
704
+ provider to Codex to continue.
705
+ </p>
706
+ <p className="text-[11px] text-muted">
707
+ Only this story is changed. Other stories and fiction are not affected.
708
+ </p>
709
+ <button
710
+ type="button"
711
+ data-testid="repair-provider-codex"
712
+ disabled={repairing}
713
+ onClick={async () => {
714
+ if (repairing) return;
715
+ setRepairing(true);
716
+ try {
717
+ await onRepairProvider?.();
718
+ } finally {
719
+ setRepairing(false);
720
+ }
721
+ }}
722
+ className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim disabled:opacity-50 disabled:cursor-not-allowed"
723
+ >
724
+ {repairing ? "Setting…" : "Set this story's provider to Codex"}
725
+ </button>
726
+ </div>
727
+ </div>
728
+ )}
729
+
508
730
  {/* Discard confirmation overlay */}
509
731
  {confirmingDiscard && (
510
732
  <div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
@@ -0,0 +1,128 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { CartoonCoach, CoachUiAction } from "@app-lib/cartoon-coach";
3
+ import type { StoryProgress } from "@app-lib/story-progress";
4
+
5
+ /**
6
+ * Persistent cartoon workflow coach (#429). Converts the current story/episode
7
+ * state into one stage label + one primary next action — an agent copy-paste
8
+ * prompt or a direct in-app UI action — so a normal writer always knows the next
9
+ * step without reading terminal logs or technical warnings. It never blocks the
10
+ * terminal or advanced controls; it just makes the normal path obvious.
11
+ *
12
+ * Two pieces:
13
+ * - `WorkflowCoachView` is presentational (takes an already-loaded coach) so the
14
+ * progress overview, which already fetches the progress payload, can render it
15
+ * with no extra request.
16
+ * - `WorkflowCoach` is the self-loading container the file views use.
17
+ *
18
+ * Fiction is unaffected: the coach is null for fiction, so the view renders
19
+ * nothing.
20
+ */
21
+
22
+ interface WorkflowCoachViewProps {
23
+ coach: CartoonCoach | null | undefined;
24
+ /** Run an app-driven step (the agent steps copy a prompt instead). */
25
+ onAction: (action: CoachUiAction, episodeFile: string | null) => void;
26
+ className?: string;
27
+ }
28
+
29
+ export function WorkflowCoachView({ coach, onAction, className = "" }: WorkflowCoachViewProps) {
30
+ // Track the prompt that was copied rather than a bare boolean, so the "Copied!"
31
+ // confirmation derives to false the moment the coach (and its prompt) changes —
32
+ // no reset effect, no stale confirmation under a new stage.
33
+ const [copiedPrompt, setCopiedPrompt] = useState<string | null>(null);
34
+ const copied = copiedPrompt !== null && copiedPrompt === coach?.prompt;
35
+
36
+ if (!coach) return null;
37
+
38
+ return (
39
+ <div
40
+ className={`flex items-center gap-2 px-3 py-2 bg-accent/5 border-b border-accent/30 text-xs ${className}`}
41
+ data-testid="workflow-coach"
42
+ data-stage={coach.stageLabel}
43
+ data-action-kind={coach.actionKind}
44
+ data-ui-action={coach.uiAction ?? ""}
45
+ >
46
+ <span className="rounded-full bg-background px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-accent flex-shrink-0" data-testid="workflow-coach-stage">
47
+ {coach.stageLabel}
48
+ </span>
49
+ <span className="min-w-0 flex-1 text-foreground" data-testid="workflow-coach-action">
50
+ <span className="text-muted">Next: </span>
51
+ <span className="font-medium">{coach.action}</span>
52
+ </span>
53
+ {coach.actionKind === "agent" && coach.prompt ? (
54
+ <button
55
+ onClick={() => {
56
+ if (!coach.prompt) return;
57
+ const prompt = coach.prompt;
58
+ navigator.clipboard?.writeText(prompt).then(() => setCopiedPrompt(prompt)).catch(() => {});
59
+ }}
60
+ data-testid="workflow-coach-copy"
61
+ className="flex-shrink-0 rounded bg-accent px-2.5 py-1 text-[11px] font-medium text-white hover:bg-accent-dim transition-colors"
62
+ >
63
+ {copied ? "Copied!" : "Copy prompt"}
64
+ </button>
65
+ ) : coach.actionKind === "ui" && coach.uiAction ? (
66
+ <button
67
+ onClick={() => onAction(coach.uiAction!, coach.episodeFile)}
68
+ data-testid="workflow-coach-do"
69
+ className="flex-shrink-0 rounded bg-accent px-2.5 py-1 text-[11px] font-medium text-white hover:bg-accent-dim transition-colors"
70
+ >
71
+ {coach.action}
72
+ </button>
73
+ ) : null}
74
+ </div>
75
+ );
76
+ }
77
+
78
+ interface WorkflowCoachProps {
79
+ storyName: string;
80
+ /** The file currently in focus, so the coach speaks about that episode (#429). */
81
+ fileName?: string | null;
82
+ authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
83
+ /** Bumped by the parent to reload after a state change (cut edit / publish). */
84
+ refreshKey?: number;
85
+ onAction: (action: CoachUiAction, episodeFile: string | null) => void;
86
+ }
87
+
88
+ /**
89
+ * Self-loading coach for the file views. Fetches the story progress (scoped to
90
+ * the focused file) and renders the coach bar. The coach is cleared in EVERY
91
+ * load exit path — at the start, on a non-OK response, and on error — so a
92
+ * previous file's coach can never linger under a different file when the new
93
+ * request fails or 404s (the stale-state-on-error class flagged on #420/#427).
94
+ */
95
+ export function WorkflowCoach({ storyName, fileName, authFetch, refreshKey = 0, onAction }: WorkflowCoachProps) {
96
+ const [coach, setCoach] = useState<CartoonCoach | null>(null);
97
+
98
+ // Reset the coach the instant the target changes (file switch / refresh),
99
+ // during render — React's recommended way to reset state on a changing input.
100
+ // This clears the prior file's coach BEFORE the new load resolves; and because
101
+ // the effect below sets the coach to null on a non-OK response and never sets
102
+ // it on error, it also STAYS cleared when the new load fails or 404s (the
103
+ // stale-state-on-error class flagged on #420/#427).
104
+ //
105
+ // JSON.stringify keeps the key printable and source-safe (#437): it changes
106
+ // whenever any input changes — identical reset semantics — and it escapes the
107
+ // parts so a separator can never collide with the values' own content.
108
+ const targetKey = JSON.stringify([storyName, fileName ?? "", refreshKey]);
109
+ const [loadedKey, setLoadedKey] = useState<string | null>(null);
110
+ if (loadedKey !== targetKey) {
111
+ setCoach(null);
112
+ setLoadedKey(targetKey);
113
+ }
114
+
115
+ useEffect(() => {
116
+ let cancelled = false;
117
+ const focus = fileName ? `?focus=${encodeURIComponent(fileName)}` : "";
118
+ authFetch(`/api/stories/${storyName}/progress${focus}`)
119
+ .then((res) => (res.ok ? res.json() : null))
120
+ .then((data: (StoryProgress & { coach?: CartoonCoach | null }) | null) => {
121
+ if (!cancelled) setCoach(data?.coach ?? null);
122
+ })
123
+ .catch(() => { /* leave it cleared — the coach is best-effort */ });
124
+ return () => { cancelled = true; };
125
+ }, [storyName, fileName, authFetch, refreshKey]);
126
+
127
+ return <WorkflowCoachView coach={coach} onAction={onAction} />;
128
+ }
@@ -0,0 +1,114 @@
1
+ import { useState, useEffect } from "react";
2
+
3
+ type AuthFetch = (url: string, opts?: RequestInit) => Promise<Response>;
4
+
5
+ /** Resolve a story-relative asset path to its auth-protected API URL. */
6
+ export function assetUrl(storyName: string, assetPath: string): string {
7
+ const relative = assetPath.startsWith("assets/") ? assetPath.slice(7) : assetPath;
8
+ return `/api/stories/${storyName}/asset/${relative}`;
9
+ }
10
+
11
+ interface AssetState {
12
+ /** Same-origin blob object URL safe to use as an <img src>, or null. */
13
+ url: string | null;
14
+ loading: boolean;
15
+ error: boolean;
16
+ }
17
+
18
+ /**
19
+ * Load an auth-protected story asset as a blob object URL.
20
+ *
21
+ * Story asset routes sit behind `requireAuth`, but a browser `<img src>`
22
+ * request never carries the `Authorization: Bearer` header that `authFetch`
23
+ * adds, so the raw URL 401s and the image breaks. Instead we fetch the asset
24
+ * via `authFetch`, turn the response into a blob, and hand back an object URL.
25
+ * The object URL is revoked when the asset path changes or the component
26
+ * unmounts so we don't leak blobs across cut selections.
27
+ */
28
+ export function useAuthedAsset(
29
+ storyName: string,
30
+ assetPath: string | null | undefined,
31
+ authFetch: AuthFetch,
32
+ ): AssetState {
33
+ const [state, setState] = useState<AssetState>({
34
+ url: null,
35
+ loading: !!assetPath,
36
+ error: false,
37
+ });
38
+
39
+ useEffect(() => {
40
+ if (!assetPath) {
41
+ setState({ url: null, loading: false, error: false });
42
+ return;
43
+ }
44
+
45
+ let objectUrl: string | null = null;
46
+ let cancelled = false;
47
+ setState({ url: null, loading: true, error: false });
48
+
49
+ (async () => {
50
+ try {
51
+ const res = await authFetch(assetUrl(storyName, assetPath));
52
+ if (!res.ok) throw new Error(`asset request failed (${res.status})`);
53
+ const blob = await res.blob();
54
+ if (cancelled) return;
55
+ objectUrl = URL.createObjectURL(blob);
56
+ setState({ url: objectUrl, loading: false, error: false });
57
+ } catch {
58
+ if (!cancelled) setState({ url: null, loading: false, error: true });
59
+ }
60
+ })();
61
+
62
+ return () => {
63
+ cancelled = true;
64
+ if (objectUrl) URL.revokeObjectURL(objectUrl);
65
+ };
66
+ }, [storyName, assetPath, authFetch]);
67
+
68
+ return state;
69
+ }
70
+
71
+ interface AssetImageProps {
72
+ storyName: string;
73
+ assetPath: string;
74
+ authFetch: AuthFetch;
75
+ alt: string;
76
+ className?: string;
77
+ }
78
+
79
+ /**
80
+ * Render an auth-protected story asset as an image, loading it through
81
+ * `useAuthedAsset`. Shows a neutral placeholder while loading and a clear
82
+ * "Image not available" state on failure so a broken auth boundary surfaces
83
+ * instead of a broken-image glyph.
84
+ */
85
+ export function AssetImage({ storyName, assetPath, authFetch, alt, className }: AssetImageProps) {
86
+ const { url, loading, error } = useAuthedAsset(storyName, assetPath, authFetch);
87
+
88
+ if (error || (!loading && !url)) {
89
+ return (
90
+ <div className="w-full aspect-video bg-surface border border-border rounded flex items-center justify-center">
91
+ <span className="text-xs text-muted">Image not available</span>
92
+ </div>
93
+ );
94
+ }
95
+
96
+ if (!url) {
97
+ return (
98
+ <div
99
+ className="w-full aspect-video bg-surface border border-border rounded flex items-center justify-center"
100
+ data-testid="asset-loading"
101
+ >
102
+ <span className="text-xs text-muted">Loading image…</span>
103
+ </div>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <img
109
+ src={url}
110
+ alt={alt}
111
+ className={className ?? "w-full rounded border border-border"}
112
+ />
113
+ );
114
+ }
@@ -0,0 +1,44 @@
1
+ import { vi } from "vitest";
2
+
3
+ /** Object URL returned by the stubbed `URL.createObjectURL` in jsdom tests. */
4
+ export const MOCK_BLOB_URL = "blob:mock-asset-url";
5
+
6
+ /**
7
+ * jsdom doesn't implement the object-URL APIs that `useAuthedAsset` relies on.
8
+ * Stub them so blob-backed asset loading works in component tests, and so a
9
+ * test can assert that an `<img>` points at the object URL rather than the
10
+ * raw auth-protected API route.
11
+ */
12
+ export function installObjectUrlStub(): void {
13
+ const u = URL as unknown as {
14
+ createObjectURL: (b: Blob) => string;
15
+ revokeObjectURL: (s: string) => void;
16
+ };
17
+ u.createObjectURL = vi.fn(() => MOCK_BLOB_URL);
18
+ u.revokeObjectURL = vi.fn();
19
+ }
20
+
21
+ /**
22
+ * `authFetch` double for tests that render asset-loading components. Asset
23
+ * routes (`/asset/`) resolve to an image blob the way the real authenticated
24
+ * route does; every other route resolves to `jsonData`. This mirrors the real
25
+ * world where the Bearer header reaches both the data route and the image —
26
+ * the bug being fixed was a raw `<img src>` that never sent that header.
27
+ */
28
+ export function makeAssetAuthFetch(jsonData: unknown = {}) {
29
+ return vi.fn((url: string) =>
30
+ Promise.resolve(
31
+ url.includes("/asset/")
32
+ ? {
33
+ ok: true,
34
+ status: 200,
35
+ blob: () => Promise.resolve(new Blob(["img-bytes"], { type: "image/webp" })),
36
+ }
37
+ : {
38
+ ok: true,
39
+ status: 200,
40
+ json: () => Promise.resolve(jsonData),
41
+ },
42
+ ),
43
+ );
44
+ }