mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import type { ServerNotification } from "../../../../src/app-server/connection/rpc-messages";
|
|
import { deferred, waitForAsyncWork } from "../../../support/async";
|
|
import {
|
|
chatHost,
|
|
chatView,
|
|
completedTurn,
|
|
composerElement,
|
|
composerPlaceholder,
|
|
connectedClient,
|
|
connectionMockState,
|
|
expectRequestTimes,
|
|
panelThread,
|
|
requestMethods,
|
|
resumedThread,
|
|
setupViewConnectionHarness,
|
|
turnWithUserMessage,
|
|
} from "./view-connection-harness";
|
|
|
|
describe("CodexChatView thread state", () => {
|
|
setupViewConnectionHarness();
|
|
|
|
it("requests a workspace layout save after resuming a thread", async () => {
|
|
const requestSaveLayout = vi.fn();
|
|
const client = connectedClient();
|
|
connectionMockState().client = client;
|
|
const view = await chatView({ requestSaveLayout });
|
|
|
|
await view.surface.openThread("thread-1");
|
|
|
|
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
|
expect(requestSaveLayout).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("resumes another persistent thread before unsubscribing a running subagent", async () => {
|
|
const client = connectedClient({
|
|
"thread/resume": vi.fn((params: unknown) => {
|
|
const threadId = (params as { threadId: string }).threadId;
|
|
return Promise.resolve(
|
|
threadId === "child"
|
|
? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" })
|
|
: resumedThread(threadId),
|
|
);
|
|
}),
|
|
"thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }),
|
|
});
|
|
connectionMockState().client = client;
|
|
const view = await chatView();
|
|
|
|
await view.surface.openThread("child");
|
|
connectionMockState().onNotification?.({
|
|
method: "turn/started",
|
|
params: {
|
|
threadId: "child",
|
|
turn: {
|
|
id: "turn-child",
|
|
status: "inProgress",
|
|
startedAt: 1,
|
|
completedAt: null,
|
|
durationMs: null,
|
|
error: null,
|
|
itemsView: "full",
|
|
items: [],
|
|
},
|
|
},
|
|
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
|
|
|
|
await view.surface.openThread("other");
|
|
|
|
const unsubscribeCall = client.request.mock.calls.findIndex(([method]) => method === "thread/unsubscribe");
|
|
const otherResumeCall = client.request.mock.calls.findIndex(
|
|
([method, params]) => method === "thread/resume" && (params as { threadId: string }).threadId === "other",
|
|
);
|
|
expect(unsubscribeCall).toBeGreaterThanOrEqual(0);
|
|
expect(unsubscribeCall).toBeGreaterThan(otherResumeCall);
|
|
expect(client.request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "other" });
|
|
});
|
|
|
|
it("keeps a running subagent subscribed when openThread cannot resume the target", async () => {
|
|
const client = connectedClient({
|
|
"thread/resume": vi.fn((params: unknown) => {
|
|
const threadId = (params as { threadId: string }).threadId;
|
|
return Promise.resolve(
|
|
threadId === "child" ? resumedThread(threadId, { parentThreadId: "parent", threadSource: "subAgentThreadSpawn" }) : null,
|
|
);
|
|
}),
|
|
"thread/unsubscribe": vi.fn().mockResolvedValue({ status: "unsubscribed" }),
|
|
});
|
|
connectionMockState().client = client;
|
|
const view = await chatView();
|
|
|
|
await view.surface.openThread("child");
|
|
connectionMockState().onNotification?.({
|
|
method: "turn/started",
|
|
params: {
|
|
threadId: "child",
|
|
turn: {
|
|
id: "turn-child",
|
|
status: "inProgress",
|
|
startedAt: 1,
|
|
completedAt: null,
|
|
durationMs: null,
|
|
error: null,
|
|
itemsView: "full",
|
|
items: [],
|
|
},
|
|
},
|
|
} satisfies Extract<ServerNotification, { method: "turn/started" }>);
|
|
|
|
await view.surface.openThread("other");
|
|
|
|
expect(requestMethods(client).filter((method) => method === "thread/unsubscribe")).toEqual([]);
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: "child", turnBusy: true });
|
|
});
|
|
|
|
it("resets to an unstarted empty chat without starting a thread", async () => {
|
|
const requestSaveLayout = vi.fn();
|
|
const client = connectedClient();
|
|
connectionMockState().client = client;
|
|
const view = await chatView({ requestSaveLayout });
|
|
|
|
await view.surface.openThread("thread-1");
|
|
await view.surface.startNewThread();
|
|
|
|
expect(requestMethods(client)).not.toContain("thread/start");
|
|
expect(view.getState()).toEqual({ version: 1 });
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false });
|
|
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("restores an unavailable side-chat tab as a normal empty chat", async () => {
|
|
const view = await chatView();
|
|
await view.setState({ version: 2, ephemeralSource: { threadId: "source", title: "Source" } }, {} as never);
|
|
|
|
expect(view.getState()).toEqual({ version: 1 });
|
|
expect(view.getDisplayText()).not.toBe("Side chat");
|
|
expect(view.surface.openPanelSnapshot()).toMatchObject({ threadId: null, turnBusy: false, hasComposerDraft: false });
|
|
expect(view.containerEl.textContent).not.toContain("This side conversation is no longer available.");
|
|
});
|
|
|
|
it("focuses the composer after panel thread actions", async () => {
|
|
const client = connectedClient();
|
|
connectionMockState().client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
const focus = vi.spyOn(HTMLTextAreaElement.prototype, "focus").mockImplementation(() => undefined);
|
|
|
|
await view.surface.openThread("thread-1");
|
|
await view.surface.focusThread("thread-1");
|
|
await view.surface.startNewThread();
|
|
|
|
expect(focus).toHaveBeenCalledTimes(3);
|
|
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
|
});
|
|
|
|
it("clears the active thread when another view archives it", async () => {
|
|
const requestSaveLayout = vi.fn();
|
|
const client = connectedClient();
|
|
connectionMockState().client = client;
|
|
const view = await chatView({ requestSaveLayout });
|
|
|
|
await view.surface.openThread("thread-1");
|
|
view.surface.applyThreadArchived("thread-1");
|
|
|
|
expect(view.getState()).toEqual({ version: 1 });
|
|
expect(requestSaveLayout).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("updates restored panel title from shared rename notifications", async () => {
|
|
const view = await chatView();
|
|
|
|
await view.setState({ threadId: "thread-1", threadTitle: "Before rename" }, {} as never);
|
|
view.surface.applyThreadRenamed("thread-1", "After rename");
|
|
|
|
expect(view.getDisplayText()).toBe("Codex: After rename");
|
|
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "After rename" });
|
|
});
|
|
|
|
it("does not use restored thread identity as a composer name before the thread becomes active", async () => {
|
|
const host = chatHost();
|
|
const view = await chatView({ host });
|
|
|
|
await view.setState({ threadId: "thread-1", threadTitle: "Restored title" }, {} as never);
|
|
await view.onOpen();
|
|
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex...");
|
|
|
|
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Explicit name" })]);
|
|
await waitForAsyncWork(() => {
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex...");
|
|
});
|
|
|
|
view.surface.applyThreadRenamed("thread-1", "Explicit name");
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(composerPlaceholder(view)).toBe("Ask Codex...");
|
|
});
|
|
});
|
|
|
|
it("keeps composer draft and selection while updating the placeholder", async () => {
|
|
const client = connectedClient();
|
|
connectionMockState().client = client;
|
|
const host = chatHost();
|
|
const view = await chatView({ host });
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
view.surface.setComposerText("keep this draft");
|
|
const composer = composerElement(view);
|
|
await waitForAsyncWork(() => {
|
|
expect(composer.value).toBe("keep this draft");
|
|
});
|
|
composer.setSelectionRange(5, 9);
|
|
|
|
host.receiveActiveThreads([panelThread({ id: "thread-1", name: "Renamed thread" })]);
|
|
view.surface.applyThreadRenamed("thread-1", "Renamed thread");
|
|
|
|
await waitForAsyncWork(() => {
|
|
expect(composer.value).toBe("keep this draft");
|
|
expect(composer.selectionStart).toBe(5);
|
|
expect(composer.selectionEnd).toBe(9);
|
|
expect(composer.getAttribute("placeholder")).toBe("Ask Codex in “Renamed thread”...");
|
|
});
|
|
});
|
|
|
|
it("renders resumed thread metadata before history hydration completes", async () => {
|
|
const history = deferred<{ data: unknown[]; nextCursor: null }>();
|
|
const client = connectedClient({
|
|
"thread/turns/list": vi.fn(() => history.promise),
|
|
});
|
|
connectionMockState().client = client;
|
|
const view = await chatView();
|
|
|
|
const opening = view.surface.openThread("thread-1");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"thread/turns/list",
|
|
expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }),
|
|
);
|
|
});
|
|
|
|
expect(view.getState()).toEqual({ version: 1, threadId: "thread-1", threadTitle: "Restored thread" });
|
|
expect(view.containerEl.textContent).not.toContain("Loading thread...");
|
|
|
|
history.resolve({ data: [], nextCursor: null });
|
|
await opening;
|
|
});
|
|
|
|
it("hydrates resumed threads from the initial turns page without a second history request", async () => {
|
|
const client = connectedClient({
|
|
"thread/resume": vi.fn().mockResolvedValue({
|
|
...resumedThread("thread-1"),
|
|
initialTurnsPage: {
|
|
data: [completedTurn("turn-1")],
|
|
nextCursor: "older-cursor",
|
|
backwardsCursor: null,
|
|
},
|
|
}),
|
|
"thread/turns/list": vi.fn().mockResolvedValue({ data: [turnWithUserMessage("fallback prompt")], nextCursor: null }),
|
|
});
|
|
connectionMockState().client = client;
|
|
const view = await chatView();
|
|
|
|
await view.onOpen();
|
|
await view.surface.openThread("thread-1");
|
|
|
|
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
|
|
expect(requestMethods(client)).not.toContain("thread/turns/list");
|
|
await waitForAsyncWork(() => {
|
|
expect(view.containerEl.textContent).toContain("hello");
|
|
expect(view.containerEl.textContent).toContain("done");
|
|
});
|
|
});
|
|
|
|
it("ignores stale resume results when another thread is opened first", async () => {
|
|
const firstResume = deferred<ReturnType<typeof resumedThread>>();
|
|
const secondResume = deferred<ReturnType<typeof resumedThread>>();
|
|
const client = connectedClient({
|
|
"thread/resume": vi.fn((params: unknown) =>
|
|
(params as { threadId: string }).threadId === "thread-1" ? firstResume.promise : secondResume.promise,
|
|
),
|
|
});
|
|
connectionMockState().client = client;
|
|
const view = await chatView();
|
|
|
|
const firstOpen = view.surface.openThread("thread-1");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-1", cwd: "/vault" }));
|
|
});
|
|
const secondOpen = view.surface.openThread("thread-2");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.request).toHaveBeenCalledWith("thread/resume", expect.objectContaining({ threadId: "thread-2", cwd: "/vault" }));
|
|
});
|
|
|
|
secondResume.resolve(resumedThread("thread-2"));
|
|
await secondOpen;
|
|
firstResume.resolve(resumedThread("thread-1"));
|
|
await firstOpen;
|
|
|
|
expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" });
|
|
expectRequestTimes(client, "thread/turns/list", 1);
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"thread/turns/list",
|
|
expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }),
|
|
);
|
|
});
|
|
|
|
it("invalidates stale history hydration when a second resume starts", async () => {
|
|
const firstHistory = deferred<{ data: unknown[]; nextCursor: null }>();
|
|
const client = connectedClient({
|
|
"thread/resume": vi.fn((params: unknown) => Promise.resolve(resumedThread((params as { threadId: string }).threadId))),
|
|
"thread/turns/list": vi.fn((params: unknown) =>
|
|
(params as { threadId: string }).threadId === "thread-1" ? firstHistory.promise : Promise.resolve({ data: [], nextCursor: null }),
|
|
),
|
|
});
|
|
connectionMockState().client = client;
|
|
const view = await chatView();
|
|
|
|
const firstOpen = view.surface.openThread("thread-1");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"thread/turns/list",
|
|
expect.objectContaining({ threadId: "thread-1", cursor: null, limit: 20 }),
|
|
);
|
|
});
|
|
const secondOpen = view.surface.openThread("thread-2");
|
|
await waitForAsyncWork(() => {
|
|
expect(client.request).toHaveBeenCalledWith(
|
|
"thread/turns/list",
|
|
expect.objectContaining({ threadId: "thread-2", cursor: null, limit: 20 }),
|
|
);
|
|
});
|
|
|
|
firstHistory.resolve({ data: [turnWithUserMessage("first prompt")], nextCursor: null });
|
|
await firstOpen;
|
|
await secondOpen;
|
|
|
|
expect(view.getState()).toEqual({ version: 1, threadId: "thread-2", threadTitle: "Restored thread" });
|
|
expect(view.containerEl.textContent).not.toContain("first prompt");
|
|
});
|
|
});
|