From 5708e28a9e5fcb3f2a6ac77cc36d1f0db35f66d8 Mon Sep 17 00:00:00 2001 From: murashit Date: Thu, 28 May 2026 21:34:27 +0900 Subject: [PATCH] Clarify view and local lifecycle transitions --- src/features/chat/thread-history.ts | 24 +++++- src/features/chat/thread-rename.ts | 80 ++++++++++++++----- src/features/chat/view-lifecycle.ts | 72 +++++++++++++++++ src/features/chat/view.ts | 58 +++++++------- src/features/threads-view/view-lifecycle.ts | 42 ++++++++++ src/features/threads-view/view.ts | 34 ++++---- tests/features/chat/view-lifecycle.test.ts | 52 +++++++++++- .../threads-view/view-lifecycle.test.ts | 33 +++++++- 8 files changed, 323 insertions(+), 72 deletions(-) diff --git a/src/features/chat/thread-history.ts b/src/features/chat/thread-history.ts index fdcdfe84..924e7044 100644 --- a/src/features/chat/thread-history.ts +++ b/src/features/chat/thread-history.ts @@ -14,6 +14,10 @@ export interface ThreadHistoryLoaderHost { type ThreadHistoryLoadLifecycleState = { kind: "idle" } | { kind: "loading"; threadId: string; mode: "latest" | "older" }; type ActiveThreadHistoryLoad = Extract; +type ThreadHistoryLoadLifecycleEvent = + | { type: "started"; load: ActiveThreadHistoryLoad } + | { type: "finished"; load: ActiveThreadHistoryLoad } + | { type: "invalidated" }; export class ThreadHistoryLoader { private lifecycle: ThreadHistoryLoadLifecycleState = { kind: "idle" }; @@ -29,7 +33,7 @@ export class ThreadHistoryLoader { } invalidate(): void { - this.lifecycle = { kind: "idle" }; + this.lifecycle = transitionThreadHistoryLoadLifecycle(this.lifecycle, { type: "invalidated" }); this.dispatch({ type: "history/loading-set", loading: false }); } @@ -81,7 +85,7 @@ export class ThreadHistoryLoader { private startLoading(threadId: string, mode: ActiveThreadHistoryLoad["mode"]): ActiveThreadHistoryLoad { const load: ActiveThreadHistoryLoad = { kind: "loading", threadId, mode }; - this.lifecycle = load; + this.lifecycle = transitionThreadHistoryLoadLifecycle(this.lifecycle, { type: "started", load }); this.dispatch({ type: "history/loading-set", loading: true }); this.host.render(); return load; @@ -89,7 +93,7 @@ export class ThreadHistoryLoader { private finishLoading(load: ActiveThreadHistoryLoad): void { if (this.isStale(load)) return; - this.lifecycle = { kind: "idle" }; + this.lifecycle = transitionThreadHistoryLoadLifecycle(this.lifecycle, { type: "finished", load }); this.dispatch({ type: "history/loading-set", loading: false }); this.host.render(); } @@ -98,3 +102,17 @@ export class ThreadHistoryLoader { return this.lifecycle !== load || this.state.activeThreadId !== load.threadId; } } + +function transitionThreadHistoryLoadLifecycle( + state: ThreadHistoryLoadLifecycleState, + event: ThreadHistoryLoadLifecycleEvent, +): ThreadHistoryLoadLifecycleState { + switch (event.type) { + case "started": + return event.load; + case "finished": + return state === event.load ? { kind: "idle" } : state; + case "invalidated": + return state.kind === "idle" ? state : { kind: "idle" }; + } +} diff --git a/src/features/chat/thread-rename.ts b/src/features/chat/thread-rename.ts index bb21dae7..185cda16 100644 --- a/src/features/chat/thread-rename.ts +++ b/src/features/chat/thread-rename.ts @@ -23,6 +23,14 @@ type RenameLifecycleState = | { kind: "editing"; threadId: string; draft: string } | { kind: "generating"; threadId: string; draft: string; originalDraft: string }; type RenameGeneratingState = Extract; +type RenameLifecycleEvent = + | { type: "started"; threadId: string; draft: string } + | { type: "draft-updated"; threadId: string; draft: string } + | { type: "cancelled"; threadId: string } + | { type: "generation-started"; threadId: string; originalDraft: string } + | { type: "generation-succeeded"; generatingState: RenameGeneratingState; draft: string } + | { type: "generation-finished"; threadId: string; generatingState: RenameGeneratingState } + | { type: "cleared" }; export interface ThreadRenameControllerHost { stateStore: ChatStateStore; @@ -72,19 +80,21 @@ export class ThreadRenameController { start(threadId: string): void { const thread = this.thread(threadId); if (!thread) return; - this.renameState = { kind: "editing", threadId, draft: getThreadTitle(thread) }; + this.renameState = transitionRenameLifecycle(this.renameState, { type: "started", threadId, draft: getThreadTitle(thread) }); this.host.render(); } updateDraft(threadId: string, value: string): void { - if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId) return; - this.renameState = { ...this.renameState, draft: value }; + const next = transitionRenameLifecycle(this.renameState, { type: "draft-updated", threadId, draft: value }); + if (next === this.renameState) return; + this.renameState = next; this.host.render(); } cancel(threadId: string): void { - if (this.renameState.kind === "idle" || this.renameState.threadId !== threadId) return; - this.clear(); + const next = transitionRenameLifecycle(this.renameState, { type: "cancelled", threadId }); + if (next === this.renameState) return; + this.renameState = next; this.host.render(); } @@ -125,12 +135,12 @@ export class ThreadRenameController { await this.host.ensureConnected(); if (this.renameState !== editingState) return; - const generatingState: RenameLifecycleState = { - kind: "generating", + const generatingState = transitionRenameLifecycle(this.renameState, { + type: "generation-started", threadId, - draft: editingState.draft, originalDraft: editingState.draft, - }; + }); + if (generatingState.kind !== "generating") return; this.renameState = generatingState; this.host.render(); @@ -139,9 +149,11 @@ export class ThreadRenameController { if (!context) throw new Error(THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE); const title = await this.generateTitle(context); if (!title) throw new Error("Codex did not return a usable thread title."); - if (this.renameState !== generatingState) return; - if (this.renameState.draft !== generatingState.originalDraft) return; - this.renameState = { kind: "generating", threadId, draft: title, originalDraft: generatingState.originalDraft }; + this.renameState = transitionRenameLifecycle(this.renameState, { + type: "generation-succeeded", + generatingState, + draft: title, + }); } catch (error) { if (this.renameState === generatingState) { this.host.addSystemMessage(error instanceof Error ? error.message : String(error)); @@ -207,19 +219,13 @@ export class ThreadRenameController { } private clear(): void { - this.renameState = { kind: "idle" }; + this.renameState = transitionRenameLifecycle(this.renameState, { type: "cleared" }); } private finishAutoNameDraftGeneration(threadId: string, generatingState: RenameGeneratingState): void { - if (this.renameState === generatingState) { - this.renameState = { kind: "editing", threadId, draft: generatingState.draft }; - this.host.render(); - return; - } - - const currentState = this.renameState; - if (currentState.kind !== "generating" || currentState.threadId !== threadId) return; - this.renameState = { kind: "editing", threadId, draft: currentState.draft }; + const next = transitionRenameLifecycle(this.renameState, { type: "generation-finished", threadId, generatingState }); + if (next === this.renameState) return; + this.renameState = next; this.host.render(); } @@ -231,3 +237,33 @@ export class ThreadRenameController { return this.state.listedThreads.find((item) => item.id === threadId); } } + +function transitionRenameLifecycle(state: RenameLifecycleState, event: RenameLifecycleEvent): RenameLifecycleState { + switch (event.type) { + case "started": + return { kind: "editing", threadId: event.threadId, draft: event.draft }; + case "draft-updated": + if (state.kind === "idle" || state.threadId !== event.threadId) return state; + return { ...state, draft: event.draft }; + case "cancelled": + if (state.kind === "idle" || state.threadId !== event.threadId) return state; + return { kind: "idle" }; + case "generation-started": + if (state.kind !== "editing" || state.threadId !== event.threadId) return state; + return { + kind: "generating", + threadId: event.threadId, + draft: state.draft, + originalDraft: event.originalDraft, + }; + case "generation-succeeded": + if (state !== event.generatingState || state.draft !== event.generatingState.originalDraft) return state; + return { ...state, draft: event.draft }; + case "generation-finished": + if (state === event.generatingState) return { kind: "editing", threadId: event.threadId, draft: event.generatingState.draft }; + if (state.kind !== "generating" || state.threadId !== event.threadId) return state; + return { kind: "editing", threadId: event.threadId, draft: state.draft }; + case "cleared": + return state.kind === "idle" ? state : { kind: "idle" }; + } +} diff --git a/src/features/chat/view-lifecycle.ts b/src/features/chat/view-lifecycle.ts index 1be0770b..13c3d039 100644 --- a/src/features/chat/view-lifecycle.ts +++ b/src/features/chat/view-lifecycle.ts @@ -2,6 +2,34 @@ export interface ChatViewRenderScheduleOptions { forceSlots?: boolean; } +export interface RestoredThreadState { + threadId: string; + title: string | null; + explicitName: string | null; +} + +export type ChatResumeLifecycleState = { kind: "idle" } | { kind: "resuming"; threadId: string }; +export type ActiveChatResume = Extract; +export type ChatResumeLifecycleEvent = { type: "started"; resume: ActiveChatResume } | { type: "invalidated" }; + +export type ChatConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise | null }; +export type ActiveChatConnection = Extract; +export type ChatConnectionLifecycleEvent = + | { type: "started"; connection: ActiveChatConnection } + | { type: "finished"; connection: ActiveChatConnection; promise: Promise } + | { type: "invalidated" }; + +export type RestoredThreadLifecycleState = + | { kind: "idle" } + | { kind: "placeholder"; threadId: string; title: string | null; explicitName: string | null; loading: Promise | null }; +export type RestoredThreadPlaceholderState = Extract; +export type RestoredThreadLifecycleEvent = + | { type: "placeholder-restored"; restoredThread: RestoredThreadState } + | { type: "renamed"; threadId: string; name: string | null } + | { type: "loading-started"; loading: Promise } + | { type: "loading-finished"; loading: Promise } + | { type: "cleared" }; + type TimerWindow = Pick; export class ChatViewDeferredTasks { @@ -80,3 +108,47 @@ export class ChatViewDeferredTasks { this.clearRender(); } } + +export function transitionChatConnectionLifecycle( + state: ChatConnectionLifecycleState, + event: ChatConnectionLifecycleEvent, +): ChatConnectionLifecycleState { + switch (event.type) { + case "started": + return event.connection; + case "finished": + return state === event.connection && state.promise === event.promise ? { kind: "idle" } : state; + case "invalidated": + return state.kind === "idle" ? state : { kind: "idle" }; + } +} + +export function transitionChatResumeLifecycle(state: ChatResumeLifecycleState, event: ChatResumeLifecycleEvent): ChatResumeLifecycleState { + switch (event.type) { + case "started": + return event.resume; + case "invalidated": + return state.kind === "idle" ? state : { kind: "idle" }; + } +} + +export function transitionRestoredThreadLifecycle( + state: RestoredThreadLifecycleState, + event: RestoredThreadLifecycleEvent, +): RestoredThreadLifecycleState { + switch (event.type) { + case "placeholder-restored": + return { kind: "placeholder", ...event.restoredThread, loading: null }; + case "renamed": + if (state.kind !== "placeholder" || state.threadId !== event.threadId) return state; + return { ...state, title: event.name, explicitName: event.name }; + case "loading-started": + if (state.kind !== "placeholder") return state; + return { ...state, loading: event.loading }; + case "loading-finished": + if (state.kind !== "placeholder" || state.loading !== event.loading) return state; + return { ...state, loading: null }; + case "cleared": + return state.kind === "idle" ? state : { kind: "idle" }; + } +} diff --git a/src/features/chat/view.ts b/src/features/chat/view.ts index 68d7b6f0..0efae3e1 100644 --- a/src/features/chat/view.ts +++ b/src/features/chat/view.ts @@ -79,7 +79,20 @@ import type { OpenCodexPanelSnapshot } from "../../runtime/open-panel-snapshot"; import type { SharedAppServerMetadata } from "../../runtime/shared-app-server-state"; import { ChatThreadActionController } from "./thread-actions"; import { unmountReactRoot } from "../../shared/ui/react-root"; -import { ChatViewDeferredTasks, type ChatViewRenderScheduleOptions } from "./view-lifecycle"; +import { + ChatViewDeferredTasks, + transitionChatConnectionLifecycle, + transitionChatResumeLifecycle, + transitionRestoredThreadLifecycle, + type ActiveChatConnection, + type ActiveChatResume, + type ChatConnectionLifecycleState, + type ChatResumeLifecycleState, + type ChatViewRenderScheduleOptions, + type RestoredThreadLifecycleState, + type RestoredThreadPlaceholderState, + type RestoredThreadState, +} from "./view-lifecycle"; export interface CodexChatHost { readonly settings: CodexPanelSettings; @@ -98,21 +111,6 @@ export interface CodexChatHost { cachedAppServerMetadata(): SharedAppServerMetadata | null; } -interface RestoredThreadState { - threadId: string; - title: string | null; - explicitName: string | null; -} - -type ChatResumeLifecycleState = { kind: "idle" } | { kind: "resuming"; threadId: string }; -type ActiveChatResume = Extract; -type ChatConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise | null }; -type ActiveChatConnection = Extract; -type RestoredThreadLifecycleState = - | { kind: "idle" } - | { kind: "placeholder"; threadId: string; title: string | null; explicitName: string | null; loading: Promise | null }; -type RestoredThreadPlaceholderState = Extract; - export class CodexChatView extends ItemView { private client: AppServerClient | null = null; private readonly connection: ConnectionManager; @@ -435,7 +433,7 @@ export class CodexChatView extends ItemView { this.dispatch({ type: "thread/list-applied", threads: listedThreads }); const restoredThread = this.restoredThreadPlaceholder(); if (restoredThread?.threadId === threadId && (restoredThread.title !== name || restoredThread.explicitName !== name)) { - this.restoredThreadLifecycle = { ...restoredThread, title: name, explicitName: name }; + this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "renamed", threadId, name }); changed = true; } const activeThreadChanged = this.state.activeThreadId === threadId || this.isRestoredThreadPending(threadId); @@ -511,9 +509,7 @@ export class CodexChatView extends ItemView { try { await promise; } finally { - if (this.connectionLifecycle === connection && connection.promise === promise) { - this.connectionLifecycle = { kind: "idle" }; - } + this.connectionLifecycle = transitionChatConnectionLifecycle(this.connectionLifecycle, { type: "finished", connection, promise }); } } @@ -546,12 +542,12 @@ export class CodexChatView extends ItemView { private beginConnectionWork(): ActiveChatConnection { const connection: ActiveChatConnection = { kind: "connecting", promise: null }; - this.connectionLifecycle = connection; + this.connectionLifecycle = transitionChatConnectionLifecycle(this.connectionLifecycle, { type: "started", connection }); return connection; } private invalidateConnectionWork(): void { - this.connectionLifecycle = { kind: "idle" }; + this.connectionLifecycle = transitionChatConnectionLifecycle(this.connectionLifecycle, { type: "invalidated" }); } private activeConnection(): ActiveChatConnection | null { @@ -1110,7 +1106,10 @@ export class CodexChatView extends ItemView { private restoreThreadPlaceholder(restoredThread: RestoredThreadState): void { this.invalidateResumeWork(); - this.restoredThreadLifecycle = { kind: "placeholder", ...restoredThread, loading: null }; + this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { + type: "placeholder-restored", + restoredThread, + }); this.dispatch({ type: "thread/restored-placeholder", threadId: restoredThread.threadId, @@ -1123,13 +1122,13 @@ export class CodexChatView extends ItemView { private beginResumeWork(threadId: string): ActiveChatResume { const resume: ActiveChatResume = { kind: "resuming", threadId }; - this.resumeLifecycle = resume; + this.resumeLifecycle = transitionChatResumeLifecycle(this.resumeLifecycle, { type: "started", resume }); this.history.invalidate(); return resume; } private invalidateResumeWork(): void { - this.resumeLifecycle = { kind: "idle" }; + this.resumeLifecycle = transitionChatResumeLifecycle(this.resumeLifecycle, { type: "invalidated" }); this.history.invalidate(); } @@ -1149,14 +1148,11 @@ export class CodexChatView extends ItemView { const threadId = restoredThread.threadId; const loading = this.resumeThread(threadId); - this.restoredThreadLifecycle = { ...restoredThread, loading }; + this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "loading-started", loading }); try { await loading; } finally { - const current = this.restoredThreadPlaceholder(); - if (current?.loading === loading) { - this.restoredThreadLifecycle = { ...current, loading: null }; - } + this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "loading-finished", loading }); } return !this.isRestoredThreadPending(threadId); } @@ -1207,7 +1203,7 @@ export class CodexChatView extends ItemView { } private clearRestoredThreadLifecycle(): void { - this.restoredThreadLifecycle = { kind: "idle" }; + this.restoredThreadLifecycle = transitionRestoredThreadLifecycle(this.restoredThreadLifecycle, { type: "cleared" }); } private composerPlaceholder(): string { diff --git a/src/features/threads-view/view-lifecycle.ts b/src/features/threads-view/view-lifecycle.ts index f3c7ab2a..c0f0595e 100644 --- a/src/features/threads-view/view-lifecycle.ts +++ b/src/features/threads-view/view-lifecycle.ts @@ -1,5 +1,19 @@ type TimerWindow = Pick; +export type ThreadsViewRefreshLifecycleState = { kind: "idle" } | { kind: "loading" }; +export type ActiveThreadsViewRefresh = Extract; +export type ThreadsViewRefreshLifecycleEvent = + | { type: "started"; refresh: ActiveThreadsViewRefresh } + | { type: "finished"; refresh: ActiveThreadsViewRefresh } + | { type: "invalidated" }; + +export type ThreadsViewConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise | null }; +export type ActiveThreadsViewConnection = Extract; +export type ThreadsViewConnectionLifecycleEvent = + | { type: "started"; connection: ActiveThreadsViewConnection } + | { type: "finished"; connection: ActiveThreadsViewConnection; promise: Promise } + | { type: "invalidated" }; + export class ThreadsViewDeferredTasks { private renderTimer: ReturnType | null = null; private refreshTimer: ReturnType | null = null; @@ -39,3 +53,31 @@ export class ThreadsViewDeferredTasks { this.refreshTimer = null; } } + +export function transitionThreadsViewRefreshLifecycle( + state: ThreadsViewRefreshLifecycleState, + event: ThreadsViewRefreshLifecycleEvent, +): ThreadsViewRefreshLifecycleState { + switch (event.type) { + case "started": + return event.refresh; + case "finished": + return state === event.refresh ? { kind: "idle" } : state; + case "invalidated": + return state.kind === "idle" ? state : { kind: "idle" }; + } +} + +export function transitionThreadsViewConnectionLifecycle( + state: ThreadsViewConnectionLifecycleState, + event: ThreadsViewConnectionLifecycleEvent, +): ThreadsViewConnectionLifecycleState { + switch (event.type) { + case "started": + return event.connection; + case "finished": + return state === event.connection && state.promise === event.promise ? { kind: "idle" } : state; + case "invalidated": + return state.kind === "idle" ? state : { kind: "idle" }; + } +} diff --git a/src/features/threads-view/view.ts b/src/features/threads-view/view.ts index a07d727c..609745f0 100644 --- a/src/features/threads-view/view.ts +++ b/src/features/threads-view/view.ts @@ -11,7 +11,15 @@ import { findThreadNamingContext, THREAD_NAMING_CONTEXT_UNAVAILABLE_MESSAGE } fr import { generateThreadTitleWithCodex } from "../../app-server/thread-naming"; import { renderThreadsView, unmountThreadsView } from "./renderer"; import { threadRows, type ThreadsRenameState } from "./state"; -import { ThreadsViewDeferredTasks } from "./view-lifecycle"; +import { + ThreadsViewDeferredTasks, + transitionThreadsViewConnectionLifecycle, + transitionThreadsViewRefreshLifecycle, + type ActiveThreadsViewConnection, + type ActiveThreadsViewRefresh, + type ThreadsViewConnectionLifecycleState, + type ThreadsViewRefreshLifecycleState, +} from "./view-lifecycle"; export interface CodexThreadsHost { readonly settings: CodexPanelSettings; @@ -25,10 +33,6 @@ export interface CodexThreadsHost { cachedThreadList(): readonly Thread[] | null; } -type ThreadsViewRefreshLifecycleState = { kind: "idle" } | { kind: "loading" }; -type ActiveThreadsViewRefresh = Extract; -type ThreadsViewConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise | null }; -type ActiveThreadsViewConnection = Extract; type ThreadsViewStatus = | { kind: "idle" } | { kind: "loading"; message: string } @@ -67,7 +71,7 @@ export class CodexThreadsView extends ItemView { onExit: () => { this.client = null; this.invalidateConnectionWork(); - this.refreshLifecycle = { kind: "idle" }; + this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" }); this.status = { kind: "error", message: "Codex app-server stopped." }; this.render(); }, @@ -100,7 +104,7 @@ export class CodexThreadsView extends ItemView { override async onClose(): Promise { this.invalidateConnectionWork(); - this.refreshLifecycle = { kind: "idle" }; + this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" }); this.deferredTasks.clearAll(); this.connection.disconnect(); this.client = null; @@ -132,13 +136,13 @@ export class CodexThreadsView extends ItemView { private startRefresh(): ActiveThreadsViewRefresh { const refresh: ActiveThreadsViewRefresh = { kind: "loading" }; - this.refreshLifecycle = refresh; + this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "started", refresh }); return refresh; } private finishRefresh(refresh: ActiveThreadsViewRefresh): void { if (this.isStaleRefresh(refresh)) return; - this.refreshLifecycle = { kind: "idle" }; + this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "finished", refresh }); this.render(); } @@ -163,9 +167,11 @@ export class CodexThreadsView extends ItemView { this.client = this.connection.currentClient(); }) .finally(() => { - if (this.connectionLifecycle === connection && connection.promise === promise) { - this.connectionLifecycle = { kind: "idle" }; - } + this.connectionLifecycle = transitionThreadsViewConnectionLifecycle(this.connectionLifecycle, { + type: "finished", + connection, + promise, + }); }); connection.promise = promise; return promise; @@ -173,12 +179,12 @@ export class CodexThreadsView extends ItemView { private beginConnectionWork(): ActiveThreadsViewConnection { const connection: ActiveThreadsViewConnection = { kind: "connecting", promise: null }; - this.connectionLifecycle = connection; + this.connectionLifecycle = transitionThreadsViewConnectionLifecycle(this.connectionLifecycle, { type: "started", connection }); return connection; } private invalidateConnectionWork(): void { - this.connectionLifecycle = { kind: "idle" }; + this.connectionLifecycle = transitionThreadsViewConnectionLifecycle(this.connectionLifecycle, { type: "invalidated" }); } private activeConnection(): ActiveThreadsViewConnection | null { diff --git a/tests/features/chat/view-lifecycle.test.ts b/tests/features/chat/view-lifecycle.test.ts index db911c7c..96d48b91 100644 --- a/tests/features/chat/view-lifecycle.test.ts +++ b/tests/features/chat/view-lifecycle.test.ts @@ -2,7 +2,13 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { ChatViewDeferredTasks } from "../../../src/features/chat/view-lifecycle"; +import { + ChatViewDeferredTasks, + transitionChatConnectionLifecycle, + transitionChatResumeLifecycle, + transitionRestoredThreadLifecycle, + type ActiveChatConnection, +} from "../../../src/features/chat/view-lifecycle"; describe("ChatViewDeferredTasks", () => { beforeEach(() => { @@ -43,3 +49,47 @@ describe("ChatViewDeferredTasks", () => { expect(warmup).not.toHaveBeenCalled(); }); }); + +describe("chat view lifecycle transitions", () => { + it("keeps stale connection completions from clearing the active connection", () => { + const firstPromise = Promise.resolve(); + const secondPromise = Promise.resolve(); + const first: ActiveChatConnection = { kind: "connecting", promise: firstPromise }; + const second: ActiveChatConnection = { kind: "connecting", promise: secondPromise }; + const state = transitionChatConnectionLifecycle({ kind: "idle" }, { type: "started", connection: second }); + + expect(transitionChatConnectionLifecycle(state, { type: "finished", connection: first, promise: firstPromise })).toBe(state); + expect(transitionChatConnectionLifecycle(state, { type: "finished", connection: second, promise: firstPromise })).toBe(state); + expect(transitionChatConnectionLifecycle(state, { type: "finished", connection: second, promise: secondPromise })).toEqual({ + kind: "idle", + }); + }); + + it("invalidates resume work explicitly", () => { + const resume = { kind: "resuming" as const, threadId: "thread" }; + + expect(transitionChatResumeLifecycle({ kind: "idle" }, { type: "started", resume })).toBe(resume); + expect(transitionChatResumeLifecycle(resume, { type: "invalidated" })).toEqual({ kind: "idle" }); + }); + + it("clears restored-thread loading only for the active loading promise", () => { + const firstLoading = Promise.resolve(); + const secondLoading = Promise.resolve(); + const placeholder = transitionRestoredThreadLifecycle( + { kind: "idle" }, + { type: "placeholder-restored", restoredThread: { threadId: "thread", title: "Old", explicitName: null } }, + ); + const loading = transitionRestoredThreadLifecycle(placeholder, { type: "loading-started", loading: secondLoading }); + + expect(transitionRestoredThreadLifecycle(loading, { type: "loading-finished", loading: firstLoading })).toBe(loading); + expect(transitionRestoredThreadLifecycle(loading, { type: "renamed", threadId: "thread", name: "New" })).toMatchObject({ + title: "New", + explicitName: "New", + loading: secondLoading, + }); + expect(transitionRestoredThreadLifecycle(loading, { type: "loading-finished", loading: secondLoading })).toMatchObject({ + kind: "placeholder", + loading: null, + }); + }); +}); diff --git a/tests/features/threads-view/view-lifecycle.test.ts b/tests/features/threads-view/view-lifecycle.test.ts index 06867c0d..0c62f6ca 100644 --- a/tests/features/threads-view/view-lifecycle.test.ts +++ b/tests/features/threads-view/view-lifecycle.test.ts @@ -2,7 +2,13 @@ import { describe, expect, it, vi } from "vitest"; -import { ThreadsViewDeferredTasks } from "../../../src/features/threads-view/view-lifecycle"; +import { + ThreadsViewDeferredTasks, + transitionThreadsViewConnectionLifecycle, + transitionThreadsViewRefreshLifecycle, + type ActiveThreadsViewConnection, + type ActiveThreadsViewRefresh, +} from "../../../src/features/threads-view/view-lifecycle"; describe("ThreadsViewDeferredTasks", () => { it("coalesces render and refresh callbacks", () => { @@ -47,3 +53,28 @@ describe("ThreadsViewDeferredTasks", () => { } }); }); + +describe("threads view lifecycle transitions", () => { + it("keeps stale refresh completion from clearing the active refresh", () => { + const first: ActiveThreadsViewRefresh = { kind: "loading" }; + const second: ActiveThreadsViewRefresh = { kind: "loading" }; + const state = transitionThreadsViewRefreshLifecycle({ kind: "idle" }, { type: "started", refresh: second }); + + expect(transitionThreadsViewRefreshLifecycle(state, { type: "finished", refresh: first })).toBe(state); + expect(transitionThreadsViewRefreshLifecycle(state, { type: "finished", refresh: second })).toEqual({ kind: "idle" }); + }); + + it("keeps stale connection completion from clearing the active connection", () => { + const firstPromise = Promise.resolve(); + const secondPromise = Promise.resolve(); + const first: ActiveThreadsViewConnection = { kind: "connecting", promise: firstPromise }; + const second: ActiveThreadsViewConnection = { kind: "connecting", promise: secondPromise }; + const state = transitionThreadsViewConnectionLifecycle({ kind: "idle" }, { type: "started", connection: second }); + + expect(transitionThreadsViewConnectionLifecycle(state, { type: "finished", connection: first, promise: firstPromise })).toBe(state); + expect(transitionThreadsViewConnectionLifecycle(state, { type: "finished", connection: second, promise: firstPromise })).toBe(state); + expect(transitionThreadsViewConnectionLifecycle(state, { type: "finished", connection: second, promise: secondPromise })).toEqual({ + kind: "idle", + }); + }); +});