Model selection rewrite lifecycle transitions

This commit is contained in:
murashit 2026-05-28 19:12:18 +09:00
parent 1d88833f19
commit 4e30caa2e3
3 changed files with 120 additions and 17 deletions

View file

@ -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",
};
}
}

View file

@ -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 });

View file

@ -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: "" }) }));