mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Clarify view and local lifecycle transitions
This commit is contained in:
parent
ee372d0ba5
commit
5708e28a9e
8 changed files with 323 additions and 72 deletions
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue