Align TaskChatTab tests with lowercase tool-call status labels. - Update result tool-call assertion to expect lowercase status text. - Update error tool-call assertion to expect lowercase status text. Files changed: packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-6986 Fusion-Task-Lineage: 1eb16aa3-9747-45d0-a18b-067c47892f1c
2559 lines
118 KiB
TypeScript
2559 lines
118 KiB
TypeScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
|
import { userEvent } from "@testing-library/user-event";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
import type { AgentLogEntry, Task } from "@fusion/core";
|
|
import { TaskChatTab } from "../TaskChatTab";
|
|
import { isCliSessionLive, type CliSessionSummaryRecord } from "../TaskDetailModal";
|
|
import { useAgentLogs } from "../../hooks/useAgentLogs";
|
|
import { addSteeringComment, refineTask } from "../../api";
|
|
|
|
vi.mock("../../hooks/useAgentLogs", () => ({
|
|
useAgentLogs: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../../api", () => ({
|
|
addSteeringComment: vi.fn(),
|
|
refineTask: vi.fn(),
|
|
}));
|
|
|
|
const mockedUseAgentLogs = vi.mocked(useAgentLogs);
|
|
const mockedAddSteeringComment = vi.mocked(addSteeringComment);
|
|
const mockedRefineTask = vi.mocked(refineTask);
|
|
const originalScrollTopDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollTop");
|
|
const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "scrollHeight");
|
|
const originalClientHeightDescriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight");
|
|
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
|
const originalCancelAnimationFrame = window.cancelAnimationFrame;
|
|
const originalMatchMediaDescriptor = Object.getOwnPropertyDescriptor(window, "matchMedia");
|
|
|
|
function makeTask(overrides: Partial<Task> = {}): Task {
|
|
return {
|
|
id: "FN-001",
|
|
title: "Task",
|
|
description: "Task description",
|
|
column: "in-progress",
|
|
dependencies: [],
|
|
steps: [],
|
|
currentStep: 0,
|
|
assignedAgentId: "agent-1",
|
|
status: undefined,
|
|
...overrides,
|
|
} as Task;
|
|
}
|
|
|
|
function makeCliSession(agentState: CliSessionSummaryRecord["agentState"]): CliSessionSummaryRecord {
|
|
return {
|
|
id: "session-1",
|
|
taskId: "FN-001",
|
|
projectId: "project-1",
|
|
adapterId: "claude",
|
|
agentState,
|
|
terminationReason: null,
|
|
};
|
|
}
|
|
|
|
function makeEntry(overrides: Partial<AgentLogEntry>): AgentLogEntry {
|
|
return {
|
|
timestamp: "2026-06-12T00:00:00.000Z",
|
|
taskId: "FN-001",
|
|
type: "text",
|
|
text: "message",
|
|
...overrides,
|
|
} as AgentLogEntry;
|
|
}
|
|
|
|
function makeSteeringComment(overrides: Partial<NonNullable<Task["steeringComments"]>[number]> = {}): NonNullable<Task["steeringComments"]>[number] {
|
|
return {
|
|
id: "steer-1",
|
|
text: "Persisted user guidance",
|
|
createdAt: "2026-06-12T00:00:01.000Z",
|
|
author: "user",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function deferred<T>() {
|
|
let resolve!: (value: T) => void;
|
|
let reject!: (reason?: unknown) => void;
|
|
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
|
resolve = promiseResolve;
|
|
reject = promiseReject;
|
|
});
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function getCssRuleBlock(css: string, selector: string): string {
|
|
const selectorIndex = css.indexOf(selector);
|
|
if (selectorIndex < 0) return "";
|
|
const ruleStart = css.indexOf("{", selectorIndex);
|
|
const ruleEnd = css.indexOf("}", ruleStart);
|
|
return ruleStart >= 0 && ruleEnd >= 0 ? css.slice(ruleStart + 1, ruleEnd) : "";
|
|
}
|
|
|
|
function getCssAfter(css: string, marker: string): string {
|
|
const markerIndex = css.indexOf(marker);
|
|
return markerIndex >= 0 ? css.slice(markerIndex) : "";
|
|
}
|
|
|
|
function getCssDeclaration(rule: string, propertyName: string): string {
|
|
const declarationMatch = new RegExp(`${propertyName}\\s*:\\s*([^;]+);`).exec(rule);
|
|
return declarationMatch?.[1]?.trim() ?? "";
|
|
}
|
|
|
|
function getRootTokenPxValues(css: string): Record<string, number> {
|
|
const rootRule = getCssRuleBlock(css, ":root");
|
|
const tokenValues: Record<string, number> = {};
|
|
for (const match of rootRule.matchAll(/(--(?:space|icon-size)-[\w-]+)\s*:\s*(\d+)px;/g)) {
|
|
tokenValues[match[1]] = Number(match[2]);
|
|
}
|
|
return tokenValues;
|
|
}
|
|
|
|
function resolveCssPxToken(value: string, tokenValues: Record<string, number>): number {
|
|
const tokenName = /^var\((--[\w-]+)\)$/.exec(value.trim())?.[1];
|
|
if (!tokenName || tokenValues[tokenName] === undefined) {
|
|
throw new Error(`Unable to resolve CSS token value: ${value}`);
|
|
}
|
|
return tokenValues[tokenName];
|
|
}
|
|
|
|
function resolveCssCalcSumPx(value: string, tokenValues: Record<string, number>): number {
|
|
const calcBody = /^calc\((var\(--[\w-]+\)(?:\s*\+\s*var\(--[\w-]+\))+)\)$/.exec(value.trim())?.[1];
|
|
const tokenNames = [...(calcBody?.matchAll(/var\((--[\w-]+)\)/g) ?? [])].map((match) => match[1]);
|
|
if (tokenNames.length === 0 || tokenNames.some((tokenName) => tokenValues[tokenName] === undefined)) {
|
|
throw new Error(`Unable to resolve CSS calc sum: ${value}`);
|
|
}
|
|
return tokenNames.reduce((sum, tokenName) => sum + tokenValues[tokenName], 0);
|
|
}
|
|
|
|
function mockLogs(
|
|
entries: AgentLogEntry[] = [],
|
|
loading = false,
|
|
overrides: Partial<ReturnType<typeof useAgentLogs>> = {},
|
|
) {
|
|
mockedUseAgentLogs.mockReturnValue({
|
|
entries,
|
|
loading,
|
|
clear: vi.fn(),
|
|
loadMore: vi.fn(async () => {}),
|
|
hasMore: false,
|
|
total: entries.length,
|
|
loadingMore: false,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
function expectComposerSendableAfterDraft(message = "Please continue") {
|
|
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).toBeDisabled();
|
|
|
|
fireEvent.change(input, { target: { value: message } });
|
|
expect(sendButton).not.toBeDisabled();
|
|
}
|
|
|
|
function expectTranscriptTextOrder(...texts: string[]) {
|
|
const transcriptText = screen.getByTestId("task-chat-transcript").textContent ?? "";
|
|
let previousIndex = -1;
|
|
for (const text of texts) {
|
|
const index = transcriptText.indexOf(text);
|
|
expect(index, `Expected transcript to contain ${text}`).toBeGreaterThanOrEqual(0);
|
|
expect(index, `Expected ${text} to appear after the previous transcript text`).toBeGreaterThan(previousIndex);
|
|
previousIndex = index;
|
|
}
|
|
}
|
|
|
|
function expectAgentHeaderBeforeBubbles(group: HTMLElement) {
|
|
const header = group.querySelector(".task-chat-group-header");
|
|
const bubbles = group.querySelector(".task-chat-group-bubbles");
|
|
expect(header).not.toBeNull();
|
|
expect(bubbles).not.toBeNull();
|
|
expect(header?.compareDocumentPosition(bubbles as Element) ?? 0).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
|
}
|
|
|
|
function renderListSplitTaskChat(task: Task = makeTask()) {
|
|
return render(
|
|
<div className="list-split-detail-content" data-testid="list-split-detail-content">
|
|
<TaskChatTab task={task} active addToast={vi.fn()} />
|
|
</div>,
|
|
);
|
|
}
|
|
|
|
function expectNoComposerGuidanceShell() {
|
|
/*
|
|
* FNXC:TaskDetailChat 2026-06-24-00:00:
|
|
* Task chat composers communicate the active/idle/done action through placeholders only; tests must prove the removed guidance block does not leave an empty status shell above the entry row.
|
|
*/
|
|
expect(screen.queryByTestId("task-chat-idle-hint")).not.toBeInTheDocument();
|
|
expect(document.querySelector(".task-chat-session-hint")).toBeNull();
|
|
expect(document.querySelector(".task-chat-session-hint--idle")).toBeNull();
|
|
expect(screen.queryByText(/no agent is working on this task right now/i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/message the active agent session\. guidance is delivered to the running session in real time\./i)).not.toBeInTheDocument();
|
|
expect(screen.queryByText(/^send a message to start a refinement task for this completed task\.$/i)).not.toBeInTheDocument();
|
|
}
|
|
|
|
function expectActivePlaceholderWithoutGuidance() {
|
|
expectNoComposerGuidanceShell();
|
|
expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument();
|
|
}
|
|
|
|
function expectDonePlaceholderWithoutGuidance() {
|
|
expectNoComposerGuidanceShell();
|
|
expect(screen.getByPlaceholderText("Start a refinement task for this completed task")).toBeInTheDocument();
|
|
}
|
|
|
|
function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) {
|
|
if (descriptor) {
|
|
Object.defineProperty(HTMLElement.prototype, name, descriptor);
|
|
return;
|
|
}
|
|
delete (HTMLElement.prototype as Record<string, unknown>)[name];
|
|
}
|
|
|
|
function mockTranscriptMetrics({
|
|
scrollHeight = 1200,
|
|
clientHeight = 240,
|
|
initialScrollTop = 0,
|
|
}: {
|
|
scrollHeight?: number;
|
|
clientHeight?: number;
|
|
initialScrollTop?: number;
|
|
} = {}) {
|
|
let scrollTopValue = initialScrollTop;
|
|
let scrollHeightValue = scrollHeight;
|
|
Object.defineProperty(HTMLElement.prototype, "scrollHeight", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? scrollHeightValue : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "clientHeight", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? clientHeight : 0;
|
|
},
|
|
});
|
|
Object.defineProperty(HTMLElement.prototype, "scrollTop", {
|
|
configurable: true,
|
|
get() {
|
|
return this instanceof HTMLElement && this.classList.contains("task-chat-transcript") ? scrollTopValue : 0;
|
|
},
|
|
set(value) {
|
|
if (this instanceof HTMLElement && this.classList.contains("task-chat-transcript")) {
|
|
scrollTopValue = Number(value);
|
|
}
|
|
},
|
|
});
|
|
return {
|
|
get scrollTop() {
|
|
return scrollTopValue;
|
|
},
|
|
set scrollTop(value: number) {
|
|
scrollTopValue = value;
|
|
},
|
|
get scrollHeight() {
|
|
return scrollHeightValue;
|
|
},
|
|
set scrollHeight(value: number) {
|
|
scrollHeightValue = value;
|
|
},
|
|
};
|
|
}
|
|
|
|
function mockMatchMedia(matches: boolean) {
|
|
Object.defineProperty(window, "matchMedia", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: vi.fn().mockImplementation((query: string) => ({
|
|
matches,
|
|
media: query,
|
|
onchange: null,
|
|
addEventListener: vi.fn(),
|
|
removeEventListener: vi.fn(),
|
|
addListener: vi.fn(),
|
|
removeListener: vi.fn(),
|
|
dispatchEvent: vi.fn(),
|
|
})),
|
|
});
|
|
}
|
|
|
|
function mockRequestAnimationFrame() {
|
|
let nextId = 1;
|
|
const callbacks = new Map<number, FrameRequestCallback>();
|
|
const requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
|
|
const id = nextId;
|
|
nextId += 1;
|
|
callbacks.set(id, callback);
|
|
return id;
|
|
});
|
|
const cancelAnimationFrame = vi.fn((id: number) => {
|
|
callbacks.delete(id);
|
|
});
|
|
|
|
Object.defineProperty(window, "requestAnimationFrame", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: requestAnimationFrame,
|
|
});
|
|
Object.defineProperty(window, "cancelAnimationFrame", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: cancelAnimationFrame,
|
|
});
|
|
|
|
return {
|
|
requestAnimationFrame,
|
|
cancelAnimationFrame,
|
|
flushNext() {
|
|
const next = callbacks.entries().next();
|
|
if (next.done) return false;
|
|
const [id, callback] = next.value;
|
|
callbacks.delete(id);
|
|
callback(performance.now());
|
|
return true;
|
|
},
|
|
get pendingCount() {
|
|
return callbacks.size;
|
|
},
|
|
};
|
|
}
|
|
|
|
describe("TaskChatTab", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockLogs();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
restoreMetricDescriptor("scrollTop", originalScrollTopDescriptor);
|
|
restoreMetricDescriptor("scrollHeight", originalScrollHeightDescriptor);
|
|
restoreMetricDescriptor("clientHeight", originalClientHeightDescriptor);
|
|
Object.defineProperty(window, "requestAnimationFrame", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalRequestAnimationFrame,
|
|
});
|
|
Object.defineProperty(window, "cancelAnimationFrame", {
|
|
configurable: true,
|
|
writable: true,
|
|
value: originalCancelAnimationFrame,
|
|
});
|
|
if (originalMatchMediaDescriptor) {
|
|
Object.defineProperty(window, "matchMedia", originalMatchMediaDescriptor);
|
|
} else {
|
|
delete (window as Partial<Window>).matchMedia;
|
|
}
|
|
});
|
|
|
|
it("subscribes to live agent logs only when active", () => {
|
|
render(<TaskChatTab task={makeTask()} active={false} projectId="project-1" addToast={vi.fn()} />);
|
|
expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1");
|
|
});
|
|
|
|
it("renders empty state without timestamp shells when no transcript messages exist", () => {
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByText(/No agent output yet/)).toBeTruthy();
|
|
expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument();
|
|
expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument();
|
|
expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/);
|
|
});
|
|
|
|
it("renders the collapsed icon-only expand toggle inside the chat view and calls the toggle handler", () => {
|
|
const onToggleExpanded = vi.fn();
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded={false} onToggleExpanded={onToggleExpanded} />);
|
|
|
|
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
|
expect(transcript).not.toContainElement(toggle);
|
|
expect(document.querySelector(".task-chat-toolbar")).toBeNull();
|
|
expect(toggle).toHaveClass("btn-icon");
|
|
expect(toggle).toHaveClass("task-chat-expand-toggle--overlay");
|
|
expect(toggle).toHaveAttribute("aria-label", "Expand chat to full modal");
|
|
expect(toggle).toHaveAttribute("aria-pressed", "false");
|
|
expect(toggle).not.toHaveTextContent("Expand");
|
|
expect(toggle).not.toHaveTextContent("Collapse");
|
|
|
|
fireEvent.click(toggle);
|
|
expect(onToggleExpanded).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("renders the expanded icon-only collapse toggle", () => {
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded onToggleExpanded={vi.fn()} />);
|
|
|
|
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
|
expect(toggle).toHaveAttribute("aria-label", "Collapse chat");
|
|
expect(toggle).toHaveAttribute("aria-pressed", "true");
|
|
expect(toggle).not.toHaveTextContent("Collapse");
|
|
expect(toggle).not.toHaveTextContent("Expand");
|
|
});
|
|
|
|
it("renders the icon-only expand toggle while the transcript is loading", () => {
|
|
mockLogs([], true);
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
|
|
|
|
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
|
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
|
expect(toggle).not.toHaveTextContent("Expand");
|
|
expect(screen.getByText("Loading agent output…")).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the icon-only expand toggle in the empty transcript state", () => {
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
|
|
|
|
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
|
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
|
expect(toggle).not.toHaveTextContent("Expand");
|
|
expect(screen.getByText(/No agent output yet/)).toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the icon-only expand toggle in the populated transcript state", () => {
|
|
mockLogs([makeEntry({ agent: "executor", text: "executor output" })]);
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} onToggleExpanded={vi.fn()} />);
|
|
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
|
expect(screen.getByTestId("task-chat-tab")).toContainElement(toggle);
|
|
expect(transcript).not.toContainElement(toggle);
|
|
expect(toggle).not.toHaveTextContent("Expand");
|
|
expect(within(transcript).getByText("executor output")).toBeInTheDocument();
|
|
});
|
|
|
|
it("labels every agent role and the legacy undefined-agent fallback", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "triage", text: "planning output" }),
|
|
makeEntry({ agent: "executor", text: "executor output" }),
|
|
makeEntry({ agent: "reviewer", text: "reviewer output" }),
|
|
makeEntry({ agent: "merger", text: "merger output" }),
|
|
makeEntry({ text: "legacy output" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText("Planner")).toBeTruthy();
|
|
expect(screen.getByText("Executor")).toBeTruthy();
|
|
expect(screen.getByText("Reviewer")).toBeTruthy();
|
|
expect(screen.getByText("Merger")).toBeTruthy();
|
|
expect(screen.getByText("Agent")).toBeTruthy();
|
|
expect(screen.getByText("legacy output")).toBeTruthy();
|
|
expect(screen.getAllByLabelText(/model provider unknown/)).toHaveLength(5);
|
|
});
|
|
|
|
it("renders provider icons for task chat roles from task model overrides", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "triage", text: "planning output" }),
|
|
makeEntry({ agent: "executor", text: "executor output" }),
|
|
makeEntry({ agent: "reviewer", text: "reviewer output" }),
|
|
makeEntry({ agent: "merger", text: "merger output" }),
|
|
]);
|
|
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({
|
|
planningModelProvider: "google",
|
|
planningModelId: "gemini-pro",
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
validatorModelProvider: "anthropic",
|
|
validatorModelId: "claude-sonnet-4-5",
|
|
})}
|
|
active
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(document.querySelector(".task-chat-provider-icon [data-provider='google']")).toBeTruthy();
|
|
expect(document.querySelector(".task-chat-provider-icon [data-provider='openai']")).toBeTruthy();
|
|
expect(document.querySelectorAll(".task-chat-provider-icon [data-provider='anthropic']")).toHaveLength(2);
|
|
});
|
|
|
|
it("renders provider icons for task chat roles from runtime model markers", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "triage", text: "Triage using model: google/gemini-pro" }),
|
|
makeEntry({ agent: "executor", text: "Executor using model: openai/gpt-4o" }),
|
|
makeEntry({ agent: "reviewer", text: "Reviewer using model: anthropic/claude-sonnet-4-5" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(document.querySelector(".task-chat-provider-icon [data-provider='google']")).toBeTruthy();
|
|
expect(document.querySelector(".task-chat-provider-icon [data-provider='openai']")).toBeTruthy();
|
|
expect(document.querySelector(".task-chat-provider-icon [data-provider='anthropic']")).toBeTruthy();
|
|
});
|
|
|
|
it("renders provider icons for task chat roles from effective default models", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "executor output without model marker" }),
|
|
]);
|
|
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask()}
|
|
active
|
|
addToast={vi.fn()}
|
|
effectiveModels={{ executor: { provider: "openai-codex", modelId: "gpt-5.5" } }}
|
|
/>,
|
|
);
|
|
|
|
expect(document.querySelector(".task-chat-provider-icon [data-provider='openai-codex']")).toBeTruthy();
|
|
expect(screen.queryByLabelText("Executor: model provider unknown")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps List View split chat agent headers before text tool thinking and user output", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "executor compact text", timestamp: "2026-06-12T00:00:00.000Z" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok", timestamp: "2026-06-12T00:00:02.000Z" }),
|
|
makeEntry({ agent: "executor", type: "thinking", text: "checking compact layout", timestamp: "2026-06-12T00:00:03.000Z" }),
|
|
makeEntry({ text: "fallback compact text", timestamp: "2026-06-12T00:00:05.000Z" }),
|
|
]);
|
|
|
|
renderListSplitTaskChat(
|
|
makeTask({
|
|
modelProvider: "openai",
|
|
modelId: "gpt-4o",
|
|
steeringComments: [makeSteeringComment({ id: "compact-user", text: "compact user guidance", createdAt: "2026-06-12T00:00:04.000Z" })],
|
|
}),
|
|
);
|
|
|
|
const host = screen.getByTestId("list-split-detail-content");
|
|
const executorGroup = within(host).getByLabelText("Executor messages");
|
|
const fallbackGroup = within(host).getByLabelText("Agent messages");
|
|
|
|
expectAgentHeaderBeforeBubbles(executorGroup);
|
|
expectAgentHeaderBeforeBubbles(fallbackGroup);
|
|
expect(within(executorGroup).getAllByText("Executor")).toHaveLength(1);
|
|
expect(within(fallbackGroup).getAllByText("Agent")).toHaveLength(1);
|
|
expect(within(executorGroup).getByText("executor compact text")).toBeVisible();
|
|
expect(within(executorGroup).getByTestId("task-chat-tool-group")).toBeInTheDocument();
|
|
expect(within(executorGroup).getByTestId("task-chat-thinking")).toBeInTheDocument();
|
|
expect(within(host).getByText("compact user guidance").closest(".task-chat-user-group")).not.toBeNull();
|
|
expect(within(host).getByText("fallback compact text")).toBeVisible();
|
|
expect(document.querySelector(".task-chat-provider-icon [data-provider='openai']")).toBeTruthy();
|
|
expect(within(fallbackGroup).getByLabelText("Agent: model provider unknown")).toBeVisible();
|
|
});
|
|
|
|
it("keeps List View split chat empty and loading states free of header shells", () => {
|
|
mockLogs([], true);
|
|
const loading = renderListSplitTaskChat();
|
|
expect(screen.getByText(/Loading agent output/)).toBeVisible();
|
|
expect(document.querySelector(".task-chat-group-header")).not.toBeInTheDocument();
|
|
expect(document.querySelector(".task-chat-group-bubbles")).not.toBeInTheDocument();
|
|
|
|
loading.unmount();
|
|
mockLogs([]);
|
|
renderListSplitTaskChat();
|
|
expect(screen.getByText(/No agent output yet/)).toBeVisible();
|
|
expect(document.querySelector(".task-chat-group-header")).not.toBeInTheDocument();
|
|
expect(document.querySelector(".task-chat-group-bubbles")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("groups consecutive entries by agent role", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "first" }),
|
|
makeEntry({ agent: "executor", text: "second" }),
|
|
makeEntry({ agent: "reviewer", text: "third" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText("2 entries")).toBeTruthy();
|
|
expect(screen.getByLabelText("Executor messages")).toBeTruthy();
|
|
expect(screen.getByLabelText("Reviewer messages")).toBeTruthy();
|
|
});
|
|
|
|
it("renders a relative timestamp for a single-entry agent group", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "single timestamped response", timestamp: "2026-06-17T14:59:30.000Z" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText("1 entry")).toBeVisible();
|
|
expect(screen.getByTestId("task-chat-group-time")).toHaveTextContent("just now");
|
|
});
|
|
|
|
it("renders the latest-entry relative timestamp alongside multi-entry group meta", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "older group entry", timestamp: "2026-06-17T14:50:00.000Z" }),
|
|
makeEntry({ agent: "executor", text: "latest group entry", timestamp: "2026-06-17T14:58:00.000Z" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const executorGroup = screen.getByLabelText("Executor messages");
|
|
const groupMeta = executorGroup.querySelector(".task-chat-group-meta");
|
|
expect(groupMeta).not.toBeNull();
|
|
expect(within(groupMeta as HTMLElement).getByText("2 entries")).toBeVisible();
|
|
expect(within(groupMeta as HTMLElement).getByTestId("task-chat-group-time")).toHaveTextContent("2m ago");
|
|
});
|
|
|
|
it("keeps agent and user timestamp parity in the inline chat surface", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "inline agent response", timestamp: "2026-06-17T14:58:30.000Z" }),
|
|
]);
|
|
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({
|
|
steeringComments: [makeSteeringComment({ id: "inline-user", text: "inline user guidance", createdAt: "2026-06-17T14:57:00.000Z" })],
|
|
})}
|
|
active
|
|
expanded={false}
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByText("inline agent response")).toBeVisible();
|
|
expect(within(transcript).getByText("inline user guidance")).toBeVisible();
|
|
expect(within(transcript).getByTestId("task-chat-group-time")).toHaveTextContent("1m ago");
|
|
expect(within(transcript).getByTestId("task-chat-user-time")).toHaveTextContent("3m ago");
|
|
});
|
|
|
|
it("keeps agent and user timestamp parity in the expanded chat surface", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "expanded single entry", timestamp: "2026-06-17T14:59:30.000Z" }),
|
|
makeEntry({ agent: "reviewer", text: "expanded older reviewer entry", timestamp: "2026-06-17T14:53:00.000Z" }),
|
|
makeEntry({ agent: "reviewer", text: "expanded latest reviewer entry", timestamp: "2026-06-17T14:55:00.000Z" }),
|
|
]);
|
|
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({
|
|
steeringComments: [makeSteeringComment({ id: "expanded-user", text: "expanded user guidance", createdAt: "2026-06-17T14:57:00.000Z" })],
|
|
})}
|
|
active
|
|
expanded
|
|
onToggleExpanded={vi.fn()}
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const executorMeta = screen.getByLabelText("Executor messages").querySelector(".task-chat-group-meta");
|
|
const reviewerMeta = screen.getByLabelText("Reviewer messages").querySelector(".task-chat-group-meta");
|
|
const userHeader = screen.getByText("You").closest(".task-chat-user-header");
|
|
expect(executorMeta).not.toBeNull();
|
|
expect(reviewerMeta).not.toBeNull();
|
|
expect(userHeader).not.toBeNull();
|
|
expect(within(executorMeta as HTMLElement).getByText("1 entry")).toBeVisible();
|
|
expect(within(executorMeta as HTMLElement).getByTestId("task-chat-group-time")).toHaveTextContent("just now");
|
|
expect(within(reviewerMeta as HTMLElement).getByText("2 entries")).toBeVisible();
|
|
expect(within(reviewerMeta as HTMLElement).getByTestId("task-chat-group-time")).toHaveTextContent("5m ago");
|
|
expect(within(userHeader as HTMLElement).getByTestId("task-chat-user-time")).toHaveTextContent("3m ago");
|
|
});
|
|
|
|
it.each([
|
|
["inline", false],
|
|
["expanded", true],
|
|
])("omits composer guidance in the %s task chat surface", (_label, expanded) => {
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined, status: undefined })}
|
|
active
|
|
expanded={expanded}
|
|
onToggleExpanded={expanded ? vi.fn() : undefined}
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
});
|
|
|
|
it.each([
|
|
["inline planning", false, "planning"],
|
|
["expanded planning", true, "planning"],
|
|
["inline cleared status", false, null],
|
|
["expanded cleared status", true, null],
|
|
] as const)("uses placeholder-only planning composer in the %s task chat surface", (_label, expanded, status) => {
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "triage", status, assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
active
|
|
expanded={expanded}
|
|
onToggleExpanded={expanded ? vi.fn() : undefined}
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
});
|
|
|
|
it.each([
|
|
["empty", [], false, makeTask({ column: "triage", status: "planning", assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["populated", [makeEntry({ agent: "triage", text: "Planner is drafting the spec" })], false, makeTask({
|
|
column: "triage",
|
|
status: "planning",
|
|
assignedAgentId: undefined,
|
|
checkedOutBy: undefined,
|
|
steeringComments: [makeSteeringComment({ id: "planning-populated-user", text: "Earlier planning guidance" })],
|
|
})],
|
|
["loading", [], true, makeTask({ column: "triage", status: null, assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
] as const)("keeps planning-session composer placeholder-only with an %s transcript", (_label, entries, loading, task) => {
|
|
mockLogs([...entries], loading);
|
|
render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
});
|
|
|
|
it.each([
|
|
["empty", [], makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined, status: undefined })],
|
|
["populated", [makeEntry({ agent: "executor", text: "Earlier agent output" })], makeTask({
|
|
column: "todo",
|
|
assignedAgentId: undefined,
|
|
checkedOutBy: undefined,
|
|
status: undefined,
|
|
steeringComments: [makeSteeringComment({ id: "idle-populated-user", text: "Earlier saved guidance" })],
|
|
})],
|
|
] as const)("omits composer guidance with an %s idle transcript", (_label, entries, task) => {
|
|
mockLogs([...entries]);
|
|
render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
});
|
|
|
|
it("renders a single text entry as one text bubble", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "single response" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const textBubbles = screen.getAllByTestId("task-chat-entry-text");
|
|
expect(textBubbles).toHaveLength(1);
|
|
expect(within(textBubbles[0]).getByText("single response")).toBeVisible();
|
|
});
|
|
|
|
it("combines consecutive text entries into one continuous text bubble", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "first chunk " }),
|
|
makeEntry({ agent: "executor", text: "second chunk" }),
|
|
makeEntry({ agent: "executor", text: " third chunk" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const textBubbles = screen.getAllByTestId("task-chat-entry-text");
|
|
expect(textBubbles).toHaveLength(1);
|
|
expect(textBubbles[0]).toHaveClass("task-chat-entry", "task-chat-entry--text");
|
|
expect(textBubbles[0]).toHaveTextContent("first chunk second chunk third chunk");
|
|
expect(within(textBubbles[0]).queryByRole("separator")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps text entries on different agent-role runs in separate bubbles", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "executor first" }),
|
|
makeEntry({ agent: "executor", text: " executor second" }),
|
|
makeEntry({ agent: "reviewer", text: "reviewer first" }),
|
|
makeEntry({ agent: "reviewer", text: " reviewer second" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const textBubbles = screen.getAllByTestId("task-chat-entry-text");
|
|
expect(textBubbles).toHaveLength(2);
|
|
expect(within(screen.getByLabelText("Executor messages")).getByTestId("task-chat-entry-text"))
|
|
.toHaveTextContent("executor first executor second");
|
|
expect(within(screen.getByLabelText("Reviewer messages")).getByTestId("task-chat-entry-text"))
|
|
.toHaveTextContent("reviewer first reviewer second");
|
|
});
|
|
|
|
it("counts a tool call plus result as one collapsed invocation and shows the tool name", async () => {
|
|
const user = userEvent.setup();
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const toolGroup = screen.getByTestId("task-chat-tool-group");
|
|
const summary = toolGroup.querySelector("summary");
|
|
expect(summary).toBeTruthy();
|
|
expect(toolGroup).toHaveClass("task-chat-tool-group");
|
|
expect(summary).toHaveClass("task-chat-tool-group-summary");
|
|
expect(toolGroup).not.toHaveAttribute("open");
|
|
expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible();
|
|
expect(within(summary as HTMLElement).getByText("bash")).toBeVisible();
|
|
expect(screen.queryByText("2 tool calls")).not.toBeInTheDocument();
|
|
expect(screen.getByText("pnpm test")).not.toBeVisible();
|
|
expect(screen.getByText("ok")).not.toBeVisible();
|
|
|
|
await user.click(within(summary as HTMLElement).getByText("1 tool call"));
|
|
|
|
expect(toolGroup).toHaveAttribute("open");
|
|
const invocation = screen.getByTestId("task-chat-tool-invocation");
|
|
const kicker = screen.getByText("Tool call → result");
|
|
expect(invocation).toHaveClass("task-chat-tool-entry", "task-chat-tool-invocation");
|
|
expect(kicker).toHaveClass("task-chat-entry-kicker");
|
|
expect(kicker).toBeVisible();
|
|
expect(screen.getByText("Arguments")).toBeVisible();
|
|
expect(screen.getByText("Result")).toBeVisible();
|
|
expect(screen.getByText("pnpm test")).toBeVisible();
|
|
expect(screen.getByText("ok")).toBeVisible();
|
|
});
|
|
|
|
it("summarizes multiple invocations with deduped names and overflow", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "run tests" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "read", detail: "open file" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "read", detail: "contents" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "edit", detail: "patch" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "edit", detail: "done" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "grep", detail: "search" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "grep", detail: "matches" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "find", detail: "glob" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "find", detail: "paths" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "write", detail: "file" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "write", detail: "saved" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "rerun" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok again" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const summary = screen.getByTestId("task-chat-tool-group").querySelector("summary");
|
|
expect(summary).toBeTruthy();
|
|
expect(within(summary as HTMLElement).getByText("7 tool calls")).toBeVisible();
|
|
const names = within(summary as HTMLElement).getByLabelText("Tool names");
|
|
expect(names).toHaveTextContent("bash, read, edit, grep, find, +1 more");
|
|
expect(within(summary as HTMLElement).getByText(", +1 more")).toBeVisible();
|
|
});
|
|
|
|
it("surfaces tool errors in the summary and paired expanded body", async () => {
|
|
const user = userEvent.setup();
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test" }),
|
|
makeEntry({ agent: "executor", type: "tool_error", text: "bash", detail: "stderr" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const toolGroup = screen.getByTestId("task-chat-tool-group");
|
|
const summary = toolGroup.querySelector("summary");
|
|
expect(summary).toBeTruthy();
|
|
const errorCount = within(summary as HTMLElement).getByText("1 error");
|
|
expect(errorCount).toBeVisible();
|
|
expect(errorCount).toHaveClass("task-chat-tool-group-error-count");
|
|
expect(screen.getByText("stderr")).not.toBeVisible();
|
|
|
|
await user.click(within(summary as HTMLElement).getByText("1 tool call"));
|
|
|
|
expect(screen.getByText("Tool call → error")).toBeVisible();
|
|
expect(screen.getByText("Error")).toBeVisible();
|
|
expect(screen.getByText("stderr")).toBeVisible();
|
|
});
|
|
|
|
it("renders a single tool entry as one collapsed group and tolerates missing detail", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: undefined }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const toolGroup = screen.getByTestId("task-chat-tool-group");
|
|
const summary = toolGroup.querySelector("summary");
|
|
expect(summary).toBeTruthy();
|
|
expect(toolGroup).not.toHaveAttribute("open");
|
|
expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible();
|
|
expect(within(summary as HTMLElement).getByText("bash")).toBeVisible();
|
|
expect(screen.queryByText("Arguments")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("falls back to result entries when a tool completion has no preceding call", async () => {
|
|
const user = userEvent.setup();
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const toolGroup = screen.getByTestId("task-chat-tool-group");
|
|
const summary = toolGroup.querySelector("summary");
|
|
expect(summary).toBeTruthy();
|
|
expect(toolGroup).not.toHaveAttribute("open");
|
|
expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible();
|
|
expect(within(summary as HTMLElement).getByText("bash")).toBeVisible();
|
|
expect(screen.queryByText("0 tool calls")).not.toBeInTheDocument();
|
|
|
|
await user.click(within(summary as HTMLElement).getByText("1 tool call"));
|
|
|
|
const standaloneEntry = screen.getByTestId("task-chat-entry-tool_result");
|
|
const standaloneKicker = screen.getByText("Tool result");
|
|
expect(standaloneEntry).toHaveClass("task-chat-tool-entry");
|
|
expect(standaloneKicker).toHaveClass("task-chat-entry-kicker");
|
|
});
|
|
|
|
it("renders thinking in an expanded-by-default collapsible block", async () => {
|
|
const user = userEvent.setup();
|
|
mockLogs([
|
|
makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const thinking = screen.getByTestId("task-chat-thinking");
|
|
expect(thinking).toHaveAttribute("open");
|
|
expect(within(thinking).getByText("Thinking")).toBeVisible();
|
|
expect(screen.getByText("I am considering options")).toBeVisible();
|
|
expect(within(thinking).getAllByTestId("task-chat-entry-thinking")).toHaveLength(1);
|
|
|
|
await user.click(within(thinking).getByText("Thinking"));
|
|
|
|
expect(thinking).not.toHaveAttribute("open");
|
|
expect(screen.getByText("I am considering options")).not.toBeVisible();
|
|
});
|
|
|
|
it("renders consecutive thinking entries as one continuous section", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "triage", type: "thinking", text: "First" }),
|
|
makeEntry({ agent: "triage", type: "thinking", text: "Second", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const thinking = screen.getByTestId("task-chat-thinking");
|
|
const summary = thinking.querySelector("summary");
|
|
expect(summary).toBeTruthy();
|
|
expect(within(summary as HTMLElement).getByText("Thinking")).toBeVisible();
|
|
expect(screen.queryByText("2 thinking entries")).not.toBeInTheDocument();
|
|
const thinkingBlocks = within(thinking).getAllByTestId("task-chat-entry-thinking");
|
|
expect(thinkingBlocks).toHaveLength(1);
|
|
expect(thinkingBlocks[0]).toHaveTextContent("FirstSecond");
|
|
expect(thinkingBlocks[0].nextElementSibling).toBeNull();
|
|
});
|
|
|
|
it("creates distinct tool segments when text or thinking entries are interleaved", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", type: "tool", text: "first tool", detail: "first detail" }),
|
|
makeEntry({ agent: "executor", text: "plain response" }),
|
|
makeEntry({ agent: "executor", type: "thinking", text: "thinking between tools" }),
|
|
makeEntry({ agent: "executor", type: "tool_result", text: "second tool", detail: "second detail" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const toolGroups = screen.getAllByTestId("task-chat-tool-group");
|
|
expect(toolGroups).toHaveLength(2);
|
|
expect(toolGroups[0]).not.toHaveAttribute("open");
|
|
expect(toolGroups[1]).not.toHaveAttribute("open");
|
|
expect(screen.getAllByText("1 tool call")).toHaveLength(2);
|
|
expect(within(toolGroups[0]).getByLabelText("Tool names")).toHaveTextContent("first tool");
|
|
expect(within(toolGroups[1]).getByLabelText("Tool names")).toHaveTextContent("second tool");
|
|
expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(1);
|
|
expect(screen.getByText("plain response")).toBeVisible();
|
|
expect(screen.getByText("thinking between tools")).toBeVisible();
|
|
});
|
|
|
|
it("appends newly streamed entries from the hook without auto-opening tool groups", () => {
|
|
const firstEntries = [makeEntry({ agent: "executor", text: "first live chunk" })];
|
|
const secondEntries = [
|
|
...firstEntries,
|
|
makeEntry({ agent: "executor", type: "tool", text: "streamed tool", detail: "streamed detail", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
makeEntry({ agent: "executor", text: "second live chunk", timestamp: "2026-06-12T00:00:02.000Z" }),
|
|
];
|
|
mockedUseAgentLogs.mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false });
|
|
mockedUseAgentLogs.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 3, loadingMore: false });
|
|
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(screen.getByText("first live chunk")).toBeVisible();
|
|
|
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
const toolGroup = screen.getByTestId("task-chat-tool-group");
|
|
expect(toolGroup).not.toHaveAttribute("open");
|
|
expect(screen.getByText("1 tool call")).toBeVisible();
|
|
expect(screen.getByText("streamed detail")).not.toBeVisible();
|
|
expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(2);
|
|
expect(screen.getByText("second live chunk")).toBeVisible();
|
|
});
|
|
|
|
it.each([
|
|
["desktop", false],
|
|
["mobile", true],
|
|
])("snaps populated transcripts to the bottom on initial %s render", (_label, matchesMobile) => {
|
|
mockMatchMedia(matchesMobile);
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1400, clientHeight: 240, initialScrollTop: 0 });
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "older output" }),
|
|
makeEntry({ agent: "executor", text: "latest output", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByTestId("task-chat-transcript")).toBeTruthy();
|
|
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
|
});
|
|
|
|
it("snaps to the bottom when the tab reactivates with unchanged cached entries", () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
|
const cachedEntries = [
|
|
makeEntry({ agent: "executor", text: "cached first" }),
|
|
makeEntry({ agent: "executor", text: "cached latest", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
];
|
|
mockLogs(cachedEntries);
|
|
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} active={false} addToast={vi.fn()} />);
|
|
expect(metrics.scrollTop).toBe(0);
|
|
|
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
|
});
|
|
|
|
it("snaps when entries first become populated after an active empty render", () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1100, clientHeight: 240, initialScrollTop: 0 });
|
|
const loadedEntries = [makeEntry({ agent: "executor", text: "loaded output" })];
|
|
mockedUseAgentLogs
|
|
.mockReturnValueOnce({ entries: [], loading: true, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 0, loadingMore: false })
|
|
.mockReturnValueOnce({ entries: loadedEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false });
|
|
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(metrics.scrollTop).toBe(0);
|
|
|
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
|
});
|
|
|
|
it("FN-6337: re-pins populated transcripts to the bottom after async height growth", () => {
|
|
const raf = mockRequestAnimationFrame();
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 600, clientHeight: 240, initialScrollTop: 0 });
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "older output" }),
|
|
makeEntry({ agent: "executor", type: "thinking", text: "expanded thinking", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-12T00:00:02.000Z" }),
|
|
]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(metrics.scrollTop).toBe(600);
|
|
metrics.scrollHeight = 900;
|
|
expect(raf.flushNext()).toBe(true);
|
|
expect(metrics.scrollTop).toBe(900);
|
|
|
|
metrics.scrollHeight = 1200;
|
|
expect(raf.flushNext()).toBe(true);
|
|
expect(metrics.scrollTop).toBe(1200);
|
|
|
|
expect(raf.flushNext()).toBe(true);
|
|
expect(metrics.scrollTop).toBe(1200);
|
|
expect(raf.flushNext()).toBe(true);
|
|
expect(metrics.scrollTop).toBe(metrics.scrollHeight);
|
|
expect(raf.pendingCount).toBe(0);
|
|
});
|
|
|
|
it("FN-6337: bounds and cleans up the settle loop", () => {
|
|
const raf = mockRequestAnimationFrame();
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 500, clientHeight: 240, initialScrollTop: 0 });
|
|
mockLogs([makeEntry({ agent: "executor", text: "output" })]);
|
|
|
|
const { unmount } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
for (let frame = 0; frame < 5; frame += 1) {
|
|
metrics.scrollHeight += 100;
|
|
expect(raf.flushNext()).toBe(true);
|
|
}
|
|
expect(metrics.scrollTop).toBe(1000);
|
|
expect(raf.pendingCount).toBe(0);
|
|
|
|
metrics.scrollHeight = 1300;
|
|
mockLogs([makeEntry({ agent: "executor", text: "output after remount" })]);
|
|
const mountedAgain = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(raf.pendingCount).toBe(1);
|
|
mountedAgain.unmount();
|
|
expect(raf.cancelAnimationFrame).toHaveBeenCalled();
|
|
expect(raf.pendingCount).toBe(0);
|
|
|
|
metrics.scrollHeight = 1600;
|
|
expect(raf.flushNext()).toBe(false);
|
|
expect(metrics.scrollTop).toBe(1300);
|
|
unmount();
|
|
});
|
|
|
|
it("does not mutate scroll position for an empty transcript", () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 900, clientHeight: 240, initialScrollTop: 25 });
|
|
mockLogs([]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/No agent output yet/)).toBeTruthy();
|
|
expect(metrics.scrollTop).toBe(25);
|
|
});
|
|
|
|
it("continues following new entries when the user is near the bottom", () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
|
const firstEntries = [makeEntry({ agent: "executor", text: "first output" })];
|
|
const secondEntries = [...firstEntries, makeEntry({ agent: "executor", text: "second output", timestamp: "2026-06-12T00:00:01.000Z" })];
|
|
mockedUseAgentLogs
|
|
.mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false })
|
|
.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 2, loadingMore: false });
|
|
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(metrics.scrollTop).toBe(1000);
|
|
|
|
metrics.scrollTop = 720;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
metrics.scrollHeight = 1400;
|
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(metrics.scrollTop).toBe(1400);
|
|
});
|
|
|
|
it("does not yank a scrolled-up user when a new entry arrives", () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
|
const firstEntries = [makeEntry({ agent: "executor", text: "first output" })];
|
|
const secondEntries = [...firstEntries, makeEntry({ agent: "executor", text: "second output", timestamp: "2026-06-12T00:00:01.000Z" })];
|
|
mockedUseAgentLogs
|
|
.mockReturnValueOnce({ entries: firstEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 1, loadingMore: false })
|
|
.mockReturnValueOnce({ entries: secondEntries, loading: false, clear: vi.fn(), loadMore: vi.fn(), hasMore: false, total: 2, loadingMore: false });
|
|
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(metrics.scrollTop).toBe(1000);
|
|
|
|
metrics.scrollTop = 120;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
metrics.scrollHeight = 1400;
|
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(metrics.scrollTop).toBe(120);
|
|
});
|
|
|
|
it("loads previous messages on scroll-to-top and via the expanded-mode button", async () => {
|
|
const user = userEvent.setup();
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 1000 });
|
|
const loadMore = vi.fn(async () => {});
|
|
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: true, loadMore });
|
|
|
|
render(<TaskChatTab task={makeTask()} active expanded onToggleExpanded={vi.fn()} addToast={vi.fn()} />);
|
|
|
|
metrics.scrollTop = 0;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(1));
|
|
|
|
await user.click(screen.getByTestId("task-chat-load-previous"));
|
|
expect(loadMore).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("renders load-previous affordances only for available older history", () => {
|
|
const loadMore = vi.fn(async () => {});
|
|
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: true, loadMore });
|
|
const { unmount } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
const button = screen.getByTestId("task-chat-load-previous");
|
|
expect(button).toBeVisible();
|
|
expect(button).toHaveAccessibleName("Load previous messages");
|
|
unmount();
|
|
|
|
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: true, loadingMore: true, loadMore });
|
|
const loading = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(screen.getByTestId("task-chat-load-previous-loading")).toHaveTextContent("Loading earlier messages…");
|
|
expect(screen.queryByTestId("task-chat-load-previous")).not.toBeInTheDocument();
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
expect(loadMore).not.toHaveBeenCalled();
|
|
loading.unmount();
|
|
|
|
mockLogs([makeEntry({ agent: "executor", text: "current output" })], false, { hasMore: false, loadMore });
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(screen.queryByTestId("task-chat-load-previous")).not.toBeInTheDocument();
|
|
expect(screen.queryByTestId("task-chat-load-previous-loading")).not.toBeInTheDocument();
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
expect(loadMore).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("preserves scroll position when older entries are prepended", async () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
|
const loadMoreDeferred = deferred<void>();
|
|
const loadMore = vi.fn(() => loadMoreDeferred.promise);
|
|
const currentEntries = [
|
|
makeEntry({ agent: "executor", text: "current first", timestamp: "2026-06-12T00:00:02.000Z" }),
|
|
makeEntry({ agent: "executor", text: "current latest", timestamp: "2026-06-12T00:00:03.000Z" }),
|
|
];
|
|
mockLogs(currentEntries, false, { hasMore: true, loadMore });
|
|
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} active expanded onToggleExpanded={vi.fn()} addToast={vi.fn()} />);
|
|
metrics.scrollTop = 0;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
expect(loadMore).toHaveBeenCalledTimes(1);
|
|
|
|
metrics.scrollHeight = 1400;
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "older history", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
...currentEntries,
|
|
], false, { hasMore: false, loadMore });
|
|
await act(async () => {
|
|
loadMoreDeferred.resolve();
|
|
await loadMoreDeferred.promise;
|
|
});
|
|
rerender(<TaskChatTab task={makeTask()} active expanded onToggleExpanded={vi.fn()} addToast={vi.fn()} />);
|
|
|
|
expect(metrics.scrollTop).toBe(400);
|
|
expect(metrics.scrollTop).not.toBe(metrics.scrollHeight);
|
|
expect(screen.getByTestId("task-chat-jump-to-bottom")).toBeVisible();
|
|
});
|
|
|
|
it("keeps live appends following the bottom while load-previous is in flight", async () => {
|
|
const user = userEvent.setup();
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
|
const loadMoreDeferred = deferred<void>();
|
|
const loadMore = vi.fn(() => loadMoreDeferred.promise);
|
|
const currentEntries = [makeEntry({ agent: "executor", text: "current output", timestamp: "2026-06-12T00:00:02.000Z" })];
|
|
mockLogs(currentEntries, false, { hasMore: true, loadMore });
|
|
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(metrics.scrollTop).toBe(1000);
|
|
await user.click(screen.getByTestId("task-chat-load-previous"));
|
|
expect(loadMore).toHaveBeenCalledTimes(1);
|
|
|
|
metrics.scrollHeight = 1300;
|
|
mockLogs([
|
|
...currentEntries,
|
|
makeEntry({ agent: "executor", text: "live tail", timestamp: "2026-06-12T00:00:03.000Z" }),
|
|
], false, { hasMore: true, loadMore });
|
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(metrics.scrollTop).toBe(1300);
|
|
|
|
metrics.scrollHeight = 1700;
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "older history", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
...currentEntries,
|
|
makeEntry({ agent: "executor", text: "live tail", timestamp: "2026-06-12T00:00:03.000Z" }),
|
|
], false, { hasMore: false, loadMore });
|
|
await act(async () => {
|
|
loadMoreDeferred.resolve();
|
|
await loadMoreDeferred.promise;
|
|
});
|
|
rerender(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(metrics.scrollTop).toBe(1700);
|
|
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("preserves steering-comment ordering after older entries are prepended", async () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1000, clientHeight: 240, initialScrollTop: 0 });
|
|
const loadMoreDeferred = deferred<void>();
|
|
const loadMore = vi.fn(() => loadMoreDeferred.promise);
|
|
const currentEntries = [makeEntry({ agent: "executor", text: "newer agent output", timestamp: "2026-06-12T00:00:03.000Z" })];
|
|
const task = makeTask({
|
|
steeringComments: [makeSteeringComment({ text: "middle user guidance", createdAt: "2026-06-12T00:00:02.000Z" })],
|
|
});
|
|
mockLogs(currentEntries, false, { hasMore: true, loadMore });
|
|
|
|
const { rerender } = render(<TaskChatTab task={task} active addToast={vi.fn()} />);
|
|
metrics.scrollTop = 0;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
|
|
metrics.scrollHeight = 1400;
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "older agent output", timestamp: "2026-06-12T00:00:01.000Z" }),
|
|
...currentEntries,
|
|
], false, { hasMore: false, loadMore });
|
|
await act(async () => {
|
|
loadMoreDeferred.resolve();
|
|
await loadMoreDeferred.promise;
|
|
});
|
|
rerender(<TaskChatTab task={task} active addToast={vi.fn()} />);
|
|
|
|
const transcriptText = screen.getByTestId("task-chat-transcript").textContent ?? "";
|
|
expect(transcriptText.indexOf("older agent output")).toBeLessThan(transcriptText.indexOf("middle user guidance"));
|
|
expect(transcriptText.indexOf("middle user guidance")).toBeLessThan(transcriptText.indexOf("newer agent output"));
|
|
});
|
|
|
|
it("does not render the jump-to-bottom button for loading or empty transcripts", () => {
|
|
mockLogs([], true);
|
|
const loading = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(screen.getByText(/Loading agent output/)).toBeVisible();
|
|
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
|
|
loading.unmount();
|
|
|
|
mockLogs([]);
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(screen.getByText(/No agent output yet/)).toBeVisible();
|
|
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("renders the jump-to-bottom button only after a populated transcript is scrolled up", () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
|
mockLogs([makeEntry({ agent: "executor", text: "latest output" })]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(metrics.scrollTop).toBe(1200);
|
|
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
|
|
|
|
metrics.scrollTop = 920;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
|
|
|
|
metrics.scrollTop = 600;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
const jumpButton = screen.getByTestId("task-chat-jump-to-bottom");
|
|
expect(jumpButton).toBeVisible();
|
|
expect(jumpButton).toHaveAccessibleName("Jump to latest message");
|
|
expect(screen.getByRole("button", { name: "Jump to latest message" })).toBe(jumpButton);
|
|
});
|
|
|
|
it("keeps the icon-only expand toggle accessible after transcript scrolling", () => {
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
|
mockLogs([makeEntry({ agent: "executor", text: "scrollable output" })]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} expanded={false} onToggleExpanded={vi.fn()} />);
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
|
|
metrics.scrollTop = 600;
|
|
fireEvent.scroll(transcript);
|
|
|
|
const toggle = screen.getByTestId("task-chat-expand-toggle");
|
|
expect(toggle).toBeInTheDocument();
|
|
expect(toggle).toBeVisible();
|
|
expect(toggle).toHaveAccessibleName("Expand chat to full modal");
|
|
expect(toggle).not.toHaveTextContent("Expand");
|
|
expect(transcript).not.toContainElement(toggle);
|
|
});
|
|
|
|
it("clicking the jump-to-bottom button snaps to the latest message and removes the control", async () => {
|
|
const user = userEvent.setup();
|
|
const metrics = mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
|
mockLogs([makeEntry({ agent: "executor", text: "latest output" })]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
metrics.scrollTop = 120;
|
|
fireEvent.scroll(screen.getByTestId("task-chat-transcript"));
|
|
|
|
await user.click(screen.getByTestId("task-chat-jump-to-bottom"));
|
|
|
|
expect(metrics.scrollTop).toBe(1200);
|
|
expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("keeps the jump-to-bottom affordance available at the mobile breakpoint", () => {
|
|
mockMatchMedia(true);
|
|
mockTranscriptMetrics({ scrollHeight: 1200, clientHeight: 240, initialScrollTop: 0 });
|
|
mockLogs([makeEntry({ agent: "executor", text: "mobile output" })]);
|
|
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
transcript.scrollTop = 120;
|
|
fireEvent.scroll(transcript);
|
|
|
|
expect(screen.getByTestId("task-chat-jump-to-bottom")).toBeVisible();
|
|
expect(screen.getByRole("button", { name: "Jump to latest message" })).toHaveClass("task-chat-jump-to-bottom");
|
|
});
|
|
|
|
it("renders an icon-only send button with preserved accessible name and new placeholder", () => {
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByPlaceholderText("Steer the currently executing agent")).toBeInTheDocument();
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).toHaveClass("task-chat-send");
|
|
expect(sendButton).toHaveTextContent("");
|
|
});
|
|
|
|
it("posts composer text through addSteeringComment and clears on success", async () => {
|
|
const user = userEvent.setup();
|
|
const onTaskUpdated = vi.fn();
|
|
const updatedTask = makeTask();
|
|
mockedAddSteeringComment.mockResolvedValue(updatedTask);
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
await user.type(input, "Please inspect the failing test");
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please inspect the failing test", "project-1");
|
|
});
|
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
|
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
|
expect(input).toHaveValue("");
|
|
});
|
|
|
|
it("routes done-task composer sends to refineTask without replacing the current task", async () => {
|
|
const user = userEvent.setup();
|
|
const addToast = vi.fn();
|
|
const onTaskUpdated = vi.fn();
|
|
const refinementTask = makeTask({ id: "FN-222", column: "todo" });
|
|
mockedRefineTask.mockResolvedValue(refinementTask);
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "done", status: undefined })}
|
|
projectId="project-1"
|
|
active
|
|
addToast={addToast}
|
|
onTaskUpdated={onTaskUpdated}
|
|
/>,
|
|
);
|
|
|
|
expectDonePlaceholderWithoutGuidance();
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
await user.type(input, "Please add a follow-up report");
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Please add a follow-up report", "project-1");
|
|
});
|
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getByText("You")).toBeVisible();
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getByText("Please add a follow-up report")).toBeVisible();
|
|
expect(input).toHaveValue("");
|
|
expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-222", "success");
|
|
expect(onTaskUpdated).not.toHaveBeenCalledWith(refinementTask);
|
|
expect(onTaskUpdated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("sends an in-progress task steering message on plain Enter", async () => {
|
|
const onTaskUpdated = vi.fn();
|
|
const updatedTask = makeTask();
|
|
mockedAddSteeringComment.mockResolvedValue(updatedTask);
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} onTaskUpdated={onTaskUpdated} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
fireEvent.change(input, { target: { value: "Plain Enter guidance" } });
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Plain Enter guidance", "project-1");
|
|
});
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
|
expect(onTaskUpdated).toHaveBeenCalledWith(updatedTask);
|
|
});
|
|
|
|
it("sends a done-task refinement on plain Enter", async () => {
|
|
const refinementTask = makeTask({ id: "FN-224", column: "todo" });
|
|
mockedRefineTask.mockResolvedValue(refinementTask);
|
|
render(<TaskChatTab task={makeTask({ column: "done" })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
fireEvent.change(input, { target: { value: "Plain Enter refinement" } });
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Plain Enter refinement", "project-1");
|
|
});
|
|
expect(mockedRefineTask).toHaveBeenCalledTimes(1);
|
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps Shift+Enter as textarea newline input without sending", async () => {
|
|
const user = userEvent.setup();
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
await user.click(input);
|
|
await user.keyboard("Line one");
|
|
await user.keyboard("{Shift>}{Enter}{/Shift}Line two");
|
|
|
|
expect(input).toHaveValue("Line one\nLine two");
|
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["", " \n "])("does not send a %s draft on Enter", async (draft) => {
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
fireEvent.change(input, { target: { value: draft } });
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it("does not submit another Enter while a send is already in flight", async () => {
|
|
const send = deferred<Task>();
|
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
fireEvent.change(input, { target: { value: "Only send once" } });
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
|
});
|
|
expect(screen.getByRole("button", { name: "Sending" })).toBeDisabled();
|
|
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter" });
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
|
|
|
await act(async () => {
|
|
send.resolve(makeTask());
|
|
await send.promise;
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["isComposing", { isComposing: true }],
|
|
["keyCode 229", { keyCode: 229 }],
|
|
])("does not send Enter during IME composition signaled by %s", async (_label, eventPatch) => {
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
fireEvent.change(input, { target: { value: "Composing text" } });
|
|
const event = new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true, cancelable: true });
|
|
for (const [key, value] of Object.entries(eventPatch)) {
|
|
Object.defineProperty(event, key, { value });
|
|
}
|
|
fireEvent(input, event);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["Cmd+Enter", { metaKey: true }],
|
|
["Ctrl+Enter", { ctrlKey: true }],
|
|
])("keeps %s sending for backward compatibility", async (_label, modifier) => {
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask());
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
fireEvent.change(input, { target: { value: "Shortcut guidance" } });
|
|
fireEvent.keyDown(input, { key: "Enter", code: "Enter", ...modifier });
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Shortcut guidance", "project-1");
|
|
});
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([undefined, null, "failed", "done"])("routes done-task sends to refineTask regardless of %s status", async (status) => {
|
|
const user = userEvent.setup();
|
|
mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-333", column: "todo" }));
|
|
render(<TaskChatTab task={makeTask({ column: "done", status })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
await user.type(screen.getByLabelText("Message active agent session"), `Refine from ${String(status)}`);
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", `Refine from ${String(status)}`, "project-1");
|
|
});
|
|
expect(mockedAddSteeringComment).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["in-progress", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: "queued" })],
|
|
["in-review", makeTask({ column: "in-review", assignedAgentId: "agent-1", status: "reviewing" })],
|
|
["todo", makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["triage", makeTask({ column: "triage", assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["archived", makeTask({ column: "archived", assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
])("keeps %s sends routed to addSteeringComment", async (_label, task) => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(task);
|
|
render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
await user.type(screen.getByLabelText("Message active agent session"), "Keep steering");
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Keep steering", "project-1");
|
|
});
|
|
expect(mockedRefineTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders a sent user message in the chat transcript", async () => {
|
|
const user = userEvent.setup();
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "I am checking the failure", timestamp: "2026-06-12T00:00:00.000Z" }),
|
|
]);
|
|
const send = deferred<Task>();
|
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
|
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1" })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
await user.type(input, "Please inspect the failing test");
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByText("You")).toBeVisible();
|
|
expect(within(transcript).getByText("Please inspect the failing test")).toBeVisible();
|
|
expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible();
|
|
expect(within(transcript).getByTestId("task-chat-user-time")).toBeVisible();
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please inspect the failing test", "project-1");
|
|
|
|
await act(async () => {
|
|
send.resolve(makeTask({ steeringComments: [makeSteeringComment({ id: "steer-sent", text: "Please inspect the failing test" })] }));
|
|
await send.promise;
|
|
});
|
|
|
|
expect(within(transcript).getByText("Please inspect the failing test")).toBeVisible();
|
|
expect(input).toHaveValue("");
|
|
});
|
|
|
|
it("renders a just-now timestamp for an optimistic user message", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
|
|
mockedAddSteeringComment.mockReturnValue(deferred<Task>().promise);
|
|
render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Optimistic timestamp" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByText("Optimistic timestamp")).toBeVisible();
|
|
expect(within(transcript).getByTestId("task-chat-user-time")).toHaveTextContent("just now");
|
|
});
|
|
|
|
it("renders a sent user message after pre-existing agent output under client-behind-server clock skew", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z"));
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "agent output with server timestamp", timestamp: "2026-06-12T00:00:05.000Z" }),
|
|
]);
|
|
const send = deferred<Task>();
|
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
|
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1" })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Please stay below the agent output" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
expectTranscriptTextOrder("agent output with server timestamp", "Please stay below the agent output");
|
|
});
|
|
|
|
it.each([
|
|
["in-review client-ahead mobile", makeTask({ column: "in-review", assignedAgentId: "agent-1", status: "reviewing" }), "2026-06-12T00:00:10.000Z", true],
|
|
["in-progress clock-sync desktop", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: "queued" }), "2026-06-12T00:00:05.000Z", false],
|
|
])("keeps sent user messages at the transcript tail for %s", (_label, task, now, mobile) => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date(now));
|
|
mockMatchMedia(mobile);
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "pre-existing agent output", timestamp: "2026-06-12T00:00:05.000Z" }),
|
|
]);
|
|
mockedAddSteeringComment.mockReturnValue(deferred<Task>().promise);
|
|
render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Tail guidance" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
expectTranscriptTextOrder("pre-existing agent output", "Tail guidance");
|
|
});
|
|
|
|
it("renders agent follow-up below the newly-sent user message", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z"));
|
|
const task = makeTask({ steeringComments: [makeSteeringComment({ id: "old-user", text: "historical guidance", createdAt: "2026-06-12T00:00:02.000Z" })] });
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "pre-existing output", timestamp: "2026-06-12T00:00:05.000Z" }),
|
|
]);
|
|
mockedAddSteeringComment.mockReturnValue(deferred<Task>().promise);
|
|
const { rerender } = render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "New steering" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
expectTranscriptTextOrder("historical guidance", "pre-existing output", "New steering");
|
|
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "pre-existing output", timestamp: "2026-06-12T00:00:05.000Z" }),
|
|
makeEntry({ agent: "executor", text: "agent follow-up after steering", timestamp: "2026-06-12T00:00:06.000Z" }),
|
|
]);
|
|
rerender(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
expectTranscriptTextOrder("pre-existing output", "New steering", "agent follow-up after steering");
|
|
});
|
|
|
|
it("keeps a reconciled persisted steering comment at the clamped tail without duplication", async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z"));
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "agent output before send", timestamp: "2026-06-12T00:00:05.000Z" }),
|
|
]);
|
|
const send = deferred<Task>();
|
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
|
const persistedComment = makeSteeringComment({ id: "steer-reconciled", text: "Reconciled guidance", createdAt: "2026-06-12T00:00:01.000Z" });
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Reconciled guidance" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
expectTranscriptTextOrder("agent output before send", "Reconciled guidance");
|
|
|
|
await act(async () => {
|
|
send.resolve(makeTask({ steeringComments: [persistedComment] }));
|
|
await send.promise;
|
|
});
|
|
rerender(<TaskChatTab task={makeTask({ steeringComments: [persistedComment] })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
expectTranscriptTextOrder("agent output before send", "Reconciled guidance");
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Reconciled guidance")).toHaveLength(1);
|
|
});
|
|
|
|
it("inserts done-task refinement messages immediately at the transcript tail", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-12T00:00:00.000Z"));
|
|
mockLogs([
|
|
makeEntry({ agent: "reviewer", text: "final agent summary", timestamp: "2026-06-12T00:00:05.000Z" }),
|
|
]);
|
|
mockedRefineTask.mockReturnValue(deferred<Task>().promise);
|
|
render(<TaskChatTab task={makeTask({ column: "done", status: "done", assignedAgentId: undefined })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
fireEvent.change(screen.getByLabelText("Message active agent session"), { target: { value: "Please refine this task" } });
|
|
fireEvent.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
expectTranscriptTextOrder("final agent summary", "Please refine this task");
|
|
expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Please refine this task", "project-1");
|
|
});
|
|
|
|
it("renders persisted user steering comments with a relative timestamp but not agent-authored steering comments", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z"));
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({
|
|
steeringComments: [
|
|
makeSteeringComment({ id: "user-steer", text: "Persisted user guidance", author: "user", createdAt: "2026-06-17T14:00:00.000Z" }),
|
|
makeSteeringComment({ id: "agent-steer", text: "Internal agent note", author: "agent" }),
|
|
],
|
|
})}
|
|
active
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByText("You")).toBeVisible();
|
|
expect(within(transcript).getByText("Persisted user guidance")).toBeVisible();
|
|
expect(within(transcript).getByTestId("task-chat-user-time")).toHaveTextContent("1h ago");
|
|
expect(within(transcript).queryByText("Internal agent note")).not.toBeInTheDocument();
|
|
});
|
|
|
|
it("omits invalid relative timestamps without crashing or rendering invalid-date text", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "invalid agent timestamp", timestamp: "not-a-date" }),
|
|
]);
|
|
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({
|
|
steeringComments: [makeSteeringComment({ id: "invalid-user-time", text: "invalid user timestamp", createdAt: "not-a-date" })],
|
|
})}
|
|
active
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByText("invalid agent timestamp")).toBeVisible();
|
|
expect(within(transcript).getByText("invalid user timestamp")).toBeVisible();
|
|
expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument();
|
|
expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument();
|
|
expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/);
|
|
});
|
|
|
|
it("deduplicates optimistic messages when matching persisted comments arrive", async () => {
|
|
const user = userEvent.setup();
|
|
const persistedComment = makeSteeringComment({ id: "steer-dedup", text: "Do not duplicate me" });
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({ steeringComments: [persistedComment] }));
|
|
const { rerender } = render(<TaskChatTab task={makeTask()} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
await user.type(screen.getByLabelText("Message active agent session"), "Do not duplicate me");
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Do not duplicate me", "project-1");
|
|
});
|
|
|
|
rerender(<TaskChatTab task={makeTask({ steeringComments: [persistedComment] })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Do not duplicate me")).toHaveLength(1);
|
|
});
|
|
|
|
it("deduplicates persisted user comments by fallback text and timestamp", () => {
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({
|
|
steeringComments: [
|
|
makeSteeringComment({ id: "", text: "Fallback duplicate", createdAt: "2026-06-12T00:00:04.000Z" }),
|
|
makeSteeringComment({ id: "", text: "Fallback duplicate", createdAt: "2026-06-12T00:00:04.000Z" }),
|
|
],
|
|
})}
|
|
active
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getAllByText("Fallback duplicate")).toHaveLength(1);
|
|
});
|
|
|
|
it("interleaves user messages chronologically with agent output", () => {
|
|
mockLogs([
|
|
makeEntry({ agent: "executor", text: "first agent output", timestamp: "2026-06-12T00:00:00.000Z" }),
|
|
makeEntry({ agent: "executor", text: "second agent output", timestamp: "2026-06-12T00:00:02.000Z" }),
|
|
]);
|
|
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ steeringComments: [makeSteeringComment({ text: "middle user guidance", createdAt: "2026-06-12T00:00:01.000Z" })] })}
|
|
active
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const transcriptText = screen.getByTestId("task-chat-transcript").textContent ?? "";
|
|
expect(transcriptText.indexOf("first agent output")).toBeLessThan(transcriptText.indexOf("middle user guidance"));
|
|
expect(transcriptText.indexOf("middle user guidance")).toBeLessThan(transcriptText.indexOf("second agent output"));
|
|
});
|
|
|
|
it.each([undefined, []])("does not render a phantom user bubble for %s steering comments", (steeringComments) => {
|
|
render(<TaskChatTab task={makeTask({ steeringComments })} active addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument();
|
|
expect(screen.getByText(/No agent output yet/)).toBeVisible();
|
|
});
|
|
|
|
it.each([
|
|
["queued", "Please continue after dispatch"],
|
|
[undefined, "Please continue with a cleared status"],
|
|
])("enables in-progress steering for realistic %s status and posts guidance", async (status, message) => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({ status }));
|
|
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
await user.type(input, message);
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", message, "project-1");
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["idle todo task without an attached agent", makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined, status: undefined })],
|
|
["paused task", makeTask({ status: "paused" })],
|
|
])("FN-6354 keeps the composer sendable for %s", async (_label, task) => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({
|
|
...task,
|
|
steeringComments: [makeSteeringComment({ id: "steer-new", text: "Queue this for later" })],
|
|
}));
|
|
render(
|
|
<TaskChatTab
|
|
task={task}
|
|
projectId="project-1"
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
|
expectActivePlaceholderWithoutGuidance();
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).toBeDisabled();
|
|
|
|
await user.type(input, "Queue this for later");
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Queue this for later", "project-1");
|
|
});
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getByText("Queue this for later")).toBeVisible();
|
|
});
|
|
|
|
it.each([
|
|
["todo task with no agent", makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined, status: undefined })],
|
|
["user-paused in-progress task", makeTask({ column: "in-progress", status: "queued", userPaused: true })],
|
|
])("makes the no-reply send path non-silent for a %s", async (_label, task) => {
|
|
const user = userEvent.setup();
|
|
const send = deferred<Task>();
|
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
|
const message = `Will anyone answer ${_label}?`;
|
|
render(<TaskChatTab task={task} projectId="project-1" active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
await user.type(screen.getByLabelText("Message active agent session"), message);
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", message, "project-1");
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getByText(message)).toBeVisible();
|
|
|
|
await act(async () => {
|
|
send.resolve(makeTask({ ...task, steeringComments: [makeSteeringComment({ id: `persisted-${task.column}`, text: message })] }));
|
|
await send.promise;
|
|
});
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expect(within(screen.getByTestId("task-chat-transcript")).getByText(message)).toBeVisible();
|
|
});
|
|
|
|
it.each(["starting", "ready", "busy", "waitingOnInput"] as const)(
|
|
"enables steering for a live %s CLI session when static task fields are not steerable",
|
|
async (agentState) => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-review", status: "queued" }));
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-review", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
projectId="project-1"
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={isCliSessionLive(makeCliSession(agentState))}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
|
expectActivePlaceholderWithoutGuidance();
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
await user.type(input, `Please continue ${agentState}`);
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", `Please continue ${agentState}`, "project-1");
|
|
});
|
|
},
|
|
);
|
|
|
|
it("enables steering for a live CLI session in a terminal column that static task fields reject", () => {
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "todo", status: undefined, assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={true}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
|
});
|
|
|
|
it.each(["done", "dead", "needsAttention", null] as const)("omits guidance but stays sendable when the CLI session is not live: %s", (agentState) => {
|
|
const sessionLive = agentState === null ? isCliSessionLive(null) : isCliSessionLive(makeCliSession(agentState));
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={sessionLive}
|
|
/>,
|
|
);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expectComposerSendableAfterDraft();
|
|
});
|
|
|
|
it.each([undefined, "planning", "merging", "merging-fix"])(
|
|
"treats an actively-executing in-progress task as an active session even without an assignment (ephemeral mode): %s status",
|
|
(status) => {
|
|
// In the default ephemeral-agents mode the scheduler never writes
|
|
// assignedAgentId/checkedOutBy, so a running in-progress task has no
|
|
// assignment field yet IS being worked. It must keep the compact
|
|
// placeholder-only composer without a guidance shell.
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-progress", status, assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
|
},
|
|
);
|
|
|
|
it("keeps a queued (waiting) unassigned in-progress task idle in ephemeral mode", () => {
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
});
|
|
|
|
it.each(["busy", "ready", "starting", "waitingOnInput"] as const)("treats %s CLI sessions as live", (agentState) => {
|
|
expect(isCliSessionLive(makeCliSession(agentState))).toBe(true);
|
|
});
|
|
|
|
it.each(["done", "dead", "needsAttention"] as const)("treats %s CLI sessions as not live", (agentState) => {
|
|
expect(isCliSessionLive(makeCliSession(agentState))).toBe(false);
|
|
});
|
|
|
|
it("treats a missing CLI session as not live", () => {
|
|
expect(isCliSessionLive(null)).toBe(false);
|
|
});
|
|
|
|
it.each([undefined, null, "queued", "planning", "merging", "merging-fix"])(
|
|
"enables in-progress steering for assigned agents with %s status",
|
|
(status) => {
|
|
render(<TaskChatTab task={makeTask({ column: "in-progress", assignedAgentId: "agent-1", status })} active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
|
},
|
|
);
|
|
|
|
it("enables a non-CLI engine agent when the forwarded task carries full-detail agent fields", async () => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-progress", assignedAgentId: "agent-full", checkedOutBy: "agent-full", status: "queued" }));
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-progress", assignedAgentId: "agent-full", checkedOutBy: "agent-full", status: "queued" })}
|
|
projectId="project-1"
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expect(screen.queryByText(/No active steerable agent session/)).not.toBeInTheDocument();
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
await user.type(input, "Continue from the worktree");
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Continue from the worktree", "project-1");
|
|
});
|
|
});
|
|
|
|
it("enables in-progress steering with checkedOutBy when no assignedAgentId exists", async () => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({ status: "queued" }));
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: "agent-1" })}
|
|
projectId="project-1"
|
|
active
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
await user.type(input, "Please keep going");
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please keep going", "project-1");
|
|
});
|
|
});
|
|
|
|
it.each(["reviewing", "merging", "merging-fix", "fixing"])(
|
|
"enables in-review steering while %s with an assigned agent",
|
|
async (status) => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-review", status }));
|
|
render(<TaskChatTab task={makeTask({ column: "in-review", status })} projectId="project-1" active addToast={vi.fn()} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
await user.type(input, `Please continue ${status}`);
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", `Please continue ${status}`, "project-1");
|
|
});
|
|
},
|
|
);
|
|
|
|
it("enables in-review steering with checkedOutBy when no assignedAgentId exists", async () => {
|
|
const user = userEvent.setup();
|
|
mockedAddSteeringComment.mockResolvedValue(makeTask({ column: "in-review", status: "reviewing" }));
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-review", status: "reviewing", assignedAgentId: undefined, checkedOutBy: "agent-1" })}
|
|
projectId="project-1"
|
|
active
|
|
addToast={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
expect(input).not.toBeDisabled();
|
|
await user.type(input, "Please review this follow-up");
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
await waitFor(() => {
|
|
expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "Please review this follow-up", "project-1");
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
["in-progress task", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: undefined }), true],
|
|
["queued in-progress task", makeTask({ column: "in-progress", assignedAgentId: "agent-1", status: "queued" }), false],
|
|
["in-review task", makeTask({ column: "in-review", assignedAgentId: "agent-1", status: "reviewing" }), true],
|
|
["todo task", makeTask({ column: "todo", assignedAgentId: "agent-1", status: undefined }), false],
|
|
["triage task", makeTask({ column: "triage", assignedAgentId: "agent-1", status: undefined }), true],
|
|
["done task", makeTask({ column: "done", assignedAgentId: "agent-1", status: undefined }), false],
|
|
["archived task", makeTask({ column: "archived", assignedAgentId: "agent-1", status: undefined }), false],
|
|
])("keeps the composer sendable for %s column", (_label, task, _previouslyShowedActiveCopy) => {
|
|
render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
if (task.column === "done") {
|
|
expectDonePlaceholderWithoutGuidance();
|
|
} else {
|
|
expectActivePlaceholderWithoutGuidance();
|
|
}
|
|
expectComposerSendableAfterDraft();
|
|
});
|
|
|
|
it.each([
|
|
["in-progress task without an assigned or checked-out agent", makeTask({ column: "in-progress", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["triage task waiting in the queue", makeTask({ column: "triage", status: "queued", assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["paused triage task", makeTask({ column: "triage", status: "planning", paused: true, assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["user-paused triage task", makeTask({ column: "triage", status: "planning", userPaused: true, assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["paused in-progress task", makeTask({ column: "in-progress", status: "queued", paused: true })],
|
|
["user-paused in-progress task", makeTask({ column: "in-progress", status: "queued", userPaused: true })],
|
|
// Paused early-return must win over the ephemeral executionImpliesActiveAgent path:
|
|
// a paused/unassigned in-progress task in an otherwise-active status stays idle.
|
|
["paused unassigned in-progress task in an active status", makeTask({ column: "in-progress", status: "planning", paused: true, assignedAgentId: undefined, checkedOutBy: undefined })],
|
|
["paused in-review task", makeTask({ column: "in-review", status: "reviewing", paused: true })],
|
|
["user-paused in-review task", makeTask({ column: "in-review", status: "reviewing", userPaused: true })],
|
|
])("keeps the composer sendable without guidance for %s", (_label, task) => {
|
|
render(<TaskChatTab task={task} active addToast={vi.fn()} />);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expectComposerSendableAfterDraft();
|
|
});
|
|
|
|
it.each(["reviewing", "merging", "merging-fix", "fixing"])(
|
|
"treats an actively-reviewing in-review task as an active session even without an assignment (ephemeral mode): %s status",
|
|
(status) => {
|
|
// A reviewer/merger runs ephemerally with no assignedAgentId/checkedOutBy,
|
|
// so an in-review task in an active review/merge status must keep the
|
|
// compact placeholder-only composer without a guidance shell.
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-review", status, assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled();
|
|
},
|
|
);
|
|
|
|
it("keeps a null-status in-review task (awaiting human review) idle without an assignment", () => {
|
|
render(
|
|
<TaskChatTab
|
|
task={makeTask({ column: "in-review", status: undefined, assignedAgentId: undefined, checkedOutBy: undefined })}
|
|
active
|
|
addToast={vi.fn()}
|
|
sessionLive={false}
|
|
/>,
|
|
);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
});
|
|
|
|
it.each([
|
|
["paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", paused: true })],
|
|
["user-paused in-progress task with a live session", makeTask({ column: "in-progress", status: "queued", userPaused: true })],
|
|
["paused in-review task with a live session", makeTask({ column: "in-review", status: "reviewing", paused: true })],
|
|
["user-paused in-review task with a live session", makeTask({ column: "in-review", status: "reviewing", userPaused: true })],
|
|
])("keeps the composer sendable without guidance for %s", (_label, task) => {
|
|
render(<TaskChatTab task={task} active addToast={vi.fn()} sessionLive={true} />);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expectComposerSendableAfterDraft();
|
|
});
|
|
|
|
it.each(["paused", "awaiting-user-input", "awaiting-cli-approval", "awaiting-user-review", "awaiting-approval", "awaiting-integration", "failed", "needs-replan"])(
|
|
"keeps active-column steering sendable without guidance for %s status",
|
|
(status) => {
|
|
render(<TaskChatTab task={makeTask({ column: "triage", assignedAgentId: "agent-1", status })} active addToast={vi.fn()} />);
|
|
|
|
expectActivePlaceholderWithoutGuidance();
|
|
expectComposerSendableAfterDraft();
|
|
},
|
|
);
|
|
|
|
it("disables the composer only while a send is in flight", async () => {
|
|
const user = userEvent.setup();
|
|
const send = deferred<Task>();
|
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
|
render(<TaskChatTab task={makeTask({ column: "todo", assignedAgentId: undefined, checkedOutBy: undefined })} active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(input).not.toBeDisabled();
|
|
expect(sendButton).toBeDisabled();
|
|
|
|
await user.type(input, "Please queue this while idle");
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
const sendingButton = screen.getByRole("button", { name: "Sending" });
|
|
expect(sendingButton).toBeDisabled();
|
|
expect(sendingButton).toHaveTextContent("");
|
|
expect(input).toBeDisabled();
|
|
|
|
await act(async () => {
|
|
send.resolve(makeTask({ steeringComments: [makeSteeringComment({ text: "Please queue this while idle" })] }));
|
|
await send.promise;
|
|
});
|
|
|
|
expect(input).not.toBeDisabled();
|
|
expect(input).toHaveValue("");
|
|
});
|
|
|
|
it("uses the same send lifecycle while creating a done-task refinement", async () => {
|
|
const user = userEvent.setup();
|
|
const send = deferred<Task>();
|
|
mockedRefineTask.mockReturnValue(send.promise);
|
|
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} sessionLive={false} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
const sendButton = screen.getByRole("button", { name: "Send" });
|
|
expect(input).not.toBeDisabled();
|
|
expect(sendButton).toBeDisabled();
|
|
|
|
await user.type(input, " ");
|
|
expect(sendButton).toBeDisabled();
|
|
await user.clear(input);
|
|
await user.type(input, "Create follow-up");
|
|
expect(sendButton).not.toBeDisabled();
|
|
await user.click(sendButton);
|
|
|
|
const sendingButton = screen.getByRole("button", { name: "Sending" });
|
|
expect(sendingButton).toBeDisabled();
|
|
expect(sendingButton).toHaveTextContent("");
|
|
expect(input).toBeDisabled();
|
|
|
|
await act(async () => {
|
|
send.resolve(makeTask({ id: "FN-444", column: "todo" }));
|
|
await send.promise;
|
|
});
|
|
|
|
expect(input).not.toBeDisabled();
|
|
expect(input).toHaveValue("");
|
|
});
|
|
|
|
it("rolls back optimistic messages and surfaces send failures through addToast", async () => {
|
|
const user = userEvent.setup();
|
|
const addToast = vi.fn();
|
|
const send = deferred<Task>();
|
|
mockedAddSteeringComment.mockReturnValue(send.promise);
|
|
render(<TaskChatTab task={makeTask()} active addToast={addToast} />);
|
|
|
|
await user.type(screen.getByLabelText("Message active agent session"), "hello");
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible();
|
|
expect(within(transcript).getByText("hello")).toBeVisible();
|
|
|
|
await act(async () => {
|
|
send.reject(new Error("network down"));
|
|
try {
|
|
await send.promise;
|
|
} catch {
|
|
// Expected rejection drives the component rollback path.
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument();
|
|
expect(addToast).toHaveBeenCalledWith("Unable to send message: network down", "error");
|
|
});
|
|
});
|
|
|
|
it("rolls back done-task optimistic messages when refinement creation fails", async () => {
|
|
const user = userEvent.setup();
|
|
const addToast = vi.fn();
|
|
const onTaskUpdated = vi.fn();
|
|
const send = deferred<Task>();
|
|
mockedRefineTask.mockReturnValue(send.promise);
|
|
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={addToast} onTaskUpdated={onTaskUpdated} />);
|
|
|
|
const input = screen.getByLabelText("Message active agent session");
|
|
await user.type(input, "make a follow-up");
|
|
await user.click(screen.getByRole("button", { name: "Send" }));
|
|
const transcript = screen.getByTestId("task-chat-transcript");
|
|
expect(within(transcript).getByTestId("task-chat-entry-user")).toBeVisible();
|
|
expect(within(transcript).getByText("make a follow-up")).toBeVisible();
|
|
|
|
await act(async () => {
|
|
send.reject(new Error("refine failed"));
|
|
try {
|
|
await send.promise;
|
|
} catch {
|
|
// Expected rejection drives the component rollback path.
|
|
}
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByTestId("task-chat-entry-user")).not.toBeInTheDocument();
|
|
expect(addToast).toHaveBeenCalledWith("Unable to send message: refine failed", "error");
|
|
});
|
|
expect(input).toHaveValue("make a follow-up");
|
|
expect(onTaskUpdated).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("renders the same composer affordance shell on desktop and mobile breakpoints", () => {
|
|
mockMatchMedia(false);
|
|
const desktop = render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument();
|
|
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input");
|
|
expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send");
|
|
desktop.unmount();
|
|
|
|
mockMatchMedia(true);
|
|
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
|
expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument();
|
|
expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument();
|
|
expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input");
|
|
expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send");
|
|
});
|
|
|
|
it("FN-6347 pins the composer while the transcript flex-fills without fixed viewport caps", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const tabRule = getCssRuleBlock(css, ".task-chat-tab");
|
|
const transcriptRule = getCssRuleBlock(css, ".task-chat-transcript");
|
|
const composerRule = getCssRuleBlock(css, ".task-chat-composer");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileTranscriptRule = getCssRuleBlock(mobileCss, ".task-chat-transcript");
|
|
|
|
expect(tabRule).toContain("display: flex");
|
|
expect(tabRule).toContain("flex: 1");
|
|
expect(tabRule).toContain("min-height: 0");
|
|
expect(transcriptRule).toContain("flex: 1 1 auto");
|
|
expect(transcriptRule).toContain("min-height: 0");
|
|
expect(transcriptRule).toContain("overflow-y: auto");
|
|
expect(transcriptRule).not.toContain("max-height");
|
|
expect(composerRule).toContain("flex: 0 0 auto");
|
|
expect(mobileTranscriptRule).toContain("flex: 1 1 auto");
|
|
expect(mobileTranscriptRule).toContain("min-height: 0");
|
|
expect(mobileTranscriptRule).not.toContain("max-height");
|
|
expect(css).not.toContain("70vh");
|
|
expect(css).not.toContain("62vh");
|
|
});
|
|
|
|
it("positions the icon-only expand toggle as a tokenized chat-view overlay with no toolbar shell", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const tabRule = getCssRuleBlock(css, ".task-chat-tab");
|
|
const transcriptRule = getCssRuleBlock(css, ".task-chat-transcript");
|
|
const toggleRule = getCssRuleBlock(css, ".task-chat-expand-toggle");
|
|
const overlayRule = getCssRuleBlock(css, ".task-chat-expand-toggle--overlay");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileOverlayRule = getCssRuleBlock(mobileCss, ".task-chat-expand-toggle--overlay");
|
|
|
|
expect(css).not.toContain(".task-chat-toolbar");
|
|
expect(tabRule).toContain("position: relative");
|
|
expect(transcriptRule).toContain("position: relative");
|
|
expect(toggleRule).toContain("justify-content: center");
|
|
expect(toggleRule).toContain("min-inline-size: var(--space-2xl)");
|
|
expect(toggleRule).toContain("min-block-size: var(--space-2xl)");
|
|
expect(toggleRule).not.toContain("gap");
|
|
expect(overlayRule).toContain("position: absolute");
|
|
expect(overlayRule).toContain("top: var(--space-md)");
|
|
expect(overlayRule).toContain("right: var(--space-md)");
|
|
expect(overlayRule).toContain("background: var(--surface)");
|
|
expect(overlayRule).toContain("border-color: var(--border)");
|
|
expect(overlayRule).toContain("box-shadow: var(--shadow-sm)");
|
|
expect(mobileOverlayRule).toContain("top: var(--space-sm)");
|
|
expect(mobileOverlayRule).toContain("right: var(--space-sm)");
|
|
expect(mobileOverlayRule).toContain("min-inline-size");
|
|
expect(mobileOverlayRule).toContain("min-block-size");
|
|
});
|
|
|
|
it("keeps tokenized sticky styling for the jump-to-bottom control on desktop and mobile", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const jumpRule = getCssRuleBlock(css, ".task-chat-jump-to-bottom");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileJumpRule = getCssRuleBlock(mobileCss, ".task-chat-jump-to-bottom");
|
|
|
|
expect(jumpRule).toContain("position: sticky");
|
|
expect(jumpRule).toContain("bottom: var(--space-md)");
|
|
expect(jumpRule).toContain("right: var(--space-md)");
|
|
expect(jumpRule).toContain("background: var(--surface)");
|
|
expect(jumpRule).toContain("border: var(--btn-border-width) solid var(--border)");
|
|
expect(jumpRule).toContain("box-shadow: var(--shadow-md)");
|
|
expect(jumpRule).toContain("border-radius: var(--radius-md)");
|
|
expect(mobileJumpRule).toContain("bottom: var(--space-sm)");
|
|
expect(mobileJumpRule).toContain("right: var(--space-sm)");
|
|
expect(mobileJumpRule).toContain("min-inline-size");
|
|
expect(mobileJumpRule).toContain("min-block-size");
|
|
});
|
|
|
|
it("keeps tokenized mobile touch targets for the load-previous affordance", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const rowRule = getCssRuleBlock(css, ".task-chat-load-previous-row");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileRowRule = getCssRuleBlock(mobileCss, ".task-chat-load-previous-row");
|
|
const mobileButtonRule = getCssRuleBlock(mobileCss, ".task-chat-load-previous,");
|
|
|
|
expect(rowRule).toContain("justify-content: center");
|
|
expect(rowRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(css).toContain("gap: var(--space-xs)");
|
|
expect(css).toContain("color: var(--text-muted)");
|
|
expect(rowRule).not.toContain("px");
|
|
expect(css).not.toContain("#");
|
|
expect(mobileRowRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(mobileButtonRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
});
|
|
|
|
it("scales the task chat send glyph without shrinking the desktop or mobile touch target", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const sharedStyles = readFileSync(resolve(__dirname, "../../styles.css"), "utf8");
|
|
const sendRule = getCssRuleBlock(css, ".task-chat-send");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileSendRule = getCssRuleBlock(mobileCss, ".task-chat-send");
|
|
const mobileInputRule = getCssRuleBlock(mobileCss, ".task-chat-input");
|
|
const tokenValues = getRootTokenPxValues(sharedStyles);
|
|
const defaultIconSizePx = tokenValues["--icon-size-md"];
|
|
const desktopIconSizePx = resolveCssPxToken(getCssDeclaration(sendRule, "--btn-icon-size"), tokenValues);
|
|
const mobileIconSizePx = resolveCssCalcSumPx(getCssDeclaration(mobileSendRule, "--btn-icon-size"), tokenValues);
|
|
const desktopBoxSizePx = resolveCssCalcSumPx(getCssDeclaration(sendRule, "inline-size"), tokenValues);
|
|
const mobileBoxSizePx = resolveCssCalcSumPx(getCssDeclaration(mobileSendRule, "inline-size"), tokenValues);
|
|
const mobileInputMinHeightPx = resolveCssCalcSumPx(getCssDeclaration(mobileInputRule, "min-height"), tokenValues);
|
|
|
|
expect(defaultIconSizePx).toBe(16);
|
|
expect(desktopIconSizePx).toBeGreaterThan(defaultIconSizePx);
|
|
expect(mobileIconSizePx).toBeGreaterThan(defaultIconSizePx);
|
|
expect(mobileBoxSizePx).toBeGreaterThan(desktopBoxSizePx);
|
|
expect(mobileIconSizePx).toBeGreaterThan(desktopIconSizePx);
|
|
expect(desktopIconSizePx / desktopBoxSizePx).toBeGreaterThanOrEqual(0.75);
|
|
expect(mobileIconSizePx / mobileBoxSizePx).toBeGreaterThanOrEqual(0.8);
|
|
expect(mobileInputMinHeightPx).toBe(mobileBoxSizePx);
|
|
expect(sendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(sendRule).toContain("min-inline-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(sendRule).toContain("block-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(sendRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(mobileSendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-lg))");
|
|
expect(mobileSendRule).toContain("min-inline-size: calc(var(--space-2xl) + var(--space-lg))");
|
|
expect(mobileSendRule).toContain("block-size: calc(var(--space-2xl) + var(--space-lg))");
|
|
expect(mobileSendRule).toContain("min-block-size: calc(var(--space-2xl) + var(--space-lg))");
|
|
expect(mobileInputRule).toContain("min-height: calc(var(--space-2xl) + var(--space-lg))");
|
|
});
|
|
|
|
it("keeps TaskDetailModal inline and expanded chat on the canonical TaskChatTab renderer", () => {
|
|
const source = readFileSync(resolve(__dirname, "../TaskDetailModal.tsx"), "utf8");
|
|
const taskChatMounts = source.match(/<TaskChatTab\b/g) ?? [];
|
|
|
|
expect(source).toContain('import { TaskChatTab } from "./TaskChatTab"');
|
|
expect(taskChatMounts).toHaveLength(1);
|
|
expect(source).toContain("const isChatExpanded = chatExpanded && activeTab === \"chat\" && !isEditing");
|
|
expect(source).toContain("task-detail-content--chat-expanded");
|
|
expect(source).toContain("expanded={chatExpanded}");
|
|
});
|
|
|
|
it("stacks agent headers only inside the List View split-detail chat host", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const desktopGroupRule = getCssRuleBlock(css, ".task-chat-group");
|
|
const listSplitGroupRule = getCssRuleBlock(css, ".list-split-detail-content .task-chat-group");
|
|
const listSplitHeaderRule = getCssRuleBlock(css, ".list-split-detail-content .task-chat-group-header");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileGroupRule = getCssRuleBlock(mobileCss, ".task-chat-group");
|
|
|
|
expect(desktopGroupRule).toContain("grid-template-columns: auto minmax(0, 1fr)");
|
|
expect(listSplitGroupRule).toContain("grid-template-columns: 1fr");
|
|
expect(listSplitHeaderRule).toContain("min-width: 0");
|
|
expect(mobileGroupRule).toContain("grid-template-columns: 1fr");
|
|
});
|
|
|
|
it("keeps List View as the only split-pane host for compact task chat", () => {
|
|
const listSource = readFileSync(resolve(__dirname, "../ListView.tsx"), "utf8");
|
|
const mainContentSource = readFileSync(resolve(__dirname, "../dashboard/MainContent.tsx"), "utf8");
|
|
|
|
expect(listSource).toContain('className="list-split-detail-content"');
|
|
expect(listSource).toContain("<TaskDetailContent");
|
|
expect(listSource).toContain("embedded");
|
|
expect(mainContentSource).toContain('className="task-detail-main-panel-body"');
|
|
expect(mainContentSource).toContain("<TaskDetailContent");
|
|
});
|
|
|
|
it("keeps task chat timestamp styling tokenized and mobile-safe", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const groupMetaRule = getCssRuleBlock(css, ".task-chat-group-meta");
|
|
const userHeaderRule = getCssRuleBlock(css, ".task-chat-user-header");
|
|
const timestampRule = getCssRuleBlock(css, ".task-chat-timestamp");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileUserHeaderRule = getCssRuleBlock(mobileCss, ".task-chat-user-header");
|
|
const mobileTimestampRule = getCssRuleBlock(mobileCss, ".task-chat-timestamp");
|
|
|
|
expect(groupMetaRule).toContain("display: inline-flex");
|
|
expect(groupMetaRule).toContain("flex-wrap: wrap");
|
|
expect(groupMetaRule).toContain("gap: var(--space-xs)");
|
|
expect(timestampRule).toContain("color: var(--text-muted)");
|
|
expect(timestampRule).toContain("font-size: calc(var(--space-md) - (var(--space-xs) / 2))");
|
|
expect(timestampRule).not.toContain("px");
|
|
expect(timestampRule).not.toContain("#");
|
|
expect(userHeaderRule).toContain("display: inline-flex");
|
|
expect(userHeaderRule).toContain("flex-wrap: wrap");
|
|
expect(mobileUserHeaderRule).toContain("justify-content: flex-end");
|
|
expect(mobileTimestampRule).toContain("white-space: normal");
|
|
});
|
|
|
|
it("keeps mobile breakpoint scaffolding for the transcript, composer, and collapsible groups", () => {
|
|
const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8");
|
|
const sendRule = getCssRuleBlock(css, ".task-chat-send");
|
|
const mobileCss = getCssAfter(css, "@media (max-width: 768px)");
|
|
const mobileComposerRule = getCssRuleBlock(mobileCss, ".task-chat-composer-row");
|
|
const mobileSendRule = getCssRuleBlock(mobileCss, ".task-chat-send");
|
|
|
|
expect(css).toContain("@media (max-width: 768px)");
|
|
expect(css).toContain(".task-chat-transcript");
|
|
expect(css).toContain(".task-chat-jump-to-bottom");
|
|
expect(css).toContain(".task-chat-composer-row");
|
|
expect(sendRule).toContain("--btn-icon-size: var(--space-2xl)");
|
|
expect(sendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(sendRule).toContain("block-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(sendRule).not.toContain("gap");
|
|
expect(mobileComposerRule).toContain("align-items: flex-end");
|
|
expect(mobileComposerRule).not.toContain("flex-direction: column");
|
|
expect(mobileComposerRule).not.toContain("align-items: stretch");
|
|
expect(mobileSendRule).toContain("--btn-icon-size: calc(var(--space-2xl) + var(--space-sm))");
|
|
expect(mobileSendRule).toContain("inline-size: calc(var(--space-2xl) + var(--space-lg))");
|
|
expect(css).toContain(".task-chat-tool-group-summary");
|
|
expect(css).toContain(".task-chat-tool-group-names");
|
|
expect(css).toContain(".task-chat-tool-group-error-count");
|
|
expect(css).toContain(".task-chat-thinking-summary");
|
|
expect(css).not.toContain(".task-chat-thinking-markdown + .task-chat-thinking-markdown");
|
|
expect(css).toContain(".task-chat-user-group");
|
|
expect(css).toContain(".task-chat-entry--user");
|
|
});
|
|
});
|