From 380587e2be9b81d105c51cf1bf2e445aff2a89b5 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 21 Jun 2026 15:55:02 +0900 Subject: [PATCH] Centralize observed query projections --- src/app-server/query/observed-result.ts | 13 +++++++++ src/features/chat/host/session-graph.ts | 10 +++++-- src/features/threads-view/session.ts | 30 ++++++++++++++----- src/settings/dynamic-data-controller.ts | 13 +++++---- tests/app-server/observed-result.test.ts | 37 ++++++++++++++++++++++++ tests/features/threads-view/view.test.ts | 36 +++++++++++++++++++++++ 6 files changed, 124 insertions(+), 15 deletions(-) create mode 100644 src/app-server/query/observed-result.ts create mode 100644 tests/app-server/observed-result.test.ts diff --git a/src/app-server/query/observed-result.ts b/src/app-server/query/observed-result.ts new file mode 100644 index 00000000..efd458ab --- /dev/null +++ b/src/app-server/query/observed-result.ts @@ -0,0 +1,13 @@ +import type { AppServerObservedQueryResult } from "./cache"; + +export function observedQueryData(result: AppServerObservedQueryResult): T | null { + return result.data; +} + +export function observedQueryInitialLoading(result: AppServerObservedQueryResult, currentData: T | null | undefined): boolean { + return currentData == null && result.isFetching; +} + +export function observedQueryInitialError(result: AppServerObservedQueryResult, currentData: T | null | undefined): Error | null { + return currentData == null ? result.error : null; +} diff --git a/src/features/chat/host/session-graph.ts b/src/features/chat/host/session-graph.ts index 9984ff93..7ffe5c63 100644 --- a/src/features/chat/host/session-graph.ts +++ b/src/features/chat/host/session-graph.ts @@ -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): 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): 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): void => { - if (result.data) receiveModels(result.data); + const data = observedQueryData(result); + if (data) receiveModels(data); }; const unsubscribe = (): void => { while (unsubscribers.length > 0) { diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 95d79ac8..3b66f859 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -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(); 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 { 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): 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; } diff --git a/src/settings/dynamic-data-controller.ts b/src/settings/dynamic-data-controller.ts index f3bfdca7..434ccf34 100644 --- a/src/settings/dynamic-data-controller.ts +++ b/src/settings/dynamic-data-controller.ts @@ -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): 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): 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, }); } diff --git a/tests/app-server/observed-result.test.ts b/tests/app-server/observed-result.test.ts new file mode 100644 index 00000000..fb557fdd --- /dev/null +++ b/tests/app-server/observed-result.test.ts @@ -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({ data: null, isFetching: true }); + const error = new Error("boom"); + const failed = observedResult({ 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( + overrides: Partial> & Pick, "data">, +): AppServerObservedQueryResult { + return { + error: null, + isFetching: false, + ...overrides, + } as AppServerObservedQueryResult; +} diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index dd70eb73..0dd85b77 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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) => 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) => void) => { + observedThreads = listener; + return () => undefined; + }), + }, + }), + ); + + await view.onOpen(); + observedThreads(queryResult([])); + observedThreads(queryResult(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): Thread { }; } +function queryResult(data: T | null, error: Error | null = null): AppServerObservedQueryResult { + return { + data, + error, + isFetching: false, + } as AppServerObservedQueryResult; +} + function threadFixture(overrides: Record = {}): Record { return { id: "thread",