mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor(chat): simplify turn and rename transitions
This commit is contained in:
parent
98a17574e2
commit
f7e5319c3e
10 changed files with 165 additions and 576 deletions
|
|
@ -11,18 +11,10 @@ export type ThreadRenameLifecycleEvent =
|
|||
| { type: "draft-updated"; draft: string }
|
||||
| { type: "cancelled" }
|
||||
| { type: "generation-started"; generationToken: number }
|
||||
| { type: "generation-succeeded"; generatingState: ThreadRenameGeneratingState; draft: string }
|
||||
| { type: "generation-finished"; generatingState: ThreadRenameGeneratingState }
|
||||
| { type: "generation-succeeded"; generationToken: number; draft: string }
|
||||
| { type: "generation-finished"; generationToken: number }
|
||||
| { type: "cleared" };
|
||||
|
||||
type ThreadRenameLifecycleKind = ThreadRenameLifecycleState["kind"];
|
||||
type ThreadRenameLifecycleEventType = ThreadRenameLifecycleEvent["type"];
|
||||
type ThreadRenameLifecycleTransition = (state: ThreadRenameLifecycleState, event: ThreadRenameLifecycleEvent) => ThreadRenameLifecycleState;
|
||||
type ThreadRenameLifecycleTransitionTable = Record<
|
||||
ThreadRenameLifecycleKind,
|
||||
Record<ThreadRenameLifecycleEventType, ThreadRenameLifecycleTransition>
|
||||
>;
|
||||
|
||||
export function initialThreadRenameLifecycleState(): ThreadRenameLifecycleState {
|
||||
return { kind: "idle" };
|
||||
}
|
||||
|
|
@ -31,96 +23,41 @@ export function transitionThreadRenameLifecycleState(
|
|||
state: ThreadRenameLifecycleState,
|
||||
event: ThreadRenameLifecycleEvent,
|
||||
): ThreadRenameLifecycleState {
|
||||
return threadRenameLifecycleTransitions[state.kind][event.type](state, event);
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
return { kind: "editing", draft: event.draft };
|
||||
case "draft-updated":
|
||||
return state.kind === "idle" ? state : { ...state, draft: event.draft };
|
||||
case "cancelled":
|
||||
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
case "generation-started":
|
||||
if (state.kind !== "editing") return state;
|
||||
return {
|
||||
kind: "generating",
|
||||
draft: state.draft,
|
||||
originalDraft: state.draft,
|
||||
generationToken: event.generationToken,
|
||||
};
|
||||
case "generation-succeeded":
|
||||
if (!threadRenameGenerationStillActive(state, event.generationToken) || state.draft !== state.originalDraft) return state;
|
||||
return { ...state, draft: event.draft };
|
||||
case "generation-finished":
|
||||
if (!threadRenameGenerationStillActive(state, event.generationToken)) return state;
|
||||
return { kind: "editing", draft: state.draft };
|
||||
case "cleared":
|
||||
return state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
default:
|
||||
return unhandledThreadRenameLifecycleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
export function threadRenameGenerationStillActive(
|
||||
state: ThreadRenameLifecycleState,
|
||||
generatingState: ThreadRenameGeneratingState,
|
||||
generationToken: number,
|
||||
): state is ThreadRenameGeneratingState {
|
||||
return (
|
||||
state.kind === "generating" &&
|
||||
state.originalDraft === generatingState.originalDraft &&
|
||||
state.generationToken === generatingState.generationToken
|
||||
);
|
||||
return state.kind === "generating" && state.generationToken === generationToken;
|
||||
}
|
||||
|
||||
const keepThreadRenameState: ThreadRenameLifecycleTransition = (state) => state;
|
||||
|
||||
const startThreadRenameTransition: ThreadRenameLifecycleTransition = (_state, event) => ({
|
||||
kind: "editing",
|
||||
draft: requireRenameDraft(event),
|
||||
});
|
||||
|
||||
const updateThreadRenameDraftTransition: ThreadRenameLifecycleTransition = (state, event) => {
|
||||
if (state.kind === "idle") return state;
|
||||
return { ...state, draft: requireRenameDraft(event) };
|
||||
};
|
||||
|
||||
const cancelThreadRenameTransition: ThreadRenameLifecycleTransition = () => initialThreadRenameLifecycleState();
|
||||
|
||||
const startThreadRenameGenerationTransition: ThreadRenameLifecycleTransition = (state, event) => {
|
||||
if (state.kind !== "editing") return state;
|
||||
return {
|
||||
kind: "generating",
|
||||
draft: state.draft,
|
||||
originalDraft: state.draft,
|
||||
generationToken: requireRenameGenerationToken(event),
|
||||
};
|
||||
};
|
||||
|
||||
const succeedThreadRenameGenerationTransition: ThreadRenameLifecycleTransition = (state, event) => {
|
||||
const generatingState = requireRenameGeneratingState(event);
|
||||
if (!threadRenameGenerationStillActive(state, generatingState) || state.draft !== state.originalDraft) return state;
|
||||
return { ...state, draft: requireRenameDraft(event) };
|
||||
};
|
||||
|
||||
const finishThreadRenameGenerationTransition: ThreadRenameLifecycleTransition = (state, event) => {
|
||||
if (!threadRenameGenerationStillActive(state, requireRenameGeneratingState(event))) return state;
|
||||
return { kind: "editing", draft: state.draft };
|
||||
};
|
||||
|
||||
const clearThreadRenameTransition: ThreadRenameLifecycleTransition = (state) =>
|
||||
state.kind === "idle" ? state : initialThreadRenameLifecycleState();
|
||||
|
||||
const threadRenameActiveTransitions = {
|
||||
started: startThreadRenameTransition,
|
||||
"draft-updated": updateThreadRenameDraftTransition,
|
||||
cancelled: cancelThreadRenameTransition,
|
||||
"generation-started": startThreadRenameGenerationTransition,
|
||||
"generation-succeeded": succeedThreadRenameGenerationTransition,
|
||||
"generation-finished": finishThreadRenameGenerationTransition,
|
||||
cleared: clearThreadRenameTransition,
|
||||
} satisfies Record<ThreadRenameLifecycleEventType, ThreadRenameLifecycleTransition>;
|
||||
|
||||
const threadRenameLifecycleTransitions: ThreadRenameLifecycleTransitionTable = {
|
||||
idle: {
|
||||
...threadRenameActiveTransitions,
|
||||
"draft-updated": keepThreadRenameState,
|
||||
cancelled: keepThreadRenameState,
|
||||
"generation-started": keepThreadRenameState,
|
||||
"generation-succeeded": keepThreadRenameState,
|
||||
"generation-finished": keepThreadRenameState,
|
||||
cleared: clearThreadRenameTransition,
|
||||
},
|
||||
editing: threadRenameActiveTransitions,
|
||||
generating: {
|
||||
...threadRenameActiveTransitions,
|
||||
"generation-started": keepThreadRenameState,
|
||||
},
|
||||
};
|
||||
|
||||
function requireRenameDraft(event: ThreadRenameLifecycleEvent): string {
|
||||
if ("draft" in event) return event.draft;
|
||||
throw new Error(`Thread rename lifecycle event ${event.type} does not include a draft.`);
|
||||
}
|
||||
|
||||
function requireRenameGenerationToken(event: ThreadRenameLifecycleEvent): number {
|
||||
if ("generationToken" in event) return event.generationToken;
|
||||
throw new Error(`Thread rename lifecycle event ${event.type} does not include a generation token.`);
|
||||
}
|
||||
|
||||
function requireRenameGeneratingState(event: ThreadRenameLifecycleEvent): ThreadRenameGeneratingState {
|
||||
if ("generatingState" in event) return event.generatingState;
|
||||
throw new Error(`Thread rename lifecycle event ${event.type} does not include generating state.`);
|
||||
function unhandledThreadRenameLifecycleEvent(event: never): never {
|
||||
throw new Error(`Unhandled thread rename lifecycle event: ${String(event)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { type PendingRequestId, pendingRequestDerivedKeyPrefix } from "../../../
|
|||
import type { ThreadGoal } from "../../../../domain/threads/goal";
|
||||
import {
|
||||
type ThreadRenameActiveState,
|
||||
type ThreadRenameGeneratingState,
|
||||
type ThreadRenameLifecycleEvent,
|
||||
type ThreadRenameLifecycleState,
|
||||
threadRenameGenerationStillActive,
|
||||
|
|
@ -27,8 +26,6 @@ type ChatRenameUiAction = Extract<
|
|||
| "ui/rename-cleared";
|
||||
}
|
||||
>;
|
||||
type ChatRenameUiActionType = ChatRenameUiAction["type"];
|
||||
type ChatRenameUiTransition = (state: ChatRenameUiState, action: ChatRenameUiAction) => ChatRenameUiState;
|
||||
|
||||
type ChatGoalEditorUiState =
|
||||
| { readonly kind: "closed" }
|
||||
|
|
@ -76,8 +73,8 @@ export type UiAction =
|
|||
| { type: "ui/rename-draft-updated"; threadId: string; draft: string }
|
||||
| { type: "ui/rename-cancelled"; threadId: string }
|
||||
| { type: "ui/rename-generation-started"; threadId: string; generationToken: number }
|
||||
| { type: "ui/rename-generation-succeeded"; generatingState: ChatRenameGeneratingUiState; draft: string }
|
||||
| { type: "ui/rename-generation-finished"; threadId: string; generatingState: ChatRenameGeneratingUiState }
|
||||
| { type: "ui/rename-generation-succeeded"; threadId: string; generationToken: number; draft: string }
|
||||
| { type: "ui/rename-generation-finished"; threadId: string; generationToken: number }
|
||||
| { type: "ui/rename-cleared" }
|
||||
| { type: "ui/goal-editor-started"; threadId: string | null; objective: string; tokenBudget: number | null }
|
||||
| { type: "ui/goal-editor-draft-updated"; objective: string }
|
||||
|
|
@ -173,11 +170,10 @@ export function maybeClearGoalObjectiveExpansion(
|
|||
|
||||
export function renameGenerationStillActive(
|
||||
state: ChatRenameUiState,
|
||||
generatingState: ChatRenameGeneratingUiState,
|
||||
threadId: string,
|
||||
generationToken: number,
|
||||
): state is ChatRenameGeneratingUiState {
|
||||
return (
|
||||
state.kind === "generating" && state.threadId === generatingState.threadId && threadRenameGenerationStillActive(state, generatingState)
|
||||
);
|
||||
return state.kind === "generating" && state.threadId === threadId && threadRenameGenerationStillActive(state, generationToken);
|
||||
}
|
||||
|
||||
export function clearAllRequestDisclosures(state: ChatUiState): ChatUiState {
|
||||
|
|
@ -259,63 +255,44 @@ function goalEditorDraftUpdated(state: ChatGoalEditorUiState, objective: string)
|
|||
}
|
||||
|
||||
function transitionChatRenameUiState(state: ChatRenameUiState, action: ChatRenameUiAction): ChatRenameUiState {
|
||||
return chatRenameUiTransitions[action.type](state, action);
|
||||
switch (action.type) {
|
||||
case "ui/rename-started":
|
||||
return { kind: "editing", threadId: action.threadId, draft: action.draft };
|
||||
case "ui/rename-draft-updated":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "draft-updated", draft: action.draft });
|
||||
case "ui/rename-cancelled":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, { type: "cancelled" });
|
||||
case "ui/rename-generation-started":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||
type: "generation-started",
|
||||
generationToken: action.generationToken,
|
||||
});
|
||||
case "ui/rename-generation-succeeded":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||
type: "generation-succeeded",
|
||||
generationToken: action.generationToken,
|
||||
draft: action.draft,
|
||||
});
|
||||
case "ui/rename-generation-finished":
|
||||
return transitionScopedChatRenameUiState(state, action.threadId, {
|
||||
type: "generation-finished",
|
||||
generationToken: action.generationToken,
|
||||
});
|
||||
case "ui/rename-cleared":
|
||||
return chatRenameUiStateFromThreadRenameState(
|
||||
state.kind === "idle" ? null : state.threadId,
|
||||
transitionThreadRenameLifecycleState(chatRenameLifecycleStateWithoutThreadId(state), { type: "cleared" }),
|
||||
);
|
||||
default:
|
||||
return unhandledChatRenameUiAction(action);
|
||||
}
|
||||
}
|
||||
|
||||
const startRenameUiTransition: ChatRenameUiTransition = (_state, action) => ({
|
||||
threadId: requireRenameThreadId(action),
|
||||
...requireThreadRenameActiveState(
|
||||
transitionThreadRenameLifecycleState(initialRenameUiState(), { type: "started", draft: requireRenameDraft(action) }),
|
||||
),
|
||||
});
|
||||
|
||||
const updateRenameUiDraftTransition: ChatRenameUiTransition = (state, action) =>
|
||||
transitionScopedChatRenameUiState(state, action, { type: "draft-updated", draft: requireRenameDraft(action) });
|
||||
|
||||
const cancelRenameUiTransition: ChatRenameUiTransition = (state, action) =>
|
||||
transitionScopedChatRenameUiState(state, action, { type: "cancelled" });
|
||||
|
||||
const startRenameGenerationTransition: ChatRenameUiTransition = (state, action) =>
|
||||
transitionScopedChatRenameUiState(state, action, {
|
||||
type: "generation-started",
|
||||
generationToken: requireRenameGenerationToken(action),
|
||||
});
|
||||
|
||||
const succeedRenameGenerationTransition: ChatRenameUiTransition = (state, action) =>
|
||||
transitionScopedChatRenameUiState(state, action, {
|
||||
type: "generation-succeeded",
|
||||
generatingState: chatRenameGeneratingStateWithoutThreadId(requireRenameGeneratingState(action)),
|
||||
draft: requireRenameDraft(action),
|
||||
});
|
||||
|
||||
const finishRenameGenerationTransition: ChatRenameUiTransition = (state, action) =>
|
||||
transitionScopedChatRenameUiState(state, action, {
|
||||
type: "generation-finished",
|
||||
generatingState: chatRenameGeneratingStateWithoutThreadId(requireRenameGeneratingState(action)),
|
||||
});
|
||||
|
||||
const clearRenameUiTransition: ChatRenameUiTransition = (state) =>
|
||||
chatRenameUiStateFromThreadRenameState(
|
||||
state.kind === "idle" ? null : state.threadId,
|
||||
transitionThreadRenameLifecycleState(chatRenameLifecycleStateWithoutThreadId(state), { type: "cleared" }),
|
||||
);
|
||||
|
||||
const chatRenameUiTransitions = {
|
||||
"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>;
|
||||
|
||||
function transitionScopedChatRenameUiState(
|
||||
state: ChatRenameUiState,
|
||||
action: ChatRenameUiAction,
|
||||
threadId: string,
|
||||
event: ThreadRenameLifecycleEvent,
|
||||
): ChatRenameUiState {
|
||||
const threadId = requireRenameThreadId(action);
|
||||
if (state.kind === "idle" || state.threadId !== threadId) return state;
|
||||
const lifecycleState = chatRenameLifecycleStateWithoutThreadId(state);
|
||||
const nextLifecycleState = transitionThreadRenameLifecycleState(lifecycleState, event);
|
||||
|
|
@ -342,45 +319,14 @@ function chatRenameActiveStateWithoutThreadId(state: Exclude<ChatRenameUiState,
|
|||
}
|
||||
}
|
||||
|
||||
function chatRenameGeneratingStateWithoutThreadId(state: ChatRenameGeneratingUiState): ThreadRenameGeneratingState {
|
||||
return {
|
||||
kind: "generating",
|
||||
draft: state.draft,
|
||||
originalDraft: state.originalDraft,
|
||||
generationToken: state.generationToken,
|
||||
};
|
||||
}
|
||||
|
||||
function chatRenameUiStateFromThreadRenameState(threadId: string | null, state: ThreadRenameLifecycleState): ChatRenameUiState {
|
||||
if (state.kind === "idle") return state;
|
||||
if (threadId === null) return initialRenameUiState();
|
||||
return { ...state, threadId };
|
||||
}
|
||||
|
||||
function requireThreadRenameActiveState(state: ThreadRenameLifecycleState): ThreadRenameActiveState {
|
||||
if (state.kind !== "idle") return state;
|
||||
throw new Error("Expected thread rename lifecycle to start editing.");
|
||||
}
|
||||
|
||||
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 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 unhandledChatRenameUiAction(action: never): never {
|
||||
throw new Error(`Unhandled chat rename UI action: ${String(action)}`);
|
||||
}
|
||||
|
||||
function filterStringSet(values: ReadonlySet<string>, keep: (value: string) => boolean): ReadonlySet<string> {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import type { ThreadTitleContext } from "../../../../domain/threads/title-genera
|
|||
import type { ChatAction, ChatState } from "../state/root-reducer";
|
||||
import type { ChatStateStore } from "../state/store";
|
||||
import { threadStreamItems } from "../state/thread-stream";
|
||||
import { type ChatRenameGeneratingUiState, type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state";
|
||||
import { type ChatRenameUiState, renameGenerationStillActive } from "../state/ui-state";
|
||||
import { firstThreadTitleContextFromThreadStreamItems } from "./title-context";
|
||||
|
||||
interface RenameEditState {
|
||||
|
|
@ -92,19 +92,19 @@ export function createThreadRenameEditorActions(host: ThreadRenameEditorActionsH
|
|||
threadId,
|
||||
generationToken: nextRenameGenerationToken,
|
||||
});
|
||||
const generatingState: ChatRenameUiState = host.stateStore.getState().ui.rename;
|
||||
if (generatingState.kind !== "generating") return;
|
||||
const generationToken = nextRenameGenerationToken;
|
||||
if (!renameGenerationStillActive(renameState(host), threadId, generationToken)) return;
|
||||
nextRenameGenerationToken += 1;
|
||||
|
||||
try {
|
||||
const title = await host.generateThreadTitle(threadId);
|
||||
dispatch(host, { type: "ui/rename-generation-succeeded", generatingState, draft: title });
|
||||
dispatch(host, { type: "ui/rename-generation-succeeded", threadId, generationToken, draft: title });
|
||||
} catch (error) {
|
||||
if (renameGenerationStillActive(renameState(host), generatingState)) {
|
||||
if (renameGenerationStillActive(renameState(host), threadId, generationToken)) {
|
||||
host.addSystemMessage(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
finishAutoNameDraftGeneration(host, threadId, generatingState);
|
||||
finishAutoNameDraftGeneration(host, threadId, generationToken);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -124,10 +124,6 @@ function dispatch(host: ThreadRenameEditorActionsHost, action: ChatAction): void
|
|||
host.stateStore.dispatch(action);
|
||||
}
|
||||
|
||||
function finishAutoNameDraftGeneration(
|
||||
host: ThreadRenameEditorActionsHost,
|
||||
threadId: string,
|
||||
generatingState: ChatRenameGeneratingUiState,
|
||||
): void {
|
||||
dispatch(host, { type: "ui/rename-generation-finished", threadId, generatingState });
|
||||
function finishAutoNameDraftGeneration(host: ThreadRenameEditorActionsHost, threadId: string, generationToken: number): void {
|
||||
dispatch(host, { type: "ui/rename-generation-finished", threadId, generationToken });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,11 +19,6 @@ export type ChatTurnLifecycleEvent =
|
|||
| { type: "start-failed" }
|
||||
| { type: "pending-start-hook-upserted"; pendingTurnStart: PendingTurnStart | null };
|
||||
|
||||
type ChatTurnLifecycleKind = ChatTurnLifecycleState["kind"];
|
||||
type ChatTurnLifecycleEventType = ChatTurnLifecycleEvent["type"];
|
||||
type ChatTurnLifecycleTransition = (state: ChatTurnLifecycleState, event: ChatTurnLifecycleEvent) => ChatTurnLifecycleState;
|
||||
type ChatTurnLifecycleTransitionTable = Record<ChatTurnLifecycleKind, Record<ChatTurnLifecycleEventType, ChatTurnLifecycleTransition>>;
|
||||
|
||||
export interface ChatTurnState {
|
||||
readonly lifecycle: ChatTurnLifecycleState;
|
||||
}
|
||||
|
|
@ -49,7 +44,28 @@ export function pendingTurnStart(state: { turn: ChatTurnState } | { lifecycle: C
|
|||
}
|
||||
|
||||
export function transitionChatTurnLifecycleState(state: ChatTurnLifecycleState, event: ChatTurnLifecycleEvent): ChatTurnLifecycleState {
|
||||
return chatTurnLifecycleTransitions[state.kind][event.type](state, event);
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
return { kind: "running", turnId: event.turnId };
|
||||
case "completed":
|
||||
return state.kind === "running" && state.turnId === event.turnId ? { kind: "idle" } : state;
|
||||
case "cleared":
|
||||
return state.kind === "idle" ? state : { kind: "idle" };
|
||||
case "optimistic-started":
|
||||
return { kind: "starting", pendingTurnStart: event.pendingTurnStart };
|
||||
case "start-acknowledged":
|
||||
if (state.kind === "starting" || (state.kind === "running" && state.turnId === event.turnId)) {
|
||||
return { kind: "running", turnId: event.turnId };
|
||||
}
|
||||
return state;
|
||||
case "start-failed":
|
||||
return state.kind === "starting" ? { kind: "idle" } : state;
|
||||
case "pending-start-hook-upserted":
|
||||
if (event.pendingTurnStart) return { kind: "starting", pendingTurnStart: event.pendingTurnStart };
|
||||
return state.kind === "starting" ? { kind: "idle" } : state;
|
||||
default:
|
||||
return unhandledChatTurnLifecycleEvent(event);
|
||||
}
|
||||
}
|
||||
|
||||
function turnLifecycleFor(state: { turn: ChatTurnState } | { lifecycle: ChatTurnLifecycleState }): ChatTurnLifecycleState {
|
||||
|
|
@ -57,81 +73,6 @@ function turnLifecycleFor(state: { turn: ChatTurnState } | { lifecycle: ChatTurn
|
|||
return state.lifecycle;
|
||||
}
|
||||
|
||||
const keepLifecycleState: ChatTurnLifecycleTransition = (state) => state;
|
||||
|
||||
const clearLifecycleState: ChatTurnLifecycleTransition = (state) => (state.kind === "idle" ? state : { kind: "idle" });
|
||||
|
||||
const runningFromStartedEvent: ChatTurnLifecycleTransition = (_state, event) => ({
|
||||
kind: "running",
|
||||
turnId: requireTurnId(event),
|
||||
});
|
||||
|
||||
const startingFromOptimisticStartEvent: ChatTurnLifecycleTransition = (_state, event) => ({
|
||||
kind: "starting",
|
||||
pendingTurnStart: requirePendingTurnStart(event),
|
||||
});
|
||||
|
||||
const runningCompletionTransition: ChatTurnLifecycleTransition = (state, event) => {
|
||||
if (state.kind !== "running" || state.turnId !== requireTurnId(event)) return state;
|
||||
return { kind: "idle" };
|
||||
};
|
||||
|
||||
const startingAcknowledgementTransition: ChatTurnLifecycleTransition = (_state, event) => ({
|
||||
kind: "running",
|
||||
turnId: requireTurnId(event),
|
||||
});
|
||||
|
||||
const runningAcknowledgementTransition: ChatTurnLifecycleTransition = (state, event) => {
|
||||
if (state.kind !== "running" || state.turnId !== requireTurnId(event)) return state;
|
||||
return { kind: "running", turnId: state.turnId };
|
||||
};
|
||||
|
||||
const pendingStartHookTransition: ChatTurnLifecycleTransition = (state, event) => {
|
||||
const pendingTurnStart = optionalPendingTurnStart(event);
|
||||
if (pendingTurnStart) return { kind: "starting", pendingTurnStart };
|
||||
return state.kind === "starting" ? { kind: "idle" } : state;
|
||||
};
|
||||
|
||||
const chatTurnLifecycleTransitions: ChatTurnLifecycleTransitionTable = {
|
||||
idle: {
|
||||
started: runningFromStartedEvent,
|
||||
completed: keepLifecycleState,
|
||||
cleared: clearLifecycleState,
|
||||
"optimistic-started": startingFromOptimisticStartEvent,
|
||||
"start-acknowledged": keepLifecycleState,
|
||||
"start-failed": keepLifecycleState,
|
||||
"pending-start-hook-upserted": pendingStartHookTransition,
|
||||
},
|
||||
starting: {
|
||||
started: runningFromStartedEvent,
|
||||
completed: keepLifecycleState,
|
||||
cleared: clearLifecycleState,
|
||||
"optimistic-started": startingFromOptimisticStartEvent,
|
||||
"start-acknowledged": startingAcknowledgementTransition,
|
||||
"start-failed": clearLifecycleState,
|
||||
"pending-start-hook-upserted": pendingStartHookTransition,
|
||||
},
|
||||
running: {
|
||||
started: runningFromStartedEvent,
|
||||
completed: runningCompletionTransition,
|
||||
cleared: clearLifecycleState,
|
||||
"optimistic-started": startingFromOptimisticStartEvent,
|
||||
"start-acknowledged": runningAcknowledgementTransition,
|
||||
"start-failed": keepLifecycleState,
|
||||
"pending-start-hook-upserted": pendingStartHookTransition,
|
||||
},
|
||||
};
|
||||
|
||||
function requireTurnId(event: ChatTurnLifecycleEvent): string {
|
||||
if ("turnId" in event) return event.turnId;
|
||||
throw new Error(`Turn lifecycle event ${event.type} does not include a turn id.`);
|
||||
}
|
||||
|
||||
function requirePendingTurnStart(event: ChatTurnLifecycleEvent): PendingTurnStart {
|
||||
if ("pendingTurnStart" in event && event.pendingTurnStart) return event.pendingTurnStart;
|
||||
throw new Error(`Turn lifecycle event ${event.type} does not include pending turn start state.`);
|
||||
}
|
||||
|
||||
function optionalPendingTurnStart(event: ChatTurnLifecycleEvent): PendingTurnStart | null {
|
||||
return "pendingTurnStart" in event ? event.pendingTurnStart : null;
|
||||
function unhandledChatTurnLifecycleEvent(event: never): never {
|
||||
throw new Error(`Unhandled chat turn lifecycle event: ${String(event)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/
|
|||
import type { ReasoningEffort } from "../../domain/catalog/metadata";
|
||||
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
import type { ThreadRenameLifecycleEvent } from "../../domain/threads/rename-lifecycle";
|
||||
import { DeferredTask } from "../../shared/runtime/deferred-task";
|
||||
import { OwnerLifetime } from "../../shared/runtime/owner-lifetime";
|
||||
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
|
||||
|
|
@ -16,14 +17,7 @@ import type { ThreadNameMutationCoordinator } from "../threads/workflows/thread-
|
|||
import { createThreadOperations, type ThreadOperations } from "../threads/workflows/thread-operations";
|
||||
import { createThreadTitleService, type ThreadTitleService } from "../threads/workflows/thread-title-service";
|
||||
import { isThreadsArchiveConfirmPointer, renderThreadsViewShell, unmountThreadsViewShell } from "./shell.dom";
|
||||
import {
|
||||
type ThreadsGeneratingRenameState,
|
||||
type ThreadsRenameLifecycleEvent,
|
||||
type ThreadsRenameState,
|
||||
type ThreadsViewPanelActivity,
|
||||
threadRows,
|
||||
transitionThreadsRenameState,
|
||||
} from "./state";
|
||||
import { type ThreadsRenameState, type ThreadsViewPanelActivity, threadRows, transitionThreadsRenameState } from "./state";
|
||||
export interface ThreadsViewHost {
|
||||
readonly settings: ThreadsViewSettingsAccess;
|
||||
readonly vaultPath: string;
|
||||
|
|
@ -355,9 +349,10 @@ export class ThreadsViewSession {
|
|||
const lifetime = this.lifetime.signal();
|
||||
const operationGeneration = this.operationGeneration;
|
||||
const previousState = this.renameStates.get(threadId);
|
||||
const generationToken = this.nextRenameGenerationToken;
|
||||
const generatingState = this.transitionRenameState(threadId, {
|
||||
type: "auto-name-started",
|
||||
generationToken: this.nextRenameGenerationToken,
|
||||
type: "generation-started",
|
||||
generationToken,
|
||||
});
|
||||
if (generatingState === previousState || generatingState?.kind !== "generating") return;
|
||||
this.nextRenameGenerationToken += 1;
|
||||
|
|
@ -367,7 +362,7 @@ export class ThreadsViewSession {
|
|||
if (this.renameStates.get(threadId) !== generatingState) return;
|
||||
const title = await this.titleService.generateTitle(threadId);
|
||||
if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return;
|
||||
this.transitionRenameState(threadId, { type: "auto-name-generated", generatingState, title });
|
||||
this.transitionRenameState(threadId, { type: "generation-succeeded", generationToken, draft: title });
|
||||
} catch (error) {
|
||||
if (!this.lifetime.isCurrent(lifetime) || operationGeneration !== this.operationGeneration) return;
|
||||
if (this.renameStates.get(threadId) === generatingState) {
|
||||
|
|
@ -375,7 +370,7 @@ export class ThreadsViewSession {
|
|||
}
|
||||
} finally {
|
||||
if (this.lifetime.isCurrent(lifetime) && operationGeneration === this.operationGeneration) {
|
||||
this.finishAutoNameThread(threadId, generatingState);
|
||||
this.finishAutoNameThread(threadId, generationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -411,13 +406,13 @@ export class ThreadsViewSession {
|
|||
}
|
||||
}
|
||||
|
||||
private finishAutoNameThread(threadId: string, generatingState: ThreadsGeneratingRenameState): void {
|
||||
private finishAutoNameThread(threadId: string, generationToken: number): void {
|
||||
const previousState = this.renameStates.get(threadId);
|
||||
const nextState = this.transitionRenameState(threadId, { type: "auto-name-finished", generatingState });
|
||||
const nextState = this.transitionRenameState(threadId, { type: "generation-finished", generationToken });
|
||||
if (nextState !== previousState) this.render();
|
||||
}
|
||||
|
||||
private transitionRenameState(threadId: string, event: ThreadsRenameLifecycleEvent): ThreadsRenameState | undefined {
|
||||
private transitionRenameState(threadId: string, event: ThreadRenameLifecycleEvent): ThreadsRenameState | undefined {
|
||||
const nextState = transitionThreadsRenameState(this.renameStates.get(threadId), event);
|
||||
if (nextState) {
|
||||
this.renameStates.set(threadId, nextState);
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
import { type Thread, threadRecencyAt } from "../../domain/threads/model";
|
||||
import {
|
||||
initialThreadRenameLifecycleState,
|
||||
type ThreadRenameLifecycleEvent as SharedThreadRenameLifecycleEvent,
|
||||
type ThreadRenameLifecycleState as SharedThreadRenameLifecycleState,
|
||||
type ThreadRenameActiveState,
|
||||
type ThreadRenameGeneratingState,
|
||||
type ThreadRenameLifecycleEvent,
|
||||
type ThreadRenameLifecycleState,
|
||||
transitionThreadRenameLifecycleState,
|
||||
} from "../../domain/threads/rename-lifecycle";
|
||||
import { type ThreadRowCoreProjection, threadRowCoreProjection } from "../threads/list/row-projection";
|
||||
|
|
@ -27,15 +26,7 @@ export interface ThreadsRowModel extends ThreadRowCoreProjection {
|
|||
}
|
||||
|
||||
export type ThreadsRenameState = ThreadRenameActiveState;
|
||||
export type ThreadsGeneratingRenameState = ThreadRenameGeneratingState;
|
||||
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 };
|
||||
|
||||
const STATUS_PRIORITY: Record<ThreadsLiveStatus, number> = {
|
||||
pending: 2,
|
||||
|
|
@ -85,31 +76,12 @@ function liveStateForPanelActivities(panelActivities: ThreadsViewPanelActivity[]
|
|||
|
||||
export function transitionThreadsRenameState(
|
||||
state: ThreadsRenameLifecycleState,
|
||||
event: ThreadsRenameLifecycleEvent,
|
||||
event: ThreadRenameLifecycleEvent,
|
||||
): ThreadsRenameLifecycleState {
|
||||
return activeThreadsRenameState(
|
||||
transitionThreadRenameLifecycleState(state ?? initialThreadRenameLifecycleState(), sharedThreadsRenameLifecycleEvent(event)),
|
||||
);
|
||||
return activeThreadsRenameState(transitionThreadRenameLifecycleState(state ?? initialThreadRenameLifecycleState(), event));
|
||||
}
|
||||
|
||||
function sharedThreadsRenameLifecycleEvent(event: ThreadsRenameLifecycleEvent): SharedThreadRenameLifecycleEvent {
|
||||
switch (event.type) {
|
||||
case "started":
|
||||
return { type: "started", draft: event.draft };
|
||||
case "draft-updated":
|
||||
return { type: "draft-updated", draft: event.draft };
|
||||
case "cancelled":
|
||||
return { type: "cancelled" };
|
||||
case "auto-name-started":
|
||||
return { type: "generation-started", generationToken: event.generationToken };
|
||||
case "auto-name-generated":
|
||||
return { type: "generation-succeeded", generatingState: event.generatingState, draft: event.title };
|
||||
case "auto-name-finished":
|
||||
return { type: "generation-finished", generatingState: event.generatingState };
|
||||
}
|
||||
}
|
||||
|
||||
function activeThreadsRenameState(state: SharedThreadRenameLifecycleState): ThreadsRenameLifecycleState {
|
||||
function activeThreadsRenameState(state: ThreadRenameLifecycleState): ThreadsRenameLifecycleState {
|
||||
return state.kind === "idle" ? undefined : state;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ describe("thread rename lifecycle", () => {
|
|||
|
||||
const staleGenerated = transitionThreadRenameLifecycleState(generating, {
|
||||
type: "generation-succeeded",
|
||||
generatingState: { ...generating, generationToken: 2 },
|
||||
generationToken: 2,
|
||||
draft: "Late title",
|
||||
});
|
||||
expect(staleGenerated).toBe(generating);
|
||||
|
|
@ -21,31 +21,17 @@ describe("thread rename lifecycle", () => {
|
|||
const manuallyEdited = transitionThreadRenameLifecycleState(generating, { type: "draft-updated", draft: "Manual draft" });
|
||||
const generatedAfterManualEdit = transitionThreadRenameLifecycleState(manuallyEdited, {
|
||||
type: "generation-succeeded",
|
||||
generatingState: generating,
|
||||
generationToken: generating.generationToken,
|
||||
draft: "Generated title",
|
||||
});
|
||||
expect(generatedAfterManualEdit).toBe(manuallyEdited);
|
||||
|
||||
expect(transitionThreadRenameLifecycleState(manuallyEdited, { type: "generation-finished", generatingState: generating })).toEqual({
|
||||
kind: "editing",
|
||||
draft: "Manual draft",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores stale generation state with a mismatched original draft", () => {
|
||||
const generating = generatingRenameState("Original draft", 1);
|
||||
const staleGenerating = { ...generating, originalDraft: "Other draft" };
|
||||
|
||||
expect(
|
||||
transitionThreadRenameLifecycleState(generating, {
|
||||
type: "generation-succeeded",
|
||||
generatingState: staleGenerating,
|
||||
draft: "Late title",
|
||||
transitionThreadRenameLifecycleState(manuallyEdited, {
|
||||
type: "generation-finished",
|
||||
generationToken: generating.generationToken,
|
||||
}),
|
||||
).toBe(generating);
|
||||
expect(transitionThreadRenameLifecycleState(generating, { type: "generation-finished", generatingState: staleGenerating })).toBe(
|
||||
generating,
|
||||
);
|
||||
).toEqual({ kind: "editing", draft: "Manual draft" });
|
||||
});
|
||||
|
||||
it("does not create an editor from a stray draft update", () => {
|
||||
|
|
|
|||
|
|
@ -611,7 +611,8 @@ describe("chatReducer", () => {
|
|||
|
||||
const staleSucceeded = chatReducer(state, {
|
||||
type: "ui/rename-generation-succeeded",
|
||||
generatingState: { ...generatingState, generationToken: 2 },
|
||||
threadId: "thread",
|
||||
generationToken: 2,
|
||||
draft: "Late title",
|
||||
});
|
||||
expect(staleSucceeded).toBe(state);
|
||||
|
|
@ -619,7 +620,8 @@ describe("chatReducer", () => {
|
|||
const manuallyEdited = chatReducer(state, { type: "ui/rename-draft-updated", threadId: "thread", draft: "Manual draft" });
|
||||
const generatedAfterManualEdit = chatReducer(manuallyEdited, {
|
||||
type: "ui/rename-generation-succeeded",
|
||||
generatingState,
|
||||
threadId: "thread",
|
||||
generationToken: generatingState.generationToken,
|
||||
draft: "Generated title",
|
||||
});
|
||||
expect(generatedAfterManualEdit).toBe(manuallyEdited);
|
||||
|
|
@ -627,7 +629,7 @@ describe("chatReducer", () => {
|
|||
const finished = chatReducer(manuallyEdited, {
|
||||
type: "ui/rename-generation-finished",
|
||||
threadId: "thread",
|
||||
generatingState,
|
||||
generationToken: generatingState.generationToken,
|
||||
});
|
||||
expect(finished.ui.rename).toEqual({ kind: "editing", threadId: "thread", draft: "Manual draft" });
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,220 +8,38 @@ import {
|
|||
} from "../../../../../src/features/chat/application/turns/turn-state";
|
||||
|
||||
describe("chat turn lifecycle state machine", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "idle starts from server notification",
|
||||
state: idle(),
|
||||
event: started("turn"),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "starting starts from server notification",
|
||||
state: starting(pendingA),
|
||||
event: started("turn"),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "running starts from newer server notification",
|
||||
state: running("previous-turn"),
|
||||
event: started("turn"),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "idle ignores completion",
|
||||
state: idle(),
|
||||
event: completed("turn"),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "starting ignores stale completion",
|
||||
state: starting(pendingA),
|
||||
event: completed("turn"),
|
||||
expected: starting(pendingA),
|
||||
},
|
||||
{
|
||||
name: "running completes matching turn",
|
||||
state: running("turn"),
|
||||
event: completed("turn"),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "running ignores stale completion",
|
||||
state: running("turn"),
|
||||
event: completed("stale-turn"),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "idle stays idle when cleared",
|
||||
state: idle(),
|
||||
event: cleared(),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "starting clears",
|
||||
state: starting(pendingA),
|
||||
event: cleared(),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "running clears",
|
||||
state: running("turn"),
|
||||
event: cleared(),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "idle records optimistic start",
|
||||
state: idle(),
|
||||
event: optimisticStarted(pendingA),
|
||||
expected: starting(pendingA),
|
||||
},
|
||||
{
|
||||
name: "starting replaces optimistic start",
|
||||
state: starting(pendingA),
|
||||
event: optimisticStarted(pendingB),
|
||||
expected: starting(pendingB),
|
||||
},
|
||||
{
|
||||
name: "running can be replaced by optimistic start",
|
||||
state: running("turn"),
|
||||
event: optimisticStarted(pendingA),
|
||||
expected: starting(pendingA),
|
||||
},
|
||||
{
|
||||
name: "idle ignores start acknowledgement",
|
||||
state: idle(),
|
||||
event: startAcknowledged("turn"),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "starting acknowledges turn start",
|
||||
state: starting(pendingA),
|
||||
event: startAcknowledged("turn"),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "running accepts matching acknowledgement",
|
||||
state: running("turn"),
|
||||
event: startAcknowledged("turn"),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "running ignores stale acknowledgement",
|
||||
state: running("turn"),
|
||||
event: startAcknowledged("stale-turn"),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "idle ignores start failure",
|
||||
state: idle(),
|
||||
event: startFailed(),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "starting clears after start failure",
|
||||
state: starting(pendingA),
|
||||
event: startFailed(),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "running ignores stale start failure",
|
||||
state: running("turn"),
|
||||
event: startFailed(),
|
||||
expected: running("turn"),
|
||||
},
|
||||
{
|
||||
name: "idle records pending hook state",
|
||||
state: idle(),
|
||||
event: pendingStartHookUpserted(pendingA),
|
||||
expected: starting(pendingA),
|
||||
},
|
||||
{
|
||||
name: "idle ignores cleared pending hook state",
|
||||
state: idle(),
|
||||
event: pendingStartHookUpserted(null),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "starting replaces pending hook state",
|
||||
state: starting(pendingA),
|
||||
event: pendingStartHookUpserted(pendingB),
|
||||
expected: starting(pendingB),
|
||||
},
|
||||
{
|
||||
name: "starting clears when pending hook state clears",
|
||||
state: starting(pendingA),
|
||||
event: pendingStartHookUpserted(null),
|
||||
expected: idle(),
|
||||
},
|
||||
{
|
||||
name: "running records pending hook state",
|
||||
state: running("turn"),
|
||||
event: pendingStartHookUpserted(pendingA),
|
||||
expected: starting(pendingA),
|
||||
},
|
||||
{
|
||||
name: "running ignores cleared pending hook state",
|
||||
state: running("turn"),
|
||||
event: pendingStartHookUpserted(null),
|
||||
expected: running("turn"),
|
||||
},
|
||||
])("$name", ({ state, event, expected }) => {
|
||||
expect(transitionChatTurnLifecycleState(state, event)).toEqual(expected);
|
||||
it("moves an optimistic turn through acknowledgement and matching completion", () => {
|
||||
const startingState = transitionChatTurnLifecycleState(idle(), optimisticStarted(pendingA));
|
||||
expect(startingState).toEqual(starting(pendingA));
|
||||
|
||||
const runningState = transitionChatTurnLifecycleState(startingState, startAcknowledged("turn"));
|
||||
expect(runningState).toEqual(running("turn"));
|
||||
|
||||
expect(transitionChatTurnLifecycleState(runningState, completed("turn"))).toEqual(idle());
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "idle completion",
|
||||
state: idle(),
|
||||
event: completed("turn"),
|
||||
},
|
||||
{
|
||||
name: "idle clear",
|
||||
state: idle(),
|
||||
event: cleared(),
|
||||
},
|
||||
{
|
||||
name: "idle acknowledgement",
|
||||
state: idle(),
|
||||
event: startAcknowledged("turn"),
|
||||
},
|
||||
{
|
||||
name: "idle start failure",
|
||||
state: idle(),
|
||||
event: startFailed(),
|
||||
},
|
||||
{
|
||||
name: "idle cleared pending hook state",
|
||||
state: idle(),
|
||||
event: pendingStartHookUpserted(null),
|
||||
},
|
||||
{
|
||||
name: "starting completion",
|
||||
state: starting(pendingA),
|
||||
event: completed("turn"),
|
||||
},
|
||||
{
|
||||
name: "running stale completion",
|
||||
state: running("turn"),
|
||||
event: completed("stale-turn"),
|
||||
},
|
||||
{
|
||||
name: "running stale acknowledgement",
|
||||
state: running("turn"),
|
||||
event: startAcknowledged("stale-turn"),
|
||||
},
|
||||
{
|
||||
name: "running start failure",
|
||||
state: running("turn"),
|
||||
event: startFailed(),
|
||||
},
|
||||
{
|
||||
name: "running cleared pending hook state",
|
||||
state: running("turn"),
|
||||
event: pendingStartHookUpserted(null),
|
||||
},
|
||||
])("preserves state identity for ignored transition: $name", ({ state, event }) => {
|
||||
expect(transitionChatTurnLifecycleState(state, event)).toBe(state);
|
||||
it("accepts server start before acknowledgement and completes that turn", () => {
|
||||
const startingState = transitionChatTurnLifecycleState(idle(), optimisticStarted(pendingA));
|
||||
const runningState = transitionChatTurnLifecycleState(startingState, started("server-turn"));
|
||||
|
||||
expect(runningState).toEqual(running("server-turn"));
|
||||
expect(transitionChatTurnLifecycleState(runningState, completed("server-turn"))).toEqual(idle());
|
||||
});
|
||||
|
||||
it("returns a failed optimistic start to idle", () => {
|
||||
const startingState = transitionChatTurnLifecycleState(idle(), optimisticStarted(pendingA));
|
||||
|
||||
expect(transitionChatTurnLifecycleState(startingState, startFailed())).toEqual(idle());
|
||||
});
|
||||
|
||||
it("distinguishes stale and accepted acknowledgements for reducer publication", () => {
|
||||
const current = running("turn");
|
||||
|
||||
expect(transitionChatTurnLifecycleState(current, startAcknowledged("stale-turn"))).toBe(current);
|
||||
|
||||
const accepted = transitionChatTurnLifecycleState(current, startAcknowledged("turn"));
|
||||
expect(accepted).not.toBe(current);
|
||||
expect(accepted).toEqual(current);
|
||||
});
|
||||
|
||||
it("model-checks stale callbacks that must not clear a running turn", () => {
|
||||
|
|
@ -332,10 +150,6 @@ function completed(turnId: string): ChatTurnLifecycleEvent {
|
|||
return { type: "completed", turnId };
|
||||
}
|
||||
|
||||
function cleared(): ChatTurnLifecycleEvent {
|
||||
return { type: "cleared" };
|
||||
}
|
||||
|
||||
function optimisticStarted(pendingTurnStart: PendingTurnStart): ChatTurnLifecycleEvent {
|
||||
return { type: "optimistic-started", pendingTurnStart };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,15 +4,15 @@ import type { Thread } from "../../../src/domain/threads/model";
|
|||
import { type ThreadsViewPanelActivity, threadRows, transitionThreadsRenameState } from "../../../src/features/threads-view/state";
|
||||
|
||||
describe("threads view rename state", () => {
|
||||
it("maps threads view auto-name events through the shared rename lifecycle", () => {
|
||||
it("keeps auto-name results scoped to the active unchanged generation", () => {
|
||||
const editing = transitionThreadsRenameState(undefined, { type: "started", draft: "Original draft" });
|
||||
const generating = transitionThreadsRenameState(editing, { type: "auto-name-started", generationToken: 1 });
|
||||
const generating = transitionThreadsRenameState(editing, { type: "generation-started", generationToken: 1 });
|
||||
if (generating?.kind !== "generating") throw new Error("Expected generating state");
|
||||
|
||||
const generated = transitionThreadsRenameState(generating, {
|
||||
type: "auto-name-generated",
|
||||
generatingState: generating,
|
||||
title: "Generated title",
|
||||
type: "generation-succeeded",
|
||||
generationToken: generating.generationToken,
|
||||
draft: "Generated title",
|
||||
});
|
||||
|
||||
expect(generated).toEqual({
|
||||
|
|
@ -21,7 +21,7 @@ describe("threads view rename state", () => {
|
|||
originalDraft: "Original draft",
|
||||
generationToken: 1,
|
||||
});
|
||||
expect(transitionThreadsRenameState(generated, { type: "auto-name-finished", generatingState: generating })).toEqual({
|
||||
expect(transitionThreadsRenameState(generated, { type: "generation-finished", generationToken: generating.generationToken })).toEqual({
|
||||
kind: "editing",
|
||||
draft: "Generated title",
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue