mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
689 lines
26 KiB
TypeScript
689 lines
26 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { type ComponentChild, h } from "preact";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
import { type ConfigReadResult, runtimeConfigSnapshotFromAppServerConfig } from "../../../../../src/app-server/protocol/runtime-config";
|
|
import type { ModelMetadata } from "../../../../../src/domain/catalog/metadata";
|
|
import type { RuntimeConfigSnapshot } from "../../../../../src/domain/runtime/config";
|
|
import { createServerDiagnostics } from "../../../../../src/domain/server/diagnostics";
|
|
import type { ThreadGoal } from "../../../../../src/domain/threads/goal";
|
|
import type { Thread } from "../../../../../src/domain/threads/model";
|
|
import type { ChatState } from "../../../../../src/features/chat/application/state/root-reducer";
|
|
import { createChatStateStore } from "../../../../../src/features/chat/application/state/store";
|
|
import { setCollaborationModeIntent } from "../../../../../src/features/chat/domain/runtime/intent";
|
|
import {
|
|
selectChatPanelComposer,
|
|
selectChatPanelGoal,
|
|
selectChatPanelThreadStream,
|
|
selectChatPanelToolbar,
|
|
} from "../../../../../src/features/chat/panel/shell-selectors";
|
|
import type { ChatPanelComposerProjectionActions } from "../../../../../src/features/chat/panel/surface/composer-projection";
|
|
import { chatPanelComposerProjection } from "../../../../../src/features/chat/panel/surface/composer-projection";
|
|
import { ChatPanelGoal, type ChatPanelGoalSurface } from "../../../../../src/features/chat/panel/surface/goal-projection";
|
|
import {
|
|
type ChatThreadStreamSurfaceContext,
|
|
threadStreamSurfaceProjectionFromModel,
|
|
} from "../../../../../src/features/chat/panel/surface/thread-stream-projection";
|
|
import { ChatPanelToolbar } from "../../../../../src/features/chat/panel/surface/toolbar-projection";
|
|
import type { ToolbarActions } from "../../../../../src/features/chat/ui/toolbar";
|
|
import { renderUiRoot, unmountUiRoot } from "../../../../../src/shared/dom/preact-root.dom";
|
|
import { installObsidianDomShims } from "../../../../support/dom";
|
|
import { composerModelFromChatState } from "../../support/shell-selectors";
|
|
import { chatStateFixture, chatStateWith } from "../../support/state";
|
|
import { withChatStateThreadStreamItems } from "../../support/thread-stream";
|
|
|
|
installObsidianDomShims();
|
|
|
|
describe("chat panel surface projections", () => {
|
|
it("disables subagent chat actions except starting a new chat", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, {
|
|
activeThread: {
|
|
id: "child",
|
|
provenance: {
|
|
kind: "subagent",
|
|
subagentKind: "thread-spawn",
|
|
parentThreadId: "parent",
|
|
sessionId: "session",
|
|
depth: 1,
|
|
agentNickname: "Scout",
|
|
agentRole: "explorer",
|
|
},
|
|
},
|
|
turn: { lifecycle: { kind: "running", turnId: "child-turn" } },
|
|
ui: { toolbarPanel: "chat-actions" },
|
|
});
|
|
const actions = toolbarActionsFixture();
|
|
const parent = renderWithShellModels(state, (models) =>
|
|
h(ChatPanelToolbar, { model: models.toolbar, stateStore: createChatStateStore(state), surface: toolbarSurfaceFixture(), actions }),
|
|
);
|
|
|
|
const items = [...parent.querySelectorAll<HTMLElement>(".codex-panel__chat-actions-panel-item")];
|
|
expect(items.map((item) => [item.textContent, item.classList.contains("is-disabled")])).toEqual([
|
|
["Start new chat", false],
|
|
["Start side chat", true],
|
|
["Compact context", true],
|
|
["Set goal...", true],
|
|
]);
|
|
unmountUiRoot(parent);
|
|
});
|
|
|
|
it("does not project rollback actions for side chats", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, {
|
|
activeThread: {
|
|
id: "side-thread",
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "source", sourceThreadTitle: "Source" },
|
|
},
|
|
});
|
|
state = withChatStateThreadStreamItems(state, [
|
|
{ id: "user", kind: "dialogue", dialogueKind: "user", role: "user", text: "Question", turnId: "turn" },
|
|
{
|
|
id: "assistant",
|
|
kind: "dialogue",
|
|
dialogueKind: "assistantResponse",
|
|
dialogueState: "completed",
|
|
role: "assistant",
|
|
text: "Answer",
|
|
turnId: "turn",
|
|
},
|
|
]);
|
|
|
|
const projection = threadStreamSurfaceProjectionFromModel(selectChatPanelThreadStream(state), threadStreamSurfaceContext());
|
|
expect(JSON.stringify(projection.blocks)).not.toContain('"rollback":true');
|
|
});
|
|
|
|
it("builds toolbar rows from immutable chat state snapshots", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
|
|
state = chatStateWith(state, {
|
|
threadList: { listedThreads: [threadFixture("thread-1", "Active"), threadFixture("thread-2", "Other")] },
|
|
});
|
|
state = chatStateWith(state, { turn: { lifecycle: { kind: "running", turnId: "turn" } } });
|
|
state = chatStateWith(state, { ui: { toolbarPanel: "history" } });
|
|
state = chatStateWith(state, { ui: { archiveConfirmThreadId: "thread-2" } });
|
|
state = chatStateWith(state, { ui: { rename: { kind: "editing", threadId: "thread-1", draft: "Active" } } });
|
|
state = chatStateWith(state, {
|
|
connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }) },
|
|
});
|
|
state = chatStateWith(state, { connection: { serverDiagnostics: createServerDiagnostics() } });
|
|
|
|
const parent = renderWithShellModels(state, (models) =>
|
|
h(ChatPanelToolbar, {
|
|
model: models.toolbar,
|
|
stateStore: createChatStateStore(state),
|
|
surface: toolbarSurfaceFixture({ archiveExportEnabled: true }),
|
|
actions: toolbarActionsFixture(),
|
|
}),
|
|
);
|
|
|
|
expect(parent.querySelector('[data-codex-panel-toolbar-panel="history"]')).not.toBeNull();
|
|
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.tagName).toBe("DIV");
|
|
expect(parent.querySelector<HTMLElement>(".codex-panel__new-chat")?.classList.contains("is-disabled")).toBe(false);
|
|
expect(parent.querySelector<HTMLInputElement>(".codex-panel__thread-row--selected .codex-panel__thread-rename-input")?.value).toBe(
|
|
"Active",
|
|
);
|
|
expect(parent.querySelector(".codex-panel__thread-row--archive-confirming .codex-panel__toolbar-panel-label")?.textContent).toBe(
|
|
"Other",
|
|
);
|
|
const archivedThread = parent.querySelectorAll<HTMLElement>(".codex-panel__thread")[1];
|
|
expect(archivedThread?.tagName).toBe("DIV");
|
|
expect(archivedThread?.classList.contains("is-disabled")).toBe(true);
|
|
unmountUiRoot(parent);
|
|
});
|
|
|
|
it("renders raw runtime debug details from toolbar state sources", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } });
|
|
state = chatStateWith(state, {
|
|
connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-debug", approval_policy: "on-request" }) },
|
|
});
|
|
state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-debug")] } });
|
|
state = chatStateWith(state, { runtime: { pending: { model: { kind: "set", value: "gpt-debug" } } } });
|
|
const stateStore = createChatStateStore(state);
|
|
|
|
const copyDebugDetails = vi.fn<(details: string) => void>();
|
|
const parent = renderWithShellModels(state, (models) =>
|
|
h(ChatPanelToolbar, {
|
|
model: models.toolbar,
|
|
stateStore,
|
|
surface: toolbarSurfaceFixture(),
|
|
actions: toolbarActionsFixture({ copyDebugDetails }),
|
|
}),
|
|
);
|
|
stateStore.dispatch({ type: "runtime/model-requested", model: "gpt-live" });
|
|
|
|
parent.querySelectorAll<HTMLButtonElement>(".codex-panel__status-panel-item")[2]?.click();
|
|
const debugContent = copyDebugDetails.mock.calls[0]?.[0];
|
|
if (!debugContent) throw new Error("Expected toolbar debug details");
|
|
const debugDetails = JSON.parse(debugContent) as Record<string, unknown>;
|
|
|
|
expect(debugDetails["vaultPath"]).toBe("/vault");
|
|
expect(debugDetails["configuredCommand"]).toBe("codex");
|
|
expect(debugDetails["clientVersion"]).toEqual(expect.any(String));
|
|
expect(debugDetails["activeThreadId"]).toBeNull();
|
|
expect(debugDetails["connection"]).toMatchObject({
|
|
connected: true,
|
|
phase: { kind: "idle" },
|
|
statusText: "Idle",
|
|
initializeResponse: null,
|
|
rateLimit: null,
|
|
serverDiagnostics: {
|
|
probes: expect.any(Object),
|
|
mcpServers: [],
|
|
},
|
|
});
|
|
expect(
|
|
(debugDetails["connection"] as { serverDiagnostics?: Record<string, unknown> }).serverDiagnostics?.["toolInventory"],
|
|
).toBeUndefined();
|
|
expect(debugDetails["runtimeConfig"]).toMatchObject({ model: "gpt-debug" });
|
|
expect(debugDetails["runtime"]).toMatchObject({ pending: { model: { kind: "set", value: "gpt-live" } } });
|
|
expect(debugDetails["runtimeLayers"]).toBeUndefined();
|
|
expect(debugDetails["runtimeResolution"]).toBeUndefined();
|
|
expect(debugDetails["availableModels"]).toMatchObject([{ model: "gpt-debug" }]);
|
|
unmountUiRoot(parent);
|
|
});
|
|
|
|
it("renders new thread permission baseline in the status panel before a thread is active", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { ui: { toolbarPanel: "status-panel" } });
|
|
state = chatStateWith(state, {
|
|
connection: {
|
|
runtimeConfig: runtimeConfigFixture({
|
|
default_permissions: ":workspace",
|
|
approval_policy: "on-request",
|
|
approvals_reviewer: "user",
|
|
}),
|
|
},
|
|
});
|
|
state = chatStateWith(state, {
|
|
runtime: {
|
|
pending: {
|
|
approvalsReviewer: { kind: "set", value: "auto_review" },
|
|
approvalPolicy: { kind: "unchanged" },
|
|
permissionProfile: { kind: "unchanged" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const parent = renderWithShellModels(state, (models) =>
|
|
h(ChatPanelToolbar, {
|
|
model: models.toolbar,
|
|
stateStore: createChatStateStore(state),
|
|
surface: toolbarSurfaceFixture(),
|
|
actions: toolbarActionsFixture(),
|
|
}),
|
|
);
|
|
|
|
expect(parent.textContent).toContain("Permissions & Approvals");
|
|
expect(parent.textContent).toContain("Permissions");
|
|
expect(parent.textContent).toContain("Approvals");
|
|
expect([...parent.querySelectorAll(".codex-panel__status-diagnostics-section")].map((section) => section.textContent)).not.toContain(
|
|
"New thread",
|
|
);
|
|
expect(parent.textContent).toContain(":workspace");
|
|
expect(parent.textContent).toContain("on-request");
|
|
expect(parent.textContent).toContain("off");
|
|
expect(parent.textContent).not.toContain("auto_review");
|
|
expect(parent.textContent).not.toContain("user -> auto_review");
|
|
unmountUiRoot(parent);
|
|
});
|
|
|
|
it("builds composer meta from context and runtime state", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
|
|
state = chatStateWith(state, { runtime: { pending: { collaborationMode: setCollaborationModeIntent("plan") } } });
|
|
state = chatStateWith(state, {
|
|
connection: {
|
|
runtimeConfig: runtimeConfigFixture({
|
|
model: "gpt-5.5",
|
|
model_reasoning_effort: "high",
|
|
approvals_reviewer: "auto_review",
|
|
service_tier: "fast",
|
|
}),
|
|
},
|
|
});
|
|
state = chatStateWith(state, {
|
|
activeThread: {
|
|
tokenUsage: {
|
|
last: { inputTokens: 42, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 44 },
|
|
total: { inputTokens: 40, cachedInputTokens: 0, outputTokens: 2, reasoningOutputTokens: 0, totalTokens: 42 },
|
|
modelContextWindow: 100,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), state).meta).toMatchObject({
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣿", placeholder: false },
|
|
{ text: "⣶", placeholder: false },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "42%",
|
|
},
|
|
statusSummary: "Context 42%, plan on, auto-review on, fast on, model gpt-5.5, reasoning effort high",
|
|
model: "gpt-5.5",
|
|
effort: "high",
|
|
planActive: true,
|
|
autoReviewActive: true,
|
|
});
|
|
});
|
|
|
|
it("uses a neutral composer context indicator when usage is unavailable", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
|
|
state = withChatStateThreadStreamItems(state, [
|
|
{
|
|
id: "item",
|
|
turnId: "turn-1",
|
|
kind: "dialogue",
|
|
dialogueKind: "assistantResponse",
|
|
dialogueState: "completed",
|
|
text: "Existing turn",
|
|
role: "assistant",
|
|
},
|
|
]);
|
|
state = chatStateWith(state, { connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5" }) } });
|
|
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), state).meta).toMatchObject({
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Context unavailable, plan off, auto-review off, fast off, model gpt-5.5, reasoning effort default",
|
|
model: "gpt-5.5",
|
|
effort: null,
|
|
});
|
|
});
|
|
|
|
it("shows zero percent composer context before a thread starts", () => {
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), chatStateFixture()).meta).toMatchObject({
|
|
fatal: null,
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: " 0%",
|
|
},
|
|
statusSummary: "Context 0%, plan off, auto-review off, fast off, model default, reasoning effort default",
|
|
});
|
|
});
|
|
|
|
it("keeps zero percent composer context fixed-width and visible", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { activeThread: { id: "thread-1" } });
|
|
state = chatStateWith(state, {
|
|
activeThread: {
|
|
tokenUsage: {
|
|
last: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 },
|
|
total: { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0, reasoningOutputTokens: 0, totalTokens: 0 },
|
|
modelContextWindow: 100,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), state).meta).toMatchObject({
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: " 0%",
|
|
},
|
|
statusSummary: "Context 0%, plan off, auto-review off, fast off, model default, reasoning effort default",
|
|
});
|
|
});
|
|
|
|
it("replaces composer meta with fatal connection state", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { connection: { phase: { kind: "failed", message: "Connection failed." } } });
|
|
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), state).meta).toMatchObject({
|
|
fatal: "Codex app-server disconnected",
|
|
context: {
|
|
cells: [
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
{ text: "⣀", placeholder: true },
|
|
],
|
|
percent: "--%",
|
|
},
|
|
statusSummary: "Codex app-server disconnected",
|
|
model: "",
|
|
effort: null,
|
|
planActive: false,
|
|
autoReviewActive: false,
|
|
});
|
|
});
|
|
|
|
it("builds runtime composer choices from immutable chat state snapshots", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, {
|
|
connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }) },
|
|
});
|
|
state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-5.5"), modelFixture("gpt-5-mini")] } });
|
|
const selectedModels: string[] = [];
|
|
const selectedEfforts: string[] = [];
|
|
|
|
const choices = composerProjectionFromState(
|
|
composerProjectionActionsFixture({
|
|
requestModel: async (model) => {
|
|
selectedModels.push(model);
|
|
},
|
|
requestReasoningEffort: async (effort) => {
|
|
selectedEfforts.push(effort);
|
|
},
|
|
}),
|
|
state,
|
|
).meta;
|
|
|
|
const modelChoices = choices.modelChoices ?? [];
|
|
const effortChoices = choices.effortChoices ?? [];
|
|
|
|
expect(modelChoices).toMatchObject([
|
|
{ label: "gpt-5-mini", selected: false },
|
|
{ label: "gpt-5.5", selected: true },
|
|
]);
|
|
expect(effortChoices).toMatchObject([{ label: "high", selected: true }]);
|
|
|
|
modelChoices[0]?.onClick();
|
|
effortChoices[0]?.onClick();
|
|
expect(selectedModels).toEqual(["gpt-5-mini"]);
|
|
expect(selectedEfforts).toEqual(["high"]);
|
|
});
|
|
|
|
it("routes goal status actions to the rendered goal thread", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { activeThread: { id: "thread-rendered" } });
|
|
state = chatStateWith(state, { activeThread: { goal: goalFixture("thread-rendered") } });
|
|
const statuses: [string, string][] = [];
|
|
const clears: string[] = [];
|
|
const surface = {
|
|
sendShortcut: () => "enter",
|
|
actions: {
|
|
saveObjective: async () => true,
|
|
setStatus: async (threadId, status) => {
|
|
statuses.push([threadId, status]);
|
|
},
|
|
clear: async (threadId) => {
|
|
clears.push(threadId);
|
|
},
|
|
startEditing: () => undefined,
|
|
updateObjectiveDraft: () => undefined,
|
|
setObjectiveExpanded: () => undefined,
|
|
closeEditor: () => undefined,
|
|
},
|
|
} satisfies ChatPanelGoalSurface;
|
|
|
|
const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface }));
|
|
state = chatStateWith(state, { activeThread: { id: "thread-current" } });
|
|
clickLabeledButton(parent, "Pause goal");
|
|
clickLabeledButton(parent, "Clear goal");
|
|
|
|
state = chatStateWith(state, { activeThread: { goal: { ...goalFixture("thread-rendered"), status: "paused" } } });
|
|
const resumeParent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface }));
|
|
clickLabeledButton(resumeParent, "Resume goal");
|
|
|
|
expect(statuses).toEqual([
|
|
["thread-rendered", "paused"],
|
|
["thread-rendered", "active"],
|
|
]);
|
|
expect(clears).toEqual(["thread-rendered"]);
|
|
unmountUiRoot(parent);
|
|
unmountUiRoot(resumeParent);
|
|
});
|
|
|
|
it("builds composer meta from one captured chat state", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, {
|
|
connection: { runtimeConfig: runtimeConfigFixture({ model: "gpt-5.5", model_reasoning_effort: "high" }) },
|
|
});
|
|
state = chatStateWith(state, { connection: { availableModels: [modelFixture("gpt-5.5")] } });
|
|
|
|
const projection = composerProjectionFromState(composerProjectionActionsFixture(), state);
|
|
|
|
expect(projection).toMatchObject({
|
|
placeholder: "Ask Codex...",
|
|
meta: {
|
|
model: "gpt-5.5",
|
|
effort: "high",
|
|
modelChoices: [{ label: "gpt-5.5", selected: true }],
|
|
effortChoices: [{ label: "high", selected: true }],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("derives composer placeholders", () => {
|
|
let activeState = chatStateFixture();
|
|
activeState = chatStateWith(activeState, { activeThread: { id: "thread-1" } });
|
|
activeState = chatStateWith(activeState, { threadList: { listedThreads: [threadFixture("thread-1", "Active")] } });
|
|
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe("Ask Codex in “Active”...");
|
|
activeState = chatStateWith(activeState, {
|
|
activeThread: {
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "thread-source", sourceThreadTitle: "Source title" },
|
|
},
|
|
});
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe(
|
|
"Ask in side chat for “Source title”...",
|
|
);
|
|
activeState = chatStateWith(activeState, {
|
|
activeThread: {
|
|
lifetime: { kind: "ephemeral", sourceThreadId: "thread-source", sourceThreadTitle: null },
|
|
},
|
|
});
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), activeState).placeholder).toBe("Ask in side chat...");
|
|
expect(composerProjectionFromState(composerProjectionActionsFixture(), chatStateFixture()).placeholder).toBe("Ask Codex...");
|
|
});
|
|
|
|
it("projects goal editor and disclosure state before action wiring", () => {
|
|
let state = chatStateFixture();
|
|
state = chatStateWith(state, { activeThread: { id: "thread-1", goal: goalFixture("thread-1") } });
|
|
state = chatStateWith(state, {
|
|
ui: { goalEditor: { kind: "editing", threadId: "thread-1", objectiveDraft: "Draft goal", tokenBudgetDraft: 1234 } },
|
|
});
|
|
state = chatStateWith(state, { ui: { disclosures: { goalObjectiveExpanded: new Set(["thread-1"]) } } });
|
|
|
|
const parent = renderWithShellModels(state, (models) => h(ChatPanelGoal, { model: models.goal, surface: goalSurfaceFixture() }));
|
|
|
|
expect(parent.querySelector<HTMLTextAreaElement>(".codex-panel__goal-objective-input")?.value).toBe("Draft goal");
|
|
unmountUiRoot(parent);
|
|
});
|
|
});
|
|
|
|
function renderWithShellModels(state: ChatState, node: (models: ReturnType<typeof shellModelsFromState>) => ComponentChild): HTMLElement {
|
|
const parent = document.createElement("div");
|
|
renderUiRoot(parent, node(shellModelsFromState(state)));
|
|
return parent;
|
|
}
|
|
|
|
function shellModelsFromState(state: ChatState) {
|
|
return {
|
|
toolbar: selectChatPanelToolbar(state),
|
|
goal: selectChatPanelGoal(state),
|
|
threadStream: selectChatPanelThreadStream(state),
|
|
composer: selectChatPanelComposer(state),
|
|
};
|
|
}
|
|
|
|
function threadStreamSurfaceContext(): ChatThreadStreamSurfaceContext {
|
|
return {
|
|
panelId: "test-panel",
|
|
vaultPath: "/vault",
|
|
setDisclosureOpen: vi.fn(),
|
|
setForkMenuItem: vi.fn(),
|
|
loadOlderTurns: vi.fn(),
|
|
renderObsidianMarkdown: vi.fn(),
|
|
renderStreamMarkdown: vi.fn(),
|
|
copyDialogueText: vi.fn(),
|
|
actions: {
|
|
rollbackThread: vi.fn(),
|
|
forkThreadFromTurn: vi.fn(),
|
|
implementPlan: vi.fn(),
|
|
openThreadInNewView: vi.fn(),
|
|
openTurnDiff: vi.fn(),
|
|
},
|
|
requests: {
|
|
pendingActions: () => ({
|
|
resolveApproval: vi.fn(),
|
|
resolveUserInput: vi.fn(),
|
|
cancelUserInput: vi.fn(),
|
|
resolveMcpElicitation: vi.fn(),
|
|
setUserInputDraft: vi.fn(),
|
|
setMcpElicitationDraft: vi.fn(),
|
|
}),
|
|
consumePendingAutoFocus: () => false,
|
|
},
|
|
};
|
|
}
|
|
|
|
function composerProjectionFromState(actions: ChatPanelComposerProjectionActions, state: ChatState) {
|
|
return chatPanelComposerProjection(composerModelFromChatState(state), actions);
|
|
}
|
|
|
|
function clickLabeledButton(parent: HTMLElement, label: string): void {
|
|
const button = Array.from(parent.querySelectorAll<HTMLButtonElement>("button")).find((item) => item.getAttribute("aria-label") === label);
|
|
if (!button) throw new Error(`Expected button: ${label}`);
|
|
button.click();
|
|
}
|
|
|
|
function toolbarSurfaceFixture(overrides: { archiveExportEnabled?: boolean } = {}) {
|
|
return {
|
|
connection: {
|
|
connected: () => true,
|
|
},
|
|
clock: {
|
|
nowMs: () => 0,
|
|
},
|
|
settings: {
|
|
vaultPath: () => "/vault",
|
|
configuredCommand: () => "codex",
|
|
archiveExportEnabled: () => overrides.archiveExportEnabled ?? false,
|
|
},
|
|
};
|
|
}
|
|
|
|
interface ToolbarActionOverrides {
|
|
copyDebugDetails?: (details: string) => void;
|
|
}
|
|
|
|
function toolbarActionsFixture(overrides: ToolbarActionOverrides = {}): ToolbarActions {
|
|
return {
|
|
primary: {
|
|
toggleHistory: () => undefined,
|
|
toggleChatActions: () => undefined,
|
|
toggleStatusPanel: () => undefined,
|
|
},
|
|
chat: {
|
|
startNewThread: () => undefined,
|
|
compactContext: () => undefined,
|
|
setGoal: () => undefined,
|
|
},
|
|
status: {
|
|
connect: () => undefined,
|
|
refreshStatus: () => undefined,
|
|
copyDebugDetails: overrides.copyDebugDetails ?? (() => undefined),
|
|
},
|
|
threads: {
|
|
resume: () => undefined,
|
|
archive: {
|
|
start: () => undefined,
|
|
confirm: () => undefined,
|
|
},
|
|
rename: {
|
|
start: () => undefined,
|
|
updateDraft: () => undefined,
|
|
save: () => undefined,
|
|
cancel: () => undefined,
|
|
autoName: () => undefined,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function goalSurfaceFixture(): ChatPanelGoalSurface {
|
|
return {
|
|
sendShortcut: () => "enter",
|
|
actions: {
|
|
saveObjective: async () => true,
|
|
setStatus: async () => undefined,
|
|
clear: async () => undefined,
|
|
startEditing: () => undefined,
|
|
updateObjectiveDraft: () => undefined,
|
|
setObjectiveExpanded: () => undefined,
|
|
closeEditor: () => undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
function composerProjectionActionsFixture(overrides: Partial<ChatPanelComposerProjectionActions> = {}): ChatPanelComposerProjectionActions {
|
|
return {
|
|
requestModel: async () => undefined,
|
|
requestReasoningEffort: async () => undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function runtimeConfigFixture(config: Record<string, unknown>): RuntimeConfigSnapshot {
|
|
return runtimeConfigSnapshotFromAppServerConfig({
|
|
config: config as ConfigReadResult["config"],
|
|
origins: {},
|
|
layers: null,
|
|
});
|
|
}
|
|
|
|
function threadFixture(id: string, name: string | null): Thread {
|
|
return {
|
|
id,
|
|
preview: "",
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
name,
|
|
archived: false,
|
|
provenance: { kind: "interactive" },
|
|
};
|
|
}
|
|
|
|
function modelFixture(model: string, fastTierId?: string): ModelMetadata {
|
|
return {
|
|
id: model,
|
|
model,
|
|
displayName: model,
|
|
description: "",
|
|
hidden: false,
|
|
supportedReasoningEfforts: ["high"],
|
|
defaultReasoningEffort: "high",
|
|
inputModalities: [],
|
|
serviceTiers: fastTierId ? [{ id: fastTierId, name: "Fast" }] : [],
|
|
defaultServiceTier: null,
|
|
isDefault: true,
|
|
};
|
|
}
|
|
|
|
function goalFixture(threadId: string): ThreadGoal {
|
|
return {
|
|
threadId,
|
|
objective: "Ship it",
|
|
status: "active",
|
|
tokenBudget: null,
|
|
tokensUsed: 0,
|
|
timeUsedSeconds: 0,
|
|
createdAt: 1,
|
|
updatedAt: 1,
|
|
};
|
|
}
|