diff --git a/docs/design.md b/docs/design.md index 2c0f0adb..fe145fba 100644 --- a/docs/design.md +++ b/docs/design.md @@ -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. diff --git a/src/app-server/query/cache.ts b/src/app-server/query/cache.ts index 0ad18abd..8993f4f9 100644 --- a/src/app-server/query/cache.ts +++ b/src/app-server/query/cache.ts @@ -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(); 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 { + 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 { + 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 { return this.fetchArchivedThreads(context, { force: true }); } @@ -243,10 +281,15 @@ export class AppServerQueryCache { return { queryKey: this.threadListQueryKey(refreshContext, kind), queryFn: async (): Promise => { + 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 { diff --git a/src/app-server/query/shared-queries.ts b/src/app-server/query/shared-queries.ts index 33b54a08..8834d759 100644 --- a/src/app-server/query/shared-queries.ts +++ b/src/app-server/query/shared-queries.ts @@ -44,8 +44,16 @@ export class AppServerSharedQueries { return this.options.cache.archivedThreadsSnapshot(this.context()); } - fetchActiveThreads(): Promise { - return this.runForCurrentContext((context) => this.options.cache.fetchActiveThreads(context)); + fetchAllActiveThreads(): Promise { + return this.runForCurrentContext((context) => this.options.cache.fetchAllActiveThreads(context)); + } + + hasMoreActiveThreads(): boolean { + return this.options.cache.hasMoreActiveThreads(this.context()); + } + + loadMoreActiveThreads(): Promise { + return this.runForCurrentContext((context) => this.options.cache.loadMoreActiveThreads(context)); } fetchArchivedThreads(): Promise { diff --git a/src/app-server/services/threads.ts b/src/app-server/services/threads.ts index 4cf4ec1e..eec68528 100644 --- a/src/app-server/services/threads.ts +++ b/src/app-server/services/threads.ts @@ -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 { const archived = options.archived ?? false; - const records: ThreadRecord[] = []; + const threads: Thread[] = []; const seenCursors = new Set(); 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 { + 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 } : {}), diff --git a/src/features/thread-picker/modal.obsidian.ts b/src/features/thread-picker/modal.obsidian.ts index e5b39142..df3bd19f 100644 --- a/src/features/thread-picker/modal.obsidian.ts +++ b/src/features/thread-picker/modal.obsidian.ts @@ -22,7 +22,7 @@ const THREAD_PICKER_MODIFIER_ENTER_LISTENER_OPTIONS = { capture: true } as const export async function openThreadPicker(host: ThreadPickerHost): Promise { 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 { + private threads: readonly Thread[]; + private completeThreadsPromise: Promise | 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 { super.onClose(); } - override getSuggestions(query: string): ThreadSuggestion[] { + override async getSuggestions(query: string): Promise { + 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 { new Notice(error instanceof Error ? error.message : String(error)); } } + + private async loadCompleteThreadList(): Promise { + 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; + } + } } diff --git a/src/features/threads-view/session.ts b/src/features/threads-view/session.ts index 99bab7c6..8cdfef38 100644 --- a/src/features/threads-view/session.ts +++ b/src/features/threads-view/session.ts @@ -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 { + 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) => { diff --git a/src/features/threads-view/shell.dom.tsx b/src/features/threads-view/shell.dom.tsx index 046cbab7..3ada2f27 100644 --- a/src/features/threads-view/shell.dom.tsx +++ b/src/features/threads-view/shell.dom.tsx @@ -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) => ( ))} + {model.hasMore ? ( + + ) : null} )} diff --git a/src/features/threads/catalog/thread-catalog.ts b/src/features/threads/catalog/thread-catalog.ts index 4668c576..2c572d3b 100644 --- a/src/features/threads/catalog/thread-catalog.ts +++ b/src/features/threads/catalog/thread-catalog.ts @@ -8,7 +8,9 @@ interface ThreadCatalogStore { contextKey(): string; activeThreadsSnapshot(): readonly Thread[] | null; archivedThreadsSnapshot(): readonly Thread[] | null; - fetchActiveThreads(): Promise; + fetchAllActiveThreads(): Promise; + hasMoreActiveThreads(): boolean; + loadMoreActiveThreads(): Promise; fetchArchivedThreads(): Promise; refreshActiveThreads(): Promise; refreshArchivedThreads(): Promise; @@ -64,6 +66,11 @@ export interface ThreadCatalogActiveReader { observeActive(observer: ThreadListObserver, options?: { emitCurrent?: boolean }): () => void; } +export interface ThreadCatalogPaginatedActiveReader extends ThreadCatalogActiveReader { + hasMoreActive(): boolean; + loadMoreActive(): Promise; +} + export interface ThreadCatalogArchivedReader { archivedSnapshot(): readonly Thread[] | null; loadArchived(): Promise; @@ -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(); @@ -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({ diff --git a/src/styles/40-threads-view.css b/src/styles/40-threads-view.css index 95683b9f..595c51c8 100644 --- a/src/styles/40-threads-view.css +++ b/src/styles/40-threads-view.css @@ -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; diff --git a/tests/app-server/query-cache.test.ts b/tests/app-server/query-cache.test.ts index e6b377a3..81dbda88 100644 --- a/tests/app-server/query-cache.test.ts +++ b/tests/app-server/query-cache.test.ts @@ -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" }); diff --git a/tests/features/thread-picker/modal.test.ts b/tests/features/thread-picker/modal.test.ts index cb9438c6..55013b23 100644 --- a/tests/features/thread-picker/modal.test.ts +++ b/tests/features/thread-picker/modal.test.ts @@ -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; 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 { + 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 & { id: string }): Thread { diff --git a/tests/features/threads-view/shell.test.tsx b/tests/features/threads-view/shell.test.tsx index 9f14e2d3..99b2c506 100644 --- a/tests/features/threads-view/shell.test.tsx +++ b/tests/features/threads-view/shell.test.tsx @@ -52,6 +52,7 @@ function rowFixture(overrides: Partial = {}): ThreadsRowModel { function threadsViewActions() { return { refresh: vi.fn(), + loadMore: vi.fn(), openNewPanel: vi.fn(), openThread: vi.fn(), startRename: vi.fn(), diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index b46d9176..b04f6ffa 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -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(".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 = {}) { 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 [];