Centralize observed query projections

This commit is contained in:
murashit 2026-06-21 15:55:02 +09:00
parent 80edc64443
commit 380587e2be
6 changed files with 124 additions and 15 deletions

View 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;
}

View file

@ -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) {

View file

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

View file

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

View 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>;
}

View file

@ -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",