refactor(chat): simplify turn and rename transitions

This commit is contained in:
murashit 2026-07-16 18:50:25 +09:00
parent 98a17574e2
commit f7e5319c3e
10 changed files with 165 additions and 576 deletions

View file

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

View file

@ -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> {

View file

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

View file

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

View file

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

View file

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

View file

@ -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", () => {

View file

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

View file

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

View file

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