From bbd981f255bb5848b8d3c1fa4d1106bd191451d7 Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 15 Jul 2026 13:45:00 +0900 Subject: [PATCH] refactor: simplify feature state lifecycles --- .../selection-rewrite/command.obsidian.ts | 2 +- src/features/selection-rewrite/model.ts | 151 +----------- .../selection-rewrite/popover.dom.tsx | 1 - src/features/selection-rewrite/session.ts | 54 ++-- src/features/threads-view/session.ts | 43 ++-- src/features/threads-view/view-lifecycle.ts | 41 ---- .../threads/catalog/thread-catalog.ts | 69 ++---- .../chat/host/view-connection.test.ts | 23 +- .../selection-rewrite.test.ts | 186 +++++--------- .../threads-view/view-lifecycle.test.ts | 54 ---- .../threads/catalog/thread-catalog.test.ts | 231 ++++++++++++++---- 11 files changed, 317 insertions(+), 538 deletions(-) delete mode 100644 src/features/threads-view/view-lifecycle.ts delete mode 100644 tests/features/threads-view/view-lifecycle.test.ts diff --git a/src/features/selection-rewrite/command.obsidian.ts b/src/features/selection-rewrite/command.obsidian.ts index bad3c4b8..1b940daa 100644 --- a/src/features/selection-rewrite/command.obsidian.ts +++ b/src/features/selection-rewrite/command.obsidian.ts @@ -54,7 +54,7 @@ export function registerSelectionRewriteCommand( originalText, noteText: editor.getValue(), instruction: "", - status: "editing-prompt", + status: "editing", streamText: "", replacementText: null, debugText: null, diff --git a/src/features/selection-rewrite/model.ts b/src/features/selection-rewrite/model.ts index 17820a53..a3e0a75b 100644 --- a/src/features/selection-rewrite/model.ts +++ b/src/features/selection-rewrite/model.ts @@ -3,7 +3,6 @@ import type { ReasoningEffort } from "../../domain/catalog/metadata"; export type SelectionRewriteInstructionHistoryDirection = -1 | 1; -type SelectionRewriteStatus = SelectionRewriteState["status"]; const APPLY_CONTEXT_RADIUS = 1_000; interface SelectionRewriteTextRange { @@ -25,7 +24,7 @@ interface SelectionRewriteBaseState { export type SelectionRewriteState = SelectionRewriteBaseState & ( | { - status: "editing-prompt"; + status: "editing"; streamText: string; replacementText: null; debugText: null; @@ -48,18 +47,6 @@ export type SelectionRewriteState = SelectionRewriteBaseState & replacementText: null; debugText: string | null; } - | { - status: "cancelled"; - streamText: string; - replacementText: string | null; - debugText: string | null; - } - | { - status: "applied"; - streamText: string; - replacementText: string | null; - debugText: string | null; - } ); export interface SelectionRewriteRuntimeSettings { @@ -67,21 +54,6 @@ export interface SelectionRewriteRuntimeSettings { rewriteSelectionEffort: ReasoningEffort | null; } -export type SelectionRewriteLifecycleEvent = - | { type: "generation-started"; instruction: string } - | { type: "preview-updated"; text: string } - | { type: "generation-succeeded"; replacementText: string } - | { type: "generation-failed"; debugText: string | null } - | { type: "cancelled" } - | { type: "applied" }; - -type SelectionRewriteLifecycleEventType = SelectionRewriteLifecycleEvent["type"]; -type SelectionRewriteLifecycleTransition = (state: SelectionRewriteState, event: SelectionRewriteLifecycleEvent) => SelectionRewriteState; -type SelectionRewriteLifecycleTransitionTable = Record< - SelectionRewriteStatus, - Record ->; - export interface SelectionRewriteApplyContext { currentText: string; currentNoteText: string; @@ -119,127 +91,6 @@ export function selectionRewriteTextRangeOffsets( return fallbackFrom === -1 ? null : { from: fallbackFrom, to: fallbackFrom + expectedText.length }; } -export function transitionSelectionRewriteState( - state: SelectionRewriteState, - event: SelectionRewriteLifecycleEvent, -): SelectionRewriteState { - return selectionRewriteLifecycleTransitions[state.status][event.type](state, event); -} - -const keepSelectionRewriteState: SelectionRewriteLifecycleTransition = (state) => state; - -const generationStartedTransition: SelectionRewriteLifecycleTransition = (state, event) => ({ - ...selectionRewriteBaseState(state), - instruction: requireGenerationInstruction(event), - status: "generating", - streamText: "", - replacementText: null, - debugText: null, -}); - -const previewUpdatedTransition: SelectionRewriteLifecycleTransition = (state, event) => ({ - ...selectionRewriteBaseState(state), - status: "generating", - streamText: requirePreviewText(event), - replacementText: null, - debugText: null, -}); - -const generationSucceededTransition: SelectionRewriteLifecycleTransition = (state, event) => ({ - ...selectionRewriteBaseState(state), - status: "preview", - streamText: "", - replacementText: requireReplacementText(event), - debugText: null, -}); - -const generationFailedTransition: SelectionRewriteLifecycleTransition = (state, event) => ({ - ...selectionRewriteBaseState(state), - status: "failed", - streamText: "", - replacementText: null, - debugText: optionalDebugText(event), -}); - -const cancelledTransition: SelectionRewriteLifecycleTransition = (state) => ({ - ...selectionRewriteBaseState(state), - status: "cancelled", - streamText: state.streamText, - replacementText: state.replacementText, - debugText: state.debugText, -}); - -const appliedTransition: SelectionRewriteLifecycleTransition = (state) => ({ - ...selectionRewriteBaseState(state), - status: "applied", - streamText: state.streamText, - replacementText: state.replacementText, - debugText: state.debugText, -}); - -const editableSelectionRewriteTransitions = { - "generation-started": generationStartedTransition, - "preview-updated": keepSelectionRewriteState, - "generation-succeeded": keepSelectionRewriteState, - "generation-failed": keepSelectionRewriteState, - cancelled: cancelledTransition, - applied: appliedTransition, -} satisfies Record; - -const terminalSelectionRewriteTransitions = { - "generation-started": keepSelectionRewriteState, - "preview-updated": keepSelectionRewriteState, - "generation-succeeded": keepSelectionRewriteState, - "generation-failed": keepSelectionRewriteState, - cancelled: cancelledTransition, - applied: appliedTransition, -} satisfies Record; - -const selectionRewriteLifecycleTransitions: SelectionRewriteLifecycleTransitionTable = { - "editing-prompt": editableSelectionRewriteTransitions, - generating: { - "generation-started": generationStartedTransition, - "preview-updated": previewUpdatedTransition, - "generation-succeeded": generationSucceededTransition, - "generation-failed": generationFailedTransition, - cancelled: cancelledTransition, - applied: appliedTransition, - }, - preview: editableSelectionRewriteTransitions, - failed: editableSelectionRewriteTransitions, - cancelled: terminalSelectionRewriteTransitions, - applied: terminalSelectionRewriteTransitions, -}; - -function requireGenerationInstruction(event: SelectionRewriteLifecycleEvent): string { - if ("instruction" in event) return event.instruction; - throw new Error(`Selection rewrite lifecycle event ${event.type} does not include an instruction.`); -} - -function requirePreviewText(event: SelectionRewriteLifecycleEvent): string { - if ("text" in event) return event.text; - throw new Error(`Selection rewrite lifecycle event ${event.type} does not include preview text.`); -} - -function requireReplacementText(event: SelectionRewriteLifecycleEvent): string { - if ("replacementText" in event) return event.replacementText; - throw new Error(`Selection rewrite lifecycle event ${event.type} does not include replacement text.`); -} - -function optionalDebugText(event: SelectionRewriteLifecycleEvent): string | null { - return "debugText" in event ? event.debugText : null; -} - -function selectionRewriteBaseState(state: SelectionRewriteState): SelectionRewriteBaseState { - return { - filePath: state.filePath, - targetRange: state.targetRange, - originalText: state.originalText, - noteText: state.noteText, - instruction: state.instruction, - }; -} - function selectionRewriteRangeContextFingerprint(text: string, offsets: SelectionRewriteTextRangeOffsets): string { const beforeStart = Math.max(0, offsets.from - APPLY_CONTEXT_RADIUS); const afterEnd = Math.min(text.length, offsets.to + APPLY_CONTEXT_RADIUS); diff --git a/src/features/selection-rewrite/popover.dom.tsx b/src/features/selection-rewrite/popover.dom.tsx index a18c002b..5e13067e 100644 --- a/src/features/selection-rewrite/popover.dom.tsx +++ b/src/features/selection-rewrite/popover.dom.tsx @@ -165,7 +165,6 @@ export class SelectionRewritePopover { } editor.replaceRange(replacement, state.targetRange.from, state.targetRange.to, "codex-panel-rewrite"); - this.session.apply(); this.close(); } diff --git a/src/features/selection-rewrite/session.ts b/src/features/selection-rewrite/session.ts index a6b3313e..7e80c293 100644 --- a/src/features/selection-rewrite/session.ts +++ b/src/features/selection-rewrite/session.ts @@ -1,10 +1,4 @@ -import { - type SelectionRewriteInstructionHistoryDirection, - type SelectionRewriteLifecycleEvent, - type SelectionRewriteRuntimeSettings, - type SelectionRewriteState, - transitionSelectionRewriteState, -} from "./model"; +import type { SelectionRewriteInstructionHistoryDirection, SelectionRewriteRuntimeSettings, SelectionRewriteState } from "./model"; import { SelectionRewriteOutputError } from "./output"; import { buildSelectionRewritePrompt } from "./prompt"; import type { SelectionRewriteActivity, SelectionRewriteTransport } from "./transport"; @@ -59,7 +53,7 @@ export class SelectionRewriteSession { } get isGenerating(): boolean { - return this.generationRun.kind === "running" || this.state.status === "generating"; + return this.generationRun.kind === "running"; } get hasInstruction(): boolean { @@ -110,7 +104,6 @@ export class SelectionRewriteSession { hooks.render(); } catch (error) { if (generationRun.abortController.signal.aborted) { - this.transitionState({ type: "cancelled" }); return "started"; } this.showGenerationFailure(error); @@ -126,14 +119,9 @@ export class SelectionRewriteSession { } cancel(): void { - this.transitionState({ type: "cancelled" }); this.abortGeneration(); } - apply(): void { - this.transitionState({ type: "applied" }); - } - abortGeneration(): void { if (this.generationRun.kind === "running") this.generationRun.abortController.abort(); } @@ -175,12 +163,25 @@ export class SelectionRewriteSession { rememberSelectionRewriteInstruction(instruction); this.historyCursor = null; this.historyDraft = ""; - this.transitionState({ type: "generation-started", instruction }); + this.options.state = { + ...this.state, + instruction, + status: "generating", + streamText: "", + replacementText: null, + debugText: null, + }; return generationRun; } private updatePreview(text: string): void { - this.transitionState({ type: "preview-updated", text }); + this.options.state = { + ...this.state, + status: "generating", + streamText: text, + replacementText: null, + debugText: null, + }; this.setStatus("Writing replacement", { active: true }); } @@ -189,22 +190,27 @@ export class SelectionRewriteSession { } private showPreview(replacementText: string): void { - this.transitionState({ type: "generation-succeeded", replacementText }); + this.options.state = { + ...this.state, + status: "preview", + streamText: "", + replacementText, + debugText: null, + }; this.setStatus(""); } private showGenerationFailure(error: unknown): void { - this.transitionState({ - type: "generation-failed", + this.options.state = { + ...this.state, + status: "failed", + streamText: "", + replacementText: null, debugText: error instanceof SelectionRewriteOutputError ? error.rawText : null, - }); + }; this.setStatus(error instanceof Error ? error.message : String(error)); } - private transitionState(event: SelectionRewriteLifecycleEvent): void { - this.options.state = transitionSelectionRewriteState(this.options.state, event); - } - private isActiveGenerationRun(generationRun: ActiveSelectionRewriteGenerationRun): boolean { return !generationRun.abortController.signal.aborted && this.generationRun.kind === "running" && this.generationRun === generationRun; } diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 5866371c..09b7e4ae 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -7,6 +7,7 @@ import { isStaleAppServerSharedQueryContextError } from "../../app-server/query/ import type { ReasoningEffort } from "../../domain/catalog/metadata"; import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown"; import type { Thread } from "../../domain/threads/model"; +import { DeferredTask } from "../../shared/runtime/deferred-task"; import { OwnerLifetime } from "../../shared/runtime/owner-lifetime"; import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog"; import type { ArchiveExportDestination } from "../threads/workflows/archive-export"; @@ -23,14 +24,6 @@ import { threadRows, transitionThreadsRenameState, } from "./state"; -import { - type ActiveThreadsViewRefresh, - createThreadsViewDeferredTasks, - type ThreadsViewDeferredTasks, - type ThreadsViewRefreshLifecycleState, - transitionThreadsViewRefreshLifecycle, -} from "./view-lifecycle"; - export interface ThreadsViewHost { readonly settings: ThreadsViewSettingsAccess; readonly vaultPath: string; @@ -74,8 +67,8 @@ export class ThreadsViewSession { private readonly lifetime = new OwnerLifetime(); private readonly operations: ThreadOperations; private readonly titleService: ThreadTitleService; - private readonly deferredTasks: ThreadsViewDeferredTasks; - private refreshLifecycle: ThreadsViewRefreshLifecycleState = { kind: "idle" }; + private readonly renderTask: DeferredTask; + private activeRefresh: object | null = null; private status: ThreadsViewStatus = { kind: "idle" }; private threads: readonly Thread[] = []; private threadsLoaded = false; @@ -88,7 +81,7 @@ export class ThreadsViewSession { constructor(private readonly environment: ThreadsViewSessionEnvironment) { this.observedAppServerContext = this.currentAppServerContext(); - this.deferredTasks = createThreadsViewDeferredTasks(() => this.viewWindow()); + this.renderTask = new DeferredTask(() => this.viewWindow(), 0); this.operations = createThreadOperations({ transport: this.host.threadOperationsTransport, nameMutations: this.host.threadNameMutations, @@ -128,8 +121,8 @@ export class ThreadsViewSession { close(): void { this.lifetime.dispose(); - this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" }); - this.deferredTasks.clearAll(); + this.activeRefresh = null; + this.renderTask.clear(); this.unsubscribeThreads?.(); this.unsubscribeThreads = null; unmountThreadsViewShell(this.environment.root); @@ -162,7 +155,7 @@ export class ThreadsViewSession { async loadMore(): Promise { const lifetime = this.lifetime.signal(); - if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActive() || this.refreshLifecycle.kind === "loading") return; + if (!this.lifetime.isCurrent(lifetime) || !this.host.threadCatalog.hasMoreActive() || this.activeRefresh) return; const refresh = this.startRefresh(); this.render(); try { @@ -185,8 +178,8 @@ export class ThreadsViewSession { prepareAppServerContextChange(): void { this.operationGeneration += 1; - this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "invalidated" }); - this.deferredTasks.clearAll(); + this.activeRefresh = null; + this.renderTask.clear(); this.threads = []; this.threadsLoaded = false; this.renameStates.clear(); @@ -244,20 +237,20 @@ export class ThreadsViewSession { return this.environment.host; } - private startRefresh(): ActiveThreadsViewRefresh { - const refresh: ActiveThreadsViewRefresh = { kind: "loading" }; - this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "started", refresh }); + private startRefresh(): object { + const refresh = {}; + this.activeRefresh = refresh; return refresh; } - private finishRefresh(refresh: ActiveThreadsViewRefresh): void { + private finishRefresh(refresh: object): void { if (this.isStaleRefresh(refresh)) return; - this.refreshLifecycle = transitionThreadsViewRefreshLifecycle(this.refreshLifecycle, { type: "finished", refresh }); + this.activeRefresh = null; this.render(); } - private isStaleRefresh(refresh: ActiveThreadsViewRefresh): boolean { - return this.refreshLifecycle !== refresh; + private isStaleRefresh(refresh: object): boolean { + return this.activeRefresh !== refresh; } private render(): void { @@ -266,7 +259,7 @@ export class ThreadsViewSession { this.environment.root, { status: this.status.kind === "idle" ? null : this.status.message, - loading: this.refreshLifecycle.kind === "loading", + loading: this.activeRefresh !== null, hasMore: this.host.threadCatalog.hasMoreActive(), rows: threadRows( this.threads, @@ -301,7 +294,7 @@ export class ThreadsViewSession { } private scheduleRender(): void { - this.deferredTasks.scheduleRender(() => { + this.renderTask.schedule(() => { this.render(); }); } diff --git a/src/features/threads-view/view-lifecycle.ts b/src/features/threads-view/view-lifecycle.ts deleted file mode 100644 index f0190a9b..00000000 --- a/src/features/threads-view/view-lifecycle.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { DeferredTask, type DeferredTaskWindow } from "../../shared/runtime/deferred-task"; - -export type ThreadsViewRefreshLifecycleState = { kind: "idle" } | { kind: "loading" }; -export type ActiveThreadsViewRefresh = Extract; -export type ThreadsViewRefreshLifecycleEvent = - | { type: "started"; refresh: ActiveThreadsViewRefresh } - | { type: "finished"; refresh: ActiveThreadsViewRefresh } - | { type: "invalidated" }; - -export interface ThreadsViewDeferredTasks { - scheduleRender(callback: () => void): void; - clearAll(): void; -} - -export function createThreadsViewDeferredTasks(getWindow: () => DeferredTaskWindow): ThreadsViewDeferredTasks { - const renderTask = new DeferredTask(getWindow, 0); - - return { - scheduleRender(callback): void { - renderTask.schedule(callback); - }, - - clearAll(): void { - renderTask.clear(); - }, - }; -} - -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" }; - } -} diff --git a/src/features/threads/catalog/thread-catalog.ts b/src/features/threads/catalog/thread-catalog.ts index f966dd0f..9cc5423f 100644 --- a/src/features/threads/catalog/thread-catalog.ts +++ b/src/features/threads/catalog/thread-catalog.ts @@ -43,8 +43,6 @@ interface PendingThreadListFacts { interface ThreadCatalogFacts { readonly active: PendingThreadListFacts; readonly archived: PendingThreadListFacts; - activeTestSnapshot: readonly Thread[] | null; - archivedTestSnapshot: readonly Thread[] | null; activeObservedResult: ObservedResult | null; archivedObservedResult: ObservedResult | null; } @@ -57,9 +55,6 @@ export interface ThreadCatalogOptions { } export type ThreadCatalogEvent = - // Snapshot inputs are retained as an explicit ingestion seam for deterministic ordering tests. - | { type: "active-list-snapshot-received"; threads: readonly Thread[] } - | { type: "archived-list-snapshot-received"; threads: readonly Thread[] } | { type: "thread-started"; thread: Thread } | { type: "thread-forked"; thread: Thread } | { type: "thread-touched"; threadId: string; recencyAt?: number | null } @@ -109,8 +104,8 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo const archivedObservers = new Set(); const { store } = options; const currentFacts = (): ThreadCatalogFacts => threadCatalogFactsForContext(factsByContext, store.contextKey()); - const activeRawSnapshot = (): readonly Thread[] | null => currentFacts().activeTestSnapshot ?? store.activeThreadsSnapshot(); - const archivedRawSnapshot = (): readonly Thread[] | null => currentFacts().archivedTestSnapshot ?? store.archivedThreadsSnapshot(); + const activeRawSnapshot = (): readonly Thread[] | null => store.activeThreadsSnapshot(); + const archivedRawSnapshot = (): readonly Thread[] | null => store.archivedThreadsSnapshot(); const publish = (kind: ThreadListKind): void => { const facts = currentFacts(); const raw = kind === "active" ? activeRawSnapshot() : archivedRawSnapshot(); @@ -154,22 +149,16 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo factsByContext.clear(); }, activeSnapshot: () => threadListProjection(activeRawSnapshot(), currentFacts().active), - loadActive: () => - loadThreadList(store.fetchAllActiveThreads(), currentFacts().active, () => (currentFacts().activeTestSnapshot = null)), - refreshActive: () => - loadThreadList(store.refreshActiveThreads(), currentFacts().active, () => (currentFacts().activeTestSnapshot = null)), + loadActive: () => loadThreadList(store.fetchAllActiveThreads(), currentFacts().active), + refreshActive: () => loadThreadList(store.refreshActiveThreads(), currentFacts().active), hasMoreActive: () => store.hasMoreActiveThreads(), - loadMoreActive: () => - loadThreadList(store.loadMoreActiveThreads(), currentFacts().active, () => (currentFacts().activeTestSnapshot = null)), + loadMoreActive: () => loadThreadList(store.loadMoreActiveThreads(), currentFacts().active), observeActive: (observer, observeOptions) => { activeObservers.add(observer); const unsubscribe = store.observeActiveThreadsResult((result) => { const facts = currentFacts(); facts.activeObservedResult = result; - if (result.value) { - facts.activeTestSnapshot = null; - acknowledgeThreadListSnapshot(facts.active, result.value); - } + if (result.value) acknowledgeThreadListSnapshot(facts.active, result.value); observer({ ...result, value: threadListProjection(result.value, facts.active), @@ -181,17 +170,13 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo }; }, archivedSnapshot: () => threadListProjection(archivedRawSnapshot(), currentFacts().archived), - refreshArchived: () => - loadThreadList(store.refreshArchivedThreads(), currentFacts().archived, () => (currentFacts().archivedTestSnapshot = null)), + refreshArchived: () => loadThreadList(store.refreshArchivedThreads(), currentFacts().archived), observeArchived: (observer, observeOptions) => { archivedObservers.add(observer); const unsubscribe = store.observeArchivedThreadsResult((result) => { const facts = currentFacts(); facts.archivedObservedResult = result; - if (result.value) { - facts.archivedTestSnapshot = null; - acknowledgeThreadListSnapshot(facts.archived, result.value); - } + if (result.value) acknowledgeThreadListSnapshot(facts.archived, result.value); observer({ ...result, value: threadListProjection(result.value, facts.archived), @@ -227,8 +212,6 @@ function threadCatalogFactsForContext(factsByContext: Map, - facts: PendingThreadListFacts, - receiveServerSnapshot: () => void, -): Promise { +async function loadThreadList(threadsPromise: Promise, facts: PendingThreadListFacts): Promise { const threads = await threadsPromise; - receiveServerSnapshot(); acknowledgeThreadListSnapshot(facts, threads); return threadListProjection(threads, facts) ?? []; } @@ -315,9 +285,7 @@ function applyThreadTouchedEvent( const existingFact = activeFacts.upserts.get(threadId)?.thread ?? null; let touchedThread = existingFact ? touchedActiveThread(existingFact, recencyAt) : null; const currentThread = - threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( - (thread) => thread.id === threadId, - ) ?? touchedThread; + threadListProjection(store.activeThreadsSnapshot(), activeFacts)?.find((thread) => thread.id === threadId) ?? touchedThread; if (currentThread) touchedThread = touchedActiveThread(currentThread, recencyAt); if (!touchedThread) return; const promotedThread = touchedThread; @@ -330,23 +298,16 @@ function applyThreadTouchedEvent( function applyThreadRenamedEvent(store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, threadId: string, name: string | null): void { const { active: activeFacts, archived: archivedFacts } = facts; - const activeThread = - threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( - (thread) => thread.id === threadId, - ) ?? null; + const activeThread = threadListProjection(store.activeThreadsSnapshot(), activeFacts)?.find((thread) => thread.id === threadId) ?? null; if (activeThread) rememberPendingThreadUpsert(activeFacts, { ...activeThread, name }, acknowledgedByName(name)); const archivedThread = - threadListProjection(facts.archivedTestSnapshot ?? store.archivedThreadsSnapshot(), archivedFacts)?.find( - (thread) => thread.id === threadId, - ) ?? null; + threadListProjection(store.archivedThreadsSnapshot(), archivedFacts)?.find((thread) => thread.id === threadId) ?? null; if (archivedThread) rememberPendingThreadUpsert(archivedFacts, { ...archivedThread, name }, acknowledgedByName(name)); } function applyThreadArchivedEvent(store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, threadId: string): void { const { active: activeFacts, archived: archivedFacts } = facts; - const archivedThread = threadListProjection(facts.activeTestSnapshot ?? store.activeThreadsSnapshot(), activeFacts)?.find( - (thread) => thread.id === threadId, - ); + const archivedThread = threadListProjection(store.activeThreadsSnapshot(), activeFacts)?.find((thread) => thread.id === threadId); rememberPendingThreadRemoval(activeFacts, threadId); if (archivedThread) { const pendingArchivedThread = { ...archivedThread, archived: true }; @@ -369,9 +330,7 @@ function applyThreadRestoredEvent(activeFacts: PendingThreadListFacts, archivedF function applyThreadUnarchivedEvent(store: ThreadCatalogEventStore, facts: ThreadCatalogFacts, threadId: string): void { const { active: activeFacts, archived: archivedFacts } = facts; const restoredThread = - threadListProjection(facts.archivedTestSnapshot ?? store.archivedThreadsSnapshot(), archivedFacts)?.find( - (thread) => thread.id === threadId, - ) ?? null; + threadListProjection(store.archivedThreadsSnapshot(), archivedFacts)?.find((thread) => thread.id === threadId) ?? null; rememberPendingThreadRemoval(archivedFacts, threadId); if (restoredThread) { upsertActiveThread(activeFacts, { ...restoredThread, archived: false }, acknowledgeByThreadId); diff --git a/tests/features/chat/host/view-connection.test.ts b/tests/features/chat/host/view-connection.test.ts index 9a13f5a7..60620ba9 100644 --- a/tests/features/chat/host/view-connection.test.ts +++ b/tests/features/chat/host/view-connection.test.ts @@ -21,6 +21,7 @@ import { chatPanelSettingsAccess } from "../support/settings"; interface TestCodexChatHost extends CodexChatHost { readonly settingsSource: CodexPanelSettings; + receiveActiveThreads(threads: readonly Thread[]): void; } let CodexChatView: typeof import("../../../../src/features/chat/host/view.obsidian")["CodexChatView"]; interface TrackedView { @@ -577,17 +578,14 @@ describe("CodexChatView connection lifecycle", () => { await view.setState({ threadId: "thread-named" }, {} as never); await view.onOpen(); - host.threadCatalog.apply({ type: "active-list-snapshot-received", threads: [panelThread({ id: "thread-named", name: "作業メモ" })] }); + host.receiveActiveThreads([panelThread({ id: "thread-named", name: "作業メモ" })]); expect(view.getDisplayText()).toBe("Codex: 作業メモ"); - host.threadCatalog.apply({ - type: "active-list-snapshot-received", - threads: [panelThread({ id: "thread-named", name: null, preview: "初回依頼" })], - }); + host.receiveActiveThreads([panelThread({ id: "thread-named", name: null, preview: "初回依頼" })]); expect(view.getDisplayText()).toBe("Codex: 初回依頼"); await view.setState({ threadId: "019e061e-0000-7000-8000-000000000001" }, {} as never); - host.threadCatalog.apply({ type: "active-list-snapshot-received", threads: [] }); + host.receiveActiveThreads([]); expect(view.getDisplayText()).toBe("Codex: 019e061e"); }); @@ -875,7 +873,7 @@ describe("CodexChatView connection lifecycle", () => { expect(composerPlaceholder(view)).toBe("Ask Codex..."); - host.threadCatalog.apply({ type: "active-list-snapshot-received", threads: [panelThread({ id: "thread-1", name: "Explicit name" })] }); + host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Explicit name" })]); await waitForAsyncWork(() => { expect(composerPlaceholder(view)).toBe("Ask Codex..."); }); @@ -902,7 +900,7 @@ describe("CodexChatView connection lifecycle", () => { }); composer.setSelectionRange(5, 9); - host.threadCatalog.apply({ type: "active-list-snapshot-received", threads: [panelThread({ id: "thread-1", name: "Renamed thread" })] }); + host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Renamed thread" })]); view.surface.applyThreadRenamed("thread-1", "Renamed thread"); await waitForAsyncWork(() => { @@ -1364,10 +1362,6 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost { }; const applyThreadCatalogEvent = (event: ThreadCatalogEvent): void => { switch (event.type) { - case "active-list-snapshot-received": - activeThreads = event.threads; - emitActiveThreads(); - return; case "thread-archived": case "thread-deleted": activeThreads = activeThreads?.filter((thread) => thread.id !== event.threadId) ?? null; @@ -1389,13 +1383,16 @@ function chatHost(overrides: ChatHostFixtureOverrides = {}): TestCodexChatHost { ) ?? activeThreads; emitActiveThreads(); return; - case "archived-list-snapshot-received": case "thread-unarchived": return; } }; return { settingsSource: settings, + receiveActiveThreads: (threads) => { + activeThreads = threads; + emitActiveThreads(); + }, threadNameMutations: createThreadNameMutationCoordinator(), settingsRef: { settings: chatPanelSettingsAccess(settings), diff --git a/tests/features/selection-rewrite/selection-rewrite.test.ts b/tests/features/selection-rewrite/selection-rewrite.test.ts index 3c93ba19..d15730a6 100644 --- a/tests/features/selection-rewrite/selection-rewrite.test.ts +++ b/tests/features/selection-rewrite/selection-rewrite.test.ts @@ -13,13 +13,8 @@ import { createAppServerSelectionRewriteTransport, } from "../../../src/features/selection-rewrite/app-server-transport"; import { buildSelectionUnifiedDiff } from "../../../src/features/selection-rewrite/diff"; -import { - canApplySelectionRewrite, - type SelectionRewriteLifecycleEvent, - type SelectionRewriteState, - transitionSelectionRewriteState, -} from "../../../src/features/selection-rewrite/model"; -import { selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output"; +import { canApplySelectionRewrite, type SelectionRewriteState } from "../../../src/features/selection-rewrite/model"; +import { SelectionRewriteOutputError, selectionRewriteOutputParseResultFromText } from "../../../src/features/selection-rewrite/output"; import { SelectionRewritePopover } from "../../../src/features/selection-rewrite/popover.dom"; import { positionSelectionRewritePopover } from "../../../src/features/selection-rewrite/position.dom"; import { buildSelectionRewritePrompt } from "../../../src/features/selection-rewrite/prompt"; @@ -214,94 +209,6 @@ describe("selection rewrite positioning", () => { }); }); -describe("selection rewrite lifecycle", () => { - it("transitions through generation and preview states", () => { - const generating = transitionSelectionRewriteState(rewriteState({ replacementText: "old", debugText: "old debug" }), { - type: "generation-started", - instruction: "Shorten it.", - }); - - expect(generating).toMatchObject({ - instruction: "Shorten it.", - status: "generating", - streamText: "", - replacementText: null, - debugText: null, - }); - - const streaming = transitionSelectionRewriteState(generating, { type: "preview-updated", text: "New" }); - expect(streaming.streamText).toBe("New"); - - const preview = transitionSelectionRewriteState(streaming, { type: "generation-succeeded", replacementText: "New text." }); - expect(preview).toMatchObject({ - status: "preview", - streamText: "", - replacementText: "New text.", - }); - }); - - it("ignores stale generation callbacks after generation is no longer active", () => { - const state = rewriteState({ status: "cancelled", streamText: "" }); - - expect(transitionSelectionRewriteState(state, { type: "generation-started", instruction: "late" })).toBe(state); - expect(transitionSelectionRewriteState(state, { type: "preview-updated", text: "late" })).toBe(state); - expect(transitionSelectionRewriteState(state, { type: "generation-succeeded", replacementText: "late" })).toBe(state); - expect(transitionSelectionRewriteState(state, { type: "generation-failed", debugText: "late" })).toBe(state); - }); - - it("marks terminal user actions explicitly", () => { - expect(transitionSelectionRewriteState(rewriteState(), { type: "cancelled" }).status).toBe("cancelled"); - expect(transitionSelectionRewriteState(rewriteState({ replacementText: "New text." }), { type: "applied" }).status).toBe("applied"); - }); - - it.each([{ status: "editing-prompt" }, { status: "preview" }, { status: "failed" }] satisfies { - status: SelectionRewriteState["status"]; - }[])("allows regeneration from $status state", ({ status }) => { - const next = transitionSelectionRewriteState(selectionRewriteStateWithStatus(status), { - type: "generation-started", - instruction: "Try again.", - }); - - expect(next).toMatchObject({ - status: "generating", - instruction: "Try again.", - streamText: "", - replacementText: null, - debugText: null, - }); - }); - - it("preserves state identity for ignored transitions", () => { - const ignoredTransitions = [ - { status: "editing-prompt", event: previewUpdatedEvent() }, - { status: "editing-prompt", event: generationSucceededEvent() }, - { status: "editing-prompt", event: generationFailedEvent() }, - { status: "preview", event: previewUpdatedEvent() }, - { status: "preview", event: generationSucceededEvent() }, - { status: "preview", event: generationFailedEvent() }, - { status: "failed", event: previewUpdatedEvent() }, - { status: "failed", event: generationSucceededEvent() }, - { status: "failed", event: generationFailedEvent() }, - { status: "cancelled", event: { type: "generation-started", instruction: "late" } }, - { status: "cancelled", event: previewUpdatedEvent() }, - { status: "cancelled", event: generationSucceededEvent() }, - { status: "cancelled", event: generationFailedEvent() }, - { status: "applied", event: { type: "generation-started", instruction: "late" } }, - { status: "applied", event: previewUpdatedEvent() }, - { status: "applied", event: generationSucceededEvent() }, - { status: "applied", event: generationFailedEvent() }, - ] satisfies { - status: SelectionRewriteState["status"]; - event: SelectionRewriteLifecycleEvent; - }[]; - - for (const { status, event } of ignoredTransitions) { - const state = selectionRewriteStateWithStatus(status); - expect(transitionSelectionRewriteState(state, event), `${status} via ${event.type}`).toBe(state); - } - }); -}); - describe("selection rewrite runner lifecycle", () => { it("keeps pre-acknowledgement completion when turn notifications arrive before turn/start resolves", async () => { const { clientFactory, client } = fakeSelectionRewriteClientFactory((fake) => { @@ -556,6 +463,64 @@ describe("selection rewrite popover", () => { expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Rewritten once."); }); + it("regenerates from a preview with a new instruction", async () => { + selectionRewriteGenerate + .mockResolvedValueOnce({ replacementText: "First rewrite." }) + .mockResolvedValueOnce({ replacementText: "Second rewrite." }); + const popover = new SelectionRewritePopover(popoverOptions()); + + openPopover(popover); + await act(async () => { + expectPresent(document.querySelector('button[aria-label="Generate"]')).click(); + await flushPromises(); + }); + const instruction = expectPresent(document.querySelector(".codex-panel-selection-rewrite__instruction")); + void act(() => { + setTextareaValue(instruction, "Try another version."); + instruction.dispatchEvent(new Event("input", { bubbles: true })); + }); + await act(async () => { + expectPresent(document.querySelector('button[aria-label="Regenerate"]')).click(); + await flushPromises(); + }); + + expect(selectionRewriteGenerate).toHaveBeenCalledTimes(2); + expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Second rewrite."); + closePopover(popover); + }); + + it("retries after a failed generation and applies only the successful replacement", async () => { + const editor = editorFixture(); + selectionRewriteGenerate + .mockRejectedValueOnce(new SelectionRewriteOutputError("Invalid rewrite response.", "raw invalid output")) + .mockResolvedValueOnce({ replacementText: "Recovered rewrite." }); + const popover = new SelectionRewritePopover(popoverOptions({ editor: editor.editor })); + + openPopover(popover); + await act(async () => { + expectPresent(document.querySelector('button[aria-label="Generate"]')).click(); + await flushPromises(); + }); + + expect(document.body.textContent).toContain("Invalid rewrite response."); + expect(document.body.textContent).toContain("raw invalid output"); + + await act(async () => { + expectPresent(document.querySelector('button[aria-label="Generate"]')).click(); + await flushPromises(); + }); + + expect(selectionRewriteGenerate).toHaveBeenCalledTimes(2); + expect(document.querySelector(".codex-panel-selection-rewrite__diff")?.textContent).toContain("Recovered rewrite."); + + await act(async () => { + expectPresent(document.querySelector('button[aria-label="Apply"]')).click(); + await Promise.resolve(); + }); + + expect(editor.replaceRange).toHaveBeenCalledWith("Recovered rewrite.", { line: 1, ch: 0 }, { line: 1, ch: 22 }, "codex-panel-rewrite"); + }); + it("restores the current draft after accidental instruction history navigation", async () => { selectionRewriteGenerate.mockResolvedValue({ replacementText: "Rewritten." }); @@ -675,7 +640,7 @@ function rewriteState(overrides: Partial = {}): Selection originalText: "Revise this sentence.", noteText: "# Heading\n\nRevise this sentence.\n\nNext paragraph.", instruction: "Make it clearer.", - status: "editing-prompt", + status: "editing", streamText: "", replacementText: null, debugText: null, @@ -683,35 +648,6 @@ function rewriteState(overrides: Partial = {}): Selection } as SelectionRewriteState; } -function selectionRewriteStateWithStatus(status: SelectionRewriteState["status"]): SelectionRewriteState { - switch (status) { - case "editing-prompt": - return rewriteState({ status: "editing-prompt", streamText: "", replacementText: null, debugText: null }); - case "generating": - return rewriteState({ status: "generating", streamText: "draft", replacementText: null, debugText: null }); - case "preview": - return rewriteState({ status: "preview", streamText: "", replacementText: "Preview text.", debugText: null }); - case "failed": - return rewriteState({ status: "failed", streamText: "", replacementText: null, debugText: "debug" }); - case "cancelled": - return rewriteState({ status: "cancelled", streamText: "partial", replacementText: null, debugText: "debug" }); - case "applied": - return rewriteState({ status: "applied", streamText: "", replacementText: "Applied text.", debugText: null }); - } -} - -function previewUpdatedEvent(): SelectionRewriteLifecycleEvent { - return { type: "preview-updated", text: "late preview" }; -} - -function generationSucceededEvent(): SelectionRewriteLifecycleEvent { - return { type: "generation-succeeded", replacementText: "late replacement" }; -} - -function generationFailedEvent(): SelectionRewriteLifecycleEvent { - return { type: "generation-failed", debugText: "late debug" }; -} - function openPopover(popover: SelectionRewritePopover): void { void act(() => { popover.open(); diff --git a/tests/features/threads-view/view-lifecycle.test.ts b/tests/features/threads-view/view-lifecycle.test.ts deleted file mode 100644 index 7d48cc71..00000000 --- a/tests/features/threads-view/view-lifecycle.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -// @vitest-environment jsdom - -import { describe, expect, it, vi } from "vitest"; - -import { - type ActiveThreadsViewRefresh, - createThreadsViewDeferredTasks, - transitionThreadsViewRefreshLifecycle, -} from "../../../src/features/threads-view/view-lifecycle"; - -describe("createThreadsViewDeferredTasks", () => { - it("coalesces render callbacks", () => { - vi.useFakeTimers(); - try { - const tasks = createThreadsViewDeferredTasks(() => window); - const render = vi.fn(); - - tasks.scheduleRender(render); - tasks.scheduleRender(render); - - vi.advanceTimersByTime(0); - expect(render).toHaveBeenCalledTimes(1); - } finally { - vi.useRealTimers(); - } - }); - - it("clears pending callbacks", () => { - vi.useFakeTimers(); - try { - const tasks = createThreadsViewDeferredTasks(() => window); - const render = vi.fn(); - - tasks.scheduleRender(render); - tasks.clearAll(); - vi.runAllTimers(); - - expect(render).not.toHaveBeenCalled(); - } finally { - vi.useRealTimers(); - } - }); -}); - -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" }); - }); -}); diff --git a/tests/features/threads/catalog/thread-catalog.test.ts b/tests/features/threads/catalog/thread-catalog.test.ts index d2f907c7..1aaf9956 100644 --- a/tests/features/threads/catalog/thread-catalog.test.ts +++ b/tests/features/threads/catalog/thread-catalog.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, type Mock, vi } from "vitest"; import { AppServerQueryCache } from "../../../../src/app-server/query/cache"; +import { type AppServerQueryContext, appServerQueryContextKey } from "../../../../src/app-server/query/keys"; +import type { ObservedResult, ObservedResultListener } from "../../../../src/app-server/query/observed-result"; import { AppServerSharedQueries } from "../../../../src/app-server/query/shared-queries"; import type { Thread } from "../../../../src/domain/threads/model"; import { @@ -10,25 +12,25 @@ import { } from "../../../../src/features/threads/catalog/thread-catalog"; describe("ThreadCatalog", () => { - it("applies active thread snapshot replacement events through the catalog boundary", () => { + it("projects active snapshots received from the store observer", () => { const { catalog } = catalogFixture(); const threads = [thread("thread")]; const listener = vi.fn(); catalog.observeActive(listener); - catalog.apply({ type: "active-list-snapshot-received", threads }); + receiveActive(catalog, threads); expect(catalog.activeSnapshot()).toEqual(threads); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ value: threads })); }); - it("applies archived thread snapshot events through the catalog boundary", () => { + it("projects archived snapshots received from the store observer", () => { const { catalog } = catalogFixture(); const threads = [thread("thread", true)]; const listener = vi.fn(); catalog.observeArchived(listener); - catalog.apply({ type: "archived-list-snapshot-received", threads }); + receiveArchived(catalog, threads); expect(catalog.archivedSnapshot()).toEqual(threads); expect(listener).toHaveBeenCalledWith(expect.objectContaining({ value: threads })); @@ -64,7 +66,7 @@ describe("ThreadCatalog", () => { const { catalog } = catalogFixture(); const listener = vi.fn(); catalog.observeActive(listener); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("thread"), thread("other")] }); + receiveActive(catalog, [thread("thread"), thread("other")]); catalog.apply({ type: "thread-renamed", threadId: "thread", name: "Renamed" }); @@ -80,8 +82,8 @@ describe("ThreadCatalog", () => { const archivedListener = vi.fn(); catalog.observeActive(listener); catalog.observeArchived(archivedListener); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("thread"), thread("other")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("archived", true)] }); + receiveActive(catalog, [thread("thread"), thread("other")]); + receiveArchived(catalog, [thread("archived", true)]); catalog.apply({ type: "thread-archived", threadId: "thread" }); @@ -125,8 +127,8 @@ describe("ThreadCatalog", () => { const archivedListener = vi.fn(); catalog.observeActive(listener); catalog.observeArchived(archivedListener); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("thread"), thread("other")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("thread", true), thread("archived", true)] }); + receiveActive(catalog, [thread("thread"), thread("other")]); + receiveArchived(catalog, [thread("thread", true), thread("archived", true)]); catalog.apply({ type: "thread-deleted", threadId: "thread" }); @@ -142,8 +144,8 @@ describe("ThreadCatalog", () => { const archivedListener = vi.fn(); catalog.observeActive(listener); catalog.observeArchived(archivedListener); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("existing")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("restored", true), thread("archived", true)] }); + receiveActive(catalog, [thread("existing")]); + receiveArchived(catalog, [thread("restored", true), thread("archived", true)]); catalog.apply({ type: "thread-started", thread: thread("started") }); catalog.apply({ type: "thread-forked", thread: thread("forked") }); @@ -187,7 +189,7 @@ describe("ThreadCatalog", () => { context.codexPath = "codex-b"; expect(catalog.activeSnapshot()).toBeNull(); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("thread-b")] }); + receiveActive(catalog, [thread("thread-b")]); expect(catalog.activeSnapshot()).toEqual([thread("thread-b")]); context.codexPath = "codex-a"; @@ -250,21 +252,21 @@ describe("ThreadCatalog", () => { } context.codexPath = "codex-a"; - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("server-a")] }); + receiveActive(catalog, [thread("server-a")]); expect(catalog.activeSnapshot()).toEqual([thread("started-a"), thread("server-a")]); }); - it("prunes inactive contexts after their lifecycle facts settle", () => { + it("prunes inactive lifecycle facts without discarding store snapshots", () => { const context = { codexPath: "codex-a", vaultPath: "/vault" }; const { catalog } = catalogFixture({ context: () => context }); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("server-a")] }); + receiveActive(catalog, [thread("server-a")]); context.codexPath = "codex-b"; expect(catalog.activeSnapshot()).toBeNull(); context.codexPath = "codex-a"; - expect(catalog.activeSnapshot()).toBeNull(); + expect(catalog.activeSnapshot()).toEqual([thread("server-a")]); }); it("prunes revisited contexts after pending lifecycle facts settle", () => { @@ -275,12 +277,12 @@ describe("ThreadCatalog", () => { catalog.apply({ type: "thread-started", thread: thread("started-b") }); context.codexPath = "codex-a"; - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("started-a")] }); + receiveActive(catalog, [thread("started-a")]); context.codexPath = "codex-b"; - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("started-b")] }); + receiveActive(catalog, [thread("started-b")]); context.codexPath = "codex-a"; - expect(catalog.activeSnapshot()).toBeNull(); + expect(catalog.activeSnapshot()).toEqual([thread("started-a")]); }); it("preserves raw query status when lifecycle overlays publish", async () => { @@ -303,14 +305,14 @@ describe("ThreadCatalog", () => { const forkBeforeRollback = thread("forked", false, { name: "Before", preview: "Before rollback", updatedAt: 20 }); const forkAfterRollback = thread("forked", false, { name: "After", preview: "After rollback", updatedAt: 20 }); const forkAfterFutureUpdate = thread("forked", false, { name: "Future", preview: "Future update", updatedAt: 21 }); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("existing")] }); + receiveActive(catalog, [thread("existing")]); catalog.apply({ type: "thread-forked", thread: forkAfterRollback }); - catalog.apply({ type: "active-list-snapshot-received", threads: [forkBeforeRollback, thread("existing")] }); + receiveActive(catalog, [forkBeforeRollback, thread("existing")]); expect(catalog.activeSnapshot()).toEqual([forkAfterRollback, thread("existing")]); - catalog.apply({ type: "active-list-snapshot-received", threads: [forkAfterFutureUpdate, thread("existing")] }); + receiveActive(catalog, [forkAfterFutureUpdate, thread("existing")]); expect(catalog.activeSnapshot()).toEqual([forkAfterFutureUpdate, thread("existing")]); }); @@ -322,7 +324,7 @@ describe("ThreadCatalog", () => { .mockReturnValueOnce(staleRefresh.promise) .mockResolvedValueOnce([{ ...thread("thread"), name: "Renamed" }, thread("other")]); const { catalog } = catalogFixture({ fetchThreads }); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("thread"), thread("other")] }); + receiveActive(catalog, [thread("thread"), thread("other")]); const refresh = catalog.refreshActive(); await flushMicrotasks(); @@ -343,8 +345,8 @@ describe("ThreadCatalog", () => { archived ? staleArchivedRefresh.promise : staleActiveRefresh.promise, ); const { catalog } = catalogFixture({ fetchThreads }); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("active"), thread("other")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("archived", true), thread("kept", true)] }); + receiveActive(catalog, [thread("active"), thread("other")]); + receiveArchived(catalog, [thread("archived", true), thread("kept", true)]); const activeRefresh = catalog.refreshActive(); const archivedRefresh = catalog.refreshArchived(); @@ -398,10 +400,10 @@ describe("ThreadCatalog", () => { const { catalog } = catalogFixture(); const listener = vi.fn(); catalog.observeActive(listener); - catalog.apply({ - type: "active-list-snapshot-received", - threads: [thread("active", false, { updatedAt: 1, recencyAt: 1 }), thread("other", false, { updatedAt: 10, recencyAt: 10 })], - }); + receiveActive(catalog, [ + thread("active", false, { updatedAt: 1, recencyAt: 1 }), + thread("other", false, { updatedAt: 10, recencyAt: 10 }), + ]); catalog.apply({ type: "thread-touched", threadId: "active", recencyAt: 20 }); @@ -422,8 +424,8 @@ describe("ThreadCatalog", () => { const archivedListener = vi.fn(); catalog.observeActive(listener); catalog.observeArchived(archivedListener); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("existing")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("archived", true)] }); + receiveActive(catalog, [thread("existing")]); + receiveArchived(catalog, [thread("archived", true)]); catalog.apply({ type: "thread-started", thread: thread("started") }); catalog.apply({ type: "thread-touched", threadId: "existing", recencyAt: 20 }); @@ -443,8 +445,8 @@ describe("ThreadCatalog", () => { for (const sequence of eventSequences(renameOrderingEvents(), maxEventDepth)) { const { catalog } = catalogFixture(); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("target"), thread("other")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [] }); + receiveActive(catalog, [thread("target"), thread("other")]); + receiveArchived(catalog, []); catalog.apply({ type: "thread-renamed", threadId: "target", name: "Renamed" }); let renameAcknowledged = false; @@ -461,8 +463,8 @@ describe("ThreadCatalog", () => { for (const sequence of eventSequences(archiveOrderingEvents(), maxEventDepth)) { const { catalog } = catalogFixture(); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("target"), thread("other")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("archived", true)] }); + receiveActive(catalog, [thread("target"), thread("other")]); + receiveArchived(catalog, [thread("archived", true)]); catalog.apply({ type: "thread-archived", threadId: "target" }); let activeRemovalAcknowledged = false; @@ -495,8 +497,8 @@ describe("ThreadCatalog", () => { const archivedListener = vi.fn(); catalog.observeActive(listener); catalog.observeArchived(archivedListener); - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("active")] }); - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("known", true)] }); + receiveActive(catalog, [thread("active")]); + receiveArchived(catalog, [thread("known", true)]); catalog.apply({ type: "thread-unarchived", threadId: "known" }); @@ -527,20 +529,17 @@ interface ModeledCatalogEvent { function renameOrderingEvents(): readonly ModeledCatalogEvent[] { return [ modeledCatalogEvent("stale active snapshot", (catalog) => { - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("target"), thread("other")] }); + receiveActive(catalog, [thread("target"), thread("other")]); }), modeledCatalogEvent( "rename-ack active snapshot", (catalog) => { - catalog.apply({ - type: "active-list-snapshot-received", - threads: [{ ...thread("target"), name: "Renamed" }, thread("other")], - }); + receiveActive(catalog, [{ ...thread("target"), name: "Renamed" }, thread("other")]); }, { acknowledgesRename: true }, ), modeledCatalogEvent("empty archived snapshot", (catalog) => { - catalog.apply({ type: "archived-list-snapshot-received", threads: [] }); + receiveArchived(catalog, []); }), ]; } @@ -548,22 +547,22 @@ function renameOrderingEvents(): readonly ModeledCatalogEvent[] { function archiveOrderingEvents(): readonly ModeledCatalogEvent[] { return [ modeledCatalogEvent("stale active snapshot", (catalog) => { - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("target"), thread("other")] }); + receiveActive(catalog, [thread("target"), thread("other")]); }), modeledCatalogEvent( "archive-ack active snapshot", (catalog) => { - catalog.apply({ type: "active-list-snapshot-received", threads: [thread("other")] }); + receiveActive(catalog, [thread("other")]); }, { acknowledgesActiveRemoval: true }, ), modeledCatalogEvent("stale archived snapshot", (catalog) => { - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("archived", true)] }); + receiveArchived(catalog, [thread("archived", true)]); }), modeledCatalogEvent( "archive-ack archived snapshot", (catalog) => { - catalog.apply({ type: "archived-list-snapshot-received", threads: [thread("target", true), thread("archived", true)] }); + receiveArchived(catalog, [thread("target", true), thread("archived", true)]); }, { acknowledgesArchivedUpsert: true }, ), @@ -616,6 +615,16 @@ function sequenceDescription(sequence: readonly ModeledCatalogEvent[]): string { return sequence.length === 0 ? "no additional snapshots" : sequence.map((event) => event.name).join(" -> "); } +const catalogStores = new WeakMap(); + +function receiveActive(catalog: ThreadCatalog, threads: readonly Thread[]): void { + catalogStores.get(catalog)?.receiveActive(threads); +} + +function receiveArchived(catalog: ThreadCatalog, threads: readonly Thread[]): void { + catalogStores.get(catalog)?.receiveArchived(threads); +} + function catalogFixture( options: { fetchThreads?: (context: { codexPath: string; vaultPath: string }, archived: boolean) => Promise; @@ -624,10 +633,12 @@ function catalogFixture( } = {}, ) { const cache = cacheWithThreads(options.fetchThreads ?? (() => Promise.resolve([]))); - const store = new AppServerSharedQueries({ + const context = options.context ?? (() => ({ codexPath: "codex", vaultPath: "/vault" })); + const queries = new AppServerSharedQueries({ cache, - context: options.context ?? (() => ({ codexPath: "codex", vaultPath: "/vault" })), + context, }); + const store = new TestThreadCatalogStore(cache, queries, context); const catalog = createThreadCatalog( options.onEventApplied ? { @@ -636,9 +647,131 @@ function catalogFixture( } : { store }, ); + catalogStores.set(catalog, store); + catalog.observeActive(() => undefined); + catalog.observeArchived(() => undefined); return { cache, catalog }; } +class TestThreadCatalogStore { + private readonly activeSnapshots = new Map(); + private readonly archivedSnapshots = new Map(); + private readonly activeObservers = new Set>(); + private readonly archivedObservers = new Set>(); + + constructor( + private readonly cache: AppServerQueryCache, + private readonly queries: AppServerSharedQueries, + private readonly currentContext: () => AppServerQueryContext, + ) {} + + contextKey(): string { + return appServerQueryContextKey(this.currentContext()); + } + + contextKeyFor(context: AppServerQueryContext): string { + return appServerQueryContextKey(context); + } + + activeThreadsSnapshot(): readonly Thread[] | null { + return this.activeSnapshots.get(this.contextKey()) ?? this.queries.activeThreadsSnapshot(); + } + + activeThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null { + return this.activeSnapshots.get(this.contextKeyFor(context)) ?? this.cache.activeThreadsSnapshot(context); + } + + archivedThreadsSnapshot(): readonly Thread[] | null { + return this.archivedSnapshots.get(this.contextKey()) ?? this.queries.archivedThreadsSnapshot(); + } + + archivedThreadsSnapshotFor(context: AppServerQueryContext): readonly Thread[] | null { + return this.archivedSnapshots.get(this.contextKeyFor(context)) ?? this.cache.archivedThreadsSnapshot(context); + } + + async fetchAllActiveThreads(): Promise { + return this.receiveLoadedActive(await this.queries.fetchAllActiveThreads()); + } + + hasMoreActiveThreads(): boolean { + return this.queries.hasMoreActiveThreads(); + } + + async loadMoreActiveThreads(): Promise { + return this.receiveLoadedActive(await this.queries.loadMoreActiveThreads()); + } + + async refreshActiveThreads(): Promise { + return this.receiveLoadedActive(await this.queries.refreshActiveThreads()); + } + + async refreshActiveThreadsFor(context: AppServerQueryContext): Promise { + const threads = await this.cache.refreshActiveThreads(context); + this.activeSnapshots.delete(this.contextKeyFor(context)); + return threads; + } + + async refreshArchivedThreads(): Promise { + return this.receiveLoadedArchived(await this.queries.refreshArchivedThreads()); + } + + async refreshArchivedThreadsFor(context: AppServerQueryContext): Promise { + const threads = await this.cache.refreshArchivedThreads(context); + this.archivedSnapshots.delete(this.contextKeyFor(context)); + return threads; + } + + observeActiveThreadsResult(observer: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void { + this.activeObservers.add(observer); + const unsubscribe = this.queries.observeActiveThreadsResult((result) => { + if (result.value && !result.isFetching) this.activeSnapshots.delete(this.contextKey()); + observer(result); + }, options); + return () => { + this.activeObservers.delete(observer); + unsubscribe(); + }; + } + + observeArchivedThreadsResult(observer: ObservedResultListener, options?: { emitCurrent?: boolean }): () => void { + this.archivedObservers.add(observer); + const unsubscribe = this.queries.observeArchivedThreadsResult((result) => { + if (result.value && !result.isFetching) this.archivedSnapshots.delete(this.contextKey()); + observer(result); + }, options); + return () => { + this.archivedObservers.delete(observer); + unsubscribe(); + }; + } + + receiveActive(threads: readonly Thread[]): void { + this.activeSnapshots.set(this.contextKey(), threads); + const result = observedSnapshot(threads); + for (const observer of this.activeObservers) observer(result); + } + + receiveArchived(threads: readonly Thread[]): void { + this.archivedSnapshots.set(this.contextKey(), threads); + const result = observedSnapshot(threads); + for (const observer of this.archivedObservers) observer(result); + } + + private receiveLoadedActive(threads: readonly Thread[]): readonly Thread[] { + this.activeSnapshots.delete(this.contextKey()); + return threads; + } + + private receiveLoadedArchived(threads: readonly Thread[]): readonly Thread[] { + this.archivedSnapshots.delete(this.contextKey()); + return threads; + } +} + +function observedSnapshot(threads: readonly Thread[]): ObservedResult { + return { value: threads, error: null, isFetching: false }; +} + function cacheWithThreads( fetchThreads: (context: { codexPath: string; vaultPath: string }, archived: boolean) => Promise, ): AppServerQueryCache {