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({ + + + + ) : null} + + {activeDiff ? ( +
+
+ + New version + Your unsaved edits in red, the new version in green. + +
+ + +
+
+
+ {/* Diffed against the live editor text, not a kickoff snapshot, so + typing while the review is open keeps the comparison honest. */} + +
+
+ ) : null} + {availableSources.length > 0 ? (
Generate from: diff --git a/client/src/components/pipeline/stages/TextStagePanel.test.jsx b/client/src/components/pipeline/stages/TextStagePanel.test.jsx index bde5918df..7d4f1e9cd 100644 --- a/client/src/components/pipeline/stages/TextStagePanel.test.jsx +++ b/client/src/components/pipeline/stages/TextStagePanel.test.jsx @@ -148,24 +148,28 @@ describe('TextStagePanel', () => { expect(screen.getByRole('button', { name: 'Generate' })).not.toBeDisabled(); }); - it('dirty-gate: disables Generate with the "Save or discard" title once the draft diverges, and re-enables once the saved stage is lifted back in', async () => { + it('dirty-gate: routes Generate through the confirm row once the draft diverges, and straight through once the saved stage is lifted back in', async () => { const issue = makeIssue({ status: 'ready', output: 'Saved idea text.' }); const savedStage = { status: 'edited', input: '', output: 'Edited idea text.' }; const savedIssue = { ...issue, stages: { idea: savedStage } }; updatePipelineIssue.mockResolvedValue(savedIssue); + generatePipelineStage.mockResolvedValue({ stage: savedStage }); const { rerender } = renderPanel(issue); const generateBtn = screen.getByRole('button', { name: 'Generate' }); expect(generateBtn).not.toBeDisabled(); - expect(generateBtn).not.toHaveAttribute('title', 'Save or discard your edits first'); const output = screen.getByPlaceholderText('output…'); await userEvent.clear(output); await userEvent.type(output, 'Edited idea text.'); - expect(generateBtn).toBeDisabled(); - expect(generateBtn).toHaveAttribute('title', 'Save or discard your edits first'); + // Still clickable — but it asks first instead of firing the generate. + expect(generateBtn).not.toBeDisabled(); + await userEvent.click(generateBtn); + expect(await screen.findByRole('button', { name: /Generate & compare/i })).toBeInTheDocument(); + expect(generatePipelineStage).not.toHaveBeenCalled(); + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); await userEvent.click(screen.getByRole('button', { name: /Save edits/i })); await waitFor(() => expect(updatePipelineIssue).toHaveBeenCalled()); @@ -181,7 +185,147 @@ describe('TextStagePanel', () => { onStageUpdate={() => {}} />, ); - expect(generateBtn).not.toBeDisabled(); + await userEvent.click(generateBtn); + await waitFor(() => expect(generatePipelineStage).toHaveBeenCalled()); + expect(screen.queryByRole('button', { name: /Generate & compare/i })).not.toBeInTheDocument(); + }); + + describe('regenerate-over-unsaved-edits review (#3398)', () => { + // Dirties the output editor, then drives the confirm row through to the + // generate call so the diff review is on screen. + const generateOverEdits = async ({ incoming, onStageUpdate = () => {} }) => { + const issue = makeIssue({ status: 'ready', output: 'Saved idea text.' }); + generatePipelineStage.mockResolvedValue({ + stage: { status: 'ready', input: '', output: incoming, runHistory: [] }, + }); + renderPanel(issue, { onStageUpdate }); + + const output = screen.getByPlaceholderText('output…'); + await userEvent.clear(output); + await userEvent.type(output, 'My unsaved edits.'); + + await userEvent.click(screen.getByRole('button', { name: 'Generate' })); + await userEvent.click(screen.getByRole('button', { name: /Generate & compare/i })); + await waitFor(() => expect(generatePipelineStage).toHaveBeenCalled()); + return output; + }; + + it('holds the incoming result in an inline diff instead of replacing the unsaved text', async () => { + const output = await generateOverEdits({ incoming: 'Freshly generated text.' }); + + expect(await screen.findByRole('button', { name: /Use new version/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Keep my edits/i })).toBeInTheDocument(); + // The editor still shows what the user typed, and the diff highlights both + // sides (word-level runs, so assert on the changed words). + expect(output).toHaveValue('My unsaved edits.'); + expect(screen.getByText('unsaved')).toBeInTheDocument(); + expect(screen.getByText('Freshly')).toBeInTheDocument(); + // Save is parked until the comparison is resolved. + expect(screen.getByRole('button', { name: /Save edits/i })).toBeDisabled(); + }); + + it('applying the new version replaces the editor text and dismisses the diff', async () => { + const output = await generateOverEdits({ incoming: 'Freshly generated text.' }); + + await userEvent.click(await screen.findByRole('button', { name: /Use new version/i })); + + expect(output).toHaveValue('Freshly generated text.'); + expect(screen.queryByRole('button', { name: /Use new version/i })).not.toBeInTheDocument(); + }); + + it('canceling preserves the unsaved edits untouched', async () => { + const output = await generateOverEdits({ incoming: 'Freshly generated text.' }); + + await userEvent.click(await screen.findByRole('button', { name: /Keep my edits/i })); + + expect(output).toHaveValue('My unsaved edits.'); + expect(screen.queryByRole('button', { name: /Keep my edits/i })).not.toBeInTheDocument(); + // Still dirty against the newly persisted output, so Save is live again. + expect(screen.getByRole('button', { name: /Save edits/i })).not.toBeDisabled(); + }); + + it('skips the review when the generated text matches the unsaved edits', async () => { + const output = await generateOverEdits({ incoming: 'My unsaved edits.' }); + + await waitFor(() => expect(toast.success).toHaveBeenCalledWith('Idea generated')); + expect(screen.queryByRole('button', { name: /Use new version/i })).not.toBeInTheDocument(); + expect(output).toHaveValue('My unsaved edits.'); + }); + + it('reviews edits typed while the generation was in flight', async () => { + let resolveGenerate; + generatePipelineStage.mockReturnValue(new Promise((r) => { resolveGenerate = r; })); + renderPanel(makeIssue({ status: 'ready', output: 'Saved idea text.' })); + + // Clean editor at kickoff, so Generate fires without the confirm row. + await userEvent.click(screen.getByRole('button', { name: 'Generate' })); + const output = screen.getByPlaceholderText('output…'); + await userEvent.clear(output); + await userEvent.type(output, 'Typed mid-run.'); + + await act(async () => resolveGenerate({ + stage: { status: 'ready', input: '', output: 'Freshly generated text.', runHistory: [] }, + })); + + expect(await screen.findByRole('button', { name: /Use new version/i })).toBeInTheDocument(); + expect(output).toHaveValue('Typed mid-run.'); + }); + + it('drops a result that lands after the panel moved to another record', async () => { + let resolveGenerate; + generatePipelineStage.mockReturnValue(new Promise((r) => { resolveGenerate = r; })); + const onStageUpdate = vi.fn(); + const { rerender } = renderPanel(makeIssue({ status: 'ready', output: 'Saved idea text.' }), { onStageUpdate }); + + await userEvent.click(screen.getByRole('button', { name: 'Generate' })); + + // The panel is reused across issues — swap the record mid-run. + const otherIssue = { + id: 'iss-2', + stages: { idea: { status: 'ready', input: '', output: 'Another idea entirely.', runHistory: [] } }, + }; + rerender( + , + ); + + await act(async () => resolveGenerate({ + stage: { status: 'ready', input: '', output: 'Freshly generated text.', runHistory: [] }, + })); + + expect(screen.queryByRole('button', { name: /Use new version/i })).not.toBeInTheDocument(); + expect(screen.getByPlaceholderText('output…')).toHaveValue('Another idea entirely.'); + // Lifting it would patch whichever record is on screen now. + expect(onStageUpdate).not.toHaveBeenCalled(); + }); + + it('gates Generate on an in-flight save of the same stage record', async () => { + updatePipelineIssue.mockReturnValue(new Promise(() => {})); + renderPanel(makeIssue({ status: 'ready', output: 'Saved idea text.' })); + + // Seed-only edit: the output stays clean, so Generate is otherwise live. + await userEvent.type(screen.getByPlaceholderText('seed…'), 'A seed.'); + await userEvent.click(screen.getByRole('button', { name: /Save edits/i })); + + await waitFor(() => expect(screen.getByRole('button', { name: 'Generate' })).toBeDisabled()); + expect(generatePipelineStage).not.toHaveBeenCalled(); + }); + + it('lifts the generated stage to the parent even while the review is open', async () => { + const onStageUpdate = vi.fn(); + await generateOverEdits({ incoming: 'Freshly generated text.', onStageUpdate }); + + await waitFor(() => expect(onStageUpdate).toHaveBeenCalledWith( + 'idea', + expect.objectContaining({ output: 'Freshly generated text.' }), + )); + expect(await screen.findByRole('button', { name: /Use new version/i })).toBeInTheDocument(); + }); }); describe('live generation progress (#3393)', () => {