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"; import { readBoardWorkflowSelection, removeBoardWorkflowSelection, writeBoardWorkflowSelection } from "../../utils/boardWorkflowSelection"; 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 { 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 { return { timestamp: "2026-06-12T00:00:00.000Z", taskId: "FN-001", type: "text", text: "message", ...overrides, } as AgentLogEntry; } function makeSteeringComment(overrides: Partial[number]> = {}): NonNullable[number] { return { id: "steer-1", text: "Persisted user guidance", createdAt: "2026-06-12T00:00:01.000Z", author: "user", ...overrides, }; } function deferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; const promise = new Promise((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() ?? ""; } const TOO_SMALL_TASK_TOOL_FONT_SIZE = "font-size: calc(var(--space-md) - (var(--space-xs) + (var(--space-xs) / 4)))"; const READABLE_TASK_TOOL_FONT_SIZE = "font-size: var(--space-md)"; function getRootTokenPxValues(css: string): Record { const rootRule = getCssRuleBlock(css, ":root"); const tokenValues: Record = {}; 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): 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): 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> = {}, ) { 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(
, ); } function expectNoComposerGuidanceShell(inputLabel = "Message active agent session") { /* * FNXC:TaskDetailChat 2026-06-30-23:59: * Task Activity keeps the operational composer but removed the visible steering/refinement guidance affordance block; tests must prove no empty wrapper, stale label/hint copy, or dangling aria-describedby remains 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(document.querySelector(".task-chat-composer-affordance")).toBeNull(); expect(document.querySelector(".task-chat-composer-label")).toBeNull(); expect(document.querySelector(".task-chat-composer-hint")).toBeNull(); expect(screen.queryByText(/^Steering comment$/)).not.toBeInTheDocument(); expect(screen.queryByText("Send operational guidance to the active task through steering comments.")).not.toBeInTheDocument(); expect(screen.queryByText(/^Refinement request$/)).not.toBeInTheDocument(); expect(screen.queryByText("Create a follow-up refinement task from this completed task.")).not.toBeInTheDocument(); 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(); expect(screen.getByLabelText(inputLabel)).not.toHaveAttribute("aria-describedby"); } 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 firstTapSendFromFocusedTextarea(input: HTMLElement, sendButton: HTMLElement) { input.focus(); expect(input).toHaveFocus(); fireEvent.pointerDown(sendButton, { pointerType: "touch" }); fireEvent.blur(input); fireEvent.click(sendButton); } function restoreMetricDescriptor(name: "scrollTop" | "scrollHeight" | "clientHeight", descriptor: PropertyDescriptor | undefined) { if (descriptor) { Object.defineProperty(HTMLElement.prototype, name, descriptor); return; } delete (HTMLElement.prototype as Record)[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(); 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(() => { removeBoardWorkflowSelection("project-1"); 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).matchMedia; } }); it("subscribes to live agent logs only when active", () => { render(); expect(mockedUseAgentLogs).toHaveBeenCalledWith("FN-001", false, "project-1"); }); it("renders empty state without timestamp shells when no transcript messages exist", () => { render(); 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(within(transcript).queryByTestId("task-chat-block-time")).not.toBeInTheDocument(); expect(transcript).not.toHaveTextContent(/NaN|Invalid Date/); }); it("renders loading state without timestamp shells or invalid-date text", () => { mockLogs([], true); render(); const transcript = screen.getByTestId("task-chat-transcript"); expect(within(transcript).getByText("Loading agent output…")).toBeTruthy(); expect(within(transcript).queryByTestId("task-chat-group-time")).not.toBeInTheDocument(); expect(within(transcript).queryByTestId("task-chat-user-time")).not.toBeInTheDocument(); expect(within(transcript).queryByTestId("task-chat-block-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(); 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 activity 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(); const toggle = screen.getByTestId("task-chat-expand-toggle"); expect(toggle).toHaveAttribute("aria-label", "Collapse activity"); 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(); 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(); 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(); 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(); 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( , ); 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 legacy and suffixed runtime model markers", () => { mockLogs([ makeEntry({ agent: "triage", text: "Triage using model: google/gemini-pro (thinking effort: low)" }), makeEntry({ agent: "executor", text: "Executor using model: openai/gpt-4o (thinking effort: high)" }), makeEntry({ agent: "reviewer", text: "Reviewer using model: anthropic/claude-sonnet-4-5" }), ]); render(); 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( , ); 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(); 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(); 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(); 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("renders per-block timestamps for text tool thinking and user blocks", () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z")); mockLogs([ makeEntry({ agent: "executor", text: "older text chunk ", timestamp: "2026-06-17T14:58:30.000Z" }), makeEntry({ agent: "executor", text: "latest text chunk", timestamp: "2026-06-17T14:59:00.000Z" }), makeEntry({ agent: "executor", type: "tool", text: "bash", detail: "pnpm test", timestamp: "2026-06-17T14:56:00.000Z" }), makeEntry({ agent: "executor", type: "tool_result", text: "bash", detail: "ok", timestamp: "2026-06-17T14:57:00.000Z" }), makeEntry({ agent: "executor", type: "thinking", text: "first thought", timestamp: "2026-06-17T14:54:00.000Z" }), makeEntry({ agent: "executor", type: "thinking", text: "latest thought", timestamp: "2026-06-17T14:55:00.000Z" }), ]); render( , ); const textBlock = screen.getByTestId("task-chat-entry-text"); expect(textBlock).toHaveTextContent("older text chunk latest text chunk"); expect(within(textBlock).getByLabelText("Text block timestamp")).toHaveTextContent("1m ago"); expect(screen.getByLabelText("Tool group timestamp")).toHaveTextContent("3m ago"); expect(screen.getByLabelText("Tool invocation timestamp")).toHaveTextContent("3m ago"); expect(screen.getByLabelText("Thinking block timestamp")).toHaveTextContent("5m ago"); expect(screen.getByLabelText("User message block timestamp")).toHaveTextContent("8m 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( , ); 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( , ); 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( , ); 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( , ); 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(); 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(); expectActivePlaceholderWithoutGuidance(); }); it("renders a single text entry as one text bubble", () => { mockLogs([ makeEntry({ agent: "executor", text: "single response" }), ]); render(); 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(); 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(); 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(); 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")).toHaveClass("task-chat-tool-group-count"); expect(within(summary as HTMLElement).getByText("1 tool call")).toBeVisible(); expect(within(summary as HTMLElement).getByText("bash")).toHaveClass("task-chat-tool-group-names"); 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("shows Bash tool duration in the expanded invocation and omits legacy timing labels", 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", durationMs: 842 }), makeEntry({ agent: "executor", type: "tool", text: "Read", detail: "file.ts" }), makeEntry({ agent: "executor", type: "tool_result", text: "Read", detail: "contents" }), ]); render(); expect(screen.queryByText("Duration 842ms")).not.toBeVisible(); await user.click(screen.getByText("2 tool calls")); expect(screen.getByText("Duration 842ms")).toBeVisible(); expect(screen.getAllByTestId("task-chat-timing-labels")).toHaveLength(1); }); 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(); 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).toHaveClass("task-chat-tool-group-names"); expect(names).toHaveTextContent("bash, read, edit, grep, find, +1 more"); const overflow = within(summary as HTMLElement).getByText(", +1 more"); expect(overflow).toHaveClass("task-chat-tool-group-overflow"); expect(overflow).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(); 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(); expect(screen.getByLabelText("Tool invocation timestamp")).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(); 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.each([ ["result", "tool_result", "Tool result", "ok", "4m ago"], ["error", "tool_error", "Tool error", "stderr", "2m ago"], ] as const)("falls back to standalone %s entries when a tool completion has no preceding call", (_label, type, kickerText, detail, expectedTime) => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-06-17T15:00:00.000Z")); mockLogs([ makeEntry({ agent: "executor", type, text: "bash", detail, timestamp: type === "tool_result" ? "2026-06-17T14:56:00.000Z" : "2026-06-17T14:58:00.000Z" }), ]); render(); 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(within(summary as HTMLElement).getByLabelText("Tool group timestamp")).toHaveTextContent(expectedTime); expect(screen.queryByText("0 tool calls")).not.toBeInTheDocument(); fireEvent.click(within(summary as HTMLElement).getByText("1 tool call")); const standaloneEntry = screen.getByTestId(`task-chat-entry-${type}`); const standaloneKicker = screen.getByText(kickerText); expect(standaloneEntry).toHaveClass("task-chat-tool-entry"); expect(standaloneKicker).toHaveClass("task-chat-entry-kicker"); expect(within(standaloneEntry).getByLabelText("Tool entry timestamp")).toHaveTextContent(expectedTime); }); it("renders thinking in a collapsed-by-default expandable block", async () => { const user = userEvent.setup(); mockLogs([ makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }), ]); render(); const thinking = screen.getByTestId("task-chat-thinking"); expect(thinking).not.toHaveAttribute("open"); expect(within(thinking).getByText("Thinking")).toBeVisible(); expect(screen.getByText("I am considering options")).not.toBeVisible(); expect(within(thinking).getAllByTestId("task-chat-entry-thinking")).toHaveLength(1); await user.click(within(thinking).getByText("Thinking")); expect(thinking).toHaveAttribute("open"); expect(screen.getByText("I am considering options")).toBeVisible(); 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(); 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(); 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")).not.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(); expect(screen.getByText("first live chunk")).toBeVisible(); rerender(); 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(); 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(); expect(metrics.scrollTop).toBe(0); rerender(); 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(); expect(metrics.scrollTop).toBe(0); rerender(); 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(); 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(); 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(); 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(); 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(); expect(metrics.scrollTop).toBe(1000); metrics.scrollTop = 720; fireEvent.scroll(screen.getByTestId("task-chat-transcript")); metrics.scrollHeight = 1400; rerender(); 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(); expect(metrics.scrollTop).toBe(1000); metrics.scrollTop = 120; fireEvent.scroll(screen.getByTestId("task-chat-transcript")); metrics.scrollHeight = 1400; rerender(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); expect(screen.getByText(/Loading agent output/)).toBeVisible(); expect(screen.queryByTestId("task-chat-jump-to-bottom")).not.toBeInTheDocument(); loading.unmount(); mockLogs([]); render(); 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(); 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(); 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 activity 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(); 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(); 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(); 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(); 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("sends Activity steering exactly once on the first mobile tap while the textarea is focused", async () => { const updatedTask = makeTask(); mockedAddSteeringComment.mockResolvedValue(updatedTask); render(); const input = screen.getByLabelText("Message active agent session"); fireEvent.change(input, { target: { value: "First mobile tap steering" } }); firstTapSendFromFocusedTextarea(input, screen.getByRole("button", { name: "Send" })); await waitFor(() => { expect(mockedAddSteeringComment).toHaveBeenCalledWith("FN-001", "First mobile tap steering", "project-1"); }); expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1); expect(mockedRefineTask).not.toHaveBeenCalled(); }); it("sends done-task refinement exactly once on the first mobile tap while the textarea is focused", async () => { mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-222", column: "todo" })); render(); const input = screen.getByLabelText("Message active agent session"); fireEvent.change(input, { target: { value: "First mobile tap refinement" } }); firstTapSendFromFocusedTextarea(input, screen.getByRole("button", { name: "Send" })); await waitFor(() => { expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "First mobile tap refinement", "project-1"); }); expect(mockedRefineTask).toHaveBeenCalledTimes(1); expect(mockedAddSteeringComment).not.toHaveBeenCalled(); }); it("keeps mobile first-tap guards for blank drafts and in-flight sends", async () => { const send = deferred(); mockedAddSteeringComment.mockReturnValue(send.promise); render(); const input = screen.getByLabelText("Message active agent session"); const sendButton = screen.getByRole("button", { name: "Send" }); expect(sendButton).toBeDisabled(); fireEvent.change(input, { target: { value: " \n " } }); expect(sendButton).toBeDisabled(); firstTapSendFromFocusedTextarea(input, sendButton); expect(mockedAddSteeringComment).not.toHaveBeenCalled(); fireEvent.change(input, { target: { value: "Do not duplicate mobile tap" } }); expect(sendButton).not.toBeDisabled(); firstTapSendFromFocusedTextarea(input, sendButton); fireEvent.pointerDown(sendButton, { pointerType: "touch" }); fireEvent.click(sendButton); await waitFor(() => expect(mockedAddSteeringComment).toHaveBeenCalledTimes(1)); await act(async () => { send.resolve(makeTask()); await send.promise; }); }); 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( , ); 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")).queryByText("You")).not.toBeInTheDocument(); expect(within(screen.getByTestId("task-chat-transcript")).queryByText("Please add a follow-up report")).not.toBeInTheDocument(); expect(input).toHaveValue(""); expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-222", "success"); expect(onTaskUpdated).not.toHaveBeenCalledWith(refinementTask); expect(onTaskUpdated).not.toHaveBeenCalled(); }); it("preserves durable non-default workflow context after done-task refinement success", async () => { const user = userEvent.setup(); const addToast = vi.fn(); writeBoardWorkflowSelection("project-1", "WF-custom"); mockedRefineTask.mockResolvedValue(makeTask({ id: "FN-225", column: "todo" })); render(); const input = screen.getByLabelText("Message active agent session"); await user.type(input, "Create a focused follow-up"); await user.click(screen.getByRole("button", { name: "Send" })); await waitFor(() => { expect(mockedRefineTask).toHaveBeenCalledWith("FN-001", "Create a focused follow-up", "project-1"); }); expect(input).toHaveValue(""); expect(addToast).toHaveBeenCalledWith("Refinement task created: FN-225", "success"); expect(readBoardWorkflowSelection("project-1")).toBe("WF-custom"); expect(readBoardWorkflowSelection("project-1")).not.toBe("builtin:coding"); }); it("sends an in-progress task steering message on plain Enter", async () => { const onTaskUpdated = vi.fn(); const updatedTask = makeTask(); mockedAddSteeringComment.mockResolvedValue(updatedTask); render(); 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(); 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(); 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(); 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(); mockedAddSteeringComment.mockReturnValue(send.promise); render(); 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(); 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(); 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(); 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(); 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(); mockedAddSteeringComment.mockReturnValue(send.promise); render(); 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().promise); render(); 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"); expect(within(transcript).getByLabelText("User message block timestamp")).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(); mockedAddSteeringComment.mockReturnValue(send.promise); render(); 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().promise); render(); 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().promise); const { rerender } = render(); 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(); 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(); mockedAddSteeringComment.mockReturnValue(send.promise); const persistedComment = makeSteeringComment({ id: "steer-reconciled", text: "Reconciled guidance", createdAt: "2026-06-12T00:00:01.000Z" }); const { rerender } = render(); 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(); 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().promise); render(); 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( , ); 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( , ); 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(within(transcript).queryByTestId("task-chat-block-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(); 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(); 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( , ); 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( , ); 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(); 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(); 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( , ); 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(); mockedAddSteeringComment.mockReturnValue(send.promise); const message = `Will anyone answer ${_label}?`; render(); 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( , ); 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( , ); 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( , ); 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( , ); expectActivePlaceholderWithoutGuidance(); expect(screen.getByLabelText("Message active agent session")).not.toBeDisabled(); }, ); it("keeps a queued (waiting) unassigned in-progress task idle in ephemeral mode", () => { render( , ); 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(); 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( , ); 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( , ); 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(); 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( , ); 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(); 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(); 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( , ); 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( , ); 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(); 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(); expectActivePlaceholderWithoutGuidance(); expectComposerSendableAfterDraft(); }, ); it("disables the composer only while a send is in flight", async () => { const user = userEvent.setup(); const send = deferred(); mockedAddSteeringComment.mockReturnValue(send.promise); render(); 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(); mockedRefineTask.mockReturnValue(send.promise); render(); 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(); mockedAddSteeringComment.mockReturnValue(send.promise); render(); 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(); mockedRefineTask.mockReturnValue(send.promise); render(); 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("keeps the composer usable without visible steering guidance copy on desktop and mobile breakpoints", () => { mockMatchMedia(false); const desktop = render(); expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument(); expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); expect(screen.getByRole("form", { name: "Task activity composer" })).toBeInTheDocument(); expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument(); expectActivePlaceholderWithoutGuidance(); 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(); expect(screen.getByTestId("task-chat-tab")).toBeInTheDocument(); expect(screen.getByTestId("task-chat-transcript")).toBeInTheDocument(); expect(screen.getByRole("form", { name: "Task activity composer" })).toBeInTheDocument(); expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument(); expectActivePlaceholderWithoutGuidance(); expect(screen.getByLabelText("Message active agent session")).toHaveClass("task-chat-input"); expect(screen.getByRole("button", { name: "Send" })).toHaveClass("task-chat-send"); }); it("keeps completed task refinement functional without the shared visible affordance shell", () => { render(); expect(screen.getByRole("form", { name: "Task refinement composer" })).toBeInTheDocument(); expectDonePlaceholderWithoutGuidance(); expect(screen.queryByRole("form", { name: "Steering comment" })).not.toBeInTheDocument(); expect(screen.queryByRole("form", { name: "Refinement request" })).not.toBeInTheDocument(); }); 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(composerRule).toContain("padding: 0"); 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 Activity on the canonical TaskChatTab renderer", () => { const source = readFileSync(resolve(__dirname, "../TaskDetailModal.tsx"), "utf8"); const taskChatMounts = source.match(/ !value)"); }); it("widens task chat messages in narrow host containers without changing desktop layout", () => { const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); const hostRule = getCssRuleBlock(css, ".task-chat-tab"); const desktopGroupRule = getCssRuleBlock(css, ".task-chat-group"); const listSplitGroupRule = getCssRuleBlock(css, ".list-split-detail-content .task-chat-group"); const mobileCss = getCssAfter(css, "@media (max-width: 768px)"); const mobileGroupRule = getCssRuleBlock(mobileCss, ".task-chat-group"); const narrowHostCss = getCssAfter(css, "@container task-chat-tab (max-width: 34rem)"); const narrowHostGroupRule = getCssRuleBlock(narrowHostCss, ".task-chat-group"); const narrowHostHeaderRule = getCssRuleBlock(narrowHostCss, ".task-chat-group-header"); const narrowHostUserEntryRule = getCssRuleBlock(narrowHostCss, ".task-chat-entry--user"); expect(hostRule).toContain("container-type: inline-size"); expect(hostRule).toContain("container-name: task-chat-tab"); expect(desktopGroupRule).toContain("grid-template-columns: auto minmax(0, 1fr)"); expect(css).toContain("@container task-chat-tab (max-width: 34rem)"); expect(narrowHostGroupRule).toContain("grid-template-columns: 1fr"); expect(narrowHostHeaderRule).toContain("min-width: 0"); expect(narrowHostUserEntryRule).toContain("max-width: 100%"); expect(narrowHostCss).not.toContain(TOO_SMALL_TASK_TOOL_FONT_SIZE); expect(listSplitGroupRule).toContain("grid-template-columns: 1fr"); expect(mobileGroupRule).toContain("grid-template-columns: 1fr"); }); 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(" { const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); const entryRule = getCssRuleBlock(css, ".task-chat-entry"); const userRule = getCssRuleBlock(css, ".task-chat-entry--user"); const toolGroupRule = getCssRuleBlock(css, ".task-chat-tool-group"); const toolSummaryRule = getCssRuleBlock(getCssAfter(css, "FN-7215 aligns task-detail tool-call summaries"), ".task-chat-tool-group-summary"); const toolEntriesRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-group-entries {\n gap"), ".task-chat-tool-group-entries"); const toolEntryRule = getCssRuleBlock(css, ".task-chat-tool-entry"); const thinkingRule = getCssRuleBlock(css, ".task-chat-thinking"); const thinkingBodyRule = getCssRuleBlock(css, ".task-chat-thinking-body"); const toolDetailRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-detail {"), ".task-chat-tool-detail"); const mobileCss = getCssAfter(css, "@media (max-width: 768px)"); const mobileBlockPaddingCss = getCssAfter(mobileCss, ".task-chat-entry,\n .task-chat-tool-group,\n .task-chat-thinking"); const mobileEntryRule = getCssRuleBlock(mobileBlockPaddingCss, ".task-chat-entry"); const mobileToolGroupRule = getCssRuleBlock(mobileBlockPaddingCss, ".task-chat-tool-group"); const mobileThinkingRule = getCssRuleBlock(mobileBlockPaddingCss, ".task-chat-thinking"); const mobileToolEntryRule = getCssRuleBlock(mobileCss, ".task-chat-tool-entry"); expect(entryRule).toContain("box-sizing: border-box"); expect(entryRule).toContain("padding: var(--space-md)"); expect(userRule).not.toContain("padding"); expect(toolGroupRule).toContain("box-sizing: border-box"); expect(toolGroupRule).toContain("padding: var(--space-md)"); expect(thinkingRule).toContain("box-sizing: border-box"); expect(thinkingRule).toContain("padding: var(--space-md)"); expect(toolSummaryRule).toContain("padding: var(--space-xs)"); expect(toolEntriesRule).toContain("padding: 0 var(--space-xs) var(--space-xs)"); expect(thinkingBodyRule).toContain("padding: 0 var(--space-md) var(--space-md)"); expect(toolEntryRule).toContain("box-sizing: border-box"); expect(toolEntryRule).toContain("padding: var(--space-sm)"); expect(toolDetailRule).toContain("box-sizing: border-box"); expect(toolDetailRule).toContain("padding: var(--space-xs)"); expect(mobileEntryRule).toContain("padding: var(--space-sm)"); expect(mobileToolGroupRule).toContain("padding: var(--space-sm)"); expect(mobileThinkingRule).toContain("padding: var(--space-sm)"); expect(mobileToolEntryRule).toContain("padding: var(--space-xs)"); for (const rule of [entryRule, toolGroupRule, toolEntryRule, thinkingRule, toolDetailRule, mobileEntryRule]) { expect(rule).not.toContain("px"); expect(rule).not.toContain("#"); } }); 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 blockTimestampRule = getCssRuleBlock(css, ".task-chat-entry-meta .task-chat-timestamp,"); const blockTimestampMetaRule = getCssRuleBlock(getCssAfter(css, ".task-chat-entry-meta .task-chat-timestamp {"), ".task-chat-entry-meta .task-chat-timestamp"); const summaryTimestampRule = getCssRuleBlock(getCssAfter(css, ".task-chat-entry-meta .task-chat-timestamp {\n margin-left: auto;\n}\n\n"), ".task-chat-entry-label-row .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(blockTimestampRule).toContain("display: inline-flex"); expect(blockTimestampRule).toContain("flex: 0 0 auto"); expect(blockTimestampRule).toContain("font-size: calc(var(--space-md) - (var(--space-xs) / 2))"); expect(blockTimestampRule).toContain("white-space: nowrap"); expect(blockTimestampMetaRule).toContain("margin-left: auto"); expect(summaryTimestampRule).toContain("max-inline-size: 40%"); expect(summaryTimestampRule).toContain("overflow: hidden"); expect(summaryTimestampRule).toContain("text-overflow: ellipsis"); expect(blockTimestampRule).not.toContain("px"); expect(blockTimestampRule).not.toContain("#"); expect(summaryTimestampRule).not.toContain("px"); expect(summaryTimestampRule).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 task-chat tool summaries compact and tool text readable on desktop and mobile", () => { const css = readFileSync(resolve(__dirname, "../TaskChatTab.css"), "utf8"); const chatCss = readFileSync(resolve(__dirname, "../ChatView.css"), "utf8"); const compactCss = getCssAfter(css, "FN-7215 aligns task-detail tool-call summaries"); const summaryRule = getCssRuleBlock(compactCss, ".task-chat-tool-group-summary"); const namesRule = getCssRuleBlock(css, ".task-chat-tool-group-names"); const countRule = getCssRuleBlock(css, ".task-chat-tool-group-count"); const errorRule = getCssRuleBlock(css, ".task-chat-tool-group-error-count"); const entriesRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-group-entries {\n gap"), ".task-chat-tool-group-entries"); const entryRule = getCssRuleBlock(css, ".task-chat-tool-entry"); const kickerRule = getCssRuleBlock(css, ".task-chat-entry-kicker"); const detailLabelRule = getCssRuleBlock(css, ".task-chat-tool-detail-label"); const detailRule = getCssRuleBlock(getCssAfter(css, ".task-chat-tool-detail {"), ".task-chat-tool-detail"); const chatSummaryRule = getCssRuleBlock(chatCss, ".chat-tool-calls-group-summary"); const chatNamesRule = getCssRuleBlock(chatCss, ".chat-tool-calls-names"); const mobileCss = getCssAfter(css, "@media (max-width: 768px)"); const mobileSummaryRule = getCssRuleBlock(mobileCss, ".task-chat-tool-group-summary"); const mobileNamesRule = getCssRuleBlock(mobileCss, ".task-chat-tool-group-names"); const mobileErrorRule = getCssRuleBlock(mobileCss, ".task-chat-tool-group-error-count"); expect(summaryRule).toContain("flex-wrap: nowrap"); expect(summaryRule).toContain("padding: var(--space-xs)"); expect(summaryRule).toContain(READABLE_TASK_TOOL_FONT_SIZE); expect(summaryRule).toContain("border-radius: var(--radius-sm)"); expect(summaryRule).not.toContain("px"); expect(summaryRule).not.toContain("#"); expect(namesRule).toContain("font-family: var(--font-mono, monospace)"); expect(namesRule).toContain("overflow: hidden"); expect(namesRule).toContain("text-overflow: ellipsis"); expect(namesRule).toContain("white-space: nowrap"); expect(countRule).toContain("white-space: nowrap"); expect(errorRule).toContain("color: var(--color-error)"); expect(errorRule).toContain("white-space: nowrap"); expect(entriesRule).toContain("padding: 0 var(--space-xs) var(--space-xs)"); expect(entryRule).toContain("border-radius: var(--radius-sm)"); /* * FNXC:TaskDetailChat 2026-06-29-14:13: * FN-7240 regression coverage must fail if task-detail tool-call typography returns to the too-small FN-7225 calc. The summary, kicker, detail label, and detail body all use the readable base token while retaining compact spacing. */ for (const [selector, readableToolTextRule] of [ [".task-chat-tool-group-summary", summaryRule], [".task-chat-entry-kicker", kickerRule], [".task-chat-tool-detail-label", detailLabelRule], [".task-chat-tool-detail", detailRule], ] as const) { expect(readableToolTextRule, `${selector} uses readable tool-call typography`).toContain(READABLE_TASK_TOOL_FONT_SIZE); expect(readableToolTextRule, `${selector} does not restore the too-small calc`).not.toContain(TOO_SMALL_TASK_TOOL_FONT_SIZE); } expect(chatSummaryRule).toContain("padding: var(--space-xs)"); expect(chatSummaryRule).toContain("border-radius: var(--radius-sm)"); expect(chatNamesRule).toContain("text-overflow: ellipsis"); expect(mobileSummaryRule).toContain("flex-direction: row"); expect(mobileSummaryRule).toContain("flex-wrap: nowrap"); expect(mobileCss).not.toContain(TOO_SMALL_TASK_TOOL_FONT_SIZE); expect(mobileNamesRule).toContain("width: auto"); expect(mobileErrorRule).toContain("width: auto"); }); 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).not.toContain(".task-chat-composer-affordance"); expect(css).not.toContain(".task-chat-composer-label"); expect(css).not.toContain(".task-chat-composer-hint"); 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"); }); }); /* FNXC:TaskChat-StatusEntries 2026-07-15-11:20: Regression coverage for standalone engine messages rendering as one run-on string. ## Symptom Verification Original symptom: a Reviewer card headed "14 entries" rendered as "Reviewer using model: umans/umans-kimi-k2.7Reviewer using model: umans/umans-kimi-k2.7..." — 14 complete messages glued edge-to-edge with no separator. Exact reproduction: consecutive same-role standalone engine rows in one group. Assertion it is gone: each `status` row renders as its own block, and no rendered block contains two concatenated messages. ## Surface Enumeration - `status` rows must never be glued to each other (the reported case). - `status` rows must never be glued to adjacent `text` rows either. - `text` rows MUST still be glued with no separator — they are streamed deltas, and inserting a separator here is the FN-5787/5789/5803 streamed-spacing regression this must not reintroduce. - Model resolution reads markers out of the log and must accept `status` AND legacy `text` rows (covered in effective-model-resolution.test.ts). */ describe("TaskChatTab — standalone status entries are not glued like streamed deltas", () => { const marker = "Reviewer using model: umans/umans-kimi-k2.7"; it("renders repeated standalone status messages as separate blocks", () => { mockLogs([ makeEntry({ agent: "reviewer", type: "status", text: marker }), makeEntry({ agent: "reviewer", type: "status", text: marker }), makeEntry({ agent: "reviewer", type: "status", text: marker }), ]); render(); const blocks = Array.from(document.querySelectorAll(".task-chat-markdown")); const rendered = blocks.map((el) => el.textContent ?? ""); // The bug: one block containing the message repeated with no separator. expect(rendered.some((text) => text.includes(`${marker}${marker}`))).toBe(false); expect(rendered.filter((text) => text.trim() === marker)).toHaveLength(3); }); it("does not glue a status message onto an adjacent streamed text block", () => { mockLogs([ makeEntry({ agent: "reviewer", type: "status", text: marker }), makeEntry({ agent: "reviewer", type: "text", text: "Verdict: " }), makeEntry({ agent: "reviewer", type: "text", text: "APPROVE" }), ]); render(); const rendered = Array.from(document.querySelectorAll(".task-chat-markdown")).map((el) => el.textContent ?? ""); expect(rendered.some((text) => text.includes(`${marker}Verdict`))).toBe(false); // Streamed deltas around it still re-glue with NO separator. expect(rendered.some((text) => text.includes("Verdict: APPROVE"))).toBe(true); }); it("still joins consecutive streamed text deltas with no separator", () => { mockLogs([ makeEntry({ agent: "reviewer", type: "text", text: "review" }), makeEntry({ agent: "reviewer", type: "text", text: "ing the" }), makeEntry({ agent: "reviewer", type: "text", text: " diff" }), ]); render(); const rendered = Array.from(document.querySelectorAll(".task-chat-markdown")).map((el) => el.textContent ?? ""); expect(rendered.some((text) => text.includes("reviewing the diff"))).toBe(true); }); });