mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Model rename generation lifecycles as state machines
This commit is contained in:
parent
1e42fe6ea2
commit
caf76c8ef8
6 changed files with 385 additions and 102 deletions
|
|
@ -14,6 +14,23 @@ export type ChatRenameUiState =
|
|||
};
|
||||
|
||||
export type ChatRenameGeneratingUiState = Extract<ChatRenameUiState, { kind: "generating" }>;
|
||||
type ChatRenameUiAction = Extract<
|
||||
UiAction,
|
||||
{
|
||||
type:
|
||||
| "ui/rename-started"
|
||||
| "ui/rename-draft-updated"
|
||||
| "ui/rename-cancelled"
|
||||
| "ui/rename-generation-started"
|
||||
| "ui/rename-generation-succeeded"
|
||||
| "ui/rename-generation-finished"
|
||||
| "ui/rename-cleared";
|
||||
}
|
||||
>;
|
||||
type ChatRenameUiActionType = ChatRenameUiAction["type"];
|
||||
type ChatRenameUiKind = ChatRenameUiState["kind"];
|
||||
type ChatRenameUiTransition = (state: ChatRenameUiState, action: ChatRenameUiAction) => ChatRenameUiState;
|
||||
type ChatRenameUiTransitionTable = Record<ChatRenameUiKind, Record<ChatRenameUiActionType, ChatRenameUiTransition>>;
|
||||
|
||||
type ChatGoalEditorUiState =
|
||||
| { readonly kind: "closed" }
|
||||
|
|
@ -110,21 +127,13 @@ export function reduceUiSlice(state: ChatUiState, action: UiAction): ChatUiState
|
|||
case "ui/archive-confirm-set":
|
||||
return patchObject(state, { archiveConfirmThreadId: action.threadId });
|
||||
case "ui/rename-started":
|
||||
return patchObject(state, { rename: { kind: "editing", threadId: action.threadId, draft: action.draft } });
|
||||
case "ui/rename-draft-updated":
|
||||
return patchObject(state, { rename: renameUiStateUpdated(state.rename, action.threadId, action.draft) });
|
||||
case "ui/rename-cancelled":
|
||||
return patchObject(state, { rename: renameUiStateCancelled(state.rename, action.threadId) });
|
||||
case "ui/rename-generation-started":
|
||||
return patchObject(state, {
|
||||
rename: renameUiGenerationStarted(state.rename, action.threadId, action.originalDraft, action.generationToken),
|
||||
});
|
||||
case "ui/rename-generation-succeeded":
|
||||
return patchObject(state, { rename: renameUiGenerationSucceeded(state.rename, action.generatingState, action.draft) });
|
||||
case "ui/rename-generation-finished":
|
||||
return patchObject(state, { rename: renameUiGenerationFinished(state.rename, action.threadId, action.generatingState) });
|
||||
case "ui/rename-cleared":
|
||||
return patchObject(state, { rename: initialRenameUiState() });
|
||||
return patchObject(state, { rename: transitionChatRenameUiState(state.rename, action) });
|
||||
case "ui/goal-editor-started":
|
||||
return patchObject(state, {
|
||||
goalEditor: {
|
||||
|
|
@ -254,52 +263,111 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
|
|||
return { ...state, objectiveDraft: objective };
|
||||
}
|
||||
|
||||
function renameUiStateUpdated(state: ChatRenameUiState, threadId: string, draft: string): ChatRenameUiState {
|
||||
if (state.kind === "idle" || state.threadId !== threadId) return state;
|
||||
return { ...state, draft };
|
||||
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
|
||||
return chatRenameUiTransitions[state.kind][action.type](state, action);
|
||||
}
|
||||
|
||||
function renameUiStateCancelled(state: ChatRenameUiState, threadId: string): ChatRenameUiState {
|
||||
const keepRenameUiState: ChatRenameUiTransition = (state) => state;
|
||||
|
||||
const startRenameUiTransition: ChatRenameUiTransition = (_state, action) => ({
|
||||
kind: "editing",
|
||||
threadId: requireRenameThreadId(action),
|
||||
draft: requireRenameDraft(action),
|
||||
});
|
||||
|
||||
const updateRenameUiDraftTransition: ChatRenameUiTransition = (state, action) => {
|
||||
if (state.kind === "idle" || state.threadId !== requireRenameThreadId(action)) return state;
|
||||
return { ...state, draft: requireRenameDraft(action) };
|
||||
};
|
||||
|
||||
const cancelRenameUiTransition: ChatRenameUiTransition = (state, action) => {
|
||||
const threadId = requireRenameThreadId(action);
|
||||
if (state.kind === "idle" || state.threadId !== threadId) return state;
|
||||
return initialRenameUiState();
|
||||
}
|
||||
};
|
||||
|
||||
function renameUiGenerationStarted(
|
||||
state: ChatRenameUiState,
|
||||
threadId: string,
|
||||
originalDraft: string,
|
||||
generationToken: number,
|
||||
): ChatRenameUiState {
|
||||
const startRenameGenerationTransition: ChatRenameUiTransition = (state, action) => {
|
||||
const threadId = requireRenameThreadId(action);
|
||||
if (state.kind !== "editing" || state.threadId !== threadId) return state;
|
||||
return {
|
||||
kind: "generating",
|
||||
threadId,
|
||||
draft: state.draft,
|
||||
originalDraft,
|
||||
generationToken,
|
||||
originalDraft: requireRenameOriginalDraft(action),
|
||||
generationToken: requireRenameGenerationToken(action),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
function renameUiGenerationSucceeded(
|
||||
state: ChatRenameUiState,
|
||||
generatingState: ChatRenameGeneratingUiState,
|
||||
draft: string,
|
||||
): ChatRenameUiState {
|
||||
const succeedRenameGenerationTransition: ChatRenameUiTransition = (state, action) => {
|
||||
const generatingState = requireRenameGeneratingState(action);
|
||||
if (!renameGenerationStillActive(state, generatingState) || state.draft !== generatingState.originalDraft) return state;
|
||||
return { ...state, draft };
|
||||
}
|
||||
return { ...state, draft: requireRenameDraft(action) };
|
||||
};
|
||||
|
||||
function renameUiGenerationFinished(
|
||||
state: ChatRenameUiState,
|
||||
threadId: string,
|
||||
generatingState: ChatRenameGeneratingUiState,
|
||||
): ChatRenameUiState {
|
||||
const finishRenameGenerationTransition: ChatRenameUiTransition = (state, action) => {
|
||||
const threadId = requireRenameThreadId(action);
|
||||
const generatingState = requireRenameGeneratingState(action);
|
||||
if (!renameGenerationStillActive(state, generatingState) || state.threadId !== threadId) return state;
|
||||
return {
|
||||
kind: "editing",
|
||||
threadId: state.threadId,
|
||||
draft: state.draft,
|
||||
};
|
||||
};
|
||||
|
||||
const clearRenameUiTransition: ChatRenameUiTransition = (state) => (state.kind === "idle" ? state : initialRenameUiState());
|
||||
|
||||
const renameUiStateActiveTransitions = {
|
||||
"ui/rename-started": startRenameUiTransition,
|
||||
"ui/rename-draft-updated": updateRenameUiDraftTransition,
|
||||
"ui/rename-cancelled": cancelRenameUiTransition,
|
||||
"ui/rename-generation-started": startRenameGenerationTransition,
|
||||
"ui/rename-generation-succeeded": succeedRenameGenerationTransition,
|
||||
"ui/rename-generation-finished": finishRenameGenerationTransition,
|
||||
"ui/rename-cleared": clearRenameUiTransition,
|
||||
} satisfies Record<ChatRenameUiActionType, ChatRenameUiTransition>;
|
||||
|
||||
const chatRenameUiTransitions: ChatRenameUiTransitionTable = {
|
||||
idle: {
|
||||
...renameUiStateActiveTransitions,
|
||||
"ui/rename-draft-updated": keepRenameUiState,
|
||||
"ui/rename-cancelled": keepRenameUiState,
|
||||
"ui/rename-generation-started": keepRenameUiState,
|
||||
"ui/rename-generation-succeeded": keepRenameUiState,
|
||||
"ui/rename-generation-finished": keepRenameUiState,
|
||||
"ui/rename-cleared": clearRenameUiTransition,
|
||||
},
|
||||
editing: renameUiStateActiveTransitions,
|
||||
generating: {
|
||||
...renameUiStateActiveTransitions,
|
||||
"ui/rename-generation-started": keepRenameUiState,
|
||||
},
|
||||
};
|
||||
|
||||
function requireRenameThreadId(action: ChatRenameUiAction): string {
|
||||
if ("threadId" in action) return action.threadId;
|
||||
if ("generatingState" in action) return action.generatingState.threadId;
|
||||
throw new Error(`Rename UI action ${action.type} does not include a thread id.`);
|
||||
}
|
||||
|
||||
function requireRenameDraft(action: ChatRenameUiAction): string {
|
||||
if ("draft" in action) return action.draft;
|
||||
throw new Error(`Rename UI action ${action.type} does not include a draft.`);
|
||||
}
|
||||
|
||||
function requireRenameOriginalDraft(action: ChatRenameUiAction): string {
|
||||
if ("originalDraft" in action) return action.originalDraft;
|
||||
throw new Error(`Rename UI action ${action.type} does not include an original draft.`);
|
||||
}
|
||||
|
||||
function requireRenameGenerationToken(action: ChatRenameUiAction): number {
|
||||
if ("generationToken" in action) return action.generationToken;
|
||||
throw new Error(`Rename UI action ${action.type} does not include a generation token.`);
|
||||
}
|
||||
|
||||
function requireRenameGeneratingState(action: ChatRenameUiAction): ChatRenameGeneratingUiState {
|
||||
if ("generatingState" in action) return action.generatingState;
|
||||
throw new Error(`Rename UI action ${action.type} does not include generating state.`);
|
||||
}
|
||||
|
||||
function filterStringSet(values: ReadonlySet<string>, keep: (value: string) => boolean): ReadonlySet<string> {
|
||||
|
|
|
|||
|
|
@ -12,13 +12,10 @@ import { createThreadOperations, type ThreadOperations } from "../threads/thread
|
|||
import { createThreadTitleService, type ThreadTitleService } from "../threads/thread-title-service";
|
||||
import { renderThreadsView, unmountThreadsView } from "./renderer";
|
||||
import {
|
||||
completedThreadAutoNameState,
|
||||
editingThreadRenameState,
|
||||
generatedThreadAutoNameState,
|
||||
startedThreadAutoNameState,
|
||||
threadRows,
|
||||
updatedThreadRenameState,
|
||||
transitionThreadsRenameState,
|
||||
type ThreadsGeneratingRenameState,
|
||||
type ThreadsRenameLifecycleEvent,
|
||||
type ThreadsRenameState,
|
||||
} from "./state";
|
||||
import {
|
||||
|
|
@ -72,6 +69,7 @@ export class CodexThreadsSession {
|
|||
private status: ThreadsViewStatus = { kind: "idle" };
|
||||
private threads: readonly Thread[] = [];
|
||||
private readonly renameStates = new Map<string, ThreadsRenameState>();
|
||||
private nextRenameGenerationToken = 1;
|
||||
private unsubscribeThreads: (() => void) | null = null;
|
||||
private archiveConfirmThreadId: string | null = null;
|
||||
|
||||
|
|
@ -239,17 +237,17 @@ export class CodexThreadsSession {
|
|||
|
||||
private startRename(threadId: string, value: string): void {
|
||||
this.archiveConfirmThreadId = null;
|
||||
this.renameStates.set(threadId, editingThreadRenameState(value));
|
||||
this.transitionRenameState(threadId, { type: "started", draft: value });
|
||||
this.render();
|
||||
}
|
||||
|
||||
private updateRename(threadId: string, value: string): void {
|
||||
this.renameStates.set(threadId, updatedThreadRenameState(this.renameStates.get(threadId), value));
|
||||
this.transitionRenameState(threadId, { type: "draft-updated", draft: value });
|
||||
this.render();
|
||||
}
|
||||
|
||||
private cancelRename(threadId: string): void {
|
||||
this.renameStates.delete(threadId);
|
||||
this.transitionRenameState(threadId, { type: "cancelled" });
|
||||
this.render();
|
||||
}
|
||||
|
||||
|
|
@ -271,16 +269,19 @@ export class CodexThreadsSession {
|
|||
}
|
||||
|
||||
private async autoNameThread(threadId: string): Promise<void> {
|
||||
const generatingState = startedThreadAutoNameState(this.renameStates.get(threadId));
|
||||
if (!generatingState) return;
|
||||
this.renameStates.set(threadId, generatingState);
|
||||
const previousState = this.renameStates.get(threadId);
|
||||
const generatingState = this.transitionRenameState(threadId, {
|
||||
type: "auto-name-started",
|
||||
generationToken: this.nextRenameGenerationToken,
|
||||
});
|
||||
if (generatingState === previousState || generatingState?.kind !== "generating") return;
|
||||
this.nextRenameGenerationToken += 1;
|
||||
this.render();
|
||||
|
||||
try {
|
||||
if (this.renameStates.get(threadId) !== generatingState) return;
|
||||
const title = await this.titleService.generateTitle(threadId);
|
||||
const renamedState = generatedThreadAutoNameState(this.renameStates.get(threadId), generatingState, title);
|
||||
if (renamedState) this.renameStates.set(threadId, renamedState);
|
||||
this.transitionRenameState(threadId, { type: "auto-name-generated", generatingState, title });
|
||||
} catch (error) {
|
||||
if (this.renameStates.get(threadId) === generatingState) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
|
|
@ -323,10 +324,19 @@ export class CodexThreadsSession {
|
|||
}
|
||||
|
||||
private finishAutoNameThread(threadId: string, generatingState: ThreadsGeneratingRenameState): void {
|
||||
const nextState = completedThreadAutoNameState(this.renameStates.get(threadId), generatingState);
|
||||
if (!nextState) return;
|
||||
this.renameStates.set(threadId, nextState);
|
||||
this.render();
|
||||
const previousState = this.renameStates.get(threadId);
|
||||
const nextState = this.transitionRenameState(threadId, { type: "auto-name-finished", generatingState });
|
||||
if (nextState !== previousState) this.render();
|
||||
}
|
||||
|
||||
private transitionRenameState(threadId: string, event: ThreadsRenameLifecycleEvent): ThreadsRenameState | undefined {
|
||||
const nextState = transitionThreadsRenameState(this.renameStates.get(threadId), event);
|
||||
if (nextState) {
|
||||
this.renameStates.set(threadId, nextState);
|
||||
} else {
|
||||
this.renameStates.delete(threadId);
|
||||
}
|
||||
return nextState;
|
||||
}
|
||||
|
||||
private viewWindow(): Window {
|
||||
|
|
|
|||
|
|
@ -20,8 +20,28 @@ export interface ThreadsRowModel {
|
|||
archiveConfirm: { active: boolean; defaultSaveMarkdown: boolean };
|
||||
}
|
||||
|
||||
export type ThreadsRenameState = { kind: "editing"; draft: string } | { kind: "generating"; draft: string; originalDraft: string };
|
||||
export type ThreadsRenameState =
|
||||
| { kind: "editing"; draft: string }
|
||||
| { kind: "generating"; draft: string; originalDraft: string; generationToken: number };
|
||||
export type ThreadsGeneratingRenameState = Extract<ThreadsRenameState, { kind: "generating" }>;
|
||||
type ThreadsRenameLifecycleKind = ThreadsRenameState["kind"] | "idle";
|
||||
export type ThreadsRenameLifecycleState = ThreadsRenameState | undefined;
|
||||
export type ThreadsRenameLifecycleEvent =
|
||||
| { type: "started"; draft: string }
|
||||
| { type: "draft-updated"; draft: string }
|
||||
| { type: "cancelled" }
|
||||
| { type: "auto-name-started"; generationToken: number }
|
||||
| { type: "auto-name-generated"; generatingState: ThreadsGeneratingRenameState; title: string }
|
||||
| { type: "auto-name-finished"; generatingState: ThreadsGeneratingRenameState };
|
||||
type ThreadsRenameLifecycleEventType = ThreadsRenameLifecycleEvent["type"];
|
||||
type ThreadsRenameLifecycleTransition = (
|
||||
state: ThreadsRenameLifecycleState,
|
||||
event: ThreadsRenameLifecycleEvent,
|
||||
) => ThreadsRenameLifecycleState;
|
||||
type ThreadsRenameLifecycleTransitionTable = Record<
|
||||
ThreadsRenameLifecycleKind,
|
||||
Record<ThreadsRenameLifecycleEventType, ThreadsRenameLifecycleTransition>
|
||||
>;
|
||||
|
||||
const STATUS_PRIORITY: Record<ThreadsLiveStatus, number> = {
|
||||
"needs-input": 5,
|
||||
|
|
@ -83,36 +103,102 @@ function selectedStateForSnapshots(snapshots: OpenCodexPanelSnapshot[]): boolean
|
|||
return snapshots.some((snapshot) => snapshot.threadId !== null && snapshot.lastFocused);
|
||||
}
|
||||
|
||||
export function editingThreadRenameState(draft: string): ThreadsRenameState {
|
||||
export function transitionThreadsRenameState(
|
||||
state: ThreadsRenameLifecycleState,
|
||||
event: ThreadsRenameLifecycleEvent,
|
||||
): ThreadsRenameLifecycleState {
|
||||
return threadsRenameLifecycleTransitions[threadsRenameLifecycleKind(state)][event.type](state, event);
|
||||
}
|
||||
|
||||
const keepThreadsRenameState: ThreadsRenameLifecycleTransition = (state) => state;
|
||||
|
||||
const startThreadsRenameTransition: ThreadsRenameLifecycleTransition = (_state, event) =>
|
||||
editingThreadRenameState(requireThreadsRenameDraft(event));
|
||||
|
||||
const updateThreadsRenameDraftTransition: ThreadsRenameLifecycleTransition = (state, event) => {
|
||||
const draft = requireThreadsRenameDraft(event);
|
||||
return state?.kind === "generating" ? { ...state, draft } : editingThreadRenameState(draft);
|
||||
};
|
||||
|
||||
const cancelThreadsRenameTransition: ThreadsRenameLifecycleTransition = () => undefined;
|
||||
|
||||
const startThreadsAutoNameTransition: ThreadsRenameLifecycleTransition = (state, event) => {
|
||||
if (!state || state.kind === "generating") return state;
|
||||
return { kind: "generating", draft: state.draft, originalDraft: state.draft, generationToken: requireThreadsGenerationToken(event) };
|
||||
};
|
||||
|
||||
const generatedThreadsAutoNameTransition: ThreadsRenameLifecycleTransition = (state, event) => {
|
||||
const generatingState = requireThreadsGeneratingState(event);
|
||||
if (!threadsRenameGenerationStillActive(state, generatingState)) return state;
|
||||
if (state.draft !== state.originalDraft) return state;
|
||||
return { ...state, draft: requireGeneratedThreadsTitle(event) };
|
||||
};
|
||||
|
||||
const finishThreadsAutoNameTransition: ThreadsRenameLifecycleTransition = (state, event) => {
|
||||
if (!threadsRenameGenerationStillActive(state, requireThreadsGeneratingState(event))) return state;
|
||||
return editingThreadRenameState(state.draft);
|
||||
};
|
||||
|
||||
const threadsRenameLifecycleTransitions: ThreadsRenameLifecycleTransitionTable = {
|
||||
idle: {
|
||||
started: startThreadsRenameTransition,
|
||||
"draft-updated": updateThreadsRenameDraftTransition,
|
||||
cancelled: keepThreadsRenameState,
|
||||
"auto-name-started": keepThreadsRenameState,
|
||||
"auto-name-generated": keepThreadsRenameState,
|
||||
"auto-name-finished": keepThreadsRenameState,
|
||||
},
|
||||
editing: {
|
||||
started: startThreadsRenameTransition,
|
||||
"draft-updated": updateThreadsRenameDraftTransition,
|
||||
cancelled: cancelThreadsRenameTransition,
|
||||
"auto-name-started": startThreadsAutoNameTransition,
|
||||
"auto-name-generated": keepThreadsRenameState,
|
||||
"auto-name-finished": keepThreadsRenameState,
|
||||
},
|
||||
generating: {
|
||||
started: startThreadsRenameTransition,
|
||||
"draft-updated": updateThreadsRenameDraftTransition,
|
||||
cancelled: cancelThreadsRenameTransition,
|
||||
"auto-name-started": keepThreadsRenameState,
|
||||
"auto-name-generated": generatedThreadsAutoNameTransition,
|
||||
"auto-name-finished": finishThreadsAutoNameTransition,
|
||||
},
|
||||
};
|
||||
|
||||
function threadsRenameLifecycleKind(state: ThreadsRenameLifecycleState): ThreadsRenameLifecycleKind {
|
||||
return state?.kind ?? "idle";
|
||||
}
|
||||
|
||||
function editingThreadRenameState(draft: string): ThreadsRenameState {
|
||||
return { kind: "editing", draft };
|
||||
}
|
||||
|
||||
export function updatedThreadRenameState(current: ThreadsRenameState | undefined, draft: string): ThreadsRenameState {
|
||||
return current?.kind === "generating" ? { ...current, draft } : editingThreadRenameState(draft);
|
||||
}
|
||||
|
||||
export function startedThreadAutoNameState(current: ThreadsRenameState | undefined): ThreadsGeneratingRenameState | null {
|
||||
if (!current || current.kind === "generating") return null;
|
||||
return { kind: "generating", draft: current.draft, originalDraft: current.draft };
|
||||
}
|
||||
|
||||
export function generatedThreadAutoNameState(
|
||||
current: ThreadsRenameState | undefined,
|
||||
function threadsRenameGenerationStillActive(
|
||||
state: ThreadsRenameLifecycleState,
|
||||
generatingState: ThreadsGeneratingRenameState,
|
||||
title: string,
|
||||
): ThreadsRenameState | null {
|
||||
if (current !== generatingState) return null;
|
||||
if (current.draft !== generatingState.originalDraft) return null;
|
||||
return { ...generatingState, draft: title };
|
||||
): state is ThreadsGeneratingRenameState {
|
||||
return state?.kind === "generating" && state.generationToken === generatingState.generationToken;
|
||||
}
|
||||
|
||||
export function completedThreadAutoNameState(
|
||||
current: ThreadsRenameState | undefined,
|
||||
generatingState: ThreadsGeneratingRenameState,
|
||||
): ThreadsRenameState | undefined {
|
||||
if (current?.kind !== "generating") return undefined;
|
||||
const draft = current === generatingState ? generatingState.draft : current.draft;
|
||||
return editingThreadRenameState(draft);
|
||||
function requireThreadsRenameDraft(event: ThreadsRenameLifecycleEvent): string {
|
||||
if ("draft" in event) return event.draft;
|
||||
throw new Error(`Threads rename lifecycle event ${event.type} does not include a draft.`);
|
||||
}
|
||||
|
||||
function requireThreadsGeneratingState(event: ThreadsRenameLifecycleEvent): ThreadsGeneratingRenameState {
|
||||
if ("generatingState" in event) return event.generatingState;
|
||||
throw new Error(`Threads rename lifecycle event ${event.type} does not include generating state.`);
|
||||
}
|
||||
|
||||
function requireThreadsGenerationToken(event: ThreadsRenameLifecycleEvent): number {
|
||||
if ("generationToken" in event) return event.generationToken;
|
||||
throw new Error(`Threads rename lifecycle event ${event.type} does not include a generation token.`);
|
||||
}
|
||||
|
||||
function requireGeneratedThreadsTitle(event: ThreadsRenameLifecycleEvent): string {
|
||||
if ("title" in event) return event.title;
|
||||
throw new Error(`Threads rename lifecycle event ${event.type} does not include a title.`);
|
||||
}
|
||||
|
||||
function snapshotsForThreads(snapshots: OpenCodexPanelSnapshot[]): Map<string, OpenCodexPanelSnapshot[]> {
|
||||
|
|
|
|||
|
|
@ -614,6 +614,41 @@ describe("chatReducer", () => {
|
|||
expect(state.ui.goalEditor.kind).toBe("closed");
|
||||
});
|
||||
|
||||
it("keeps rename generation callbacks scoped to the active generation", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatReducer(state, { type: "ui/rename-started", threadId: "thread", draft: "Original" });
|
||||
state = chatReducer(state, {
|
||||
type: "ui/rename-generation-started",
|
||||
threadId: "thread",
|
||||
originalDraft: "Original",
|
||||
generationToken: 1,
|
||||
});
|
||||
const generatingState = state.ui.rename;
|
||||
if (generatingState.kind !== "generating") throw new Error("Expected generating rename state.");
|
||||
|
||||
const staleSucceeded = chatReducer(state, {
|
||||
type: "ui/rename-generation-succeeded",
|
||||
generatingState: { ...generatingState, generationToken: 2 },
|
||||
draft: "Late title",
|
||||
});
|
||||
expect(staleSucceeded).toBe(state);
|
||||
|
||||
const manuallyEdited = chatReducer(state, { type: "ui/rename-draft-updated", threadId: "thread", draft: "Manual draft" });
|
||||
const generatedAfterManualEdit = chatReducer(manuallyEdited, {
|
||||
type: "ui/rename-generation-succeeded",
|
||||
generatingState,
|
||||
draft: "Generated title",
|
||||
});
|
||||
expect(generatedAfterManualEdit).toBe(manuallyEdited);
|
||||
|
||||
const finished = chatReducer(manuallyEdited, {
|
||||
type: "ui/rename-generation-finished",
|
||||
threadId: "thread",
|
||||
generatingState,
|
||||
});
|
||||
expect(finished.ui.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Manual draft" });
|
||||
});
|
||||
|
||||
it("clears expanded goal objective state when the displayed goal identity changes", () => {
|
||||
let state = chatStateFixture();
|
||||
state = chatReducer(state, { type: "active-thread/goal-set", goal: goal("thread") });
|
||||
|
|
|
|||
|
|
@ -1,45 +1,74 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Thread } from "../../../src/domain/threads/model";
|
||||
import {
|
||||
completedThreadAutoNameState,
|
||||
editingThreadRenameState,
|
||||
generatedThreadAutoNameState,
|
||||
startedThreadAutoNameState,
|
||||
threadRows,
|
||||
updatedThreadRenameState,
|
||||
} from "../../../src/features/threads-view/state";
|
||||
import { threadRows, transitionThreadsRenameState, type ThreadsRenameState } from "../../../src/features/threads-view/state";
|
||||
|
||||
describe("threads view rename state", () => {
|
||||
it("keeps a late auto-name result from reviving a cancelled rename", () => {
|
||||
const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft"));
|
||||
expect(generating).not.toBeNull();
|
||||
if (!generating) throw new Error("Expected generating state");
|
||||
const generating = generatingRenameState("Original draft", 1);
|
||||
|
||||
expect(generatedThreadAutoNameState(undefined, generating, "Late title")).toBeNull();
|
||||
expect(completedThreadAutoNameState(undefined, generating)).toBeUndefined();
|
||||
expect(
|
||||
transitionThreadsRenameState(undefined, { type: "auto-name-generated", generatingState: generating, title: "Late title" }),
|
||||
).toBeUndefined();
|
||||
expect(transitionThreadsRenameState(undefined, { type: "auto-name-finished", generatingState: generating })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a manually edited draft when auto-name finishes later", () => {
|
||||
const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft"));
|
||||
expect(generating).not.toBeNull();
|
||||
if (!generating) throw new Error("Expected generating state");
|
||||
const generating = generatingRenameState("Original draft", 1);
|
||||
|
||||
const manuallyEdited = updatedThreadRenameState(generating, "Manual draft");
|
||||
const manuallyEdited = transitionThreadsRenameState(generating, { type: "draft-updated", draft: "Manual draft" });
|
||||
|
||||
expect(generatedThreadAutoNameState(manuallyEdited, generating, "Late title")).toBeNull();
|
||||
expect(completedThreadAutoNameState(manuallyEdited, generating)).toEqual({ kind: "editing", draft: "Manual draft" });
|
||||
expect(
|
||||
transitionThreadsRenameState(manuallyEdited, { type: "auto-name-generated", generatingState: generating, title: "Late title" }),
|
||||
).toBe(manuallyEdited);
|
||||
expect(transitionThreadsRenameState(manuallyEdited, { type: "auto-name-finished", generatingState: generating })).toEqual({
|
||||
kind: "editing",
|
||||
draft: "Manual draft",
|
||||
});
|
||||
});
|
||||
|
||||
it("applies generated titles only to the active unchanged generation", () => {
|
||||
const generating = startedThreadAutoNameState(editingThreadRenameState("Original draft"));
|
||||
expect(generating).not.toBeNull();
|
||||
if (!generating) throw new Error("Expected generating state");
|
||||
const generating = generatingRenameState("Original draft", 1);
|
||||
|
||||
const generated = generatedThreadAutoNameState(generating, generating, "Generated title");
|
||||
const generated = transitionThreadsRenameState(generating, {
|
||||
type: "auto-name-generated",
|
||||
generatingState: generating,
|
||||
title: "Generated title",
|
||||
});
|
||||
|
||||
expect(generated).toEqual({ kind: "generating", draft: "Generated title", originalDraft: "Original draft" });
|
||||
expect(completedThreadAutoNameState(generated ?? undefined, generating)).toEqual({ kind: "editing", draft: "Generated title" });
|
||||
expect(generated).toEqual({
|
||||
kind: "generating",
|
||||
draft: "Generated title",
|
||||
originalDraft: "Original draft",
|
||||
generationToken: 1,
|
||||
});
|
||||
expect(transitionThreadsRenameState(generated, { type: "auto-name-finished", generatingState: generating })).toEqual({
|
||||
kind: "editing",
|
||||
draft: "Generated title",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps ignored rename lifecycle transitions as no-ops", () => {
|
||||
const editing = editingRenameState("Original draft");
|
||||
const generating = generatingRenameState("Original draft", 1);
|
||||
|
||||
expect(transitionThreadsRenameState(undefined, { type: "auto-name-started", generationToken: 1 })).toBeUndefined();
|
||||
expect(transitionThreadsRenameState(editing, { type: "auto-name-generated", generatingState: generating, title: "Late" })).toBe(
|
||||
editing,
|
||||
);
|
||||
expect(transitionThreadsRenameState(generating, { type: "auto-name-started", generationToken: 2 })).toBe(generating);
|
||||
});
|
||||
|
||||
it("keeps stale auto-name completion from finishing a newer generation", () => {
|
||||
const oldGenerating = generatingRenameState("Original draft", 1);
|
||||
const currentGenerating = generatingRenameState("Original draft", 2);
|
||||
|
||||
expect(
|
||||
transitionThreadsRenameState(currentGenerating, { type: "auto-name-generated", generatingState: oldGenerating, title: "Old" }),
|
||||
).toBe(currentGenerating);
|
||||
expect(transitionThreadsRenameState(currentGenerating, { type: "auto-name-finished", generatingState: oldGenerating })).toBe(
|
||||
currentGenerating,
|
||||
);
|
||||
});
|
||||
|
||||
it("initializes rename drafts from normalized explicit thread names", () => {
|
||||
|
|
@ -48,6 +77,22 @@ describe("threads view rename state", () => {
|
|||
});
|
||||
});
|
||||
|
||||
function editingRenameState(draft: string): ThreadsRenameState {
|
||||
return expectRenameState(transitionThreadsRenameState(undefined, { type: "started", draft }));
|
||||
}
|
||||
|
||||
function generatingRenameState(draft: string, generationToken: number): Extract<ThreadsRenameState, { kind: "generating" }> {
|
||||
const editing = editingRenameState(draft);
|
||||
const generating = transitionThreadsRenameState(editing, { type: "auto-name-started", generationToken });
|
||||
if (generating?.kind !== "generating") throw new Error("Expected generating state");
|
||||
return generating;
|
||||
}
|
||||
|
||||
function expectRenameState(state: ThreadsRenameState | undefined): ThreadsRenameState {
|
||||
if (state) return state;
|
||||
throw new Error("Expected rename state");
|
||||
}
|
||||
|
||||
function thread(overrides: Partial<Thread> = {}): Thread {
|
||||
return {
|
||||
id: "thread",
|
||||
|
|
|
|||
|
|
@ -349,6 +349,45 @@ describe("CodexThreadsView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("ignores duplicate auto-name starts while a thread rename is already generating", async () => {
|
||||
const threadTurnsList = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
turnFixture([
|
||||
{
|
||||
type: "userMessage",
|
||||
id: "u1",
|
||||
clientId: null,
|
||||
content: [{ type: "text", text: "rename stale handling", text_elements: [] }],
|
||||
},
|
||||
{ type: "agentMessage", id: "a1", text: "Handled.", phase: "final_answer", memoryCitation: null },
|
||||
]),
|
||||
],
|
||||
nextCursor: null,
|
||||
});
|
||||
const generatedTitle = deferred<string | null>();
|
||||
namingMock.generateThreadTitleWithCodex.mockReturnValue(generatedTitle.promise);
|
||||
connectionMock.state.client = clientFixture({
|
||||
listThreads: vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }),
|
||||
threadTurnsList,
|
||||
});
|
||||
const view = await threadsView();
|
||||
|
||||
await view.refresh();
|
||||
view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
|
||||
const autoName = view.containerEl.querySelector<HTMLButtonElement>('[aria-label="Auto-name thread"]');
|
||||
autoName?.click();
|
||||
autoName?.click();
|
||||
|
||||
await waitForAsyncWork(() => {
|
||||
expect(namingMock.generateThreadTitleWithCodex).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
generatedTitle.resolve("Generated title");
|
||||
await waitForAsyncWork(() => {
|
||||
expect(view.containerEl.querySelector<HTMLInputElement>(".codex-panel-threads__rename-input")?.value).toBe("Generated title");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a manually edited rename draft when threads view auto-name finishes later", async () => {
|
||||
const threadTurnsList = vi.fn().mockResolvedValue({
|
||||
data: [
|
||||
|
|
|
|||
Loading…
Reference in a new issue