FN-025: preserve user-controlled chat disclosures
Keep thinking and tool disclosure state under operator control across chat surfaces.\n\n- Start new thinking and tool groups collapsed instead of auto-opening during streaming.\n- Preserve disclosure state across streaming updates and reattached planner sessions.\n- Allow non-interactive disclosure content to dismiss expanded blocks while preserving interactive descendants.\n- Add desktop and mobile regression coverage for chat, task activity, and planner views.\n\nFiles changed:\n .../app/components/StandardChatSurface.tsx | 33 +++++++++++--\n packages/dashboard/app/components/TaskChatTab.tsx | 19 ++++++--\n .../__tests__/ChatView.core-interactions.test.tsx | 23 +++++++--\n .../components/__tests__/ChatView.core.test.tsx | 55 +++++++++++++++++++++-\n .../app/components/__tests__/TaskChatTab.test.tsx | 36 +++++++++-----\n .../__tests__/TaskPlannerChatTab.test.tsx | 49 ++++++++++++++++++-\n 6 files changed, 186 insertions(+), 29 deletions(-) Fusion-Task-Id: FN-025 Fusion-Task-Lineage: 294eadc8-d8fe-48da-8a1e-96aed559e54d Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
@@ -154,6 +154,31 @@ function formatToolResultSummary(result: unknown): string | null {
|
||||
return formatToolPreview(result, 200);
|
||||
}
|
||||
|
||||
function isInteractiveDisclosureTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("a,button,input,textarea,select,summary,[role=\"button\"],[contenteditable=\"true\"]"));
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:ChatDisclosure 2026-08-19-02:42:
|
||||
Streaming status is presentation-only: disclosure state belongs to the user and must not be taken over by a running tool or thinking delta. Keep the native summary interaction while allowing a click on non-interactive thinking content to dismiss an expanded block.
|
||||
*/
|
||||
function StandardThinkingDisclosure({ thinking }: { thinking: string }) {
|
||||
const { t } = useTranslation("app");
|
||||
const handleBodyClick = useCallback((event: React.MouseEvent<HTMLPreElement>) => {
|
||||
if (isInteractiveDisclosureTarget(event.target)) return;
|
||||
const details = event.currentTarget.closest("details");
|
||||
if (details?.open) details.open = false;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
<pre className="chat-message-thinking-content" onClick={handleBodyClick}>{linkifyFilePaths(thinking)}</pre>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function buildFailureReferenceHref(reference: FailureInfo["reference"]): string | null {
|
||||
if (!reference) return null;
|
||||
if (reference.kind === "mailbox" || reference.kind === "mailbox-message") {
|
||||
@@ -242,7 +267,7 @@ export function renderStandardToolCalls(
|
||||
return <div key={`${toolCall.toolName}-${index}`} className={className}><div className="chat-tool-call-summary">{summary}</div></div>;
|
||||
}
|
||||
return (
|
||||
<details key={`${toolCall.toolName}-${index}`} className={className} open={isRunning}>
|
||||
<details key={`${toolCall.toolName}-${index}`} className={className}>
|
||||
<summary>{summary}</summary>
|
||||
<ToolCallDetails
|
||||
className="chat-tool-call-content"
|
||||
@@ -284,7 +309,7 @@ export function renderStandardToolCalls(
|
||||
const statusSummary = hasRunning ? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null;
|
||||
return (
|
||||
<div className="chat-tool-calls" data-testid="chat-tool-calls">
|
||||
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group" open={hasRunning}>
|
||||
<details className="chat-tool-calls-group" data-testid="chat-tool-calls-group">
|
||||
<summary className="chat-tool-calls-group-summary">
|
||||
<span className="chat-tool-calls-header-icon" aria-hidden="true">•</span>
|
||||
<span className="chat-tool-calls-count">{t("chat.toolCallsCount", "{{count}} tool calls", { count: nonQuestionToolCalls.length })}</span>
|
||||
@@ -731,7 +756,7 @@ export const StandardChatMessageItem = memo(function StandardChatMessageItem({
|
||||
)}
|
||||
{hasAssistantFooterRow && (
|
||||
<div className={`chat-message-thinking-row${hasVisibleAssistantFooterContent ? "" : " chat-message-thinking-row--collapsed"}`}>
|
||||
{message.thinkingOutput && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(message.thinkingOutput)}</pre></details>}
|
||||
{message.thinkingOutput && <StandardThinkingDisclosure thinking={message.thinkingOutput} />}
|
||||
{(copyAction || onScrollToTop) && (
|
||||
<div className="chat-message-actions">
|
||||
{copyAction}
|
||||
@@ -760,7 +785,7 @@ export function StandardStreamingMessage({ streamingText, streamingThinking = ""
|
||||
{streamingText ? renderStandardAssistantContent(streamingText, forcePlain) : <div className="chat-message-content chat-message-content--waiting">{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.workingStatus", "Working…")}</div>}
|
||||
{copyAction}
|
||||
{renderStandardToolCalls(streamingToolCalls, t, { isAwaitingAnswer: true, onQuestionSubmit, toolCallRenderer })}
|
||||
{streamingThinking && <details className="chat-message-thinking"><summary>{t("chat.thinking", "Thinking")}</summary><pre className="chat-message-thinking-content">{linkifyFilePaths(streamingThinking)}</pre></details>}
|
||||
{streamingThinking && <StandardThinkingDisclosure thinking={streamingThinking} />}
|
||||
<div className="chat-typing-indicator"><span /><span /><span /></div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -589,13 +589,22 @@ function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) {
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:Chat-Thinking 2026-08-04-08:15:
|
||||
FN-8780 requires every newly mounted Task Detail Activity thinking segment to start expanded, regardless of workflow column, so operators can read reasoning immediately. State remains controlled after mount: the summary still lets operators collapse or reopen a segment, and stable segment identity preserves that choice during streaming.
|
||||
FNXC:TaskChatDisclosure 2026-08-19-02:47:
|
||||
Task Activity thinking is user-owned disclosure: every new segment starts collapsed, streaming appends preserve its controlled state, and a click on non-interactive body content closes an expanded segment without requiring a return to its summary. Interactive descendants remain usable.
|
||||
*/
|
||||
function TaskChatThinking({ entries, defaultOpen = true }: { entries: AgentLogEntry[]; defaultOpen?: boolean }) {
|
||||
function isInteractiveDisclosureTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof Element)) return false;
|
||||
return Boolean(target.closest("a,button,input,textarea,select,summary,[role=\"button\"],[contenteditable=\"true\"]"));
|
||||
}
|
||||
|
||||
function TaskChatThinking({ entries }: { entries: AgentLogEntry[] }) {
|
||||
const { t } = useTranslation("app");
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
const [open, setOpen] = useState(false);
|
||||
const combinedThinkingText = entries.map((entry) => entry.text).join("");
|
||||
const handleBodyClick = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (isInteractiveDisclosureTarget(event.target)) return;
|
||||
setOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<details
|
||||
@@ -608,7 +617,7 @@ function TaskChatThinking({ entries, defaultOpen = true }: { entries: AgentLogEn
|
||||
<span>{t("taskChat.thinking", "Thinking")}</span>
|
||||
<TaskChatTimestamp timestamp={getLatestEntryTimestamp(entries)} label="Thinking block timestamp" />
|
||||
</summary>
|
||||
<div className="task-chat-thinking-body">
|
||||
<div className="task-chat-thinking-body" onClick={handleBodyClick}>
|
||||
<div
|
||||
className="markdown-body task-chat-markdown task-chat-thinking-markdown"
|
||||
data-testid="task-chat-entry-thinking"
|
||||
|
||||
@@ -1009,27 +1009,39 @@ describe("ChatView core interactions", () => {
|
||||
expect(streamingMessage?.textContent).toContain("Typing");
|
||||
});
|
||||
|
||||
it("keeps persisted thinking blocks collapsed until expanded", async () => {
|
||||
it.each([
|
||||
["desktop", "desktop"],
|
||||
["mobile", "mobile"],
|
||||
] as const)("keeps persisted thinking user-owned on %s", async (_viewport, viewport) => {
|
||||
const viewportSpy = mockViewportMode(viewport);
|
||||
const user = userEvent.setup();
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Test Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
sessions: [activeSessionFixture],
|
||||
filteredSessions: [activeSessionFixture],
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [
|
||||
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Here's my response", thinkingOutput: "I need to think about this...", createdAt: "2026-04-08T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
|
||||
await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
if (viewport === "mobile") {
|
||||
await user.click(screen.getByTestId("chat-session-session-001"));
|
||||
}
|
||||
|
||||
const message = screen.getByTestId("chat-message-msg-001");
|
||||
const details = message.querySelector("details") as HTMLDetailsElement;
|
||||
const details = message.querySelector("details.chat-message-thinking") as HTMLDetailsElement;
|
||||
expect(details).toBeInTheDocument();
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
expect(within(message).getByText("I need to think about this...")).not.toBeVisible();
|
||||
|
||||
await user.click(within(details).getByText("Thinking"));
|
||||
|
||||
expect(details).toHaveAttribute("open");
|
||||
expect(within(message).getByText("I need to think about this...")).toBeVisible();
|
||||
|
||||
await user.click(within(details).getByText("I need to think about this..."));
|
||||
expect(details).not.toHaveAttribute("open");
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -1203,6 +1215,9 @@ describe("ChatView core interactions", () => {
|
||||
expect(thinkingDetails).toHaveAttribute("open");
|
||||
expect(within(thinkingDetails).getByText("analyzing the request...")).toBeVisible();
|
||||
|
||||
await user.click(within(thinkingDetails).getByText("analyzing the request..."));
|
||||
expect(thinkingDetails).not.toHaveAttribute("open");
|
||||
|
||||
// Typing indicator dots should be rendered
|
||||
const typingIndicator = streamingMessage?.querySelector(".chat-typing-indicator");
|
||||
expect(typingIndicator).toBeInTheDocument();
|
||||
|
||||
@@ -623,6 +623,56 @@ describe("ChatView", () => {
|
||||
expect(preview).toHaveTextContent("path=foo.ts");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["desktop", "desktop"],
|
||||
["mobile", "mobile"],
|
||||
] as const)("keeps streaming tool disclosure state user-owned on %s", async (_viewport, viewport) => {
|
||||
const viewportSpy = mockViewportMode(viewport);
|
||||
const runningToolCalls = [
|
||||
{ toolName: "read", args: { path: "foo.ts" }, isError: false, status: "running" as const },
|
||||
{ toolName: "read", args: { path: "bar.ts" }, isError: false, status: "running" as const },
|
||||
];
|
||||
const completedToolCalls = [
|
||||
{ toolName: "read", args: { path: "foo.ts" }, result: "first result", isError: false, status: "completed" as const },
|
||||
{ toolName: "read", args: { path: "bar.ts" }, result: "second result", isError: false, status: "completed" as const },
|
||||
];
|
||||
mockUseChat
|
||||
.mockReturnValueOnce({
|
||||
...defaultChatState,
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [],
|
||||
isStreaming: true,
|
||||
streamingText: "Working...",
|
||||
streamingToolCalls: runningToolCalls,
|
||||
})
|
||||
.mockReturnValue({
|
||||
...defaultChatState,
|
||||
activeSession: activeSessionFixture,
|
||||
messages: [],
|
||||
isStreaming: true,
|
||||
streamingText: "Working...",
|
||||
streamingToolCalls: completedToolCalls,
|
||||
});
|
||||
|
||||
const { rerender } = await renderWithAct(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||
expect(group).not.toHaveAttribute("open");
|
||||
|
||||
await userEvent.click(group.querySelector("summary") as HTMLElement);
|
||||
expect(group).toHaveAttribute("open");
|
||||
|
||||
await act(async () => {
|
||||
rerender(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
});
|
||||
|
||||
const updatedGroup = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||
expect(updatedGroup).toHaveAttribute("open");
|
||||
expect(within(updatedGroup).queryByText("(1 running)")).not.toBeInTheDocument();
|
||||
expect(updatedGroup).toHaveTextContent("first result");
|
||||
expect(updatedGroup).toHaveTextContent("second result");
|
||||
viewportSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("collapses multiple tool calls into single summary line", async () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
@@ -663,7 +713,7 @@ describe("ChatView", () => {
|
||||
expect(summary.querySelector(".chat-tool-calls-names")).toHaveTextContent("read, grep");
|
||||
});
|
||||
|
||||
it("auto-opens grouped tool calls when any tool call is running", async () => {
|
||||
it("keeps grouped tool calls collapsed while any tool call is running", async () => {
|
||||
setupMockChat({
|
||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||
messages: [
|
||||
@@ -694,7 +744,8 @@ describe("ChatView", () => {
|
||||
|
||||
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||
expect(group).toBeInTheDocument();
|
||||
expect(group.open).toBe(true);
|
||||
expect(group.open).toBe(false);
|
||||
expect(within(group).getByText("(1 running)")).toBeVisible();
|
||||
});
|
||||
|
||||
it("shows status counts in group summary", async () => {
|
||||
|
||||
@@ -1157,7 +1157,7 @@ describe("TaskChatTab", () => {
|
||||
["planning", "triage"],
|
||||
["terminal", "done"],
|
||||
["archived", "archived"],
|
||||
] as const)("defaults thinking blocks open for %s tasks", (_state, column) => {
|
||||
] as const)("defaults thinking blocks collapsed for %s tasks", (_state, column) => {
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "Immediately readable reasoning" }),
|
||||
]);
|
||||
@@ -1165,31 +1165,37 @@ describe("TaskChatTab", () => {
|
||||
render(<TaskChatTab task={makeTask({ column })} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(screen.getByText("Immediately readable reasoning")).toBeVisible();
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
expect(screen.getByText("Immediately readable reasoning")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("lets users collapse and reopen initially expanded thinking blocks", async () => {
|
||||
it.each([
|
||||
["desktop", false],
|
||||
["mobile", true],
|
||||
] as const)("lets users dismiss and reopen thinking blocks from the body on %s", async (_viewport, matchesMobile) => {
|
||||
const user = userEvent.setup();
|
||||
mockLogs([
|
||||
makeEntry({ agent: "triage", type: "thinking", text: "I am considering options" }),
|
||||
]);
|
||||
|
||||
mockMatchMedia(matchesMobile);
|
||||
render(<TaskChatTab task={makeTask({ column: "done" })} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
expect(within(thinking).getByText("Thinking")).toBeVisible();
|
||||
expect(screen.getByText("I am considering options")).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("I am considering options"));
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
expect(screen.getByText("I am considering options")).not.toBeVisible();
|
||||
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(screen.getByText("I am considering options")).toBeVisible();
|
||||
});
|
||||
@@ -1203,7 +1209,7 @@ describe("TaskChatTab", () => {
|
||||
render(<TaskChatTab task={makeTask()} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
const summary = thinking.querySelector("summary");
|
||||
expect(summary).toBeTruthy();
|
||||
expect(within(summary as HTMLElement).getByText("Thinking")).toBeVisible();
|
||||
@@ -1227,8 +1233,10 @@ describe("TaskChatTab", () => {
|
||||
const { rerender } = render(<TaskChatTab task={makeTask({ column: "todo" })} active addToast={vi.fn()} />);
|
||||
|
||||
const thinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
await user.click(within(thinking).getByText("First streamed thought"));
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
|
||||
mockLogs([
|
||||
@@ -1241,7 +1249,7 @@ describe("TaskChatTab", () => {
|
||||
expect(screen.getByText(/Second streamed thought/)).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("gives a genuinely new segment a fresh instance with defaultOpen applied", async () => {
|
||||
it("gives a genuinely new thinking segment a fresh collapsed instance", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockLogs([
|
||||
makeEntry({ agent: "executor", type: "thinking", text: "Original reasoning" }),
|
||||
@@ -1250,8 +1258,10 @@ describe("TaskChatTab", () => {
|
||||
const { rerender } = render(<TaskChatTab task={makeTask({ column: "in-progress" })} active addToast={vi.fn()} />);
|
||||
|
||||
const firstThinking = screen.getByTestId("task-chat-thinking");
|
||||
expect(firstThinking).toHaveAttribute("open");
|
||||
expect(firstThinking).not.toHaveAttribute("open");
|
||||
await user.click(within(firstThinking).getByText("Thinking"));
|
||||
expect(firstThinking).toHaveAttribute("open");
|
||||
await user.click(within(firstThinking).getByText("Original reasoning"));
|
||||
expect(firstThinking).not.toHaveAttribute("open");
|
||||
|
||||
mockLogs([
|
||||
@@ -1264,7 +1274,7 @@ describe("TaskChatTab", () => {
|
||||
const thinkingBlocks = screen.getAllByTestId("task-chat-thinking");
|
||||
expect(thinkingBlocks).toHaveLength(2);
|
||||
expect(thinkingBlocks[0]).not.toHaveAttribute("open");
|
||||
expect(thinkingBlocks[1]).toHaveAttribute("open");
|
||||
expect(thinkingBlocks[1]).not.toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("creates distinct tool segments when text or thinking entries are interleaved", () => {
|
||||
@@ -1286,7 +1296,7 @@ describe("TaskChatTab", () => {
|
||||
expect(within(toolGroups[1]).getByLabelText("Tool names")).toHaveTextContent("second tool");
|
||||
expect(screen.getAllByTestId("task-chat-entry-text")).toHaveLength(1);
|
||||
expect(screen.getByText("plain response")).toBeVisible();
|
||||
expect(screen.getByText("thinking between tools")).toBeVisible();
|
||||
expect(screen.getByText("thinking between tools")).not.toBeVisible();
|
||||
});
|
||||
|
||||
it("appends newly streamed entries from the hook without auto-opening tool groups", () => {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import React from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TaskPlannerChatTab } from "../TaskPlannerChatTab";
|
||||
import { ChatMessageLayoutProvider } from "../../context/ChatMessageLayoutContext";
|
||||
@@ -1250,6 +1250,53 @@ describe("TaskPlannerChatTab", () => {
|
||||
expect(details).toHaveTextContent("PLANNER_RESULT_SUFFIX");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["desktop", false],
|
||||
["mobile", true],
|
||||
] as const)("keeps reattached planner tools and thinking collapsed on %s", async (_viewport, matchesMobile) => {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: vi.fn().mockImplementation((query: string) => ({
|
||||
matches: matchesMobile && query === "(max-width: 768px)",
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
})),
|
||||
});
|
||||
const inFlightGeneration = {
|
||||
status: "generating",
|
||||
streamingText: "Planner is working",
|
||||
streamingThinking: "Planner is checking the task",
|
||||
toolCalls: [
|
||||
{ toolName: "read", args: { path: "one.ts" }, status: "running", isError: false },
|
||||
{ toolName: "read", args: { path: "two.ts" }, status: "running", isError: false },
|
||||
],
|
||||
replayFromEventId: 3,
|
||||
updatedAt: "2026-07-01T14:00:00.000Z",
|
||||
};
|
||||
const plannerSession = makePlannerSession({ isGenerating: true, inFlightGeneration });
|
||||
mockFetchTaskPlannerChatSession.mockResolvedValue({ session: plannerSession });
|
||||
mockFetchChatSession.mockResolvedValue({ session: plannerSession });
|
||||
mockFetchChatMessages.mockResolvedValue({ messages: [] });
|
||||
|
||||
const user = userEvent.setup();
|
||||
renderPlannerChat();
|
||||
|
||||
const group = await screen.findByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||
expect(group).not.toHaveAttribute("open");
|
||||
const thinking = await screen.findByTestId("chat-message-__streaming__").then((message) => message.querySelector("details.chat-message-thinking") as HTMLDetailsElement);
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
await user.click(within(thinking).getByText("Thinking"));
|
||||
expect(thinking).toHaveAttribute("open");
|
||||
await user.click(within(thinking).getByText("Planner is checking the task"));
|
||||
expect(thinking).not.toHaveAttribute("open");
|
||||
});
|
||||
|
||||
it("renders mixed persisted planner question tool calls with the shared answer UI outside collapsed details", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockFetchChatMessages.mockResolvedValue({
|
||||
|
||||
Reference in New Issue
Block a user