mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Share lifecycle and async test helpers
This commit is contained in:
parent
422304aa2b
commit
ea00f9a7e9
11 changed files with 118 additions and 144 deletions
|
|
@ -1,3 +1,11 @@
|
|||
import {
|
||||
transitionConnectionWorkLifecycle,
|
||||
type ActiveConnectionWork,
|
||||
type ConnectionWorkLifecycleEvent,
|
||||
type ConnectionWorkLifecycleState,
|
||||
} from "../../shared/lifecycle/connection-work";
|
||||
import { DeferredTask, type DeferredTaskWindow } from "../../shared/lifecycle/deferred-task";
|
||||
|
||||
export interface ChatViewRenderScheduleOptions {
|
||||
forceSlots?: boolean;
|
||||
}
|
||||
|
|
@ -12,12 +20,9 @@ export type ChatResumeLifecycleState = { kind: "idle" } | { kind: "resuming"; th
|
|||
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 ChatConnectionLifecycleState = ConnectionWorkLifecycleState;
|
||||
export type ActiveChatConnection = ActiveConnectionWork;
|
||||
export type ChatConnectionLifecycleEvent = ConnectionWorkLifecycleEvent;
|
||||
|
||||
export type RestoredThreadLifecycleState =
|
||||
| { kind: "idle" }
|
||||
|
|
@ -30,75 +35,56 @@ export type RestoredThreadLifecycleEvent =
|
|||
| { type: "loading-finished"; loading: Promise<void> }
|
||||
| { type: "cleared" };
|
||||
|
||||
type TimerWindow = Pick<Window, "setTimeout" | "clearTimeout">;
|
||||
|
||||
export class ChatViewDeferredTasks {
|
||||
private restoredThreadHydrationTimer: number | null = null;
|
||||
private renderTimer: number | null = null;
|
||||
private readonly restoredThreadHydrationTask: DeferredTask;
|
||||
private readonly renderTask: DeferredTask;
|
||||
private readonly diagnosticsTask: DeferredTask;
|
||||
private readonly appServerWarmupTask: DeferredTask;
|
||||
private renderForceSlots = false;
|
||||
private diagnosticsTimer: number | null = null;
|
||||
private appServerWarmupTimer: number | null = null;
|
||||
|
||||
constructor(private readonly getWindow: () => TimerWindow) {}
|
||||
constructor(getWindow: () => DeferredTaskWindow) {
|
||||
this.renderTask = new DeferredTask(getWindow, 50);
|
||||
this.diagnosticsTask = new DeferredTask(getWindow, 1_000);
|
||||
this.restoredThreadHydrationTask = new DeferredTask(getWindow, 1_500);
|
||||
this.appServerWarmupTask = new DeferredTask(getWindow, 0);
|
||||
}
|
||||
|
||||
scheduleRender(callback: (options: ChatViewRenderScheduleOptions) => void, options: ChatViewRenderScheduleOptions = {}): void {
|
||||
this.renderForceSlots ||= options.forceSlots ?? false;
|
||||
if (this.renderTimer !== null) return;
|
||||
this.renderTimer = this.getWindow().setTimeout(() => {
|
||||
this.renderTask.schedule(() => {
|
||||
const forceSlots = this.renderForceSlots;
|
||||
this.renderTimer = null;
|
||||
this.renderForceSlots = false;
|
||||
callback({ forceSlots });
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
clearRender(): void {
|
||||
if (this.renderTimer === null) return;
|
||||
this.getWindow().clearTimeout(this.renderTimer);
|
||||
this.renderTimer = null;
|
||||
this.renderTask.clear();
|
||||
this.renderForceSlots = false;
|
||||
}
|
||||
|
||||
scheduleDiagnostics(callback: () => void): void {
|
||||
if (this.diagnosticsTimer !== null) return;
|
||||
this.diagnosticsTimer = this.getWindow().setTimeout(() => {
|
||||
this.diagnosticsTimer = null;
|
||||
callback();
|
||||
}, 1_000);
|
||||
this.diagnosticsTask.schedule(callback);
|
||||
}
|
||||
|
||||
clearDiagnostics(): void {
|
||||
if (this.diagnosticsTimer === null) return;
|
||||
this.getWindow().clearTimeout(this.diagnosticsTimer);
|
||||
this.diagnosticsTimer = null;
|
||||
this.diagnosticsTask.clear();
|
||||
}
|
||||
|
||||
scheduleRestoredThreadHydration(callback: () => void): void {
|
||||
if (this.restoredThreadHydrationTimer !== null) return;
|
||||
this.restoredThreadHydrationTimer = this.getWindow().setTimeout(() => {
|
||||
this.restoredThreadHydrationTimer = null;
|
||||
callback();
|
||||
}, 1_500);
|
||||
this.restoredThreadHydrationTask.schedule(callback);
|
||||
}
|
||||
|
||||
clearRestoredThreadHydration(): void {
|
||||
if (this.restoredThreadHydrationTimer === null) return;
|
||||
this.getWindow().clearTimeout(this.restoredThreadHydrationTimer);
|
||||
this.restoredThreadHydrationTimer = null;
|
||||
this.restoredThreadHydrationTask.clear();
|
||||
}
|
||||
|
||||
scheduleAppServerWarmup(callback: () => void): void {
|
||||
if (this.appServerWarmupTimer !== null) return;
|
||||
this.appServerWarmupTimer = this.getWindow().setTimeout(() => {
|
||||
this.appServerWarmupTimer = null;
|
||||
callback();
|
||||
}, 0);
|
||||
this.appServerWarmupTask.schedule(callback);
|
||||
}
|
||||
|
||||
clearAppServerWarmup(): void {
|
||||
if (this.appServerWarmupTimer === null) return;
|
||||
this.getWindow().clearTimeout(this.appServerWarmupTimer);
|
||||
this.appServerWarmupTimer = null;
|
||||
this.appServerWarmupTask.clear();
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
|
|
@ -161,14 +147,7 @@ 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" };
|
||||
}
|
||||
return transitionConnectionWorkLifecycle(state, event);
|
||||
}
|
||||
|
||||
export function transitionChatResumeLifecycle(state: ChatResumeLifecycleState, event: ChatResumeLifecycleEvent): ChatResumeLifecycleState {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
type TimerWindow = Pick<Window, "setTimeout" | "clearTimeout">;
|
||||
import {
|
||||
transitionConnectionWorkLifecycle,
|
||||
type ActiveConnectionWork,
|
||||
type ConnectionWorkLifecycleEvent,
|
||||
type ConnectionWorkLifecycleState,
|
||||
} from "../../shared/lifecycle/connection-work";
|
||||
import { DeferredTask, type DeferredTaskWindow } from "../../shared/lifecycle/deferred-task";
|
||||
|
||||
export type ThreadsViewRefreshLifecycleState = { kind: "idle" } | { kind: "loading" };
|
||||
export type ActiveThreadsViewRefresh = Extract<ThreadsViewRefreshLifecycleState, { kind: "loading" }>;
|
||||
|
|
@ -7,33 +13,25 @@ export type ThreadsViewRefreshLifecycleEvent =
|
|||
| { 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 type ThreadsViewConnectionLifecycleState = ConnectionWorkLifecycleState;
|
||||
export type ActiveThreadsViewConnection = ActiveConnectionWork;
|
||||
export type ThreadsViewConnectionLifecycleEvent = ConnectionWorkLifecycleEvent;
|
||||
|
||||
export class ThreadsViewDeferredTasks {
|
||||
private renderTimer: ReturnType<TimerWindow["setTimeout"]> | null = null;
|
||||
private refreshTimer: ReturnType<TimerWindow["setTimeout"]> | null = null;
|
||||
private readonly renderTask: DeferredTask;
|
||||
private readonly refreshTask: DeferredTask;
|
||||
|
||||
constructor(private readonly getWindow: () => TimerWindow) {}
|
||||
constructor(getWindow: () => DeferredTaskWindow) {
|
||||
this.renderTask = new DeferredTask(getWindow, 0);
|
||||
this.refreshTask = new DeferredTask(getWindow, 250);
|
||||
}
|
||||
|
||||
scheduleRender(callback: () => void): void {
|
||||
if (this.renderTimer !== null) return;
|
||||
this.renderTimer = this.getWindow().setTimeout(() => {
|
||||
this.renderTimer = null;
|
||||
callback();
|
||||
}, 0);
|
||||
this.renderTask.schedule(callback);
|
||||
}
|
||||
|
||||
scheduleRefresh(callback: () => void): void {
|
||||
if (this.refreshTimer !== null) return;
|
||||
this.refreshTimer = this.getWindow().setTimeout(() => {
|
||||
this.refreshTimer = null;
|
||||
callback();
|
||||
}, 250);
|
||||
this.refreshTask.schedule(callback);
|
||||
}
|
||||
|
||||
clearAll(): void {
|
||||
|
|
@ -42,15 +40,11 @@ export class ThreadsViewDeferredTasks {
|
|||
}
|
||||
|
||||
private clearRender(): void {
|
||||
if (this.renderTimer === null) return;
|
||||
this.getWindow().clearTimeout(this.renderTimer);
|
||||
this.renderTimer = null;
|
||||
this.renderTask.clear();
|
||||
}
|
||||
|
||||
private clearRefresh(): void {
|
||||
if (this.refreshTimer === null) return;
|
||||
this.getWindow().clearTimeout(this.refreshTimer);
|
||||
this.refreshTimer = null;
|
||||
this.refreshTask.clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -72,12 +66,5 @@ 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" };
|
||||
}
|
||||
return transitionConnectionWorkLifecycle(state, event);
|
||||
}
|
||||
|
|
|
|||
20
src/shared/lifecycle/connection-work.ts
Normal file
20
src/shared/lifecycle/connection-work.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
export type ConnectionWorkLifecycleState = { kind: "idle" } | { kind: "connecting"; promise: Promise<void> | null };
|
||||
export type ActiveConnectionWork = Extract<ConnectionWorkLifecycleState, { kind: "connecting" }>;
|
||||
export type ConnectionWorkLifecycleEvent =
|
||||
| { type: "started"; connection: ActiveConnectionWork }
|
||||
| { type: "finished"; connection: ActiveConnectionWork; promise: Promise<void> }
|
||||
| { type: "invalidated" };
|
||||
|
||||
export function transitionConnectionWorkLifecycle(
|
||||
state: ConnectionWorkLifecycleState,
|
||||
event: ConnectionWorkLifecycleEvent,
|
||||
): ConnectionWorkLifecycleState {
|
||||
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" };
|
||||
}
|
||||
}
|
||||
24
src/shared/lifecycle/deferred-task.ts
Normal file
24
src/shared/lifecycle/deferred-task.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
export type DeferredTaskWindow = Pick<Window, "setTimeout" | "clearTimeout">;
|
||||
|
||||
export class DeferredTask {
|
||||
private timer: ReturnType<DeferredTaskWindow["setTimeout"]> | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly getWindow: () => DeferredTaskWindow,
|
||||
private readonly delay: number,
|
||||
) {}
|
||||
|
||||
schedule(callback: () => void): void {
|
||||
if (this.timer !== null) return;
|
||||
this.timer = this.getWindow().setTimeout(() => {
|
||||
this.timer = null;
|
||||
callback();
|
||||
}, this.delay);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
if (this.timer === null) return;
|
||||
this.getWindow().clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||
import type { ThreadLifecycleStatePort } from "../../../../../src/features/chat/controllers/state-ports";
|
||||
import { RestoredThreadController } from "../../../../../src/features/chat/controllers/thread/restored-thread-controller";
|
||||
import { ChatViewDeferredTasks } from "../../../../../src/features/chat/view-lifecycle";
|
||||
import { deferred } from "../../../../support/async";
|
||||
|
||||
describe("RestoredThreadController", () => {
|
||||
it("restores a placeholder and schedules deferred hydration", () => {
|
||||
|
|
@ -84,11 +85,3 @@ function restoredThreadState(overrides: Partial<ThreadLifecycleStatePort> = {}):
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((promiseResolve) => {
|
||||
resolve = promiseResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ThreadHistoryLoader } from "../../../src/features/chat/thread-history";
|
|||
import type { AppServerClient } from "../../../src/app-server/client";
|
||||
import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem";
|
||||
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
|
||||
import { deferred } from "../../support/async";
|
||||
|
||||
describe("ThreadHistoryLoader", () => {
|
||||
it("keeps the latest history load when an older request resolves later", async () => {
|
||||
|
|
@ -88,13 +89,3 @@ function turnFixture(items: ThreadItem[]): Turn {
|
|||
function assistantMessage(id: string, text: string): ThreadItem {
|
||||
return { type: "agentMessage", id, text, phase: "final_answer", memoryCitation: null };
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import type { Thread } from "../../../src/generated/app-server/v2/Thread";
|
|||
import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem";
|
||||
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import { deferred } from "../../support/async";
|
||||
|
||||
describe("ThreadRenameController", () => {
|
||||
it("rerenders after updating a controlled rename draft", () => {
|
||||
|
|
@ -164,16 +165,6 @@ function assistantMessage(id: string, text: string): ThreadItem {
|
|||
return { type: "agentMessage", id, text, phase: null, memoryCitation: null };
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function flushPromises(): Promise<void> {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { createChatState, type ChatState } from "../../../src/features/chat/chat
|
|||
import { composerSlotSnapshot } from "../../../src/features/chat/view-snapshot";
|
||||
import type { ServerNotification } from "../../../src/generated/app-server/ServerNotification";
|
||||
import { notices } from "../../mocks/obsidian";
|
||||
import { waitForAsyncWork } from "../../support/async";
|
||||
import { deferred, waitForAsyncWork } from "../../support/async";
|
||||
import { installObsidianDomShims } from "../../support/dom";
|
||||
|
||||
const connectionMock = vi.hoisted(() => {
|
||||
|
|
@ -908,14 +908,6 @@ function turnCompletedNotification(threadId: string, turnId: string): Extract<Se
|
|||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((innerResolve) => {
|
||||
resolve = innerResolve;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function composerElement(view: { containerEl: HTMLElement }): HTMLTextAreaElement {
|
||||
const composer = view.containerEl.querySelector<HTMLTextAreaElement>(".codex-panel__composer-input");
|
||||
if (!composer) throw new Error("Expected composer input");
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import type { ThreadItem } from "../../../src/generated/app-server/v2/ThreadItem
|
|||
import type { ThreadStartResponse } from "../../../src/generated/app-server/v2/ThreadStartResponse";
|
||||
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
|
||||
import type { TurnStartResponse } from "../../../src/generated/app-server/v2/TurnStartResponse";
|
||||
import { deferred } from "../../support/async";
|
||||
import { installObsidianDomShims } from "../../support/dom";
|
||||
|
||||
installObsidianDomShims();
|
||||
|
|
@ -472,16 +473,6 @@ async function flushPromises(): Promise<void> {
|
|||
await Promise.resolve();
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function runOptions(clientFactory: SelectionRewriteClientFactory): Parameters<typeof runSelectionRewrite>[0] {
|
||||
return {
|
||||
codexPath: "/bin/codex",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import type { Turn } from "../../../src/generated/app-server/v2/Turn";
|
||||
import type * as ThreadNamingModule from "../../../src/app-server/thread-naming";
|
||||
import { waitForAsyncWork } from "../../support/async";
|
||||
import { deferred, waitForAsyncWork } from "../../support/async";
|
||||
import { changeInputValue, installObsidianDomShims } from "../../support/dom";
|
||||
|
||||
const connectionMock = vi.hoisted(() => {
|
||||
|
|
@ -422,13 +422,3 @@ function turnFixture(items: Turn["items"], overrides: Partial<Turn> = {}): Turn
|
|||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void; reject: (error: unknown) => void } {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,19 @@
|
|||
export interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
export async function waitForAsyncWork(assertion: () => void): Promise<void> {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue