Clarify view and local lifecycle transitions

This commit is contained in:
murashit 2026-05-28 21:34:27 +09:00
parent ee372d0ba5
commit 5708e28a9e
8 changed files with 323 additions and 72 deletions

View file

@ -14,6 +14,10 @@ export interface ThreadHistoryLoaderHost {
type ThreadHistoryLoadLifecycleState = { kind: "idle" } | { kind: "loading"; threadId: string; mode: "latest" | "older" };
type ActiveThreadHistoryLoad = Extract<ThreadHistoryLoadLifecycleState, { kind: "loading" }>;
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" };
}
}

View file

@ -23,6 +23,14 @@ type RenameLifecycleState =
| { kind: "editing"; threadId: string; draft: string }
| { kind: "generating"; threadId: string; draft: string; originalDraft: string };
type RenameGeneratingState = Extract<RenameLifecycleState, { kind: "generating" }>;
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" };
}
}

View file

@ -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<ChatResumeLifecycleState, { kind: "resuming" }>;
export type ChatResumeLifecycleEvent = { type: "started"; resume: ActiveChatResume } | { type: "invalidated" };
export type ChatConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
export type ActiveChatConnection = Extract<ChatConnectionLifecycleState, { kind: "connecting" }>;
export type ChatConnectionLifecycleEvent =
| { type: "started"; connection: ActiveChatConnection }
| { type: "finished"; connection: ActiveChatConnection; promise: Promise<void> }
| { type: "invalidated" };
export type RestoredThreadLifecycleState =
| { kind: "idle" }
| { kind: "placeholder"; threadId: string; title: string | null; explicitName: string | null; loading: Promise<void> | null };
export type RestoredThreadPlaceholderState = Extract<RestoredThreadLifecycleState, { kind: "placeholder" }>;
export type RestoredThreadLifecycleEvent =
| { type: "placeholder-restored"; restoredThread: RestoredThreadState }
| { type: "renamed"; threadId: string; name: string | null }
| { type: "loading-started"; loading: Promise<void> }
| { type: "loading-finished"; loading: Promise<void> }
| { type: "cleared" };
type TimerWindow = Pick<Window, "setTimeout" | "clearTimeout">;
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" };
}
}

View file

@ -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<ChatResumeLifecycleState, { kind: "resuming" }>;
type ChatConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
type ActiveChatConnection = Extract<ChatConnectionLifecycleState, { kind: "connecting" }>;
type RestoredThreadLifecycleState =
| { kind: "idle" }
| { kind: "placeholder"; threadId: string; title: string | null; explicitName: string | null; loading: Promise<void> | null };
type RestoredThreadPlaceholderState = Extract<RestoredThreadLifecycleState, { kind: "placeholder" }>;
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 {

View file

@ -1,5 +1,19 @@
type TimerWindow = Pick<Window, "setTimeout" | "clearTimeout">;
export type ThreadsViewRefreshLifecycleState = { kind: "idle" } | { kind: "loading" };
export type ActiveThreadsViewRefresh = Extract<ThreadsViewRefreshLifecycleState, { kind: "loading" }>;
export type ThreadsViewRefreshLifecycleEvent =
| { type: "started"; refresh: ActiveThreadsViewRefresh }
| { type: "finished"; refresh: ActiveThreadsViewRefresh }
| { type: "invalidated" };
export type ThreadsViewConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
export type ActiveThreadsViewConnection = Extract<ThreadsViewConnectionLifecycleState, { kind: "connecting" }>;
export type ThreadsViewConnectionLifecycleEvent =
| { type: "started"; connection: ActiveThreadsViewConnection }
| { type: "finished"; connection: ActiveThreadsViewConnection; promise: Promise<void> }
| { type: "invalidated" };
export class ThreadsViewDeferredTasks {
private renderTimer: ReturnType<TimerWindow["setTimeout"]> | null = null;
private refreshTimer: ReturnType<TimerWindow["setTimeout"]> | 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" };
}
}

View file

@ -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<ThreadsViewRefreshLifecycleState, { kind: "loading" }>;
type ThreadsViewConnectionLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
type ActiveThreadsViewConnection = Extract<ThreadsViewConnectionLifecycleState, { kind: "connecting" }>;
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<void> {
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 {

View file

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

View file

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