diff --git a/src/features/selection-rewrite/model.ts b/src/features/selection-rewrite/model.ts index 241e60c3..af3b8291 100644 --- a/src/features/selection-rewrite/model.ts +++ b/src/features/selection-rewrite/model.ts @@ -23,6 +23,60 @@ export interface SelectionRewriteRuntimeSettings { rewriteSelectionEffort: ReasoningEffort | null; } +export type SelectionRewriteLifecycleEvent = + | { type: "generation-started"; instruction: string } + | { type: "preview-updated"; text: string } + | { type: "generation-succeeded"; replacementText: string } + | { type: "generation-failed"; debugText: string | null } + | { type: "cancelled" } + | { type: "applied" }; + export function canApplySelectionRewrite(currentText: string, originalText: string): boolean { return currentText === originalText; } + +export function transitionSelectionRewriteState( + state: SelectionRewriteState, + event: SelectionRewriteLifecycleEvent, +): SelectionRewriteState { + switch (event.type) { + case "generation-started": + return { + ...state, + instruction: event.instruction, + status: "generating", + streamText: "", + replacementText: null, + debugText: null, + }; + case "preview-updated": + if (state.status !== "generating") return state; + return { ...state, streamText: event.text }; + case "generation-succeeded": + if (state.status !== "generating") return state; + return { + ...state, + status: "preview", + streamText: "", + replacementText: event.replacementText, + }; + case "generation-failed": + if (state.status !== "generating") return state; + return { + ...state, + status: "failed", + streamText: "", + debugText: event.debugText, + }; + case "cancelled": + return { + ...state, + status: "cancelled", + }; + case "applied": + return { + ...state, + status: "applied", + }; + } +} diff --git a/src/features/selection-rewrite/popover.tsx b/src/features/selection-rewrite/popover.tsx index b0a4633c..5c1c598a 100644 --- a/src/features/selection-rewrite/popover.tsx +++ b/src/features/selection-rewrite/popover.tsx @@ -8,7 +8,13 @@ import { renderReactRoot, unmountReactRoot } from "../../shared/ui/react-root"; import { syncTextareaHeight } from "../../shared/ui/textarea-autogrow"; import { buildSelectionUnifiedDiff } from "./diff"; import { isSelectionRewriteActionKey, isSelectionRewriteGenerateKey } from "./keys"; -import { canApplySelectionRewrite, type SelectionRewriteRuntimeSettings, type SelectionRewriteState } from "./model"; +import { + canApplySelectionRewrite, + transitionSelectionRewriteState, + type SelectionRewriteLifecycleEvent, + type SelectionRewriteRuntimeSettings, + type SelectionRewriteState, +} from "./model"; import { SelectionRewriteOutputError } from "./output"; import { positionSelectionRewritePopover } from "./position"; import { buildSelectionRewritePrompt } from "./prompt"; @@ -126,7 +132,7 @@ export class SelectionRewritePopover { this.showSelectionRewritePreview(output.replacementText); } catch (error) { if (abortController.signal.aborted) { - this.options.state.status = "cancelled"; + this.transitionState({ type: "cancelled" }); return; } this.showGenerationFailure(error); @@ -139,13 +145,13 @@ export class SelectionRewritePopover { } private cancel(): void { - this.options.state.status = "cancelled"; + this.transitionState({ type: "cancelled" }); this.abortController?.abort(); this.close(); } private updatePreview(text: string): void { - this.options.state.streamText = text; + this.transitionState({ type: "preview-updated", text }); this.setStatus("Writing replacement", { active: true }); this.position(); } @@ -164,25 +170,20 @@ export class SelectionRewritePopover { } private startGeneration(instruction: string): void { - this.options.state.instruction = instruction; - this.options.state.status = "generating"; - this.options.state.streamText = ""; - this.options.state.replacementText = null; - this.options.state.debugText = null; + this.transitionState({ type: "generation-started", instruction }); this.renderView(); } private showSelectionRewritePreview(replacementText: string): void { - this.options.state.replacementText = replacementText; - this.options.state.status = "preview"; - this.options.state.streamText = ""; + this.transitionState({ type: "generation-succeeded", replacementText }); this.setStatus(""); } private showGenerationFailure(error: unknown): void { - this.options.state.status = "failed"; - this.options.state.debugText = error instanceof SelectionRewriteOutputError ? error.rawText : null; - this.options.state.streamText = ""; + this.transitionState({ + type: "generation-failed", + debugText: error instanceof SelectionRewriteOutputError ? error.rawText : null, + }); this.setStatus(error instanceof Error ? error.message : String(error)); } @@ -199,10 +200,14 @@ export class SelectionRewritePopover { } editor.replaceRange(replacement, state.targetRange.from, state.targetRange.to, "codex-panel-rewrite"); - state.status = "applied"; + this.transitionState({ type: "applied" }); this.close(); } + private transitionState(event: SelectionRewriteLifecycleEvent): void { + this.options.state = transitionSelectionRewriteState(this.options.state, event); + } + private focusApplyButton(): void { if (!this.elements?.applyButton || this.elements.applyButton.disabled) return; this.elements.applyButton.focus({ preventScroll: true }); diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 30f1c55c..8803a0f1 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -9,7 +9,11 @@ import { isSelectionRewriteGenerateKey, type SelectionRewriteGenerateKeyEvent, } from "../../../src/features/selection-rewrite/keys"; -import { canApplySelectionRewrite, type SelectionRewriteState } from "../../../src/features/selection-rewrite/model"; +import { + canApplySelectionRewrite, + transitionSelectionRewriteState, + type SelectionRewriteState, +} from "../../../src/features/selection-rewrite/model"; import { parseSelectionRewriteOutput, selectionRewriteOutputFromTurn, @@ -135,6 +139,46 @@ describe("selection rewrite apply guard", () => { }); }); +describe("selection rewrite lifecycle", () => { + it("transitions through generation and preview states", () => { + const generating = transitionSelectionRewriteState(rewriteState({ replacementText: "old", debugText: "old debug" }), { + type: "generation-started", + instruction: "Shorten it.", + }); + + expect(generating).toMatchObject({ + instruction: "Shorten it.", + status: "generating", + streamText: "", + replacementText: null, + debugText: null, + }); + + const streaming = transitionSelectionRewriteState(generating, { type: "preview-updated", text: "New" }); + expect(streaming.streamText).toBe("New"); + + const preview = transitionSelectionRewriteState(streaming, { type: "generation-succeeded", replacementText: "New text." }); + expect(preview).toMatchObject({ + status: "preview", + streamText: "", + replacementText: "New text.", + }); + }); + + it("ignores stale generation callbacks after generation is no longer active", () => { + const state = rewriteState({ status: "cancelled", streamText: "" }); + + expect(transitionSelectionRewriteState(state, { type: "preview-updated", text: "late" })).toBe(state); + expect(transitionSelectionRewriteState(state, { type: "generation-succeeded", replacementText: "late" })).toBe(state); + expect(transitionSelectionRewriteState(state, { type: "generation-failed", debugText: "late" })).toBe(state); + }); + + it("marks terminal user actions explicitly", () => { + expect(transitionSelectionRewriteState(rewriteState(), { type: "cancelled" }).status).toBe("cancelled"); + expect(transitionSelectionRewriteState(rewriteState({ replacementText: "New text." }), { type: "applied" }).status).toBe("applied"); + }); +}); + describe("selection rewrite popover", () => { it("enables Generate only after the instruction has content", () => { const popover = new SelectionRewritePopover(popoverOptions({ state: rewriteState({ instruction: "" }) }));