mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
refactor: simplify feature state lifecycles
This commit is contained in:
parent
c23c453597
commit
bbd981f255
11 changed files with 317 additions and 538 deletions
|
|
@ -54,7 +54,7 @@ export function registerSelectionRewriteCommand(
|
|||
originalText,
|
||||
noteText: editor.getValue(),
|
||||
instruction: "",
|
||||
status: "editing-prompt",
|
||||
status: "editing",
|
||||
streamText: "",
|
||||
replacementText: null,
|
||||
debugText: null,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -165,7 +165,6 @@ export class SelectionRewritePopover {
|
|||
}
|
||||
|
||||
editor.replaceRange(replacement, state.targetRange.from, state.targetRange.to, "codex-panel-rewrite");
|
||||
this.session.apply();
|
||||
this.close();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue