mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
fix(threads): keep errors out of navigation rows
This commit is contained in:
parent
2fb7d29841
commit
f707da0274
8 changed files with 209 additions and 29 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"]'));
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue