Load thread history incrementally and complete it for search

This commit is contained in:
murashit 2026-07-10 12:54:24 +09:00
parent 2a0fb96ce6
commit ee0cfa9891
13 changed files with 275 additions and 35 deletions

View file

@ -54,6 +54,8 @@ Multiple panels are separate Obsidian leaves. Treat each panel as its own Codex
Thread history, archived state, forks, catalog snapshots, and other app-server resources should follow app-server semantics. Panel-side views are read models over app-server snapshots and lifecycle events; stale or partial refreshes must not overwrite newer state. Obsidian integrations such as archive note export are convenience views of Codex state, not replacements for Codex history.
Routine panel and Threads view refreshes should load only the first recency-ordered thread page. Older active threads are appended through an explicit load-more action; workflows that explicitly need a complete inventory, such as opening the thread picker, may finish pagination on demand.
Selection rewrite is intentionally scoped to a focused edit-and-review workflow. Avoid expanding it into a broader writing assistant without a separate design decision.
Server requests should become panel UI only when the user can naturally answer them in context. Unknown or unsupported requests should stay diagnostic instead of pretending to be normal conversation text.

View file

@ -14,7 +14,7 @@ import type { AppServerClientAccessOptions } from "../connection/client-access";
import { runtimeConfigSnapshotFromAppServerConfig } from "../protocol/runtime-config";
import { listModelMetadata } from "../services/catalog";
import { readEffectiveConfig } from "../services/runtime-metadata";
import { listThreads } from "../services/threads";
import { listThreads, readThreadPage } from "../services/threads";
import {
type AppServerQueryContext,
activeThreadsQueryKey,
@ -54,6 +54,7 @@ type ThreadListUpdater = (threads: readonly Thread[] | null) => readonly Thread[
export class AppServerQueryCache {
readonly client: QueryClient;
private readonly clientRunner: AppServerQueryClientRunner | null;
private readonly activeThreadCursors = new Map<string, string | null>();
constructor(options: { client?: QueryClient; clientRunner?: AppServerQueryClientRunner } = {}) {
this.client = options.client ?? createAppServerQueryClient();
@ -61,6 +62,7 @@ export class AppServerQueryCache {
}
clear(): void {
this.activeThreadCursors.clear();
this.client.clear();
}
@ -69,6 +71,7 @@ export class AppServerQueryCache {
const filter = appServerQueriesFilter(context);
void this.client.cancelQueries(filter);
this.client.removeQueries(filter);
this.activeThreadCursors.delete(this.activeThreadCursorKey(context));
}
activeThreadsSnapshot(context: AppServerQueryContext): readonly Thread[] | null {
@ -107,6 +110,41 @@ export class AppServerQueryCache {
return this.fetchActiveThreads(context, { force: true });
}
async fetchAllActiveThreads(context: AppServerQueryContext): Promise<readonly Thread[]> {
const refreshContext = cloneAppServerQueryContext(context);
if (!appServerQueryContextIsComplete(refreshContext)) return [];
const cursorKey = this.activeThreadCursorKey(refreshContext);
const snapshot = this.activeThreadsSnapshot(refreshContext);
if (snapshot && this.activeThreadCursors.has(cursorKey) && !this.activeThreadCursors.get(cursorKey)) return snapshot;
const threads = await this.runWithClient(refreshContext, (client) => listThreads(client, refreshContext.vaultPath));
this.setActiveThreads(refreshContext, threads);
this.rememberActiveThreadCursor(refreshContext, null);
return cloneThreads(threads);
}
hasMoreActiveThreads(context: AppServerQueryContext): boolean {
if (!appServerQueryContextIsComplete(context)) return false;
return Boolean(this.activeThreadCursors.get(this.activeThreadCursorKey(context)));
}
async loadMoreActiveThreads(context: AppServerQueryContext): Promise<readonly Thread[]> {
const refreshContext = cloneAppServerQueryContext(context);
if (!appServerQueryContextIsComplete(refreshContext)) return [];
const current = this.activeThreadsSnapshot(refreshContext) ?? (await this.fetchActiveThreads(refreshContext));
const cursor = this.activeThreadCursors.get(this.activeThreadCursorKey(refreshContext)) ?? null;
if (!cursor) return current;
const page = await this.runWithClient(refreshContext, (client) =>
readThreadPage(client, refreshContext.vaultPath, { cursor, archived: false }),
);
if (page.nextCursor === cursor) throw new Error("Codex app-server returned a repeated thread list cursor.");
const latest = this.activeThreadsSnapshot(refreshContext) ?? current;
const existingIds = new Set(latest.map((thread) => thread.id));
const threads = [...latest, ...page.threads.filter((thread) => !existingIds.has(thread.id))];
this.setActiveThreads(refreshContext, threads);
this.rememberActiveThreadCursor(refreshContext, page.nextCursor);
return cloneThreads(threads);
}
async refreshArchivedThreads(context: AppServerQueryContext): Promise<readonly Thread[]> {
return this.fetchArchivedThreads(context, { force: true });
}
@ -243,10 +281,15 @@ export class AppServerQueryCache {
return {
queryKey: this.threadListQueryKey(refreshContext, kind),
queryFn: async (): Promise<readonly Thread[]> => {
if (kind === "active") {
const page = await this.runWithClient(refreshContext, (client) =>
readThreadPage(client, refreshContext.vaultPath, { archived: false }),
);
this.rememberActiveThreadCursor(refreshContext, page.nextCursor);
return cloneThreads(page.threads);
}
return cloneThreads(
await this.runWithClient(refreshContext, (client) =>
listThreads(client, refreshContext.vaultPath, { archived: kind === "archived" }),
),
await this.runWithClient(refreshContext, (client) => listThreads(client, refreshContext.vaultPath, { archived: true })),
);
},
staleTime: THREAD_LIST_STALE_TIME_MS,
@ -371,6 +414,22 @@ export class AppServerQueryCache {
}
return this.clientRunner.runWithClient(context, operation, options);
}
private activeThreadCursorKey(context: AppServerQueryContext): string {
return JSON.stringify(activeThreadsQueryKey(context));
}
private rememberActiveThreadCursor(context: AppServerQueryContext, cursor: string | null): void {
const key = this.activeThreadCursorKey(context);
this.activeThreadCursors.delete(key);
this.activeThreadCursors.set(key, cursor);
while (this.activeThreadCursors.size > 8) {
for (const oldestKey of this.activeThreadCursors.keys()) {
this.activeThreadCursors.delete(oldestKey);
break;
}
}
}
}
function metadataWithLastKnownGood(metadata: SharedServerMetadata, previous: SharedServerMetadata | null): SharedServerMetadata {

View file

@ -44,8 +44,16 @@ export class AppServerSharedQueries {
return this.options.cache.archivedThreadsSnapshot(this.context());
}
fetchActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.fetchActiveThreads(context));
fetchAllActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.fetchAllActiveThreads(context));
}
hasMoreActiveThreads(): boolean {
return this.options.cache.hasMoreActiveThreads(this.context());
}
loadMoreActiveThreads(): Promise<readonly Thread[]> {
return this.runForCurrentContext((context) => this.options.cache.loadMoreActiveThreads(context));
}
fetchArchivedThreads(): Promise<readonly Thread[]> {

View file

@ -57,12 +57,17 @@ export interface AppServerStartEphemeralThreadOptions {
developerInstructions: string;
}
interface AppServerThreadListOptions {
export interface AppServerThreadListOptions {
archived?: boolean;
cursor?: string | null;
limit?: number | null;
}
export interface ThreadPage {
readonly threads: readonly Thread[];
readonly nextCursor: string | null;
}
export function startThread(
client: AppServerRequestClient,
options: AppServerStartThreadOptions,
@ -107,19 +112,19 @@ export function resumeThread(
export async function listThreads(client: AppServerRequestClient, cwd: string, options: { archived?: boolean } = {}): Promise<Thread[]> {
const archived = options.archived ?? false;
const records: ThreadRecord[] = [];
const threads: Thread[] = [];
const seenCursors = new Set<string>();
let cursor: string | null = null;
for (;;) {
const response = await listThreadPage(client, cwd, {
const page = await readThreadPage(client, cwd, {
archived,
cursor,
limit: THREAD_LIST_PAGE_LIMIT,
});
records.push(...response.data);
threads.push(...page.threads);
cursor = response.nextCursor ?? null;
cursor = page.nextCursor;
if (!cursor) break;
if (seenCursors.has(cursor)) {
throw new Error("Codex app-server returned a repeated thread list cursor.");
@ -127,7 +132,24 @@ export async function listThreads(client: AppServerRequestClient, cwd: string, o
seenCursors.add(cursor);
}
return threadsFromThreadRecords(records, { archived });
return threads;
}
export async function readThreadPage(
client: AppServerRequestClient,
cwd: string,
options: AppServerThreadListOptions = {},
): Promise<ThreadPage> {
const archived = options.archived ?? false;
const page = await readThreadRecordPage(client, cwd, {
...options,
archived,
limit: options.limit ?? THREAD_LIST_PAGE_LIMIT,
});
return {
threads: threadsFromThreadRecords(page.data, { archived }),
nextCursor: page.nextCursor ?? null,
};
}
export function threadFromAppServerRecord(thread: ThreadRecord, options: { archived?: boolean } = {}): Thread {
@ -294,7 +316,7 @@ export function listThreadTurns(
});
}
function listThreadPage(client: AppServerRequestClient, cwd: string, options: AppServerThreadListOptions) {
function readThreadRecordPage(client: AppServerRequestClient, cwd: string, options: AppServerThreadListOptions) {
return client.request("thread/list", {
cwd,
...(options.cursor ? { cursor: options.cursor } : {}),

View file

@ -22,7 +22,7 @@ const THREAD_PICKER_MODIFIER_ENTER_LISTENER_OPTIONS = { capture: true } as const
export async function openThreadPicker(host: ThreadPickerHost): Promise<void> {
try {
const threads = await host.threadCatalog.loadActive();
const threads = await host.threadCatalog.refreshActive();
if (threads.length === 0) {
new Notice("No Codex threads found.");
return;
@ -46,11 +46,16 @@ function threadOpenModeFromEvent(evt: MouseEvent | KeyboardEvent): ThreadOpenMod
}
class ThreadPickerModal extends SuggestModal<ThreadSuggestion> {
private threads: readonly Thread[];
private completeThreadsPromise: Promise<readonly Thread[]> | null = null;
private hasCompleteThreadList = false;
constructor(
private readonly host: ThreadPickerHost,
private readonly threads: readonly Thread[],
threads: readonly Thread[],
) {
super(host.app);
this.threads = threads;
this.limit = threads.length;
this.emptyStateText = "No matching Codex threads";
this.setPlaceholder("Open Codex thread...");
@ -70,7 +75,14 @@ class ThreadPickerModal extends SuggestModal<ThreadSuggestion> {
super.onClose();
}
override getSuggestions(query: string): ThreadSuggestion[] {
override async getSuggestions(query: string): Promise<ThreadSuggestion[]> {
if (query.trim().length > 0) {
try {
await this.loadCompleteThreadList();
} catch (error) {
new Notice(error instanceof Error ? error.message : String(error));
}
}
return threadPickerSuggestions(this.threads, query);
}
@ -102,4 +114,17 @@ class ThreadPickerModal extends SuggestModal<ThreadSuggestion> {
new Notice(error instanceof Error ? error.message : String(error));
}
}
private async loadCompleteThreadList(): Promise<void> {
if (this.hasCompleteThreadList) return;
const pending = this.completeThreadsPromise ?? this.host.threadCatalog.loadActive();
this.completeThreadsPromise = pending;
try {
this.threads = await pending;
this.limit = this.threads.length;
this.hasCompleteThreadList = true;
} finally {
if (this.completeThreadsPromise === pending) this.completeThreadsPromise = null;
}
}
}

View file

@ -8,7 +8,7 @@ import type { ReasoningEffort } from "../../domain/catalog/metadata";
import type { ArchiveExportSettings } from "../../domain/threads/archive-markdown";
import type { Thread } from "../../domain/threads/model";
import { OwnerLifetime } from "../../shared/runtime/owner-lifetime";
import type { ThreadCatalogActiveReader, ThreadCatalogEventSink } from "../threads/catalog/thread-catalog";
import type { ThreadCatalogEventSink, ThreadCatalogPaginatedActiveReader } from "../threads/catalog/thread-catalog";
import type { ArchiveExportDestination } from "../threads/workflows/archive-export";
import { createThreadOperations, type ThreadOperations } from "../threads/workflows/thread-operations";
import { createThreadTitleService, type ThreadTitleService } from "../threads/workflows/thread-title-service";
@ -40,7 +40,7 @@ export interface ThreadsViewHost {
closeOpenPanelsForThread(threadId: string): void;
}
type ThreadsViewThreadCatalog = ThreadCatalogActiveReader & ThreadCatalogEventSink;
type ThreadsViewThreadCatalog = ThreadCatalogPaginatedActiveReader & ThreadCatalogEventSink;
export interface ThreadsViewSettingsAccess {
archiveExportEnabled(): boolean;
@ -156,6 +156,25 @@ 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;
const refresh = this.startRefresh();
this.render();
try {
const threads = await this.host.threadCatalog.loadMoreActive();
if (!this.lifetime.isCurrent(lifetime) || this.isStaleRefresh(refresh)) return;
this.threads = threads;
this.threadsLoaded = true;
this.status = threads.length === 0 ? { kind: "empty", message: "No threads" } : { kind: "idle" };
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleAppServerSharedQueryContextError(error)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
} finally {
this.finishRefresh(refresh);
}
}
refreshLiveState(): void {
this.scheduleRender();
}
@ -217,6 +236,7 @@ export class ThreadsViewSession {
{
status: this.status.kind === "idle" ? null : this.status.message,
loading: this.refreshLifecycle.kind === "loading",
hasMore: this.host.threadCatalog.hasMoreActive(),
rows: threadRows(
this.threads,
this.host.openPanelActivities(),
@ -227,6 +247,7 @@ export class ThreadsViewSession {
},
{
refresh: () => void this.refresh(),
loadMore: () => void this.loadMore(),
openNewPanel: () => void this.openNewPanel(),
openThread: (threadId) => void this.openThread(threadId),
startRename: (threadId, value) => {

View file

@ -11,11 +11,13 @@ type ButtonProps = ButtonHTMLAttributes & {
export interface ThreadsViewShellModel {
status: string | null;
loading: boolean;
hasMore?: boolean;
rows: ThreadsRowModel[];
}
export interface ThreadsViewShellActions {
refresh: () => void;
loadMore: () => void;
openNewPanel: () => void;
openThread: (threadId: string) => void;
startRename: (threadId: string, value: string) => void;
@ -69,6 +71,16 @@ function ThreadsViewShell({ model, actions }: { model: ThreadsViewShellModel; ac
{model.rows.map((row) => (
<ThreadRow key={row.threadId} row={row} actions={actions} />
))}
{model.hasMore ? (
<button
type="button"
className="codex-panel-ui__nav-item codex-panel-threads__load-more"
disabled={model.loading}
onClick={actions.loadMore}
>
{model.loading ? "Loading..." : "Load more threads"}
</button>
) : null}
</>
)}
</div>

View file

@ -8,7 +8,9 @@ interface ThreadCatalogStore {
contextKey(): string;
activeThreadsSnapshot(): readonly Thread[] | null;
archivedThreadsSnapshot(): readonly Thread[] | null;
fetchActiveThreads(): Promise<readonly Thread[]>;
fetchAllActiveThreads(): Promise<readonly Thread[]>;
hasMoreActiveThreads(): boolean;
loadMoreActiveThreads(): Promise<readonly Thread[]>;
fetchArchivedThreads(): Promise<readonly Thread[]>;
refreshActiveThreads(): Promise<readonly Thread[]>;
refreshArchivedThreads(): Promise<readonly Thread[]>;
@ -64,6 +66,11 @@ export interface ThreadCatalogActiveReader {
observeActive(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void;
}
export interface ThreadCatalogPaginatedActiveReader extends ThreadCatalogActiveReader {
hasMoreActive(): boolean;
loadMoreActive(): Promise<readonly Thread[]>;
}
export interface ThreadCatalogArchivedReader {
archivedSnapshot(): readonly Thread[] | null;
loadArchived(): Promise<readonly Thread[]>;
@ -75,7 +82,7 @@ export interface ThreadCatalogEventSink {
apply(event: ThreadCatalogEvent): void;
}
export interface ThreadCatalog extends ThreadCatalogActiveReader, ThreadCatalogArchivedReader, ThreadCatalogEventSink {}
export interface ThreadCatalog extends ThreadCatalogPaginatedActiveReader, ThreadCatalogArchivedReader, ThreadCatalogEventSink {}
export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalog {
const factsByContext = new Map<string, ThreadCatalogFacts>();
@ -90,8 +97,10 @@ export function createThreadCatalog(options: ThreadCatalogOptions): ThreadCatalo
return {
apply,
activeSnapshot: () => threadListProjection(store.activeThreadsSnapshot(), currentFacts().active),
loadActive: () => loadThreadList(store.fetchActiveThreads(), currentFacts().active),
loadActive: () => loadThreadList(store.fetchAllActiveThreads(), currentFacts().active),
refreshActive: () => loadThreadList(store.refreshActiveThreads(), currentFacts().active),
hasMoreActive: () => store.hasMoreActiveThreads(),
loadMoreActive: () => loadThreadList(store.loadMoreActiveThreads(), currentFacts().active),
observeActive: (observer, observeOptions) =>
store.observeActiveThreadsResult((result) => {
observer({

View file

@ -35,6 +35,13 @@
overflow: auto;
}
.codex-panel-threads__load-more {
flex: 0 0 auto;
justify-content: center;
color: var(--codex-panel-text-muted);
font-size: var(--font-ui-smaller);
}
.codex-panel-threads__row {
grid-template-columns: minmax(0, 1fr) 0;
gap: 0;

View file

@ -230,6 +230,30 @@ describe("AppServerQueryCache", () => {
expect(fetchThreads).toHaveBeenNthCalledWith(2, context, true);
});
it("loads active thread history one page at a time", async () => {
const listThreads = vi
.fn()
.mockResolvedValueOnce({ data: [thread("first")], nextCursor: "page-2" })
.mockResolvedValueOnce({ data: [thread("second")], nextCursor: null });
const cache = cacheWithRequestHandlers({ "thread/list": listThreads });
const context = cacheContext();
await expect(cache.refreshActiveThreads(context)).resolves.toEqual([thread("first")]);
expect(cache.hasMoreActiveThreads(context)).toBe(true);
expect(listThreads).toHaveBeenCalledOnce();
await expect(cache.loadMoreActiveThreads(context)).resolves.toEqual([thread("first"), thread("second")]);
expect(cache.hasMoreActiveThreads(context)).toBe(false);
expect(listThreads).toHaveBeenNthCalledWith(2, {
cwd: "/vault",
cursor: "page-2",
archived: false,
limit: 100,
sortKey: "recency_at",
sortDirection: "desc",
});
});
it("keys thread list refresh snapshots by app-server query context", async () => {
const oldContext = cacheContext({ codexPath: "codex-old" });
const newContext = cacheContext({ codexPath: "codex-new" });

View file

@ -13,7 +13,7 @@ describe("threadPickerSuggestions", () => {
thread({ id: "thread-beta", name: "Recent unrelated alpha mention", updatedAt: 30 }),
thread({ id: "alpha-thread", name: "Newest unrelated", updatedAt: 40 }),
]);
const suggestions = modal.getSuggestions("alpha");
const suggestions = await modal.getSuggestions("alpha");
expect(suggestions.map((item) => item.thread.id)).toEqual(["alpha-thread", "thread-beta", "thread-alpha"]);
});
@ -23,7 +23,7 @@ describe("threadPickerSuggestions", () => {
thread({ id: "updated-newer", updatedAt: 20, recencyAt: 10 }),
thread({ id: "recent", updatedAt: 10, recencyAt: 30 }),
]);
const suggestions = modal.getSuggestions("");
const suggestions = await modal.getSuggestions("");
expect(suggestions.map((item) => item.thread.id)).toEqual(["recent", "updated-newer"]);
});
@ -32,10 +32,22 @@ describe("threadPickerSuggestions", () => {
const modal = await openedThreadPicker(
Array.from({ length: 25 }, (_, index) => thread({ id: `thread-${String(index + 1)}`, name: "Match" })),
);
const suggestions = modal.getSuggestions("match");
const suggestions = await modal.getSuggestions("match");
expect(suggestions).toHaveLength(25);
});
it("loads complete history only when a search needs it", async () => {
const host = threadPickerHost([thread({ id: "recent" })], [thread({ id: "recent" }), thread({ id: "older-target", name: "Needle" })]);
const modal = await openedThreadPicker(host);
expect((await modal.getSuggestions("")).map((item) => item.thread.id)).toEqual(["recent"]);
expect(host.completeHistoryLoads).toBe(0);
const [needleSuggestions] = await Promise.all([modal.getSuggestions("needle"), modal.getSuggestions("target")]);
expect(needleSuggestions.map((item) => item.thread.id)).toEqual(["older-target"]);
expect(host.completeHistoryLoads).toBe(1);
});
});
describe("threadOpenModeFromEvent", () => {
@ -43,8 +55,8 @@ describe("threadOpenModeFromEvent", () => {
const host = threadPickerHost([thread({ id: "thread" })]);
const modal = await openedThreadPicker(host);
modal.onChooseSuggestion(firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter" }));
modal.onChooseSuggestion(firstSuggestion(modal), new MouseEvent("click"));
modal.onChooseSuggestion(await firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter" }));
modal.onChooseSuggestion(await firstSuggestion(modal), new MouseEvent("click"));
expect(host.openedCurrent).toEqual(["thread", "thread"]);
expect(host.openedAvailable).toEqual([]);
@ -54,8 +66,8 @@ describe("threadOpenModeFromEvent", () => {
const host = threadPickerHost([thread({ id: "thread" })]);
const modal = await openedThreadPicker(host);
modal.onChooseSuggestion(firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter", metaKey: true }));
modal.onChooseSuggestion(firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }));
modal.onChooseSuggestion(await firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter", metaKey: true }));
modal.onChooseSuggestion(await firstSuggestion(modal), new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true }));
expect(host.openedCurrent).toEqual([]);
expect(host.openedAvailable).toEqual(["thread", "thread"]);
@ -68,7 +80,7 @@ interface ThreadSuggestion {
}
interface CapturedThreadPickerModal {
getSuggestions(query: string): ThreadSuggestion[];
getSuggestions(query: string): Promise<ThreadSuggestion[]>;
onChooseSuggestion(item: ThreadSuggestion, evt: MouseEvent | KeyboardEvent): void;
}
@ -88,8 +100,8 @@ async function openedThreadPicker(input: readonly Thread[] | TestThreadPickerHos
return modal;
}
function firstSuggestion(modal: CapturedThreadPickerModal): ThreadSuggestion {
const suggestion = modal.getSuggestions("")[0];
async function firstSuggestion(modal: CapturedThreadPickerModal): Promise<ThreadSuggestion> {
const suggestion = (await modal.getSuggestions(""))[0];
if (!suggestion) throw new Error("Expected thread picker suggestion");
return suggestion;
}
@ -101,19 +113,24 @@ function isThreadPickerHost(input: readonly Thread[] | TestThreadPickerHost): in
interface TestThreadPickerHost extends ThreadPickerHost {
openedCurrent: string[];
openedAvailable: string[];
completeHistoryLoads: number;
}
function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost {
function threadPickerHost(firstPage: readonly Thread[], completeHistory: readonly Thread[] = firstPage): TestThreadPickerHost {
const openedCurrent: string[] = [];
const openedAvailable: string[] = [];
return {
const host: TestThreadPickerHost = {
app: {} as never,
openedCurrent,
openedAvailable,
completeHistoryLoads: 0,
threadCatalog: {
activeSnapshot: () => null,
loadActive: async () => threads,
refreshActive: async () => threads,
loadActive: async () => {
host.completeHistoryLoads += 1;
return completeHistory;
},
refreshActive: async () => firstPage,
observeActive: () => () => undefined,
},
openThreadInCurrentView: async (threadId) => {
@ -123,6 +140,7 @@ function threadPickerHost(threads: readonly Thread[]): TestThreadPickerHost {
openedAvailable.push(threadId);
},
};
return host;
}
function thread(options: Partial<Thread> & { id: string }): Thread {

View file

@ -52,6 +52,7 @@ function rowFixture(overrides: Partial<ThreadsRowModel> = {}): ThreadsRowModel {
function threadsViewActions() {
return {
refresh: vi.fn(),
loadMore: vi.fn(),
openNewPanel: vi.fn(),
openThread: vi.fn(),
startRename: vi.fn(),

View file

@ -221,6 +221,36 @@ describe("CodexThreadsView", () => {
});
});
it("loads another thread page only after the user requests it", async () => {
const first = threadFromRecord(threadFixture({ id: "first", preview: "First page" }));
const second = threadFromRecord(threadFixture({ id: "second", preview: "Second page" }));
let hasMore = true;
const loadMoreActive = vi.fn(async () => {
hasMore = false;
return [first, second];
});
const view = await threadsView(
threadsHost({
threadCatalog: {
refreshActive: vi.fn(async () => [first]),
hasMoreActive: vi.fn(() => hasMore),
loadMoreActive,
},
}),
);
await view.refresh();
expect(view.containerEl.textContent).toContain("First page");
expect(view.containerEl.textContent).not.toContain("Second page");
view.containerEl.querySelector<HTMLButtonElement>(".codex-panel-threads__load-more")?.click();
await waitForAsyncWork(() => {
expect(loadMoreActive).toHaveBeenCalledOnce();
expect(view.containerEl.textContent).toContain("Second page");
});
expect(view.containerEl.querySelector(".codex-panel-threads__load-more")).toBeNull();
});
it("refreshes thread lists through the plugin coordinator", async () => {
const threads = [{ id: "thread", preview: "Thread preview", name: null, archived: false, createdAt: 1, updatedAt: 1 }];
const refresh = vi.fn().mockResolvedValue(threads);
@ -591,6 +621,8 @@ function threadsHost(overrides: Record<string, unknown> = {}) {
threadCatalog: {
apply: vi.fn(),
loadActive: vi.fn(async () => []),
hasMoreActive: vi.fn(() => false),
loadMoreActive: vi.fn(async () => []),
refreshActive: vi.fn(async () => {
const client = connectionMock.state.client;
if (!client) return [];