diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md
index 311ad26e4..f6b2b8ddc 100644
--- a/.changelog/NEXT.md
+++ b/.changelog/NEXT.md
@@ -32,6 +32,7 @@
- **Universe Builder is a composition shell again.** `UniverseBuilderPage.jsx` drops from 778 to ~530 lines: the URL-backed tab/sub-bucket state (plus the two effects that strip a `?tab=`/`?bucket=` the current categories no longer support) is now `useUniverseTabs`, and generate-in-bucket / promote-to-canon / auto-sort — each with its own in-flight gate and stale-snapshot merge rules — is now `useUniverseBucketActions`. The render-completion handler that was copy-pasted between the trunk tabs and the Other tab is one shared callback. Expand/refine already lived in `useUniverseExpand`, so it stayed put. Behavior, markup, and API calls are unchanged; `useUniverseTabs` picks up 11 tests. (#3391)
- **Text-stage generation streams its progress instead of spinning blind.** A draft-gated prose/script stage can run up to three generate+judge cycles — minutes with no signal — so `generateStage` now broadcasts phase frames (context build, drafting attempt N of M, judging, best-draft restore, canon extraction) and a per-attempt scorecard over a new SSE channel, and `TextStagePanel` renders them with PolishPanel's phase-label pattern. The channel is subscribe-first (attaching opens it, so the client can't race the POST it is about to send) and purely advisory: with no subscriber every emit is a no-op, and autoRunner, Series Autopilot, and the volume beat runner call generation exactly as before. (#3393)
- **The CyberCity low tier stops paying for lights it can't show off.** A mounted light costs a per-fragment iteration in the lighting loop of every `MeshStandardMaterial` in the scene no matter how dim it is, so the adaptive-quality low tier could never shed that cost by fading lights out — only by unmounting them. `CityLights` now gates its two ground-level accent point lights (the green and red warning accents, the dimmest and smallest-radius of the set) behind the same `cityShowDetail` tier gate the street furniture and trams already use, dropping the low tier from 11 dynamic point lights to 9. Medium tier and above — and any install whose payload predates `effectiveTier` — render exactly as before. (#3397)
+- **Regenerating over unsaved stage edits now shows you the diff first.** The dirty gate on text-stage Generate turned "you might lose your edits" into "you can't generate at all" — you had to save or discard first, with no way to see what the new version would have been. Generate is live again while the editor is dirty: it opens an inline confirm row, and the result lands in an `InlineDiff` against your unsaved text rather than in the textarea. "Use new version" applies it, "Keep my edits" leaves your text exactly as typed (still dirty against the newly persisted output, so Save pushes your version back over it). The generated stage is lifted to the parent either way — the server persisted it, so history stays truthful — and switching issue/stage drops a pending comparison. (#3398)
## Better audit 2026-08-03 (UX, City scene, game/music/writing focus) — PRs #3401–#3404
diff --git a/client/src/components/pipeline/stages/TextStagePanel.jsx b/client/src/components/pipeline/stages/TextStagePanel.jsx
index 646597d6c..2e9ced24e 100644
--- a/client/src/components/pipeline/stages/TextStagePanel.jsx
+++ b/client/src/components/pipeline/stages/TextStagePanel.jsx
@@ -5,9 +5,10 @@
* generate button that calls the server's text-stage runner.
*/
-import { useEffect, useMemo, useState } from 'react';
-import { Loader2, Sparkles, Save, History, Check, X } from 'lucide-react';
+import { useEffect, useMemo, useRef, useState } from 'react';
+import { Loader2, Sparkles, Save, History, Check, X, GitCompare } from 'lucide-react';
import toast from '../../ui/Toast';
+import InlineDiff from '../../ui/InlineDiff';
import ProseEditor from '../../ui/ProseEditor';
import {
generatePipelineStage, updatePipelineIssue,
@@ -67,6 +68,28 @@ export default function TextStagePanel({
const [historyOpen, setHistoryOpen] = useState(false);
const runHistory = stage.runHistory || [];
+ // Regenerating over unsaved output edits (#3398). Two steps, so nothing the
+ // user typed is ever replaced without them seeing what replaces it:
+ // 1. `confirmRegenerate` — inline confirm row shown instead of firing the
+ // generate immediately when the editor is dirty.
+ // 2. `pendingDiff` — the incoming result held next to the unsaved text in an
+ // InlineDiff until the user applies it or keeps their edits.
+ // `null` = nothing pending, which is NOT the same as a pending empty result.
+ const [confirmRegenerate, setConfirmRegenerate] = useState(false);
+ const [pendingDiff, setPendingDiff] = useState(null);
+ // Mirrored in a ref so the parent-driven reset effect can check it without
+ // listing it as a dependency — resolving the review must not re-trigger that
+ // effect and clobber the text the user just chose to keep.
+ const pendingDiffRef = useRef(null);
+ const setPending = (next) => {
+ pendingDiffRef.current = next;
+ setPendingDiff(next);
+ };
+ // Read at generation-resolve time: a run that takes minutes can land after the
+ // user typed into the editor, or after the panel moved to another record.
+ const draftOutputRef = useRef(draftOutput);
+ useEffect(() => { draftOutputRef.current = draftOutput; }, [draftOutput]);
+
// Live generation progress (#3393). The URL is set for the duration of one
// generate call; the server opens the channel when we attach, so subscribing
// just before the POST can't race it. Frames survive the teardown so the
@@ -77,6 +100,8 @@ export default function TextStagePanel({
const [progressFor, setProgressFor] = useState(null);
const { frames } = useSseProgress(progressUrl, { enabled: !!progressUrl });
const progressKey = `${issue.id}:${stageId}`;
+ const activeKeyRef = useRef(progressKey);
+ useEffect(() => { activeKeyRef.current = progressKey; }, [progressKey]);
// Other text stages that currently have content — the candidate source
// material for this generation. Excludes the target stage itself. Lets you
@@ -104,10 +129,16 @@ export default function TextStagePanel({
// Reset local edits when the stage record changes from the parent (e.g.
// auto-run pushed a new output).
useEffect(() => {
+ setServerGenerating(stage.status === 'generating');
+ // A regeneration held for review keeps the unsaved editor text until the
+ // user decides — but only for the record it was generated against. Switching
+ // issue/stage drops the review and reloads normally.
+ if (pendingDiffRef.current?.key === progressKey) return;
+ setPending(null);
+ setConfirmRegenerate(false);
setDraftOutput(stage.output || '');
setDraftInput(stage.input || '');
- setServerGenerating(stage.status === 'generating');
- }, [stage.output, stage.input, stage.status, stage.lastRunId]);
+ }, [stage.output, stage.input, stage.status, stage.lastRunId, progressKey]);
const [runGenerate, localGenerating] = useAsyncAction(
() => generatePipelineStage(issue.id, stageId, {
@@ -123,6 +154,13 @@ export default function TextStagePanel({
const generating = localGenerating || serverGenerating;
const handleGenerate = async () => {
+ setConfirmRegenerate(false);
+ // Captured at kickoff: the persisted text this run started from, and which
+ // record it belongs to. Anything the editor holds that differs from the
+ // baseline once the result lands is an unsaved edit worth reviewing —
+ // whether it was typed before the run or while it was in flight.
+ const baseline = stage.output || '';
+ const requestKey = progressKey;
// Subscribe to the progress stream first. It is purely advisory — an
// environment without EventSource (or a channel that never opens) just
// falls back to the spinner; generation itself is unaffected.
@@ -135,10 +173,43 @@ export default function TextStagePanel({
// already landed, and the frames stay rendered after the teardown.
setProgressUrl(null);
if (!result) return;
+ const incoming = result.stage?.output || '';
+ const latestDraft = draftOutputRef.current;
+ // Nothing to decide when the editor never diverged from what this run
+ // started from, or when the result already matches what's in the editor.
+ // A result that lands after the panel moved to another issue/stage is never
+ // reviewed against the record now on screen.
+ if (activeKeyRef.current !== requestKey) {
+ // The panel moved on while this ran. The run is persisted server-side, so
+ // say so — but lifting it would patch whichever record is on screen NOW.
+ toast.success(`${PIPELINE_STAGE_LABELS[stageId]} generated`);
+ return;
+ }
+ if (latestDraft !== baseline && latestDraft !== incoming) {
+ setPending({ key: requestKey, incoming });
+ }
onStageUpdate?.(stageId, result.stage);
toast.success(`${PIPELINE_STAGE_LABELS[stageId]} generated`);
};
+ // A review only ever belongs to the record it was generated against — a
+ // lingering one from another issue/stage is inert, never rendered or applied.
+ const activeDiff = pendingDiff?.key === progressKey ? pendingDiff : null;
+
+ // Apply the reviewed generation: the server already persisted it, so this is
+ // purely "stop holding my unsaved text over it."
+ const applyPendingDiff = () => {
+ if (!activeDiff) return;
+ const { incoming } = activeDiff;
+ setPending(null);
+ setDraftOutput(incoming);
+ };
+
+ // Keep the unsaved edits. The draft is left exactly as the user typed it —
+ // it now reads as dirty against the newly persisted output, so Save still
+ // works to push their version back over it.
+ const keepMyEdits = () => setPending(null);
+
// Derived progress view. `activePhase` is the most recent phase frame that
// hasn't been superseded by a terminal frame — same walk PolishPanel does.
const showProgress = progressFor === progressKey;
@@ -212,7 +283,8 @@ export default function TextStagePanel({