diff --git a/src/domain/threads/rename-lifecycle.ts b/src/domain/threads/rename-lifecycle.ts index ce9e74cb..29b1ab7b 100644 --- a/src/domain/threads/rename-lifecycle.ts +++ b/src/domain/threads/rename-lifecycle.ts @@ -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 ->; - 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; - -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)}`); } diff --git a/src/features/chat/application/state/ui-state.ts b/src/features/chat/application/state/ui-state.ts index c496580e..ae34d966 100644 --- a/src/features/chat/application/state/ui-state.ts +++ b/src/features/chat/application/state/ui-state.ts @@ -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; - 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, keep: (value: string) => boolean): ReadonlySet { diff --git a/src/features/chat/application/threads/rename-editor-actions.ts b/src/features/chat/application/threads/rename-editor-actions.ts index 136ce902..2185cc98 100644 --- a/src/features/chat/application/threads/rename-editor-actions.ts +++ b/src/features/chat/application/threads/rename-editor-actions.ts @@ -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 }); } diff --git a/src/features/chat/application/turns/turn-state.ts b/src/features/chat/application/turns/turn-state.ts index b31d2e36..7831eb90 100644 --- a/src/features/chat/application/turns/turn-state.ts +++ b/src/features/chat/application/turns/turn-state.ts @@ -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>; - 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)}`); } diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 09b7e4ae..43a9e87b 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -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); diff --git a/src/features/threads-view/state.ts b/src/features/threads-view/state.ts index aa97bef0..d6aeb252 100644 --- a/src/features/threads-view/state.ts +++ b/src/features/threads-view/state.ts @@ -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 = { 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; } diff --git a/tests/domain/threads/rename-lifecycle.test.ts b/tests/domain/threads/rename-lifecycle.test.ts index fce9e488..df7342a4 100644 --- a/tests/domain/threads/rename-lifecycle.test.ts +++ b/tests/domain/threads/rename-lifecycle.test.ts @@ -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", () => { diff --git a/tests/features/chat/application/state/root-reducer.test.ts b/tests/features/chat/application/state/root-reducer.test.ts index b00f7c68..fafa14cd 100644 --- a/tests/features/chat/application/state/root-reducer.test.ts +++ b/tests/features/chat/application/state/root-reducer.test.ts @@ -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" }); }); diff --git a/tests/features/chat/application/turns/turn-state.test.ts b/tests/features/chat/application/turns/turn-state.test.ts index 16ba72b9..ff7043d6 100644 --- a/tests/features/chat/application/turns/turn-state.test.ts +++ b/tests/features/chat/application/turns/turn-state.test.ts @@ -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 }; } diff --git a/tests/features/threads-view/state.test.ts b/tests/features/threads-view/state.test.ts index db8144de..4d9c3865 100644 --- a/tests/features/threads-view/state.test.ts +++ b/tests/features/threads-view/state.test.ts @@ -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", });