mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Centralize observed query projections
This commit is contained in:
parent
80edc64443
commit
380587e2be
6 changed files with 124 additions and 15 deletions
13
src/app-server/query/observed-result.ts
Normal file
13
src/app-server/query/observed-result.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import type { AppServerObservedQueryResult } from "./cache";
|
||||
|
||||
export function observedQueryData<T>(result: AppServerObservedQueryResult<T>): T | null {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export function observedQueryInitialLoading<T>(result: AppServerObservedQueryResult<T>, currentData: T | null | undefined): boolean {
|
||||
return currentData == null && result.isFetching;
|
||||
}
|
||||
|
||||
export function observedQueryInitialError<T>(result: AppServerObservedQueryResult<T>, currentData: T | null | undefined): Error | null {
|
||||
return currentData == null ? result.error : null;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@ import { Notice } from "obsidian";
|
|||
import { ConnectionManager } from "../../../app-server/connection/connection-manager";
|
||||
import type { AppServerClientAccess } from "../../../app-server/connection/client-access";
|
||||
import type { AppServerObservedQueryResult } from "../../../app-server/query/cache";
|
||||
import { observedQueryData } from "../../../app-server/query/observed-result";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../../app-server/query/shared-queries";
|
||||
import type { ModelMetadata } from "../../../domain/catalog/metadata";
|
||||
import type { MessageStreamNoticeSection } from "../domain/message-stream/items";
|
||||
|
|
@ -409,19 +410,22 @@ function createChatPanelSharedStateBinding(
|
|||
refreshTabHeader(host);
|
||||
};
|
||||
const receiveThreadResult = (result: AppServerObservedQueryResult<readonly Thread[]>): void => {
|
||||
if (result.data) receiveThreads(result.data);
|
||||
const data = observedQueryData(result);
|
||||
if (data) receiveThreads(data);
|
||||
};
|
||||
const receiveAppServerMetadata = (metadata: SharedServerMetadata): void => {
|
||||
serverActions.metadata.applyAppServerMetadata(metadata);
|
||||
};
|
||||
const receiveAppServerMetadataResult = (result: AppServerObservedQueryResult<SharedServerMetadata>): void => {
|
||||
if (result.data) receiveAppServerMetadata(result.data);
|
||||
const data = observedQueryData(result);
|
||||
if (data) receiveAppServerMetadata(data);
|
||||
};
|
||||
const receiveModels = (models: readonly ModelMetadata[]): void => {
|
||||
dispatch(host.stateStore, { type: "connection/metadata-applied", availableModels: models });
|
||||
};
|
||||
const receiveModelsResult = (result: AppServerObservedQueryResult<readonly ModelMetadata[]>): void => {
|
||||
if (result.data) receiveModels(result.data);
|
||||
const data = observedQueryData(result);
|
||||
if (data) receiveModels(data);
|
||||
};
|
||||
const unsubscribe = (): void => {
|
||||
while (unsubscribers.length > 0) {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { Notice } from "obsidian";
|
|||
|
||||
import type { AppServerClientAccess } from "../../app-server/connection/client-access";
|
||||
import type { AppServerObservedQueryResult } from "../../app-server/query/cache";
|
||||
import { observedQueryData, observedQueryInitialError, observedQueryInitialLoading } from "../../app-server/query/observed-result";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/shared-queries";
|
||||
import type { ReasoningEffort } from "../../domain/catalog/metadata";
|
||||
import type { Thread } from "../../domain/threads/model";
|
||||
|
|
@ -68,6 +69,7 @@ export class CodexThreadsSession {
|
|||
private refreshLifecycle: ThreadsViewRefreshLifecycleState = { kind: "idle" };
|
||||
private status: ThreadsViewStatus = { kind: "idle" };
|
||||
private threads: readonly Thread[] = [];
|
||||
private threadsLoaded = false;
|
||||
private readonly renameStates = new Map<string, ThreadsRenameState>();
|
||||
private nextRenameGenerationToken = 1;
|
||||
private unsubscribeThreads: (() => void) | null = null;
|
||||
|
|
@ -104,6 +106,7 @@ export class CodexThreadsSession {
|
|||
const activeThreadsSnapshot = this.host.threadCatalog.activeSnapshot();
|
||||
if (activeThreadsSnapshot) {
|
||||
this.threads = activeThreadsSnapshot;
|
||||
this.threadsLoaded = true;
|
||||
}
|
||||
this.unsubscribeThreads = this.host.threadCatalog.observeActive((result) => {
|
||||
this.receiveObservedThreadsResult(result);
|
||||
|
|
@ -122,16 +125,21 @@ export class CodexThreadsSession {
|
|||
|
||||
async refresh(): Promise<void> {
|
||||
const refresh = this.startRefresh();
|
||||
this.status = this.threads.length === 0 ? { kind: "loading", message: "Loading threads..." } : { kind: "idle" };
|
||||
if (!this.currentThreadsData()) {
|
||||
this.status = { kind: "loading", message: "Loading threads..." };
|
||||
}
|
||||
this.render();
|
||||
try {
|
||||
const threads = await this.host.threadCatalog.refreshActive();
|
||||
if (this.isStaleRefresh(refresh)) return;
|
||||
this.threads = threads;
|
||||
this.threadsLoaded = true;
|
||||
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
|
||||
} catch (error) {
|
||||
if (isStaleAppServerSharedQueryContextError(error)) return;
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
if (!this.currentThreadsData()) {
|
||||
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
} finally {
|
||||
this.finishRefresh(refresh);
|
||||
}
|
||||
|
|
@ -143,26 +151,34 @@ export class CodexThreadsSession {
|
|||
|
||||
private receiveObservedThreads(threads: readonly Thread[]): void {
|
||||
this.threads = threads;
|
||||
this.threadsLoaded = true;
|
||||
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
|
||||
this.render();
|
||||
}
|
||||
|
||||
private receiveObservedThreadsResult(result: AppServerObservedQueryResult<readonly Thread[]>): void {
|
||||
if (result.data) {
|
||||
this.receiveObservedThreads(result.data);
|
||||
const data = observedQueryData(result);
|
||||
if (data) {
|
||||
this.receiveObservedThreads(data);
|
||||
return;
|
||||
}
|
||||
if (result.isFetching && this.threads.length === 0) {
|
||||
const currentData = this.currentThreadsData();
|
||||
if (observedQueryInitialLoading(result, currentData)) {
|
||||
this.status = { kind: "loading", message: "Loading threads..." };
|
||||
this.render();
|
||||
return;
|
||||
}
|
||||
if (result.error && this.threads.length === 0) {
|
||||
this.status = { kind: "error", message: result.error.message };
|
||||
const initialError = observedQueryInitialError(result, currentData);
|
||||
if (initialError) {
|
||||
this.status = { kind: "error", message: initialError.message };
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
private currentThreadsData(): readonly Thread[] | null {
|
||||
return this.threadsLoaded ? this.threads : null;
|
||||
}
|
||||
|
||||
private get host(): CodexThreadsHost {
|
||||
return this.environment.host;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { AppServerClient } from "../app-server/connection/client";
|
||||
import type { AppServerObservedQueryResult } from "../app-server/query/cache";
|
||||
import { observedQueryData } from "../app-server/query/observed-result";
|
||||
import { isStaleAppServerSharedQueryContextError } from "../app-server/query/shared-queries";
|
||||
import { setHookItemEnabled, trustHookItem } from "../app-server/catalog";
|
||||
import { restoreArchivedThread as restoreArchivedThreadOnAppServer } from "../app-server/threads";
|
||||
|
|
@ -128,19 +129,21 @@ export class SettingsDynamicDataController {
|
|||
}
|
||||
|
||||
private receiveObservedModelsResult(result: AppServerObservedQueryResult<readonly ModelMetadata[]>): void {
|
||||
if (!result.data) return;
|
||||
this.models = [...result.data];
|
||||
const data = observedQueryData(result);
|
||||
if (!data) return;
|
||||
this.models = [...data];
|
||||
this.callbacks.display("helper");
|
||||
}
|
||||
|
||||
private receiveObservedArchivedThreadsResult(result: AppServerObservedQueryResult<readonly Thread[]>): void {
|
||||
if (!result.data) return;
|
||||
this.archivedThreads = [...result.data];
|
||||
const data = observedQueryData(result);
|
||||
if (!data) return;
|
||||
this.archivedThreads = [...data];
|
||||
this.archivedThreadsLoaded = true;
|
||||
if (this.archivedThreadsLifecycle.kind !== "loading") {
|
||||
this.archivedThreadsLifecycle = transitionSettingsDynamicSectionLifecycle(this.archivedThreadsLifecycle, {
|
||||
type: "loaded",
|
||||
status: archivedThreadsStatus(result.data.length),
|
||||
status: archivedThreadsStatus(data.length),
|
||||
operationToken: this.archivedThreadsOperationToken,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
37
tests/app-server/observed-result.test.ts
Normal file
37
tests/app-server/observed-result.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { AppServerObservedQueryResult } from "../../src/app-server/query/cache";
|
||||
import { observedQueryData, observedQueryInitialError, observedQueryInitialLoading } from "../../src/app-server/query/observed-result";
|
||||
|
||||
describe("observed query result helpers", () => {
|
||||
it("treats successful empty arrays as current data", () => {
|
||||
const loading = observedResult<readonly string[]>({ data: null, isFetching: true });
|
||||
const error = new Error("boom");
|
||||
const failed = observedResult<readonly string[]>({ data: null, error });
|
||||
|
||||
expect(observedQueryInitialLoading(loading, [])).toBe(false);
|
||||
expect(observedQueryInitialError(failed, [])).toBeNull();
|
||||
});
|
||||
|
||||
it("returns initial loading and error only before current data exists", () => {
|
||||
const error = new Error("boom");
|
||||
|
||||
expect(observedQueryInitialLoading(observedResult({ data: null, isFetching: true }), null)).toBe(true);
|
||||
expect(observedQueryInitialError(observedResult({ data: null, error }), null)).toBe(error);
|
||||
});
|
||||
|
||||
it("projects nullable observed data without reinterpreting empty values", () => {
|
||||
expect(observedQueryData(observedResult({ data: [] as readonly string[] }))).toEqual([]);
|
||||
expect(observedQueryData(observedResult({ data: null }))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
function observedResult<T>(
|
||||
overrides: Partial<AppServerObservedQueryResult<T>> & Pick<AppServerObservedQueryResult<T>, "data">,
|
||||
): AppServerObservedQueryResult<T> {
|
||||
return {
|
||||
error: null,
|
||||
isFetching: false,
|
||||
...overrides,
|
||||
} as AppServerObservedQueryResult<T>;
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { DEFAULT_SETTINGS } from "../../../src/settings/model";
|
||||
import type { AppServerObservedQueryResult } from "../../../src/app-server/query/cache";
|
||||
import type { TurnRecord } from "../../../src/app-server/protocol/turn";
|
||||
import type { Thread } from "../../../src/domain/threads/model";
|
||||
import type * as ThreadTitleGeneratorModule from "../../../src/app-server/services/thread-title-generation";
|
||||
|
|
@ -268,6 +269,33 @@ describe("CodexThreadsView", () => {
|
|||
expect(view.containerEl.textContent).toContain("Cached thread");
|
||||
});
|
||||
|
||||
it("keeps successful empty thread lists as last-known-good observed data", async () => {
|
||||
let observedThreads!: (result: AppServerObservedQueryResult<readonly Thread[]>) => void;
|
||||
const view = await threadsView(
|
||||
threadsHost({
|
||||
threadCatalog: {
|
||||
refreshActive: vi.fn(
|
||||
() =>
|
||||
new Promise(() => {
|
||||
// Keep the initial refresh pending; this test drives observed query results directly.
|
||||
}),
|
||||
),
|
||||
observeActive: vi.fn((listener: (result: AppServerObservedQueryResult<readonly Thread[]>) => void) => {
|
||||
observedThreads = listener;
|
||||
return () => undefined;
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await view.onOpen();
|
||||
observedThreads(queryResult([]));
|
||||
observedThreads(queryResult<readonly Thread[]>(null, new Error("boom")));
|
||||
|
||||
expect(view.containerEl.textContent).toContain("No threads");
|
||||
expect(view.containerEl.textContent).not.toContain("boom");
|
||||
});
|
||||
|
||||
it("notifies open panels after archiving a thread", async () => {
|
||||
const archiveThread = vi.fn().mockResolvedValue({});
|
||||
connectionMock.state.client = clientFixture({
|
||||
|
|
@ -520,6 +548,14 @@ function threadFromRecord(record: Record<string, unknown>): Thread {
|
|||
};
|
||||
}
|
||||
|
||||
function queryResult<T>(data: T | null, error: Error | null = null): AppServerObservedQueryResult<T> {
|
||||
return {
|
||||
data,
|
||||
error,
|
||||
isFetching: false,
|
||||
} as AppServerObservedQueryResult<T>;
|
||||
}
|
||||
|
||||
function threadFixture(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return {
|
||||
id: "thread",
|
||||
|
|
|
|||
Loading…
Reference in a new issue