diff --git a/src/features/chat/ui/toolbar.tsx b/src/features/chat/ui/toolbar.tsx index 590455d4..bab9fffe 100644 --- a/src/features/chat/ui/toolbar.tsx +++ b/src/features/chat/ui/toolbar.tsx @@ -368,7 +368,11 @@ function ThreadList({ {threads.map((thread) => ( ))} - {error ? : null} + {error ? ( +
+ {error} +
+ ) : null} {hasMore ? (
{model.rows.length === 0 ? ( -
{model.status ?? (model.loading ? "Loading threads..." : "No threads")}
+ model.status ? ( +
+ {model.status.message} +
+ ) : ( +
No threads
+ ) ) : ( <> - {model.status ?
{model.status}
: null} {model.rows.map((row) => ( ))} diff --git a/src/styles/21-chat-toolbar.css b/src/styles/21-chat-toolbar.css index f433ee5c..c5e792da 100644 --- a/src/styles/21-chat-toolbar.css +++ b/src/styles/21-chat-toolbar.css @@ -295,6 +295,18 @@ overflow-y: visible; } +.codex-panel__thread-list-status { + padding: var(--codex-panel-control-gap) var(--codex-panel-item-gap); + color: var(--codex-panel-text-muted); + font-size: var(--codex-panel-toolbar-text-size); + line-height: var(--codex-panel-toolbar-line-height); + overflow-wrap: anywhere; +} + +.codex-panel__thread-list-status--error { + color: var(--codex-panel-color-danger); +} + .codex-panel__thread-row { grid-template-columns: minmax(0, 1fr) 0 0; gap: 0; diff --git a/src/styles/40-threads-view.css b/src/styles/40-threads-view.css index 595c51c8..b5b23ae0 100644 --- a/src/styles/40-threads-view.css +++ b/src/styles/40-threads-view.css @@ -13,6 +13,15 @@ line-height: var(--line-height-tight); } +.codex-panel-threads__status { + padding: var(--codex-panel-control-gap) var(--codex-panel-item-gap); + overflow-wrap: anywhere; +} + +.codex-panel-threads__status--error { + color: var(--codex-panel-color-danger); +} + .codex-panel-threads__empty { box-sizing: border-box; min-height: var(--codex-panel-size-nav-item); diff --git a/tests/features/chat/ui/toolbar.test.ts b/tests/features/chat/ui/toolbar.test.ts index 5ba3270e..af6a0089 100644 --- a/tests/features/chat/ui/toolbar.test.ts +++ b/tests/features/chat/ui/toolbar.test.ts @@ -527,6 +527,22 @@ describe("Toolbar decisions", () => { expect(parent.textContent).toContain("Loading threads…"); expect(parent.textContent).not.toContain("No threads"); }); + + it("renders thread list failures as non-navigation status", () => { + const parent = document.createElement("div"); + + mountToolbar( + parent, + toolbarModel({ historyOpen: true, openPanel: "history", threads: [], threadListError: "Could not load threads." }), + toolbarActions(), + ); + + const status = expectPresent(parent.querySelector(".codex-panel__thread-list-status")); + expect(status.textContent).toBe("Could not load threads."); + expect(status.getAttribute("role")).toBe("status"); + expect(status.classList.contains("codex-panel-ui__nav-item")).toBe(false); + expect(parent.querySelector(".codex-panel__thread--error")).toBeNull(); + }); }); function toolbarModel(overrides: Partial = {}): ToolbarViewModel { diff --git a/tests/features/threads-view/shell.test.tsx b/tests/features/threads-view/shell.test.tsx index 7e3bd1e7..4b02734e 100644 --- a/tests/features/threads-view/shell.test.tsx +++ b/tests/features/threads-view/shell.test.tsx @@ -68,6 +68,31 @@ function threadsViewActions() { } describe("threads view renderer decisions", () => { + it("renders an initial load failure as status instead of a navigation row or empty state", () => { + const parent = document.createElement("div"); + + renderThreadsViewShell( + parent, + { status: { kind: "error", message: "Could not load threads." }, loading: false, rows: [] }, + threadsViewActions(), + ); + + const status = expectPresent(parent.querySelector(".codex-panel-threads__status")); + expect(status.textContent).toBe("Could not load threads."); + expect(status.getAttribute("role")).toBe("status"); + expect(status.classList.contains("codex-panel-ui__nav-item")).toBe(false); + expect(parent.querySelector(".codex-panel-threads__empty")).toBeNull(); + }); + + it("reserves the empty state for a successfully loaded empty list", () => { + const parent = document.createElement("div"); + + renderThreadsViewShell(parent, { status: null, loading: false, rows: [] }, threadsViewActions()); + + expect(parent.querySelector(".codex-panel-threads__empty")?.textContent).toBe("No threads"); + expect(parent.querySelector(".codex-panel-threads__status")).toBeNull(); + }); + it("prioritizes open panel live state per thread", () => { expect( threadRows( @@ -112,7 +137,7 @@ describe("threads view renderer decisions", () => { new Map(), ); - renderThreadsViewShell(parent, { status: "2 threads", loading: false, rows }, actions); + renderThreadsViewShell(parent, { status: null, loading: false, rows }, actions); const main = expectPresent(parent.querySelector(".codex-panel-threads__row--pending")); expect(main.textContent).toContain("Open thread"); @@ -136,7 +161,7 @@ describe("threads view renderer decisions", () => { archiveConfirm: { active: true, defaultSaveMarkdown: false }, }); - renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); const confirm = expectPresent(parent.querySelector(".codex-panel-threads__archive-confirm")); const archiveButtons = [ @@ -158,7 +183,7 @@ describe("threads view renderer decisions", () => { const actions = threadsViewActions(); const row = rowFixture(); - renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); parent.querySelector('[aria-label="Archive thread"]')?.click(); expect(actions.startArchive).toHaveBeenCalledWith("thread"); @@ -174,7 +199,7 @@ describe("threads view renderer decisions", () => { rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false }, }); - renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); const input = expectPresent(parent.querySelector(".codex-panel-threads__rename-input")); expect(document.activeElement).toBe(input); @@ -185,7 +210,7 @@ describe("threads view renderer decisions", () => { renderThreadsViewShell( parent, { - status: "1 thread", + status: null, loading: false, rows: [{ ...row, rename: { active: true, draft: "New name", generating: false, autoNameDisabled: false } }], }, @@ -215,7 +240,7 @@ describe("threads view renderer decisions", () => { rename: { active: true, draft: "Old name", generating: false, autoNameDisabled: false }, }); - renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); expect(parent.querySelector(".codex-panel-threads__rename-form")).toBeTruthy(); parent.querySelector('[aria-label="Auto-name thread"]')?.click(); @@ -231,7 +256,7 @@ describe("threads view renderer decisions", () => { rename: { active: true, draft: "Old name", generating: true, autoNameDisabled: false }, }); - renderThreadsViewShell(parent, { status: "1 thread", loading: false, rows: [row] }, actions); + renderThreadsViewShell(parent, { status: null, loading: false, rows: [row] }, actions); expect(parent.querySelector(".codex-panel-threads__rename-input")?.disabled).toBe(true); const cancelAutoName = expectPresent(parent.querySelector('[aria-label="Cancel auto-name"]')); diff --git a/tests/features/threads-view/view.test.ts b/tests/features/threads-view/view.test.ts index 170abc81..ac4bd726 100644 --- a/tests/features/threads-view/view.test.ts +++ b/tests/features/threads-view/view.test.ts @@ -9,6 +9,7 @@ import { createThreadOperationsTransport, createThreadTitleTransport } from "../ import { createThreadNameMutationCoordinator } from "../../../src/features/threads/workflows/thread-name-mutation-coordinator"; import type { ThreadsViewHost } from "../../../src/features/threads-view/session"; import { DEFAULT_SETTINGS } from "../../../src/settings/model"; +import { notices } from "../../mocks/obsidian"; import { deferred, waitForAsyncWork } from "../../support/async"; import { changeInputValue, installObsidianDomShims } from "../../support/dom"; @@ -80,6 +81,7 @@ installObsidianDomShims(); describe("CodexThreadsView", () => { beforeEach(() => { vi.useRealTimers(); + notices.length = 0; connectionMock.reset(); namingMock.generateThreadTitleWithCodex.mockReset(); }); @@ -129,7 +131,26 @@ describe("CodexThreadsView", () => { await view.refresh(); - expect(view.containerEl.textContent).toContain("Codex app-server stopped."); + const status = view.containerEl.querySelector(".codex-panel-threads__status"); + expect(status?.textContent).toContain("Codex app-server stopped."); + expect(status?.classList.contains("codex-panel-ui__nav-item")).toBe(false); + expect(view.containerEl.querySelector(".codex-panel-threads__empty")).toBeNull(); + }); + + it("keeps existing threads and notifies when an explicit refresh fails", async () => { + const listThreads = vi + .fn() + .mockResolvedValueOnce({ data: [threadFixture({ id: "thread", preview: "Cached thread" })] }) + .mockRejectedValueOnce(new Error("Refresh failed.")); + connectionMock.state.client = clientFixture({ "thread/list": listThreads }); + const view = await threadsView(); + await waitForAsyncWork(() => expect(view.containerEl.textContent).toContain("Cached thread")); + + await view.refresh(); + + expect(view.containerEl.textContent).toContain("Cached thread"); + expect(view.containerEl.querySelector(".codex-panel-threads__status")).toBeNull(); + expect(notices).toContain("Refresh failed."); }); it("uses the shared query observer as the authoritative list projection", async () => { @@ -236,6 +257,27 @@ describe("CodexThreadsView", () => { expect(view.containerEl.querySelector(".codex-panel-threads__load-more")).toBeNull(); }); + it("keeps existing threads and notifies when loading another page fails", async () => { + const first = threadFromRecord(threadFixture({ id: "first", preview: "First page" })); + const loadMoreActive = vi.fn().mockRejectedValue(new Error("Load more failed.")); + const view = await threadsView( + threadsHost({ + threadCatalog: { + activeSnapshot: vi.fn(() => [first]), + loadActive: vi.fn().mockResolvedValue([first]), + hasMoreActive: vi.fn(() => true), + loadMoreActive, + }, + }), + ); + + view.containerEl.querySelector(".codex-panel-threads__load-more")?.click(); + await waitForAsyncWork(() => expect(notices).toContain("Load more failed.")); + + expect(view.containerEl.textContent).toContain("First page"); + expect(view.containerEl.querySelector(".codex-panel-threads__status")).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); @@ -301,6 +343,34 @@ describe("CodexThreadsView", () => { expect(view.containerEl.textContent).not.toContain("boom"); }); + it("keeps observed refresh failures out of an existing thread list", async () => { + let observedThreads!: (result: ObservedPaginatedResult) => void; + const existing = threadFromRecord(threadFixture({ id: "existing", preview: "Existing thread" })); + const view = await threadsView( + threadsHost({ + threadCatalog: { + loadActive: vi.fn( + () => + new Promise(() => { + // Drive the shared query state directly. + }), + ), + observeActive: vi.fn((listener: (result: ObservedPaginatedResult) => void) => { + observedThreads = listener; + return () => undefined; + }), + }, + }), + ); + + observedThreads(queryResult([existing])); + observedThreads(queryResult([existing], new Error("Background refresh failed."))); + + expect(view.containerEl.textContent).toContain("Existing thread"); + expect(view.containerEl.textContent).not.toContain("Background refresh failed."); + expect(view.containerEl.querySelector(".codex-panel-threads__status")).toBeNull(); + }); + it("publishes an archive catalog event without closing panel leaves", async () => { const archiveThread = vi.fn().mockResolvedValue({}); connectionMock.state.client = clientFixture({ @@ -446,6 +516,42 @@ describe("CodexThreadsView", () => { expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("New draft"); expect(view.containerEl.textContent).not.toContain("Older save failed."); + expect(notices).not.toContain("Older save failed."); + }); + + it("notifies a current rename failure without adding list status", async () => { + connectionMock.state.client = clientFixture({ + "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + "thread/name/set": vi.fn().mockRejectedValue(new Error("Rename failed.")), + }); + const view = await threadsView(); + await waitForAsyncWork(() => expect(view.containerEl.textContent).toContain("Thread preview")); + + view.containerEl.querySelector('[aria-label="Rename thread"]')?.click(); + const input = view.containerEl.querySelector(".codex-panel-threads__rename-input"); + if (!input) throw new Error("Missing rename input"); + changeInputValue(input, "New name"); + input.dispatchEvent(new FocusEvent("blur")); + await waitForAsyncWork(() => expect(notices).toContain("Rename failed.")); + + expect(view.containerEl.querySelector(".codex-panel-threads__status")).toBeNull(); + expect(view.containerEl.querySelector(".codex-panel-threads__rename-input")?.value).toBe("New name"); + }); + + it("notifies an archive failure without adding list status", async () => { + connectionMock.state.client = clientFixture({ + "thread/list": vi.fn().mockResolvedValue({ data: [threadFixture({ id: "thread", preview: "Thread preview" })] }), + "thread/archive": vi.fn().mockRejectedValue(new Error("Archive failed.")), + }); + const view = await threadsView(); + await waitForAsyncWork(() => expect(view.containerEl.textContent).toContain("Thread preview")); + + view.containerEl.querySelector('[aria-label="Archive thread"]')?.click(); + view.containerEl.querySelector('[aria-label="Archive thread without saving"]')?.click(); + await waitForAsyncWork(() => expect(notices).toContain("Archive failed.")); + + expect(view.containerEl.querySelector(".codex-panel-threads__status")).toBeNull(); + expect(view.containerEl.querySelector(".codex-panel-threads__archive-confirm")).not.toBeNull(); }); it("auto-names a thread rename draft from completed history", async () => {