fix(threads): keep errors out of navigation rows

This commit is contained in:
murashit 2026-07-21 13:16:50 +09:00
parent 2fb7d29841
commit f707da0274
8 changed files with 209 additions and 29 deletions

View file

@ -368,7 +368,11 @@ function ThreadList({
{threads.map((thread) => (
<ThreadListRow key={thread.threadId} thread={thread} actions={actions} />
))}
{error ? <ToolbarPanelItem label={error} className="codex-panel__thread codex-panel__thread--error" interactive={false} /> : null}
{error ? (
<div className="codex-panel__thread-list-status codex-panel__thread-list-status--error" role="status">
{error}
</div>
) : null}
{hasMore ? (
<ToolbarPanelItem
label={loadingMore ? "Loading more threads…" : "Load more threads"}

View file

@ -44,12 +44,7 @@ export interface ThreadsViewSessionEnvironment {
viewWindow(): Window | null;
}
type ThreadsViewStatus =
| { kind: "idle" }
| { kind: "loading"; message: string }
| { kind: "empty"; message: string }
| { kind: "log"; message: string }
| { kind: "error"; message: string };
type ThreadsViewStatus = { kind: "idle" } | { kind: "loading"; message: string } | { kind: "error"; message: string };
interface ThreadsViewOperationLease {
readonly lifetime: AbortSignal;
@ -148,6 +143,8 @@ export class ThreadsViewSession {
if (!this.currentThreadsSnapshot()) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
} else {
this.noticeError(error);
}
}
}
@ -159,8 +156,7 @@ export class ThreadsViewSession {
await this.host.threadCatalog.loadMoreActive();
} catch (error) {
if (!this.lifetime.isCurrent(lifetime) || isStaleExecutionRuntimeError(error)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
this.noticeError(error);
}
}
@ -177,13 +173,10 @@ export class ThreadsViewSession {
this.observedFetchingNextPage = result.isFetchingNextPage;
const observedThreads = result.value;
if (observedThreads) {
const hadThreadsSnapshot = this.currentThreadsSnapshot() !== null;
this.threads = observedThreads;
this.threadsLoaded = true;
this.status = result.error
? { kind: "error", message: result.error.message }
: observedThreads.length === 0
? { kind: "empty", message: "No threads" }
: { kind: "idle" };
this.status = result.error && !hadThreadsSnapshot ? { kind: "error", message: result.error.message } : { kind: "idle" };
this.render();
return;
}
@ -213,7 +206,7 @@ export class ThreadsViewSession {
renderThreadsViewShell(
this.environment.root,
{
status: this.status.kind === "idle" ? null : this.status.message,
status: this.status.kind === "idle" ? null : this.status,
loading: this.observedFetchingNextPage,
fetching: this.observedFetching,
hasMore: this.host.threadCatalog.hasMoreActive(),
@ -339,7 +332,7 @@ export class ThreadsViewSession {
} catch (error) {
if (!this.operationViewIsCurrent(lease)) return;
if (this.renameStates.get(threadId) === generatingState) {
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.noticeError(error);
}
} finally {
const operation = this.renameGenerationControllers.get(threadId);
@ -378,8 +371,7 @@ export class ThreadsViewSession {
this.renameStates.delete(threadId);
} catch (error) {
if (!this.operationViewIsCurrent(lease)) return;
this.status = { kind: "error", message: error instanceof Error ? error.message : String(error) };
this.render();
this.noticeError(error);
}
}
@ -395,6 +387,10 @@ export class ThreadsViewSession {
return this.lifetime.isCurrent(lease.lifetime);
}
private noticeError(error: unknown): void {
new Notice(error instanceof Error ? error.message : String(error));
}
private finishAutoNameThread(threadId: string, generationToken: number): void {
const previousState = this.renameStates.get(threadId);
const nextState = this.transitionRenameState(threadId, { type: "generation-finished", generationToken });

View file

@ -9,7 +9,7 @@ type ButtonProps = ButtonHTMLAttributes & {
};
export interface ThreadsViewShellModel {
status: string | null;
status: { kind: "loading" | "error"; message: string } | null;
loading: boolean;
fetching?: boolean;
hasMore?: boolean;
@ -66,10 +66,22 @@ function ThreadsViewShell({ model, actions }: { model: ThreadsViewShellModel; ac
</div>
<div className="codex-panel-threads__list">
{model.rows.length === 0 ? (
<div className="codex-panel-threads__empty">{model.status ?? (model.loading ? "Loading threads..." : "No threads")}</div>
model.status ? (
<div
className={
model.status.kind === "error"
? "codex-panel-threads__status codex-panel-threads__status--error"
: "codex-panel-threads__status"
}
role="status"
>
{model.status.message}
</div>
) : (
<div className="codex-panel-threads__empty">No threads</div>
)
) : (
<>
{model.status ? <div className="codex-panel-threads__status">{model.status}</div> : null}
{model.rows.map((row) => (
<ThreadRow key={row.threadId} row={row} actions={actions} />
))}

View file

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

View file

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

View file

@ -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<HTMLElement>(".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> = {}): ToolbarViewModel {

View file

@ -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<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".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<HTMLButtonElement>('[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<HTMLInputElement>(".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<HTMLElement>(".codex-panel-threads__rename-form")).toBeTruthy();
parent.querySelector<HTMLButtonElement>('[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<HTMLInputElement>(".codex-panel-threads__rename-input")?.disabled).toBe(true);
const cancelAutoName = expectPresent(parent.querySelector<HTMLButtonElement>('[aria-label="Cancel auto-name"]'));

View file

@ -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<HTMLElement>(".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<HTMLButtonElement>(".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<readonly Thread[]>) => 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<readonly Thread[]>) => 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<HTMLInputElement>(".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<HTMLButtonElement>('[aria-label="Rename thread"]')?.click();
const input = view.containerEl.querySelector<HTMLInputElement>(".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<HTMLInputElement>(".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<HTMLButtonElement>('[aria-label="Archive thread"]')?.click();
view.containerEl.querySelector<HTMLButtonElement>('[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 () => {