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
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
154 changes: 146 additions & 8 deletions client/src/components/pipeline/stages/TextStagePanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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, {
Expand All @@ -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.
Expand All @@ -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;
Expand Down Expand Up @@ -212,17 +283,22 @@ export default function TextStagePanel({
<button
type="button"
onClick={handleSave}
disabled={!dirty || saving}
disabled={!dirty || saving || !!activeDiff}
title={activeDiff ? 'Resolve the new-version comparison first' : undefined}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-port-card border border-port-border text-white text-sm hover:border-port-accent/50 disabled:opacity-40"
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
Save edits
</button>
<button
type="button"
onClick={handleGenerate}
disabled={generating || actionsGated || outputDirty}
title={actionsGated ? 'Saving settings…' : (outputDirty ? 'Save or discard your edits first' : undefined)}
onClick={outputDirty ? () => setConfirmRegenerate(true) : handleGenerate}
// `saving` too: an in-flight save PATCH writes the same stage record
// this run will overwrite when it lands.
disabled={generating || actionsGated || saving || !!activeDiff}
title={actionsGated
? 'Saving settings…'
: (outputDirty ? 'You have unsaved edits — you’ll compare them against the new version first' : undefined)}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-port-accent text-white text-sm font-medium disabled:opacity-50"
>
{generating ? <Loader2 size={14} className="animate-spin" /> : <Sparkles size={14} />}
Expand All @@ -231,6 +307,68 @@ export default function TextStagePanel({
</div>
</div>

{confirmRegenerate && outputDirty ? (
<div className="flex items-center justify-between gap-3 flex-wrap border border-port-warning/40 rounded p-3 bg-port-warning/5">
<span className="text-xs text-gray-300">
You have unsaved edits. Generating won’t replace them — you’ll see the new version diffed against your text and choose.
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleGenerate}
// Same gates as the Generate button it stands in for — the confirm
// row can sit open while a save or a settings write starts.
disabled={generating || actionsGated || saving}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-port-accent text-white text-sm font-medium disabled:opacity-50"
>
<GitCompare size={14} />
Generate & compare
</button>
<button
type="button"
onClick={() => setConfirmRegenerate(false)}
className="px-3 py-1.5 rounded-lg bg-port-card border border-port-border text-white text-sm hover:border-port-accent/50"
>
Cancel
</button>
</div>
</div>
) : null}

{activeDiff ? (
<div className="border border-port-accent/50 rounded bg-port-bg/40 overflow-hidden">
<div className="flex items-center justify-between gap-3 flex-wrap p-3">
<span className="text-xs text-gray-300">
<span className="uppercase tracking-wider text-gray-500 mr-2">New version</span>
Your unsaved edits in red, the new version in green.
</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={applyPendingDiff}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-port-accent text-white text-sm font-medium"
>
<Check size={14} />
Use new version
</button>
<button
type="button"
onClick={keepMyEdits}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-port-card border border-port-border text-white text-sm hover:border-port-accent/50"
>
<X size={14} />
Keep my edits
</button>
</div>
</div>
<div className="max-h-72 overflow-y-auto">
{/* Diffed against the live editor text, not a kickoff snapshot, so
typing while the review is open keeps the comparison honest. */}
<InlineDiff oldText={draftOutput} newText={activeDiff.incoming} />
</div>
</div>
) : null}

{availableSources.length > 0 ? (
<div className="flex items-center gap-2 flex-wrap text-xs">
<span className="uppercase tracking-wider text-gray-500">Generate from:</span>
Expand Down
Loading