refactor: simplify feature state lifecycles

This commit is contained in:
murashit 2026-07-15 13:45:00 +09:00
parent c23c453597
commit bbd981f255
11 changed files with 317 additions and 538 deletions

View file

@ -54,7 +54,7 @@ export function registerSelectionRewriteCommand(
originalText,
noteText: editor.getValue(),
instruction: "",
status: "editing-prompt",
status: "editing",
streamText: "",
replacementText: null,
debugText: null,

View file

@ -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<SelectionRewriteLifecycleEventType, SelectionRewriteLifecycleTransition>
>;
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<SelectionRewriteLifecycleEventType, SelectionRewriteLifecycleTransition>;
const terminalSelectionRewriteTransitions = {
"generation-started": keepSelectionRewriteState,
"preview-updated": keepSelectionRewriteState,
"generation-succeeded": keepSelectionRewriteState,
"generation-failed": keepSelectionRewriteState,
cancelled: cancelledTransition,
applied: appliedTransition,
} satisfies Record<SelectionRewriteLifecycleEventType, SelectionRewriteLifecycleTransition>;
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);

View file

@ -165,7 +165,6 @@ export class SelectionRewritePopover {
}
editor.replaceRange(replacement, state.targetRange.from, state.targetRange.to, "codex-panel-rewrite");
this.session.apply();
this.close();
}

View file

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

View file

@ -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<void> {
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();
});
}

View file

@ -1,41 +0,0 @@
import { DeferredTask, type DeferredTaskWindow } from "../../shared/runtime/deferred-task";
export type ThreadsViewRefreshLifecycleState = { kind: "idle" } | { kind: "loading" };
export type ActiveThreadsViewRefresh = Extract<ThreadsViewRefreshLifecycleState, { kind: "loading" }>;
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" };
}
}

View file

@ -43,8 +43,6 @@ interface PendingThreadListFacts {
interface ThreadCatalogFacts {
readonly active: PendingThreadListFacts;
readonly archived: PendingThreadListFacts;
activeTestSnapshot: readonly Thread[] | null;
archivedTestSnapshot: readonly Thread[] | null;
activeObservedResult: ObservedResult<readonly Thread[]> | null;
archivedObservedResult: ObservedResult<readonly Thread[]> | 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<ThreadListObserver>();
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<string, ThreadCatalogF
const facts = {
active: pendingThreadListFacts(),
archived: pendingThreadListFacts(),
activeTestSnapshot: null,
archivedTestSnapshot: null,
activeObservedResult: null,
archivedObservedResult: null,
};
@ -255,14 +238,6 @@ function applyThreadCatalogEvent(
): { active: boolean; archived: boolean } {
const { active: activeFacts, archived: archivedFacts } = facts;
switch (event.type) {
case "active-list-snapshot-received":
acknowledgeThreadListSnapshot(activeFacts, event.threads);
facts.activeTestSnapshot = event.threads;
return { active: true, archived: false };
case "archived-list-snapshot-received":
acknowledgeThreadListSnapshot(archivedFacts, event.threads);
facts.archivedTestSnapshot = event.threads;
return { active: false, archived: true };
case "thread-started":
upsertActiveThread(activeFacts, event.thread, acknowledgeByThreadId);
return { active: true, archived: false };
@ -290,13 +265,8 @@ function applyThreadCatalogEvent(
}
}
async function loadThreadList(
threadsPromise: Promise<readonly Thread[]>,
facts: PendingThreadListFacts,
receiveServerSnapshot: () => void,
): Promise<readonly Thread[]> {
async function loadThreadList(threadsPromise: Promise<readonly Thread[]>, facts: PendingThreadListFacts): Promise<readonly Thread[]> {
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);

View file

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

View file

@ -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<HTMLButtonElement>('button[aria-label="Generate"]')).click();
await flushPromises();
});
const instruction = expectPresent(document.querySelector<HTMLTextAreaElement>(".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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('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<SelectionRewriteState> = {}): 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<SelectionRewriteState> = {}): 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();

View file

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

View file

@ -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<ThreadCatalog, TestThreadCatalogStore>();
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<readonly Thread[]>;
@ -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<string, readonly Thread[]>();
private readonly archivedSnapshots = new Map<string, readonly Thread[]>();
private readonly activeObservers = new Set<ObservedResultListener<readonly Thread[]>>();
private readonly archivedObservers = new Set<ObservedResultListener<readonly Thread[]>>();
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<readonly Thread[]> {
return this.receiveLoadedActive(await this.queries.fetchAllActiveThreads());
}
hasMoreActiveThreads(): boolean {
return this.queries.hasMoreActiveThreads();
}
async loadMoreActiveThreads(): Promise<readonly Thread[]> {
return this.receiveLoadedActive(await this.queries.loadMoreActiveThreads());
}
async refreshActiveThreads(): Promise<readonly Thread[]> {
return this.receiveLoadedActive(await this.queries.refreshActiveThreads());
}
async refreshActiveThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
const threads = await this.cache.refreshActiveThreads(context);
this.activeSnapshots.delete(this.contextKeyFor(context));
return threads;
}
async refreshArchivedThreads(): Promise<readonly Thread[]> {
return this.receiveLoadedArchived(await this.queries.refreshArchivedThreads());
}
async refreshArchivedThreadsFor(context: AppServerQueryContext): Promise<readonly Thread[]> {
const threads = await this.cache.refreshArchivedThreads(context);
this.archivedSnapshots.delete(this.contextKeyFor(context));
return threads;
}
observeActiveThreadsResult(observer: ObservedResultListener<readonly Thread[]>, 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<readonly Thread[]>, 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<readonly Thread[]> {
return { value: threads, error: null, isFetching: false };
}
function cacheWithThreads(
fetchThreads: (context: { codexPath: string; vaultPath: string }, archived: boolean) => Promise<readonly Thread[]>,
): AppServerQueryCache {